From 5da627a8df8cc057ef7a615470077723084ae32b Mon Sep 17 00:00:00 2001 From: Henri Dickson <90480431+alphatownsman@users.noreply.github.com> Date: Wed, 9 Nov 2022 13:56:50 -0500 Subject: [PATCH] add all NeoDB features to NiceDB (#115) * fix scraping failure with wepb image (merge upstream/fix-webp-scrape) * add filetype to requirements * add proxycrawl.com as fallback for douban scraper * load 3p js/css from cdn * add fix-cover task * fix book/album cover tasks * scrapestack * bandcamp scrape and preview ; manage.py scrape <url> ; make ^C work when DEBUG * use scrapestack when fix cover * add user agent to improve compatibility * search BandCamp for music albums * add missing MovieGenre * fix search 500 when song has no parent album * adjust timeout * individual scrapers * fix tmdb parser * export marks via rq; pref to send public toot; move import to data page * fix spotify import * fix edge cases * export: fix dupe tags * use rq to manage doufen import * add django command to manage rq jobs * fix export edge case * tune rq admin * fix detail page 502 step 1: async pull mastodon follow/block/mute list * fix detail page 502 step 2: calculate relationship by local cached data * manual sync mastodon follow info * domain_blocks parsing fix * marks by who i follows * adjust label * use username in urls * add page to list a user\'s review * review widget on user home page * fix preview 500 * fix typo * minor fix * fix google books parsing * allow mark/review visible to oneself * fix auto sync masto for new user * fix search 500 * add command to restart a sync task * reset visibility * delete user data * fix tag search result pagination * not upgrade to django 4 yet * basic doc * wip: collection * wip * wip * collection use htmx * show in-collection section for entities * fix typo * add su for easier debug * fix some 500s * fix login using alternative domain * hide data from disabled user * add item to list from detail page * my tags * collection: inline comment edit * show number of ratings * fix collection delete * more detail in collection view * use item template in search result * fix 500 * write index to meilisearch * fix search * reindex in batch * fix 500 * show search result from meilisearch * more search commands * index less fields * index new items only * search highlights * fix 500 * auto set search category * classic search if no meili server * fix index stats error * support typesense backend * workaround typesense bug * make external search async * fix 500, typo * fix cover scripts * fix minor issue in douban parser * supports m.douban.com and customized bandcamp domain * move account * reword with gender-friendly and instance-neutral language * Friendica does not have vapid_key in api response * enable anonymous search * tweak book result template * API v0 API v0 * fix meilisearch reindex * fix search by url error * login via twitter.com * login via pixelfed * minor fix * no refresh on inactive users * support refresh access token * get rid of /users/number-id/ * refresh twitter handler automatically * paste image when review * support PixelFed (very long token) * fix django-markdownx version * ignore single quote for meilisearch for now * update logo * show book review/mark from same isbn * show movie review/mark from same imdb * fix login with older mastodon servers * import Goodreads book list and profile * add timestamp to Goodreads import * support new google books api * import goodreads list * minor goodreads fix * click corner action icon to add to wishlist * clean up duplicated code * fix anonymous search * fix 500 * minor fix search 500 * show rating only if votes > 5 * Entity.refresh_rating() * preference to append text when sharing; clean up duplicated code * fix missing data for user tagged view * fix page link for tag view * fix 500 when language field longer than 10 * fix 500 when sharing mark for song * fix error when reimport goodread profile * fix minor typo * fix a rare 500 * error log dump less * fix tags in marks export * fix missing param in pagination * import douban review * clarify text * fix missing sheet in review import * review: show in progress * scrape douban: ignore unknown genre * minor fix * improve review import by guess entity urls * clear guide text for review import * improve review import form text * workaround some 500 * fix mark import error * fix img in review import * load external results earlier * ignore search server errors * simplify user register flow to avoid inconsistent state * Add a learn more link on login page * Update login.html * show mark created timestamp as mark time * no 500 for api error * redirect for expired tokens * ensure preference object created. * mark collections * tag list * fix tag display * fix sorting etc * fix 500 * fix potential export 500; save shared links * fix share to twittwe * fix review url * fix 500 * fix 500 * add timeline, etc * missing status change in timeline * missing id in timeline * timeline view by default * workaround bug in markdownx... * fix typo * option to create new collection when add from detail page * add missing announcement and tags in timeline home * add missing announcement * add missing announcement * opensearch * show fediverse shared link * public review no longer requires login * fix markdownx bug * fix 500 * use cloudflare cdn * validate jquery load and domain input * fix 500 * tips for goodreads import * collaborative collection * show timeline and profile link on nav bar * minor tweak * share collection * fix Goodreads search * show wish mark in timeline * resync failed urls with local proxy * resync failed urls with local proxy: check proxy first * scraper minor fix * resync failed urls * fix fields limit * fix douban parsing error * resync * scraper minor fix * scraper minor fix * scraper minor fix * local proxy * local proxy * sync default config from neodb * configurable site name * fix 500 * fix 500 for anonymous user * add sentry * add git version in log * add git version in log * no longer rely on cdnjs.cloudflare.com * move jq/cash to _common_libs template partial * fix rare js error * fix 500 * avoid double submission error * import tag in lower case * catch some js network errors * catch some js network errors * support more goodread urls * fix unaired tv in tmdb * support more google book urls * fix related series * more goodreads urls * robust googlebooks search * robust search * Update settings.py * Update scraper.py * Update requirements.txt * make nicedb work * doc update * simplify permission check * update doc * update doc for bug report link * skip spotify tracks * fix 500 * improve search api * blind fix import compatibility * show years for movie in timeline * show years for movie in timeline; thinner font * export reviews * revert user home to use jquery https://github.com/fabiospampinato/cash/issues/246 * IGDB * use IGDB for Steam * use TMDB for IMDb * steam: igdb then fallback to steam * keep change history * keep change history: add django settings * Steam: keep localized title/brief while merging IGDB * basic Docker support * rescrape * Create codeql-analysis.yml * Create SECURITY.md * Create pysa.yml Co-authored-by: doubaniux <goodsir@vivaldi.net> Co-authored-by: Your Name <you@example.com> Co-authored-by: Their Name <they@example.com> Co-authored-by: Mt. Front <mfcndw@gmail.com> --- .github/workflows/codeql-analysis.yml | 74 + .github/workflows/pysa.yml | 50 + .gitignore | 5 +- Dockerfile | 23 + README.md | 11 +- SECURITY.md | 5 + boofilsic/context_processors.py | 5 + boofilsic/settings.py | 123 +- boofilsic/urls.py | 6 + books/admin.py | 3 +- books/apps.py | 5 + books/forms.py | 24 +- books/management/commands/fix-book-cover.py | 200 +++ books/models.py | 162 ++- books/templates/books/create_update.html | 28 +- .../templates/books/create_update_review.html | 12 +- books/templates/books/delete.html | 12 +- books/templates/books/delete_review.html | 14 +- books/templates/books/detail.html | 132 +- books/templates/books/mark_list.html | 43 +- books/templates/books/review_detail.html | 22 +- books/templates/books/review_list.html | 18 +- books/templates/books/scrape.html | 4 +- books/urls.py | 6 +- books/views.py | 137 +- collection/__init__.py | 0 collection/admin.py | 3 + collection/apps.py | 6 + collection/forms.py | 45 + collection/models.py | 126 ++ collection/templates/add_to_list.html | 45 + collection/templates/create_update.html | 71 + collection/templates/delete.html | 117 ++ collection/templates/detail.html | 147 ++ collection/templates/edit_item_comment.html | 5 + collection/templates/entity_list.html | 21 + collection/templates/list.html | 99 ++ collection/templates/share_collection.html | 56 + collection/templates/show_item_comment.html | 4 + collection/tests.py | 3 + collection/urls.py | 27 + collection/views.py | 442 ++++++ common/forms.py | 50 +- common/importers/douban.py | 270 ++++ common/importers/goodreads.py | 202 +++ common/index.py | 12 + common/management/commands/delete_job.py | 19 + common/management/commands/index_stats.py | 40 + common/management/commands/init_index.py | 18 + common/management/commands/list_jobs.py | 24 + common/management/commands/reindex.py | 40 + common/management/commands/restart_sync.py | 28 + common/management/commands/scrape.py | 25 + common/models.py | 220 ++- common/scraper.py | 1259 ++--------------- common/scrapers/bandcamp.py | 71 + common/scrapers/bangumi.py | 199 +++ common/scrapers/douban.py | 714 ++++++++++ common/scrapers/goodreads.py | 157 ++ common/scrapers/google.py | 102 ++ common/scrapers/igdb.py | 88 ++ common/scrapers/imdb.py | 116 ++ common/scrapers/spotify.py | 287 ++++ common/scrapers/steam.py | 92 ++ common/scrapers/tmdb.py | 150 ++ common/search/meilisearch.py | 183 +++ common/search/typesense.py | 215 +++ common/searcher.py | 209 +++ common/static/css/boofilsic.css | 552 +++----- common/static/css/boofilsic.min.css | 2 +- common/static/img/fediverse.svg | 5 + common/static/img/logo.svg | 141 +- common/static/img/logo_square.jpg | Bin 42490 -> 32105 bytes common/static/img/logo_square.svg | 194 ++- common/static/js/create_update_review.js | 4 +- common/static/js/detail.js | 16 +- common/static/js/home.js | 189 ++- common/static/js/mastodon.js | 72 +- common/static/js/rating-star-readonly.js | 8 +- common/static/js/scrape.js | 2 +- common/static/js/sort_layout.js | 6 +- common/static/lib/css/milligram.css | 605 -------- common/static/lib/css/multiple-select.min.css | 10 - common/static/lib/css/neo.css | 166 +++ common/static/lib/js/hyperscript-0.9.5.min.js | 2 + common/static/lib/js/multiple-select.min.js | 10 - common/static/opensearch.xml | 8 + common/static/sass/_AsideSection.sass | 2 +- common/static/sass/_Label.sass | 38 +- common/static/sass/_Modal.sass | 4 +- common/static/sass/_Vendor.sass | 5 +- common/templates/common/error.html | 5 +- .../common/external_search_result.html | 48 + common/templates/common/search_result.html | 418 +----- common/templates/partial/_announcement.html | 61 + common/templates/partial/_common_libs.html | 23 + common/templates/partial/_footer.html | 7 +- common/templates/partial/_navbar.html | 39 +- common/templates/partial/_sidebar.html | 186 +++ common/templates/partial/list_item.html | 9 + common/templates/partial/list_item_book.html | 159 +++ common/templates/partial/list_item_game.html | 139 ++ common/templates/partial/list_item_movie.html | 164 +++ common/templates/partial/list_item_music.html | 171 +++ common/templates/partial/mark_list.html | 37 + common/templatetags/highlight.py | 14 +- common/templatetags/neo.py | 48 + common/templatetags/oauth_token.py | 2 +- common/templatetags/thumb.py | 5 +- common/urls.py | 2 + common/utils.py | 2 + common/views.py | 124 +- doc/GUIDE.md | 106 ++ docker-compose.yml | 32 + docker/entrypoint.sh | 13 + docker/start.sh | 36 + games/admin.py | 3 +- games/apps.py | 5 + games/forms.py | 25 +- games/models.py | 64 +- games/templates/games/create_update.html | 29 +- .../templates/games/create_update_review.html | 12 +- games/templates/games/delete.html | 12 +- games/templates/games/delete_review.html | 14 +- games/templates/games/detail.html | 101 +- games/templates/games/mark_list.html | 42 +- games/templates/games/review_detail.html | 22 +- games/templates/games/review_list.html | 14 +- games/templates/games/scrape.html | 4 +- games/urls.py | 7 +- games/views.py | 137 +- management/models.py | 2 +- .../templates/management/create_update.html | 4 +- management/templates/management/delete.html | 4 +- management/templates/management/detail.html | 4 +- management/templates/management/list.html | 4 +- mastodon/admin.py | 2 +- mastodon/api.py | 373 ++++- mastodon/auth.py | 82 +- mastodon/decorators.py | 2 +- mastodon/management/commands/wrong_sites.py | 21 + mastodon/models.py | 10 +- mastodon/utils.py | 9 +- movies/admin.py | 3 +- movies/apps.py | 5 + movies/forms.py | 22 +- .../management/commands/fix-movie-poster.py | 203 +++ movies/models.py | 108 +- movies/templates/movies/create_update.html | 31 +- .../movies/create_update_review.html | 16 +- movies/templates/movies/delete.html | 12 +- movies/templates/movies/delete_review.html | 14 +- movies/templates/movies/detail.html | 112 +- movies/templates/movies/mark_list.html | 44 +- movies/templates/movies/review_detail.html | 24 +- movies/templates/movies/review_list.html | 19 +- movies/templates/movies/scrape.html | 4 +- movies/urls.py | 6 +- movies/views.py | 137 +- music/admin.py | 5 +- music/apps.py | 6 + music/forms.py | 33 +- music/management/commands/fix-album-cover.py | 199 +++ music/models.py | 117 +- music/templates/music/album_detail.html | 89 +- music/templates/music/album_mark_list.html | 45 +- .../templates/music/album_review_detail.html | 24 +- music/templates/music/album_review_list.html | 16 +- .../templates/music/create_update_album.html | 29 +- .../music/create_update_album_review.html | 14 +- music/templates/music/create_update_song.html | 10 +- .../music/create_update_song_review.html | 14 +- music/templates/music/delete_album.html | 12 +- .../templates/music/delete_album_review.html | 14 +- music/templates/music/delete_song.html | 12 +- music/templates/music/delete_song_review.html | 14 +- music/templates/music/scrape_album.html | 4 +- music/templates/music/scrape_song.html | 4 +- music/templates/music/song_detail.html | 89 +- music/templates/music/song_mark_list.html | 52 +- music/templates/music/song_review_detail.html | 24 +- music/templates/music/song_review_list.html | 16 +- music/urls.py | 13 +- music/views.py | 250 ++-- requirements.txt | 26 + sync/apps.py | 5 +- sync/jobs.py | 158 +-- sync/management/commands/resync.py | 91 ++ sync/models.py | 8 +- sync/views.py | 9 +- timeline/__init__.py | 0 timeline/admin.py | 3 + timeline/apps.py | 15 + .../management/commands/regen_activity.py | 20 + timeline/models.py | 63 + timeline/templates/timeline.html | 83 ++ timeline/templates/timeline_data.html | 124 ++ timeline/tests.py | 3 + timeline/urls.py | 9 + timeline/views.py | 71 + users/account.py | 255 ++++ users/data.py | 142 ++ .../management/commands/backfill_mastodon.py | 21 + users/management/commands/disable_user.py | 19 + .../management/commands/refresh_following.py | 19 + users/management/commands/refresh_mastodon.py | 25 + users/models.py | 145 +- users/static/js/followers_list.js | 19 +- users/static/js/following_list.js | 12 +- users/static/lib/js/js.cookie.min.js | 2 - users/tasks.py | 146 ++ users/templates/users/book_list.html | 282 ---- users/templates/users/data.html | 337 +++++ users/templates/users/game_list.html | 272 ---- users/templates/users/home.html | 668 ++++----- users/templates/users/home_anonymous.html | 17 + users/templates/users/item_list.html | 94 ++ users/templates/users/login.html | 108 +- users/templates/users/manage_report.html | 14 +- users/templates/users/movie_list.html | 285 ---- users/templates/users/music_list.html | 290 ---- users/templates/users/preferences.html | 93 ++ users/templates/users/register.html | 21 +- users/templates/users/relation_list.html | 18 +- users/templates/users/report.html | 10 +- users/templates/users/tags.html | 110 ++ users/urls.py | 32 +- users/views.py | 693 ++++----- 228 files changed, 12218 insertions(+), 6514 deletions(-) create mode 100644 .github/workflows/codeql-analysis.yml create mode 100644 .github/workflows/pysa.yml create mode 100644 Dockerfile create mode 100644 SECURITY.md create mode 100644 boofilsic/context_processors.py create mode 100644 books/management/commands/fix-book-cover.py create mode 100644 collection/__init__.py create mode 100644 collection/admin.py create mode 100644 collection/apps.py create mode 100644 collection/forms.py create mode 100644 collection/models.py create mode 100644 collection/templates/add_to_list.html create mode 100644 collection/templates/create_update.html create mode 100644 collection/templates/delete.html create mode 100644 collection/templates/detail.html create mode 100644 collection/templates/edit_item_comment.html create mode 100644 collection/templates/entity_list.html create mode 100644 collection/templates/list.html create mode 100644 collection/templates/share_collection.html create mode 100644 collection/templates/show_item_comment.html create mode 100644 collection/tests.py create mode 100644 collection/urls.py create mode 100644 collection/views.py create mode 100644 common/importers/douban.py create mode 100644 common/importers/goodreads.py create mode 100644 common/index.py create mode 100644 common/management/commands/delete_job.py create mode 100644 common/management/commands/index_stats.py create mode 100644 common/management/commands/init_index.py create mode 100644 common/management/commands/list_jobs.py create mode 100644 common/management/commands/reindex.py create mode 100644 common/management/commands/restart_sync.py create mode 100644 common/management/commands/scrape.py create mode 100644 common/scrapers/bandcamp.py create mode 100644 common/scrapers/bangumi.py create mode 100644 common/scrapers/douban.py create mode 100644 common/scrapers/goodreads.py create mode 100644 common/scrapers/google.py create mode 100644 common/scrapers/igdb.py create mode 100644 common/scrapers/imdb.py create mode 100644 common/scrapers/spotify.py create mode 100644 common/scrapers/steam.py create mode 100644 common/scrapers/tmdb.py create mode 100644 common/search/meilisearch.py create mode 100644 common/search/typesense.py create mode 100644 common/searcher.py create mode 100644 common/static/img/fediverse.svg delete mode 100644 common/static/lib/css/milligram.css delete mode 100644 common/static/lib/css/multiple-select.min.css create mode 100644 common/static/lib/css/neo.css create mode 100644 common/static/lib/js/hyperscript-0.9.5.min.js delete mode 100644 common/static/lib/js/multiple-select.min.js create mode 100644 common/static/opensearch.xml create mode 100644 common/templates/common/external_search_result.html create mode 100644 common/templates/partial/_announcement.html create mode 100644 common/templates/partial/_common_libs.html create mode 100644 common/templates/partial/_sidebar.html create mode 100644 common/templates/partial/list_item.html create mode 100644 common/templates/partial/list_item_book.html create mode 100644 common/templates/partial/list_item_game.html create mode 100644 common/templates/partial/list_item_movie.html create mode 100644 common/templates/partial/list_item_music.html create mode 100644 common/templates/partial/mark_list.html create mode 100644 common/templatetags/neo.py create mode 100644 doc/GUIDE.md create mode 100644 docker-compose.yml create mode 100755 docker/entrypoint.sh create mode 100755 docker/start.sh create mode 100644 mastodon/management/commands/wrong_sites.py create mode 100644 movies/management/commands/fix-movie-poster.py create mode 100644 music/management/commands/fix-album-cover.py create mode 100644 requirements.txt create mode 100644 sync/management/commands/resync.py create mode 100644 timeline/__init__.py create mode 100644 timeline/admin.py create mode 100644 timeline/apps.py create mode 100644 timeline/management/commands/regen_activity.py create mode 100644 timeline/models.py create mode 100644 timeline/templates/timeline.html create mode 100644 timeline/templates/timeline_data.html create mode 100644 timeline/tests.py create mode 100644 timeline/urls.py create mode 100644 timeline/views.py create mode 100644 users/account.py create mode 100644 users/data.py create mode 100644 users/management/commands/backfill_mastodon.py create mode 100644 users/management/commands/disable_user.py create mode 100644 users/management/commands/refresh_following.py create mode 100644 users/management/commands/refresh_mastodon.py delete mode 100644 users/static/lib/js/js.cookie.min.js create mode 100644 users/tasks.py delete mode 100644 users/templates/users/book_list.html create mode 100644 users/templates/users/data.html delete mode 100644 users/templates/users/game_list.html create mode 100644 users/templates/users/home_anonymous.html create mode 100644 users/templates/users/item_list.html delete mode 100644 users/templates/users/movie_list.html delete mode 100644 users/templates/users/music_list.html create mode 100644 users/templates/users/preferences.html create mode 100644 users/templates/users/tags.html diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml new file mode 100644 index 00000000..514df728 --- /dev/null +++ b/.github/workflows/codeql-analysis.yml @@ -0,0 +1,74 @@ +# For most projects, this workflow file will not need changing; you simply need +# to commit it to your repository. +# +# You may wish to alter this file to override the set of languages analyzed, +# or to provide custom queries or build logic. +# +# ******** NOTE ******** +# We have attempted to detect the languages in your repository. Please check +# the `language` matrix defined below to confirm you have the correct set of +# supported CodeQL languages. +# +name: "CodeQL" + +on: + push: + branches: [ "neo" ] + pull_request: + # The branches below must be a subset of the branches above + branches: [ "neo" ] + schedule: + - cron: '35 0 * * 0' + +jobs: + analyze: + name: Analyze + runs-on: ubuntu-latest + permissions: + actions: read + contents: read + security-events: write + + strategy: + fail-fast: false + matrix: + language: [ 'javascript', 'python' ] + # CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python', 'ruby' ] + # Learn more about CodeQL language support at https://aka.ms/codeql-docs/language-support + + steps: + - name: Checkout repository + uses: actions/checkout@v3 + + # Initializes the CodeQL tools for scanning. + - name: Initialize CodeQL + uses: github/codeql-action/init@v2 + with: + languages: ${{ matrix.language }} + # If you wish to specify custom queries, you can do so here or in a config file. + # By default, queries listed here will override any specified in a config file. + # Prefix the list here with "+" to use these queries and those in the config file. + + # Details on CodeQL's query packs refer to : https://docs.github.com/en/code-security/code-scanning/automatically-scanning-your-code-for-vulnerabilities-and-errors/configuring-code-scanning#using-queries-in-ql-packs + # queries: security-extended,security-and-quality + + + # Autobuild attempts to build any compiled languages (C/C++, C#, or Java). + # If this step fails, then you should remove it and run the build manually (see below) + - name: Autobuild + uses: github/codeql-action/autobuild@v2 + + # ℹ️ Command-line programs to run using the OS shell. + # 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun + + # If the Autobuild fails above, remove it and uncomment the following three lines. + # modify them (or add more) to build your code if your project, please refer to the EXAMPLE below for guidance. + + # - run: | + # echo "Run, Build Application using script" + # ./location_of_script_within_repo/buildscript.sh + + - name: Perform CodeQL Analysis + uses: github/codeql-action/analyze@v2 + with: + category: "/language:${{matrix.language}}" diff --git a/.github/workflows/pysa.yml b/.github/workflows/pysa.yml new file mode 100644 index 00000000..e4e20af3 --- /dev/null +++ b/.github/workflows/pysa.yml @@ -0,0 +1,50 @@ +# This workflow uses actions that are not certified by GitHub. +# They are provided by a third-party and are governed by +# separate terms of service, privacy policy, and support +# documentation. + +# This workflow integrates Python Static Analyzer (Pysa) with +# GitHub's Code Scanning feature. +# +# Python Static Analyzer (Pysa) is a security-focused static +# analysis tool that tracks flows of data from where they +# originate to where they terminate in a dangerous location. +# +# See https://pyre-check.org/docs/pysa-basics/ + +name: Pysa + +on: + workflow_dispatch: + push: + branches: [ "neo" ] + pull_request: + branches: [ "neo" ] + schedule: + - cron: '45 12 * * 4' + +permissions: + contents: read + +jobs: + pysa: + permissions: + actions: read + contents: read + security-events: write + + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + with: + submodules: true + + - name: Run Pysa + uses: facebook/pysa-action@f46a63777e59268613bd6e2ff4e29f144ca9e88b + with: + # To customize these inputs: + # See https://github.com/facebook/pysa-action#inputs + repo-directory: './' + requirements-path: 'requirements.txt' + infer-types: true + include-default-sapp-filters: true diff --git a/.gitignore b/.gitignore index 086f9d04..d1edae82 100644 --- a/.gitignore +++ b/.gitignore @@ -24,4 +24,7 @@ migrations/ # debug log file /log -log \ No newline at end of file +log + +# conf folder for neodb +/neodb diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 00000000..cc9bf6e8 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,23 @@ +# syntax=docker/dockerfile:1 +FROM python:3.8-slim +ENV PYTHONDONTWRITEBYTECODE=1 +ENV PYTHONUNBUFFERED=1 +RUN apt-get update \ + && apt-get install -y --no-install-recommends build-essential libpq-dev git \ + && rm -rf /var/lib/apt/lists/* +COPY requirements.txt /tmp/requirements.txt +RUN pip install --no-cache-dir -r /tmp/requirements.txt \ + && rm -rf /tmp/requirements.txt \ + && useradd -U app_user \ + && install -d -m 0755 -o app_user -g app_user /app/static + +ENV DJANGO_SETTINGS_MODULE=neodb.dev +WORKDIR /app +USER app_user:app_user +COPY --chown=app_user:app_user . . +RUN chmod +x docker/*.sh + +# Section 6- Docker Run Checks and Configurations +ENTRYPOINT [ "docker/entrypoint.sh" ] + +CMD [ "docker/start.sh", "server" ] \ No newline at end of file diff --git a/README.md b/README.md index 2cfde8ea..d9f46dbc 100644 --- a/README.md +++ b/README.md @@ -3,6 +3,13 @@ An application allows you to mark any books, movies and more things you love. Depends on Mastodon. +## Install +Please see [doc/GUIDE.md](doc/GUIDE.md) + +## Bug Report + - to file a bug for NiceDB, please create an issue [here](https://github.com/doubaniux/boofilsic/issues/new) + - to file a bug or request new features for NeoDB, please contact NeoDB on [Fediverse](https://mastodon.social/@neodb) or [Twitter](https://twitter.com/NeoDBsocial) + ## Contribution The project is based on Django. If you are familiar with this technique and willing to read through the terrible code😝, your contribution would be the most welcome! @@ -11,8 +18,6 @@ Currently looking for someone to help with: - Explaining the structure of code - Refactoring (this is something big) -This project is still in its early stage, so you are not encouraged to deploy it on your own. If you do want to give it a try, please check the [fork of *alphatownsman*](https://github.com/alphatownsman/boofilsic), which is more friendly. - ## Sponsor -If you like this project, please consider sponsoring us on [Patreon](https://patreon.com/tertius). +If you like this project, please consider sponsoring NiceDB on [Patreon](https://patreon.com/tertius). diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 00000000..cafb3b8b --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,5 @@ +# Security Policy + +## Reporting a Vulnerability + +Please DM [us on Fediverse](https://mastodon.social/@neodb) or send email to `dev`@`neodb.social` to report a vulnerability. Please do not post publicly or create pr/issues directly. Thank you. diff --git a/boofilsic/context_processors.py b/boofilsic/context_processors.py new file mode 100644 index 00000000..6fd333b3 --- /dev/null +++ b/boofilsic/context_processors.py @@ -0,0 +1,5 @@ +from django.conf import settings + + +def site_info(request): + return settings.SITE_INFO diff --git a/boofilsic/settings.py b/boofilsic/settings.py index 86d4abf2..6393f4a0 100644 --- a/boofilsic/settings.py +++ b/boofilsic/settings.py @@ -12,10 +12,13 @@ https://docs.djangoproject.com/en/3.0/ref/settings/ import os import psycopg2.extensions +from git import Repo # Build paths inside the project like this: os.path.join(BASE_DIR, ...) BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +# https://docs.djangoproject.com/en/3.2/releases/3.2/#customizing-type-of-auto-created-primary-keys +DEFAULT_AUTO_FIELD = 'django.db.models.AutoField' # Quick-start development settings - unsuitable for production # See https://docs.djangoproject.com/en/3.0/howto/deployment/checklist/ @@ -38,6 +41,8 @@ INTERNAL_IPS = [ INSTALLED_APPS = [ 'django.contrib.admin', + 'hijack', + 'hijack.contrib.admin', 'django.contrib.auth', 'django.contrib.contenttypes', 'django.contrib.sessions', @@ -45,6 +50,9 @@ INSTALLED_APPS = [ 'django.contrib.staticfiles', 'django.contrib.humanize', 'django.contrib.postgres', + 'django_sass', + 'django_rq', + 'simple_history', 'markdownx', 'management.apps.ManagementConfig', 'mastodon.apps.MastodonConfig', @@ -54,7 +62,12 @@ INSTALLED_APPS = [ 'movies.apps.MoviesConfig', 'music.apps.MusicConfig', 'games.apps.GamesConfig', + 'sync.apps.SyncConfig', + 'collection.apps.CollectionConfig', + 'timeline.apps.TimelineConfig', 'easy_thumbnails', + 'user_messages', + 'django_slack', ] MIDDLEWARE = [ @@ -65,6 +78,8 @@ MIDDLEWARE = [ 'django.contrib.auth.middleware.AuthenticationMiddleware', 'django.contrib.messages.middleware.MessageMiddleware', 'django.middleware.clickjacking.XFrameOptionsMiddleware', + 'hijack.middleware.HijackUserMiddleware', + 'simple_history.middleware.HistoryRequestMiddleware', ] ROOT_URLCONF = 'boofilsic.urls' @@ -79,7 +94,9 @@ TEMPLATES = [ 'django.template.context_processors.debug', 'django.template.context_processors.request', 'django.contrib.auth.context_processors.auth', - 'django.contrib.messages.context_processors.messages', + # 'django.contrib.messages.context_processors.messages', + "user_messages.context_processors.messages", + 'boofilsic.context_processors.site_info', ], }, }, @@ -95,10 +112,10 @@ if DEBUG: DATABASES = { 'default': { 'ENGINE': 'django.db.backends.postgresql', - 'NAME': 'test', - 'USER': 'donotban', - 'PASSWORD': 'donotbansilvousplait', - 'HOST': '172.18.116.29', + 'NAME': os.environ.get('DB_NAME', 'test'), + 'USER': os.environ.get('DB_USER', 'donotban'), + 'PASSWORD': os.environ.get('DB_PASSWORD', 'donotbansilvousplait'), + 'HOST': os.environ.get('DB_HOST', '172.18.116.29'), 'OPTIONS': { 'client_encoding': 'UTF8', # 'isolation_level': psycopg2.extensions.ISOLATION_LEVEL_DEFAULT, @@ -184,13 +201,29 @@ STATICFILES_STORAGE = 'django.contrib.staticfiles.storage.ManifestStaticFilesSto AUTH_USER_MODEL = 'users.User' +SILENCED_SYSTEM_CHECKS = [ + "auth.W004", # User.username is non-unique + "admin.E404" # Required by django-user-messages +] + MEDIA_URL = '/media/' MEDIA_ROOT = os.path.join(BASE_DIR, 'media/') +PROJECT_ROOT = os.path.abspath(os.path.dirname(__name__)) +SITE_INFO = { + 'site_name': 'NiceDB', + 'support_link': 'https://github.com/doubaniux/boofilsic/issues', + 'version_hash': None, + 'settings_module': os.getenv('DJANGO_SETTINGS_MODULE'), + 'sentry_dsn': None, +} + # Mastodon configs -CLIENT_NAME = 'NiceDB' -APP_WEBSITE = 'https://nicedb.org' -REDIRECT_URIS = "https://nicedb.org/users/OAuth2_login/\nhttps://www.nicedb.org/users/OAuth2_login/" +CLIENT_NAME = os.environ.get('APP_NAME', 'NiceDB') +SITE_INFO['site_name'] = os.environ.get('APP_NAME', 'NiceDB') +APP_WEBSITE = os.environ.get('APP_URL', 'https://nicedb.org') +REDIRECT_URIS = APP_WEBSITE + "/users/OAuth2_login/" + # Path to save report related images, ends with slash REPORT_MEDIA_PATH_ROOT = 'report/' @@ -205,10 +238,23 @@ ALBUM_MEDIA_PATH_ROOT = 'album/' DEFAULT_ALBUM_IMAGE = os.path.join(ALBUM_MEDIA_PATH_ROOT, 'default.svg') GAME_MEDIA_PATH_ROOT = 'game/' DEFAULT_GAME_IMAGE = os.path.join(GAME_MEDIA_PATH_ROOT, 'default.svg') +COLLECTION_MEDIA_PATH_ROOT = 'collection/' +DEFAULT_COLLECTION_IMAGE = os.path.join(COLLECTION_MEDIA_PATH_ROOT, 'default.svg') +SYNC_FILE_PATH_ROOT = 'sync/' +EXPORT_FILE_PATH_ROOT = 'export/' + +# Allow user to login via any Mastodon/Pleroma sites +MASTODON_ALLOW_ANY_SITE = False # Timeout of requests to Mastodon, in seconds MASTODON_TIMEOUT = 30 +MASTODON_CLIENT_SCOPE = 'read write follow' +#use the following if it's a new site +#MASTODON_CLIENT_SCOPE = 'read:accounts read:follows read:search read:blocks read:mutes write:statuses write:media' + +MASTODON_LEGACY_CLIENT_SCOPE = 'read write follow' + # Tags for toots posted from this site MASTODON_TAGS = '#NiceDB #NiceDB%(category)s #NiceDB%(category)s%(type)s' @@ -217,7 +263,7 @@ STAR_SOLID = ':star_solid:' STAR_HALF = ':star_half:' STAR_EMPTY = ':star_empty:' -# Default password for each user. since assword is not used any way, +# Default password for each user. since password is not used any way, # any string that is not empty is ok DEFAULT_PASSWORD = 'ab7nsm8didusbaqPgq' @@ -231,8 +277,12 @@ ADMIN_URL = 'tertqX7256n7ej8nbv5cwvsegdse6w7ne5rHd' LUMINATI_USERNAME = 'lum-customer-hl_nw4tbv78-zone-static' LUMINATI_PASSWORD = 'nsb7te9bw0ney' +SCRAPING_TIMEOUT = 90 + # ScraperAPI api key SCRAPERAPI_KEY = 'wnb3794v675b8w475h0e8hr7tyge' +PROXYCRAWL_KEY = None +SCRAPESTACK_KEY = None # Spotify credentials SPOTIFY_CREDENTIAL = "NzYzNkYTE6MGQ0ODY0NTY2Y2b3n645sdfgAyY2I1ljYjg3Nzc0MjIwODQ0ZWE=" @@ -240,6 +290,17 @@ SPOTIFY_CREDENTIAL = "NzYzNkYTE6MGQ0ODY0NTY2Y2b3n645sdfgAyY2I1ljYjg3Nzc0MjIwODQ0 # IMDb API service https://imdb-api.com/ IMDB_API_KEY = "k23fwewff23" +# The Movie Database (TMDB) API Keys +TMDB_API3_KEY = "deadbeef" +TMDB_API4_KEY = "deadbeef.deadbeef.deadbeef" + +# Google Books API Key +GOOGLE_API_KEY = 'deadbeef-deadbeef-deadbeef' + +# IGDB +IGDB_CLIENT_ID = 'deadbeef' +IGDB_ACCESS_TOKEN = 'deadbeef' + # Thumbnail setting # It is possible to optimize the image size even more: https://easy-thumbnails.readthedocs.io/en/latest/ref/optimize/ THUMBNAIL_ALIASES = { @@ -257,3 +318,47 @@ if DEBUG: # https://django-debug-toolbar.readthedocs.io/en/latest/ # maybe benchmarking before deployment + +REDIS_HOST = os.environ.get('REDIS_HOST', '127.0.0.1') + +RQ_QUEUES = { + 'mastodon': { + 'HOST': REDIS_HOST, + 'PORT': 6379, + 'DB': 0, + 'DEFAULT_TIMEOUT': -1, + }, + 'export': { + 'HOST': REDIS_HOST, + 'PORT': 6379, + 'DB': 0, + 'DEFAULT_TIMEOUT': -1, + }, + 'doufen': { + 'HOST': REDIS_HOST, + 'PORT': 6379, + 'DB': 0, + 'DEFAULT_TIMEOUT': -1, + } +} + +RQ_SHOW_ADMIN_LINK = True + +SEARCH_INDEX_NEW_ONLY = False + +SEARCH_BACKEND = None + +# SEARCH_BACKEND = 'MEILISEARCH' +# MEILISEARCH_SERVER = 'http://127.0.0.1:7700' +# MEILISEARCH_KEY = 'deadbeef' + +# SEARCH_BACKEND = 'TYPESENSE' +# TYPESENSE_CONNECTION = { +# 'api_key': 'deadbeef', +# 'nodes': [{ +# 'host': 'localhost', +# 'port': '8108', +# 'protocol': 'http' +# }], +# 'connection_timeout_seconds': 2 +# } diff --git a/boofilsic/urls.py b/boofilsic/urls.py index dd52087a..38d74a5a 100644 --- a/boofilsic/urls.py +++ b/boofilsic/urls.py @@ -27,10 +27,16 @@ urlpatterns = [ path('movies/', include('movies.urls')), path('music/', include('music.urls')), path('games/', include('games.urls')), + path('collections/', include('collection.urls')), + path('timeline/', include('timeline.urls')), path('sync/', include('sync.urls')), path('announcement/', include('management.urls')), + path('hijack/', include('hijack.urls')), path('', include('common.urls')), +] +urlpatterns += [ + path(settings.ADMIN_URL + '-rq/', include('django_rq.urls')) ] if settings.DEBUG: diff --git a/books/admin.py b/books/admin.py index 942dccb4..75df663b 100644 --- a/books/admin.py +++ b/books/admin.py @@ -1,7 +1,8 @@ from django.contrib import admin from .models import * +from simple_history.admin import SimpleHistoryAdmin -admin.site.register(Book) +admin.site.register(Book, SimpleHistoryAdmin) admin.site.register(BookMark) admin.site.register(BookReview) admin.site.register(BookTag) diff --git a/books/apps.py b/books/apps.py index f716137a..b03e2d23 100644 --- a/books/apps.py +++ b/books/apps.py @@ -3,3 +3,8 @@ from django.apps import AppConfig class BooksConfig(AppConfig): name = 'books' + + def ready(self): + from common.index import Indexer + from .models import Book + Indexer.update_model_indexable(Book) diff --git a/books/forms.py b/books/forms.py index da7ecee6..27abda07 100644 --- a/books/forms.py +++ b/books/forms.py @@ -1,17 +1,12 @@ from django import forms from django.utils.translation import gettext_lazy as _ -from .models import Book, BookMark, BookReview +from .models import Book, BookMark, BookReview, BookMarkStatusTranslation from common.models import MarkStatusEnum from common.forms import * def BookMarkStatusTranslator(status): - trans_dict = { - MarkStatusEnum.DO.value: _("在读"), - MarkStatusEnum.WISH.value: _("想读"), - MarkStatusEnum.COLLECT.value: _("读过") - } - return trans_dict[status] + return BookMarkStatusTranslation[status] class BookForm(forms.ModelForm): @@ -96,11 +91,8 @@ class BookMarkForm(MarkForm): 'status', 'rating', 'text', - 'is_private', - ] - labels = { - 'rating': _("评分"), - } + 'visibility', + ] widgets = { 'book': forms.TextInput(attrs={"hidden": ""}), } @@ -115,14 +107,8 @@ class BookReviewForm(ReviewForm): 'book', 'title', 'content', - 'is_private' + 'visibility' ] - labels = { - 'book': "", - 'title': _("标题"), - 'content': _("正文"), - 'share_to_mastodon': _("分享到长毛象") - } widgets = { 'book': forms.TextInput(attrs={"hidden": ""}), } diff --git a/books/management/commands/fix-book-cover.py b/books/management/commands/fix-book-cover.py new file mode 100644 index 00000000..ae9227b5 --- /dev/null +++ b/books/management/commands/fix-book-cover.py @@ -0,0 +1,200 @@ +from django.core.management.base import BaseCommand +from django.core.files.uploadedfile import SimpleUploadedFile +from django.conf import settings +from common.scraper import * +from books.models import Book +from books.forms import BookForm +import requests +import re +import filetype +from lxml import html +from PIL import Image +from io import BytesIO + + +class DoubanPatcherMixin: + @classmethod + def download_page(cls, url, headers): + url = cls.get_effective_url(url) + r = None + error = 'DoubanScrapper: error occured when downloading ' + url + content = None + + def get(url, timeout): + nonlocal r + # print('Douban GET ' + url) + try: + r = requests.get(url, timeout=timeout) + except Exception as e: + r = requests.Response() + r.status_code = f"Exception when GET {url} {e}" + url + # print('Douban CODE ' + str(r.status_code)) + return r + + def check_content(): + nonlocal r, error, content + content = None + if r.status_code == 200: + content = r.content.decode('utf-8') + if content.find('关于豆瓣') == -1: + # with open('/tmp/temp.html', 'w', encoding='utf-8') as fp: + # fp.write(content) + content = None + error = error + 'Content not authentic' # response is garbage + elif re.search('不存在[^<]+</title>', content, re.MULTILINE): + content = None + error = error + 'Not found or hidden by Douban' + else: + error = error + str(r.status_code) + + def fix_wayback_links(): + nonlocal content + # fix links + content = re.sub(r'href="http[^"]+http', r'href="http', content) + # https://img9.doubanio.com/view/subject/{l|m|s}/public/s1234.jpg + content = re.sub(r'src="[^"]+/(s\d+\.\w+)"', + r'src="https://img9.doubanio.com/view/subject/m/public/\1"', content) + # https://img9.doubanio.com/view/photo/s_ratio_poster/public/p2681329386.jpg + # https://img9.doubanio.com/view/photo/{l|m|s}/public/p1234.webp + content = re.sub(r'src="[^"]+/(p\d+\.\w+)"', + r'src="https://img9.doubanio.com/view/photo/m/public/\1"', content) + + # Wayback Machine: get latest available + def wayback(): + nonlocal r, error, content + error = error + '\nWayback: ' + get('http://archive.org/wayback/available?url=' + url, 10) + if r.status_code == 200: + w = r.json() + if w['archived_snapshots'] and w['archived_snapshots']['closest']: + get(w['archived_snapshots']['closest']['url'], 10) + check_content() + if content is not None: + fix_wayback_links() + else: + error = error + 'No snapshot available' + else: + error = error + str(r.status_code) + + # Wayback Machine: guess via CDX API + def wayback_cdx(): + nonlocal r, error, content + error = error + '\nWayback: ' + get('http://web.archive.org/cdx/search/cdx?url=' + url, 10) + if r.status_code == 200: + dates = re.findall(r'[^\s]+\s+(\d+)\s+[^\s]+\s+[^\s]+\s+\d+\s+[^\s]+\s+\d{5,}', + r.content.decode('utf-8')) + # assume snapshots whose size >9999 contain real content, use the latest one of them + if len(dates) > 0: + get('http://web.archive.org/web/' + dates[-1] + '/' + url, 10) + check_content() + if content is not None: + fix_wayback_links() + else: + error = error + 'No snapshot available' + else: + error = error + str(r.status_code) + + def latest(): + nonlocal r, error, content + if settings.SCRAPESTACK_KEY is None: + error = error + '\nDirect: ' + get(url, 60) + else: + error = error + '\nScrapeStack: ' + get(f'http://api.scrapestack.com/scrape?access_key={settings.SCRAPESTACK_KEY}&url={url}', 60) + check_content() + + wayback_cdx() + if content is None: + latest() + + if content is None: + logger.error(error) + content = '<html />' + return html.fromstring(content) + + @classmethod + def download_image(cls, url, item_url=None): + if url is None: + logger.error(f"Douban: no image url for {item_url}") + return None, None + raw_img = None + ext = None + + dl_url = url + if settings.SCRAPESTACK_KEY is not None: + dl_url = f'http://api.scrapestack.com/scrape?access_key={settings.SCRAPESTACK_KEY}&url={url}' + + try: + img_response = requests.get(dl_url, timeout=90) + if img_response.status_code == 200: + raw_img = img_response.content + img = Image.open(BytesIO(raw_img)) + img.load() # corrupted image will trigger exception + content_type = img_response.headers.get('Content-Type') + ext = filetype.get_type(mime=content_type.partition(';')[0].strip()).extension + else: + logger.error(f"Douban: download image failed {img_response.status_code} {dl_url} {item_url}") + # raise RuntimeError(f"Douban: download image failed {img_response.status_code} {dl_url}") + except Exception as e: + raw_img = None + ext = None + logger.error(f"Douban: download image failed {e} {dl_url} {item_url}") + if raw_img is None and settings.SCRAPESTACK_KEY is not None: + try: + img_response = requests.get(dl_url, timeout=90) + if img_response.status_code == 200: + raw_img = img_response.content + img = Image.open(BytesIO(raw_img)) + img.load() # corrupted image will trigger exception + content_type = img_response.headers.get('Content-Type') + ext = filetype.get_type(mime=content_type.partition(';')[0].strip()).extension + else: + logger.error(f"Douban: download image failed {img_response.status_code} {dl_url} {item_url}") + except Exception as e: + raw_img = None + ext = None + logger.error(f"Douban: download image failed {e} {dl_url} {item_url}") + return raw_img, ext + + +class DoubanBookPatcher(DoubanPatcherMixin, AbstractScraper): + site_name = SourceSiteEnum.DOUBAN.value + host = 'book.douban.com' + data_class = Book + form_class = BookForm + + regex = re.compile(r"https://book\.douban\.com/subject/\d+/{0,1}") + + def scrape(self, url): + headers = DEFAULT_REQUEST_HEADERS.copy() + headers['Host'] = self.host + content = self.download_page(url, headers) + img_url_elem = content.xpath("//*[@id='mainpic']/a/img/@src") + img_url = img_url_elem[0].strip() if img_url_elem else None + raw_img, ext = self.download_image(img_url, url) + return raw_img, ext + + +class Command(BaseCommand): + help = 'fix cover image' + + def add_arguments(self, parser): + parser.add_argument('threadId', type=int, help='% 8') + + def handle(self, *args, **options): + t = int(options['threadId']) + for m in Book.objects.filter(cover='book/default.svg', source_site='douban'): + if m.id % 8 == t: + self.stdout.write(f'Re-fetching {m.source_url}') + try: + raw_img, img_ext = DoubanBookPatcher.scrape(m.source_url) + if img_ext is not None: + m.cover = SimpleUploadedFile('temp.' + img_ext, raw_img) + m.save() + self.stdout.write(self.style.SUCCESS(f'Saved {m.source_url}')) + else: + self.stdout.write(self.style.ERROR(f'Skipped {m.source_url}')) + except Exception as e: + print(e) diff --git a/books/models.py b/books/models.py index 4f898709..8b23e9a6 100644 --- a/books/models.py +++ b/books/models.py @@ -1,98 +1,184 @@ -import uuid import django.contrib.postgres.fields as postgres -from django.utils.translation import ugettext_lazy as _ +from django.utils.translation import gettext_lazy as _ from django.db import models -from django.core.serializers.json import DjangoJSONEncoder from django.shortcuts import reverse -from common.models import Entity, Mark, Review, Tag +from common.models import Entity, Mark, Review, Tag, MarkStatusEnum from common.utils import GenerateDateUUIDMediaFilePath -from boofilsic.settings import BOOK_MEDIA_PATH_ROOT, DEFAULT_BOOK_IMAGE -from django.utils import timezone +from django.conf import settings +from django.db.models import Q +from simple_history.models import HistoricalRecords + + +BookMarkStatusTranslation = { + MarkStatusEnum.DO.value: _("在读"), + MarkStatusEnum.WISH.value: _("想读"), + MarkStatusEnum.COLLECT.value: _("读过") +} def book_cover_path(instance, filename): - return GenerateDateUUIDMediaFilePath(instance, filename, BOOK_MEDIA_PATH_ROOT) + return GenerateDateUUIDMediaFilePath(instance, filename, settings.BOOK_MEDIA_PATH_ROOT) class Book(Entity): # widely recognized name, usually in Chinese - title = models.CharField(_("title"), max_length=200) - subtitle = models.CharField(_("subtitle"), blank=True, default='', max_length=200) + title = models.CharField(_("title"), max_length=500) + subtitle = models.CharField( + _("subtitle"), blank=True, default='', max_length=500) # original name, for books in foreign language - orig_title = models.CharField(_("original title"), blank=True, default='', max_length=200) + orig_title = models.CharField( + _("original title"), blank=True, default='', max_length=500) author = postgres.ArrayField( - models.CharField(_("author"), blank=True, default='', max_length=100), + models.CharField(_("author"), blank=True, default='', max_length=200), null=True, blank=True, default=list, ) translator = postgres.ArrayField( - models.CharField(_("translator"), blank=True, default='', max_length=100), + models.CharField(_("translator"), blank=True, + default='', max_length=200), null=True, blank=True, default=list, ) - language = models.CharField(_("language"), blank=True, default='', max_length=10) - pub_house = models.CharField(_("publishing house"), blank=True, default='', max_length=200) + language = models.CharField( + _("language"), blank=True, default='', max_length=50) + pub_house = models.CharField( + _("publishing house"), blank=True, default='', max_length=200) pub_year = models.IntegerField(_("published year"), null=True, blank=True) - pub_month = models.IntegerField(_("published month"), null=True, blank=True) - binding = models.CharField(_("binding"), blank=True, default='', max_length=50) + pub_month = models.IntegerField( + _("published month"), null=True, blank=True) + binding = models.CharField( + _("binding"), blank=True, default='', max_length=200) # since data origin is not formatted and might be CNY USD or other currency, use char instead - price = models.CharField(_("pricing"), blank=True, default='', max_length=50) + price = models.CharField(_("pricing"), blank=True, + default='', max_length=50) pages = models.PositiveIntegerField(_("pages"), null=True, blank=True) - isbn = models.CharField(_("ISBN"), blank=True, null=False, max_length=20, db_index=True, default='') - # to store previously scrapped data - cover = models.ImageField(_("cover picture"), upload_to=book_cover_path, default=DEFAULT_BOOK_IMAGE, blank=True) + isbn = models.CharField(_("ISBN"), blank=True, null=False, + max_length=20, db_index=True, default='') + # to store previously scrapped data + cover = models.ImageField(_("cover picture"), upload_to=book_cover_path, + default=settings.DEFAULT_BOOK_IMAGE, blank=True) contents = models.TextField(blank=True, default="") + history = HistoricalRecords() class Meta: - # more info: https://docs.djangoproject.com/en/2.2/ref/models/options/ - # set managed=False if the model represents an existing table or - # a database view that has been created by some other means. - # check the link above for further info - # managed = True - # db_table = 'book' constraints = [ - models.CheckConstraint(check=models.Q(pub_year__gte=0), name='pub_year_lowerbound'), - models.CheckConstraint(check=models.Q(pub_month__lte=12), name='pub_month_upperbound'), - models.CheckConstraint(check=models.Q(pub_month__gte=1), name='pub_month_lowerbound'), + models.CheckConstraint(check=models.Q( + pub_year__gte=0), name='pub_year_lowerbound'), + models.CheckConstraint(check=models.Q( + pub_month__lte=12), name='pub_month_upperbound'), + models.CheckConstraint(check=models.Q( + pub_month__gte=1), name='pub_month_lowerbound'), ] def __str__(self): return self.title - + + def get_json(self): + r = { + 'subtitle': self.subtitle, + 'original_title': self.orig_title, + 'author': self.author, + 'translator': self.translator, + 'publisher': self.pub_house, + 'publish_year': self.pub_year, + 'publish_month': self.pub_month, + 'language': self.language, + 'isbn': self.isbn, + } + r.update(super().get_json()) + return r + def get_absolute_url(self): return reverse("books:retrieve", args=[self.id]) + @property + def wish_url(self): + return reverse("books:wish", args=[self.id]) + def get_tags_manager(self): return self.book_tags + def get_related_books(self): + qs = Q(orig_title=self.title) + if self.isbn: + qs = qs | Q(isbn=self.isbn) + if self.orig_title: + qs = qs | Q(title=self.orig_title) + qs = qs | Q(orig_title=self.orig_title) + qs = qs & ~Q(id=self.id) + return Book.objects.filter(qs) + + def get_identicals(self): + qs = Q(orig_title=self.title) + if self.isbn: + qs = Q(isbn=self.isbn) + # qs = qs & ~Q(id=self.id) + return Book.objects.filter(qs) + else: + return [self] # Book.objects.filter(id=self.id) + @property def verbose_category_name(self): return _("书籍") + @property + def mark_class(self): + return BookMark + + @property + def tag_class(self): + return BookTag + class BookMark(Mark): - book = models.ForeignKey(Book, on_delete=models.CASCADE, related_name='book_marks', null=True) + book = models.ForeignKey( + Book, on_delete=models.CASCADE, related_name='book_marks', null=True) + class Meta: constraints = [ - models.UniqueConstraint(fields=['owner', 'book'], name="unique_book_mark") + models.UniqueConstraint( + fields=['owner', 'book'], name="unique_book_mark") ] + @property + def translated_status(self): + return BookMarkStatusTranslation[self.status] + class BookReview(Review): - book = models.ForeignKey(Book, on_delete=models.CASCADE, related_name='book_reviews', null=True) + book = models.ForeignKey( + Book, on_delete=models.CASCADE, related_name='book_reviews', null=True) + class Meta: constraints = [ - models.UniqueConstraint(fields=['owner', 'book'], name="unique_book_review") - ] + models.UniqueConstraint( + fields=['owner', 'book'], name="unique_book_review") + ] + + @property + def url(self): + return settings.APP_WEBSITE + reverse("books:retrieve_review", args=[self.id]) + + @property + def item(self): + return self.book class BookTag(Tag): - book = models.ForeignKey(Book, on_delete=models.CASCADE, related_name='book_tags', null=True) - mark = models.ForeignKey(BookMark, on_delete=models.CASCADE, related_name='bookmark_tags', null=True) + book = models.ForeignKey( + Book, on_delete=models.CASCADE, related_name='book_tags', null=True) + mark = models.ForeignKey( + BookMark, on_delete=models.CASCADE, related_name='bookmark_tags', null=True) + class Meta: constraints = [ - models.UniqueConstraint(fields=['content', 'mark'], name="unique_bookmark_tag") + models.UniqueConstraint( + fields=['content', 'mark'], name="unique_bookmark_tag") ] + + @property + def item(self): + return self.book diff --git a/books/templates/books/create_update.html b/books/templates/books/create_update.html index fdd7ec61..de4b8ca7 100644 --- a/books/templates/books/create_update.html +++ b/books/templates/books/create_update.html @@ -10,8 +10,8 @@ <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> - <title>{% trans 'NiceDB - ' %}{{ title }}</title> - <script src="https://cdnjs.cloudflare.com/ajax/libs/jquery/3.5.1/jquery.min.js"></script> + <title>{{ site_name }} - {{ title }}</title> + <script src="https://static.neodb.social/cdnjs.cloudflare.com/ajax/libs/jquery/3.6.0/jquery.min.js"></script> <link rel="stylesheet" href="{% static 'css/boofilsic.min.css' %}"> </head> @@ -22,8 +22,24 @@ <section id="content" class="container"> <div class="grid"> + {% if is_update and form.source_site.value != 'in-site' %} + <div style="float:right;padding-left:16px"> + <div class="aside-section-wrapper"> + <div class="action-panel"> + <div class="action-panel__label">{% trans '源网站' %}: <a href="{{ form.source_url.value }}">{{ form.source_site.value }}</a></div> + <div class="action-panel__button-group"> + <form method="post" action="{% url 'books:rescrape' form.id.value %}"> + {% csrf_token %} + <input class="button" type="submit" value="{% trans '从源网站重新抓取' %}"> + </form> + </div> + </div> + </div> + </div> + {% endif %} + <div class="single-section-wrapper" id="main"> - <a href="{% url 'books:scrape' %}" class="single-section-wrapper__link single-section-wrapper__link--secondary">{% trans '>>> 试试一键剽取~ <<<' %}</a> + {% comment %} <a href="{% url 'books:scrape' %}" class="single-section-wrapper__link single-section-wrapper__link--secondary">{% trans '>>> 试试一键剽取~ <<<' %}</a> {% endcomment %} <form class="entity-form" action="{{ submit_url }}" method="post" enctype="multipart/form-data"> {% csrf_token %} {{ form.media }} @@ -38,12 +54,6 @@ </div> - {% comment %} - <div id="oauth2Token" hidden="true">{% oauth_token %}</div> - <div id="mastodonURI" hidden="true">{% mastodon request.user.mastodon_site %}</div> - <!--current user mastodon id--> - <div id="userMastodonID" hidden="true">{{ user.mastodon_id }}</div> - {% endcomment %} <script> diff --git a/books/templates/books/create_update_review.html b/books/templates/books/create_update_review.html index 9acae132..04cc0c3b 100644 --- a/books/templates/books/create_update_review.html +++ b/books/templates/books/create_update_review.html @@ -12,8 +12,8 @@ <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> - <title>{% trans 'NiceDB - ' %}{{ title }}</title> - <script src="https://cdnjs.cloudflare.com/ajax/libs/jquery/3.5.1/jquery.min.js"></script> + <title>{{ site_name }} - {{ title }}</title> + <script src="https://static.neodb.social/cdnjs.cloudflare.com/ajax/libs/jquery/3.6.0/jquery.min.js"></script> <script src="{% static 'js/create_update_review.js' %}"></script> <script src="{% static 'lib/js/rating-star.js' %}"></script> <link rel="stylesheet" href="{% static 'lib/css/rating-star.css' %}"> @@ -80,7 +80,7 @@ <div class="review-form__option"> <div class="review-form__visibility-radio"> - {{ form.is_private.label }}{{ form.is_private }} + {{ form.visibility.label }}{{ form.visibility }} </div> <div class="review-form__share-checkbox"> {{ form.share_to_mastodon }}{{ form.share_to_mastodon.label }} @@ -100,12 +100,6 @@ {% include "partial/_footer.html" %} </div> - {% comment %} - <div id="oauth2Token" hidden="true">{% oauth_token %}</div> - <div id="mastodonURI" hidden="true">{% mastodon request.user.mastodon_site %}</div> - <!--current user mastodon id--> - <div id="userMastodonID" hidden="true">{{ user.mastodon_id }}</div> - {% endcomment %} <script> diff --git a/books/templates/books/delete.html b/books/templates/books/delete.html index 5adcb246..82d2fd7a 100644 --- a/books/templates/books/delete.html +++ b/books/templates/books/delete.html @@ -11,8 +11,8 @@ <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> - <title>{% trans 'NiceDB - 删除图书' %}</title> - <script src="https://cdnjs.cloudflare.com/ajax/libs/jquery/3.5.1/jquery.min.js"></script> + <title>{{ site_name }} - {% trans '删除图书' %}</title> + <script src="https://static.neodb.social/cdnjs.cloudflare.com/ajax/libs/jquery/3.6.0/jquery.min.js"></script> <script src="{% static 'lib/js/rating-star.js' %}"></script> <script src="{% static 'js/rating-star-readonly.js' %}"></script> <link rel="stylesheet" href="{% static 'lib/css/rating-star.css' %}"> @@ -55,7 +55,7 @@ {% if book.last_editor %} <div> {% trans '最近编辑者:' %} - <a href="{% url 'users:home' book.last_editor.id %}"> + <a href="{% url 'users:home' book.last_editor.mastodon_username %}"> <span>{{ book.last_editor | default:"" }}</span> </a> </div> @@ -89,12 +89,6 @@ </div> - {% comment %} - <div id="oauth2Token" hidden="true">{% oauth_token %}</div> - <div id="mastodonURI" hidden="true">{% mastodon request.user.mastodon_site %}</div> - <!--current user mastodon id--> - <div id="userMastodonID" hidden="true">{{ user.mastodon_id }}</div> - {% endcomment %} <script> diff --git a/books/templates/books/delete_review.html b/books/templates/books/delete_review.html index 8c0a7d18..7a0fad5c 100644 --- a/books/templates/books/delete_review.html +++ b/books/templates/books/delete_review.html @@ -10,8 +10,8 @@ <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> - <title>{% trans 'NiceDB - 删除评论' %}</title> - <script src="https://cdnjs.cloudflare.com/ajax/libs/jquery/3.5.1/jquery.min.js"></script> + <title>{{ site_name }} - {% trans '删除评论' %}</title> + <script src="https://static.neodb.social/cdnjs.cloudflare.com/ajax/libs/jquery/3.6.0/jquery.min.js"></script> <link rel="stylesheet" href="{% static 'lib/css/rating-star.css' %}"> <link rel="stylesheet" href="{% static 'css/boofilsic.min.css' %}"> @@ -35,7 +35,7 @@ <h5 class="review-head__title"> {{ review.title }} </h5> - {% if review.is_private %} + {% if review.visibility > 0 %} <span class="icon-lock"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20"> @@ -47,7 +47,7 @@ <div class="review-head__body"> <div class="review-head__info"> - <a href="{% url 'users:home' review.owner.id %}" + <a href="{% url 'users:home' review.owner.mastodon_username %}" class="review-head__owner-link">{{ review.owner.username }}</a> {% if mark %} @@ -90,12 +90,6 @@ </div> - {% comment %} - <div id="oauth2Token" hidden="true">{% oauth_token %}</div> - <div id="mastodonURI" hidden="true">{% mastodon request.user.mastodon_site %}</div> - <!--current user mastodon id--> - <div id="userMastodonID" hidden="true">{{ user.mastodon_id }}</div> - {% endcomment %} <script> diff --git a/books/templates/books/detail.html b/books/templates/books/detail.html index fd8ee1e7..2452bcf1 100644 --- a/books/templates/books/detail.html +++ b/books/templates/books/detail.html @@ -1,9 +1,12 @@ {% load static %} {% load i18n %} +{% load l10n %} +{% load humanize %} {% load admin_url %} {% load mastodon %} {% load oauth_token %} {% load truncate %} +{% load strip_scheme %} {% load thumb %} <!DOCTYPE html> <html lang="en"> @@ -11,11 +14,11 @@ <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> - <meta property="og:title" content="NiceDB书 - {{ book.title }}"> + <meta property="og:title" content="{{ site_name }}书 - {{ book.title }}"> <meta property="og:type" content="book"> <meta property="og:url" content="{{ request.build_absolute_uri }}"> <meta property="og:image" content="{{ request.scheme }}://{{ request.get_host }}{{ book.cover.url }}"> - <meta property="og:site_name" content="NiceDB"> + <meta property="og:site_name" content="{{ site_name }}"> <meta property="og:description" content="{{ book.brief }}"> {% if book.author %} <meta property="og:book:author" content="{% for author in book.author %}{{ author }}{% if not forloop.last %},{% endif %}{% endfor %}"> @@ -23,13 +26,13 @@ {% if book.isbn %} <meta property="og:book:isbn" content="{{ book.isbn }}"> {% endif %} + + <title>{{ site_name }} - {% trans '书籍详情' %} | {{ book.title }}</title> + + {% include "partial/_common_libs.html" with jquery=1 %} - <title>{% trans 'NiceDB - 书籍详情' %} | {{ book.title }}</title> - <script src="https://cdnjs.cloudflare.com/ajax/libs/jquery/3.5.1/jquery.min.js"></script> <script src="{% static 'lib/js/rating-star.js' %}"></script> <script src="{% static 'js/detail.js' %}"></script> - <link rel="stylesheet" href="{% static 'css/boofilsic.min.css' %}"> - <link rel="stylesheet" href="{% static 'lib/css/rating-star.css' %}"> </head> <body> @@ -57,11 +60,12 @@ <div class="entity-detail__fields"> <div class="entity-detail__rating"> - {% if book.rating %} + {% if book.rating and book.rating_number >= 5 %} <span class="entity-detail__rating-star rating-star" data-rating-score="{{ book.rating | floatformat:"0" }}"></span> <span class="entity-detail__rating-score"> {{ book.rating }} </span> + <small>({{ book.rating_number }}人评分)</small> {% else %} - <span> {% trans '评分:暂无评分' %}</span> + <span> {% trans '评分:评分人数不足' %}</span> {% endif %} </div> <div>{% if book.isbn %}{% trans 'ISBN:' %}{{ book.isbn }}{% endif %}</div> @@ -96,7 +100,7 @@ {% if book.last_editor %} - <div>{% trans '最近编辑者:' %}<a href="{% url 'users:home' book.last_editor.id %}">{{ book.last_editor | default:"" }}</a></div> + <div>{% trans '最近编辑者:' %}<a href="{% url 'users:home' book.last_editor.mastodon_username %}">{{ book.last_editor | default:"" }}</a></div> {% endif %} <div> @@ -148,46 +152,27 @@ <div class="entity-marks"> <h5 class="entity-marks__title">{% trans '这本书的标记' %}</h5> - {% if mark_list_more %} - <a href="{% url 'books:retrieve_mark_list' book.id %}" class="entity-marks__more-link">{% trans '更多' %}</a> - {% endif %} - {% if mark_list %} - <ul class="entity-marks__mark-list"> - {% for others_mark in mark_list %} - <li class="entity-marks__mark"> - <a href="{% url 'users:home' others_mark.owner.id %}" class="entity-marks__owner-link">{{ others_mark.owner.username }}</a> - <span>{{ others_mark.get_status_display }}</span> - {% if others_mark.rating %} - <span class="entity-marks__rating-star rating-star" data-rating-score="{{ others_mark.rating | floatformat:"0" }}"></span> - {% endif %} - {% if others_mark.is_private %} - <span class="icon-lock"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20"><path d="M17,8.48h-.73V6.27a6.27,6.27,0,1,0-12.53,0V8.48H3a.67.67,0,0,0-.67.67V19.33A.67.67,0,0,0,3,20H17a.67.67,0,0,0,.67-.67V9.15A.67.67,0,0,0,17,8.48ZM6.42,6.27h0a3.57,3.57,0,0,1,7.14,0h0V8.48H6.42Z"/></svg></span> - {% endif %} - <span class="entity-marks__mark-time">{{ others_mark.edited_time }}</span> - {% if others_mark.text %} - <p class="entity-marks__mark-content">{{ others_mark.text }}</p> - {% endif %} - </li> - {% endfor %} - </ul> - {% else %} - <div>{% trans '暂无标记' %}</div> - {% endif %} + <a href="{% url 'books:retrieve_mark_list' book.id %}" class="entity-marks__more-link">{% trans '全部标记' %}</a> + <a href="{% url 'books:retrieve_mark_list' book.id 1 %}" class="entity-marks__more-link">关注的人的标记</a> + {% include "partial/mark_list.html" with mark_list=mark_list current_item=book %} </div> <div class="entity-reviews"> <h5 class="entity-reviews__title">{% trans '这本书的评论' %}</h5> {% if review_list_more %} - <a href="{% url 'books:retrieve_review_list' book.id %}" class="entity-reviews__more-link">{% trans '更多' %}</a> + <a href="{% url 'books:retrieve_review_list' book.id %}" class="entity-reviews__more-link">{% trans '全部评论' %}</a> {% endif %} {% if review_list %} <ul class="entity-reviews__review-list"> {% for others_review in review_list %} <li class="entity-reviews__review"> - <a href="{% url 'users:home' others_review.owner.id %}" class="entity-reviews__owner-link">{{ others_review.owner.username }}</a> - {% if others_review.is_private %} + <a href="{% url 'users:home' others_review.owner.mastodon_username %}" class="entity-reviews__owner-link">{{ others_review.owner.username }}</a> + {% if others_review.visibility > 0 %} <span class="icon-lock"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20"><path d="M17,8.48h-.73V6.27a6.27,6.27,0,1,0-12.53,0V8.48H3a.67.67,0,0,0-.67.67V19.33A.67.67,0,0,0,3,20H17a.67.67,0,0,0,.67-.67V9.15A.67.67,0,0,0,17,8.48ZM6.42,6.27h0a3.57,3.57,0,0,1,7.14,0h0V8.48H6.42Z"/></svg></span> {% endif %} <span class="entity-reviews__review-time">{{ others_review.edited_time }}</span> + {% if others_review.book != book %} + <span class="entity-reviews__review-time source-label"><a class="entity-reviews__review-time" href="{% url 'books:retrieve' others_review.book.id %}">{{ others_review.book.get_source_site_display }}</a></span> + {% endif %} <span class="entity-reviews__review-title"> <a href="{% url 'books:retrieve_review' others_review.id %}">{{ others_review.title }}</a></span> <span>{{ others_review.get_plain_content | truncate:100 }}</span> </li> @@ -202,7 +187,6 @@ <div class="grid__aside" id="aside"> <div class="aside-section-wrapper"> - {% if mark %} <div class="mark-panel"> @@ -212,7 +196,7 @@ <span class="mark-panel__rating-star rating-star" data-rating-score="{{ mark.rating | floatformat:"0" }}"></span> {% endif %} {% endif %} - {% if mark.is_private %} + {% if mark.visibility > 0 %} <span class="icon-lock"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20"><path d="M17,8.48h-.73V6.27a6.27,6.27,0,1,0-12.53,0V8.48H3a.67.67,0,0,0-.67.67V19.33A.67.67,0,0,0,3,20H17a.67.67,0,0,0,.67-.67V9.15A.67.67,0,0,0,17,8.48ZM6.42,6.27h0a3.57,3.57,0,0,1,7.14,0h0V8.48H6.42Z"/></svg></span> {% endif %} <span class="mark-panel__actions"> @@ -224,7 +208,7 @@ </span> <div class="mark-panel__clear"></div> - <div class="mark-panel__time">{{ mark.edited_time }}</div> + <div class="mark-panel__time">{{ mark.created_time }}</div> {% if mark.text %} <p class="mark-panel__text">{{ mark.text }}</p> @@ -245,9 +229,8 @@ <button class="action-panel__button" data-status="{{ status_enum.DO.value }}">{% trans '在读' %}</button> <button class="action-panel__button" data-status="{{ status_enum.COLLECT.value }}">{% trans '读过' %}</button> </div> - </div> + </div> {% endif %} - </div> <div class="aside-section-wrapper"> @@ -255,7 +238,7 @@ <div class="review-panel"> <span class="review-panel__label">{% trans '我的评论' %}</span> - {% if review.is_private %} + {% if review.visibility > 0 %} <span class="icon-lock"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20"><path d="M17,8.48h-.73V6.27a6.27,6.27,0,1,0-12.53,0V8.48H3a.67.67,0,0,0-.67.67V19.33A.67.67,0,0,0,3,20H17a.67.67,0,0,0,.67-.67V9.15A.67.67,0,0,0,17,8.48ZM6.42,6.27h0a3.57,3.57,0,0,1,7.14,0h0V8.48H6.42Z"/></svg></span> {% endif %} @@ -284,7 +267,53 @@ {% endif %} </div> - + + {% if book.get_related_books.count > 0 %} + <div class="aside-section-wrapper"> + <div class="action-panel"> + <div class="action-panel__label">{% trans '相关书目' %}</div> + <div > + {% for b in book.get_related_books %} + <p> + <a href="{% url 'books:retrieve' b.id %}">{{ b.title }}</a> + <small>({{ b.pub_house }} {{ b.pub_year }})</small> + <span class="source-label source-label__{{ b.source_site }}">{{ b.get_source_site_display }}</span> + </p> + {% endfor %} + </div> + </div> + </div> + {% endif %} + + {% if book.isbn %} + <div class="aside-section-wrapper"> + <div class="action-panel"> + <div class="action-panel__label">{% trans '借阅或购买' %}</div> + <div class="action-panel__button-group"> + <a class="action-panel__button" target="_blank" href="https://www.worldcat.org/isbn/{{ book.isbn }}">{% trans 'WorldCat' %}</a> + <a class="action-panel__button" target="_blank" href="https://openlibrary.org/search?isbn={{ book.isbn }}">{% trans 'Open Library' %}</a> + </div> + </div> + </div> + {% endif %} + + {% if collection_list %} + <div class="aside-section-wrapper"> + <div class="action-panel"> + <div class="action-panel__label">{% trans '相关收藏单' %}</div> + <div > + {% for c in collection_list %} + <p> + <a href="{% url 'collection:retrieve' c.id %}">{{ c.title }}</a> + </p> + {% endfor %} + <div class="action-panel__button-group action-panel__button-group--center"> + <button class="action-panel__button add-to-list" hx-get="{% url 'collection:add_to_list' 'book' book.id %}" hx-target="body" hx-swap="beforeend">{% trans '添加到收藏单' %}</button> + </div> + </div> + </div> + </div> + {% endif %} </div> </div> </section> @@ -296,7 +325,6 @@ <div id="modals"> <div class="mark-modal modal"> <div class="mark-modal__head"> - {% if not mark %} <style> .mark-modal__title::after { @@ -313,12 +341,12 @@ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20"> <polygon points="20 2.61 17.39 0 10 7.39 2.61 0 0 2.61 7.39 10 0 17.39 2.61 20 10 12.61 17.39 20 20 17.39 12.61 10 20 2.61"> - </polygon> - </svg> + </polygon> + </svg> + </span> </span> - </span> - </div> - <div class="mark-modal__body"> + </div> + <div class="mark-modal__body"> <form action="{% url 'books:create_update_mark' %}" method="post"> {{ mark_form.media }} {% csrf_token %} @@ -344,8 +372,8 @@ <div class="mark-modal__option"> <div class="mark-modal__visibility-radio"> - <span>{{ mark_form.is_private.label }}:</span> - {{ mark_form.is_private }} + <span>{{ mark_form.visibility.label }}: + {{ mark_form.visibility }}</span> </div> <div class="mark-modal__share-checkbox"> {{ mark_form.share_to_mastodon }}{{ mark_form.share_to_mastodon.label }} diff --git a/books/templates/books/mark_list.html b/books/templates/books/mark_list.html index 46c5acf1..fe96c4ad 100644 --- a/books/templates/books/mark_list.html +++ b/books/templates/books/mark_list.html @@ -12,8 +12,8 @@ <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> - <title>{% trans 'NiceDB - ' %}{{ book.title }}{% trans '的标记' %}</title> - <script src="https://cdnjs.cloudflare.com/ajax/libs/jquery/3.5.1/jquery.min.js"></script> + <title>{{ site_name }} - {{ book.title }}{% trans '的标记' %}</title> + <script src="https://static.neodb.social/cdnjs.cloudflare.com/ajax/libs/jquery/3.6.0/jquery.min.js"></script> <script src="{% static 'lib/js/rating-star.js' %}"></script> <script src="{% static 'js/rating-star-readonly.js' %}"></script> <link rel="stylesheet" href="{% static 'css/boofilsic.min.css' %}"> @@ -33,38 +33,7 @@ <h5 class="entity-marks__title entity-marks__title--stand-alone"> <a href="{% url 'books:retrieve' book.id %}">{{ book.title }}</a>{% trans ' 的标记' %} </h5> - <ul class="entity-marks__mark-list"> - - {% for mark in marks %} - - <li class="entity-marks__mark entity-marks__mark--wider"> - <a href="{% url 'users:home' mark.owner.id %}" - class="entity-marks__owner-link">{{ mark.owner.username }}</a> - <span>{{ mark.get_status_display }}</span> - {% if mark.rating %} - <span class="entity-marks__rating-star rating-star" - data-rating-score="{{ mark.rating | floatformat:"0" }}"></span> - {% endif %} - {% if mark.is_private %} - <span class="icon-lock"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20"> - - <path - d="M17,8.48h-.73V6.27a6.27,6.27,0,1,0-12.53,0V8.48H3a.67.67,0,0,0-.67.67V19.33A.67.67,0,0,0,3,20H17a.67.67,0,0,0,.67-.67V9.15A.67.67,0,0,0,17,8.48ZM6.42,6.27h0a3.57,3.57,0,0,1,7.14,0h0V8.48H6.42Z" /> - </svg></span> - {% endif %} - <span class="entity-marks__mark-time">{{ mark.edited_time }}</span> - {% if mark.text %} - <p class="entity-marks__mark-content">{{ mark.text }}</p> - {% endif %} - </li> - - {% empty %} - <div> - {% trans '无结果' %} - </div> - {% endfor %} - - </ul> + {% include "partial/mark_list.html" with mark_list=marks current_item=book %} </div> <div class="pagination"> @@ -132,12 +101,6 @@ </div> - {% comment %} - <div id="oauth2Token" hidden="true">{% oauth_token %}</div> - <div id="mastodonURI" hidden="true">{% mastodon request.user.mastodon_site %}</div> - <!--current user mastodon id--> - <div id="userMastodonID" hidden="true">{{ user.mastodon_id }}</div> - {% endcomment %} <script> diff --git a/books/templates/books/review_detail.html b/books/templates/books/review_detail.html index 76a53aaf..b4046532 100644 --- a/books/templates/books/review_detail.html +++ b/books/templates/books/review_detail.html @@ -11,17 +11,18 @@ <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> - <meta property="og:title" content="NiceDB书评 - {{ review.title }}"> + <meta property="og:title" content="{{ site_name }}书评 - {{ review.title }}"> <meta property="og:type" content="article"> <meta property="og:article:author" content="{{ review.owner.username }}"> <meta property="og:url" content="{{ request.build_absolute_uri }}"> - <meta property="og:image" content="{{ request.scheme }}://{{ request.get_host }}{% static 'img/logo_square.svg' %}"> - <title>{% trans 'NiceDB - 评论详情' %}</title> - <script src="https://cdnjs.cloudflare.com/ajax/libs/jquery/3.5.1/jquery.min.js"></script> + <meta property="og:image" content="{{ book.cover|thumb:'normal' }}"> + <title>{{ site_name }}{% trans '书评' %} - {{ review.title }}</title> + <script src="https://static.neodb.social/cdnjs.cloudflare.com/ajax/libs/jquery/3.6.0/jquery.min.js"></script> <script src="{% static 'lib/js/rating-star.js' %}"></script> <script src="{% static 'js/rating-star-readonly.js' %}"></script> <link rel="stylesheet" href="{% static 'lib/css/rating-star.css' %}"> <link rel="stylesheet" href="{% static 'css/boofilsic.min.css' %}"> + <link rel="stylesheet" href="{% static 'lib/css/neo.css' %}"> </head> <body> @@ -37,7 +38,7 @@ <h5 class="review-head__title"> {{ review.title }} </h5> - {% if review.is_private %} + {% if review.visibility > 0 %} <span class="icon-lock"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20"> <path d="M17,8.48h-.73V6.27a6.27,6.27,0,1,0-12.53,0V8.48H3a.67.67,0,0,0-.67.67V19.33A.67.67,0,0,0,3,20H17a.67.67,0,0,0,.67-.67V9.15A.67.67,0,0,0,17,8.48ZM6.42,6.27h0a3.57,3.57,0,0,1,7.14,0h0V8.48H6.42Z" /> @@ -46,7 +47,7 @@ <div class="review-head__body"> <div class="review-head__info"> - <a href="{% url 'users:home' review.owner.id %}" class="review-head__owner-link">{{ review.owner.username }}</a> + <a href="{% url 'users:home' review.owner.mastodon_username %}" class="review-head__owner-link">{{ review.owner.username }}</a> {% if mark %} @@ -71,6 +72,7 @@ {{ form.content }} </div> {{ form.media }} + {% csrf_token %} </div> </div> @@ -112,16 +114,8 @@ </div> - {% comment %} - <div id="oauth2Token" hidden="true">{% oauth_token %}</div> - <div id="mastodonURI" hidden="true">{% mastodon request.user.mastodon_site %}</div> - <!--current user mastodon id--> - <div id="userMastodonID" hidden="true">{{ user.mastodon_id }}</div> - {% endcomment %} <script> - - $(".markdownx textarea").hide(); </script> </body> diff --git a/books/templates/books/review_list.html b/books/templates/books/review_list.html index d7fad68b..a7682b0b 100644 --- a/books/templates/books/review_list.html +++ b/books/templates/books/review_list.html @@ -12,8 +12,8 @@ <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> - <title>{% trans 'NiceDB - ' %}{{ book.title }}{% trans '的评论' %}</title> - <script src="https://cdnjs.cloudflare.com/ajax/libs/jquery/3.5.1/jquery.min.js"></script> + <title>{{ site_name }} - {{ book.title }}{% trans '的评论' %}</title> + <script src="https://static.neodb.social/cdnjs.cloudflare.com/ajax/libs/jquery/3.6.0/jquery.min.js"></script> <script src="{% static 'lib/js/rating-star.js' %}"></script> <script src="{% static 'js/rating-star-readonly.js' %}"></script> <link rel="stylesheet" href="{% static 'lib/css/rating-star.css' %}"> @@ -39,12 +39,14 @@ <li class="entity-reviews__review entity-reviews__review--wider"> - <a href="{% url 'users:home' review.owner.id %}" class="entity-reviews__owner-link">{{ review.owner.username }}</a> - {% if review.is_private %} + <a href="{% url 'users:home' review.owner.mastodon_username %}" class="entity-reviews__owner-link">{{ review.owner.username }}</a> + {% if review.visibility > 0 %} <span class="icon-lock"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20"><path d="M17,8.48h-.73V6.27a6.27,6.27,0,1,0-12.53,0V8.48H3a.67.67,0,0,0-.67.67V19.33A.67.67,0,0,0,3,20H17a.67.67,0,0,0,.67-.67V9.15A.67.67,0,0,0,17,8.48ZM6.42,6.27h0a3.57,3.57,0,0,1,7.14,0h0V8.48H6.42Z"/></svg></span> {% endif %} <span class="entity-reviews__review-time">{{ review.edited_time }}</span> - + {% if review.book != book %} + <span class="entity-reviews__review-time source-label"><a href="{% url 'books:retrieve' review.book.id %}" class="entity-reviews__review-time">{{ review.book.get_source_site_display }}</a></span> + {% endif %} <span href="{% url 'books:retrieve_review' review.id %}" class="entity-reviews__review-title"><a href="{% url 'books:retrieve_review' review.id %}">{{ review.title }}</a></span> @@ -119,12 +121,6 @@ </div> - {% comment %} - <div id="oauth2Token" hidden="true">{% oauth_token %}</div> - <div id="mastodonURI" hidden="true">{% mastodon request.user.mastodon_site %}</div> - <!--current user mastodon id--> - <div id="userMastodonID" hidden="true">{{ user.mastodon_id }}</div> - {% endcomment %} <script> diff --git a/books/templates/books/scrape.html b/books/templates/books/scrape.html index 6ad5a8a1..4f0adcea 100644 --- a/books/templates/books/scrape.html +++ b/books/templates/books/scrape.html @@ -10,8 +10,8 @@ <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> - <title>{% trans 'NiceDB - 从豆瓣获取数据' %}</title> - <script src="https://cdnjs.cloudflare.com/ajax/libs/jquery/3.5.1/jquery.min.js"></script> + <title>{{ site_name }} - {% trans '从豆瓣获取数据' %}</title> + <script src="https://static.neodb.social/cdnjs.cloudflare.com/ajax/libs/jquery/3.6.0/jquery.min.js"></script> <script src="{% static 'js/scrape.js' %}"></script> <link rel="stylesheet" href="{% static 'css/boofilsic.min.css' %}"> </head> diff --git a/books/urls.py b/books/urls.py index 7ca4d24e..518ea096 100644 --- a/books/urls.py +++ b/books/urls.py @@ -1,4 +1,4 @@ -from django.urls import path +from django.urls import path, re_path from .views import * @@ -8,8 +8,10 @@ urlpatterns = [ path('<int:id>/', retrieve, name='retrieve'), path('update/<int:id>/', update, name='update'), path('delete/<int:id>/', delete, name='delete'), + path('rescrape/<int:id>/', rescrape, name='rescrape'), path('mark/', create_update_mark, name='create_update_mark'), - path('<int:book_id>/mark/list/', retrieve_mark_list, name='retrieve_mark_list'), + path('wish/<int:id>/', wish, name='wish'), + re_path('(?P<book_id>[0-9]+)/mark/list/(?:(?P<following_only>\\d+))?', retrieve_mark_list, name='retrieve_mark_list'), path('mark/delete/<int:id>/', delete_mark, name='delete_mark'), path('<int:book_id>/review/create/', create_review, name='create_review'), path('review/update/<int:id>/', update_review, name='update_review'), diff --git a/books/views.py b/books/views.py index a06c31cf..10d31b77 100644 --- a/books/views.py +++ b/books/views.py @@ -2,22 +2,24 @@ import logging from django.shortcuts import render, get_object_or_404, redirect, reverse from django.contrib.auth.decorators import login_required, permission_required from django.utils.translation import gettext_lazy as _ -from django.http import HttpResponseBadRequest, HttpResponseServerError +from django.http import HttpResponseBadRequest, HttpResponseServerError, HttpResponse from django.core.exceptions import ObjectDoesNotExist, PermissionDenied from django.db import IntegrityError, transaction from django.db.models import Count from django.utils import timezone from django.core.paginator import Paginator from mastodon import mastodon_request_included -from mastodon.api import check_visibility, post_toot, TootVisibilityEnum -from mastodon.utils import rating_to_emoji +from mastodon.models import MastodonApplication +from mastodon.api import share_mark, share_review from common.utils import PageLinksGenerator -from common.views import PAGE_LINK_NUMBER, jump_or_scrape +from common.views import PAGE_LINK_NUMBER, jump_or_scrape, go_relogin from common.models import SourceSiteEnum from .models import * from .forms import * from .forms import BookMarkStatusTranslator -from boofilsic.settings import MASTODON_TAGS +from django.conf import settings +from collection.models import CollectionItem +from common.scraper import get_scraper_by_url, get_normalized_url logger = logging.getLogger(__name__) @@ -88,6 +90,18 @@ def create(request): return HttpResponseBadRequest() +@login_required +def rescrape(request, id): + if request.method != 'POST': + return HttpResponseBadRequest() + item = get_object_or_404(Book, pk=id) + url = get_normalized_url(item.source_url) + scraper = get_scraper_by_url(url) + scraper.scrape(url) + form = scraper.save(request_user=request.user, instance=item) + return redirect(reverse("books:retrieve", args=[form.instance.id])) + + @login_required def update(request, id): if request.method == 'GET': @@ -98,6 +112,7 @@ def update(request, id): 'books/create_update.html', { 'form': form, + 'is_update': True, 'title': _('修改书籍'), 'submit_url': reverse("books:update", args=[book.id]), # provided for frontend js @@ -126,6 +141,7 @@ def update(request, id): 'books/create_update.html', { 'form': form, + 'is_update': True, 'title': _('修改书籍'), 'submit_url': reverse("books:update", args=[book.id]), # provided for frontend js @@ -166,6 +182,7 @@ def retrieve(request, id): else: mark_form = BookMarkForm(initial={ 'book': book, + 'visibility': request.user.get_preference().default_visibility if request.user.is_authenticated else 0, 'tags': mark_tags }) @@ -184,10 +201,8 @@ def retrieve(request, id): mark_list_more = None review_list_more = None else: - mark_list = BookMark.get_available( - book, request.user, request.session['oauth_token']) - review_list = BookReview.get_available( - book, request.user, request.session['oauth_token']) + mark_list = BookMark.get_available_for_identicals(book, request.user) + review_list = BookReview.get_available_for_identicals(book, request.user) mark_list_more = True if len(mark_list) > MARK_NUMBER else False mark_list = mark_list[:MARK_NUMBER] for m in mark_list: @@ -195,6 +210,7 @@ def retrieve(request, id): review_list_more = True if len( review_list) > REVIEW_NUMBER else False review_list = review_list[:REVIEW_NUMBER] + collection_list = filter(lambda c: c.is_visible_to(request.user), map(lambda i: i.collection, CollectionItem.objects.filter(book=book))) # def strip_html_tags(text): # import re @@ -219,6 +235,7 @@ def retrieve(request, id): 'review_list_more': review_list_more, 'book_tag_list': book_tag_list, 'mark_tags': mark_tags, + 'collection_list': collection_list, } ) else: @@ -263,12 +280,19 @@ def create_update_mark(request): pk = request.POST.get('id') old_rating = None old_tags = None + if not pk: + book_id = request.POST.get('book') + mark = BookMark.objects.filter(book_id=book_id, owner=request.user).first() + if mark: + pk = mark.id if pk: mark = get_object_or_404(BookMark, pk=pk) if request.user != mark.owner: return HttpResponseBadRequest() old_rating = mark.rating old_tags = mark.bookmark_tags.all() + if mark.status != request.POST.get('status'): + mark.created_time = timezone.now() # update form = BookMarkForm(request.POST, instance=mark) else: @@ -276,13 +300,13 @@ def create_update_mark(request): form = BookMarkForm(request.POST) if form.is_valid(): - if form.instance.status == MarkStatusEnum.WISH.value: + if form.instance.status == MarkStatusEnum.WISH.value or form.instance.rating == 0: form.instance.rating = None form.cleaned_data['rating'] = None form.instance.owner = request.user form.instance.edited_time = timezone.now() book = form.instance.book - + try: with transaction.atomic(): # update book rating @@ -304,27 +328,10 @@ def create_update_mark(request): return HttpResponseServerError("integrity error") if form.cleaned_data['share_to_mastodon']: - if form.cleaned_data['is_private']: - visibility = TootVisibilityEnum.PRIVATE - else: - visibility = TootVisibilityEnum.UNLISTED - url = "https://" + request.get_host() + reverse("books:retrieve", - args=[book.id]) - words = BookMarkStatusTranslator(form.cleaned_data['status']) +\ - f"《{book.title}》" + \ - rating_to_emoji(form.cleaned_data['rating']) - - # tags = MASTODON_TAGS % {'category': '书', 'type': '标记'} - tags = '' - content = words + '\n' + url + '\n' + \ - form.cleaned_data['text'] + '\n' + tags - response = post_toot( - request.user.mastodon_site, content, visibility, request.session['oauth_token']) - if response.status_code != 200: - mastodon_logger.error(f"CODE:{response.status_code} {response.text}") - return HttpResponseServerError("publishing mastodon status failed") + if not share_mark(form.instance): + return go_relogin(request) else: - return HttpResponseBadRequest("invalid form data") + return HttpResponseBadRequest(f"invalid form data {form.errors}") return redirect(reverse("books:retrieve", args=[form.instance.book.id])) else: @@ -333,11 +340,30 @@ def create_update_mark(request): @mastodon_request_included @login_required -def retrieve_mark_list(request, book_id): +def wish(request, id): + if request.method == 'POST': + book = get_object_or_404(Book, pk=id) + params = { + 'owner': request.user, + 'status': MarkStatusEnum.WISH, + 'visibility': 0, + 'book': book, + } + try: + BookMark.objects.create(**params) + except Exception: + pass + return HttpResponse("✔️") + else: + return HttpResponseBadRequest("invalid method") + + +@mastodon_request_included +@login_required +def retrieve_mark_list(request, book_id, following_only=False): if request.method == 'GET': book = get_object_or_404(Book, pk=book_id) - queryset = BookMark.get_available( - book, request.user, request.session['oauth_token']) + queryset = BookMark.get_available_for_identicals(book, request.user, following_only=following_only) paginator = Paginator(queryset, MARK_PER_PAGE) page_number = request.GET.get('page', default=1) marks = paginator.get_page(page_number) @@ -398,23 +424,8 @@ def create_review(request, book_id): form.instance.owner = request.user form.save() if form.cleaned_data['share_to_mastodon']: - if form.cleaned_data['is_private']: - visibility = TootVisibilityEnum.PRIVATE - else: - visibility = TootVisibilityEnum.UNLISTED - url = "https://" + request.get_host() + reverse("books:retrieve_review", - args=[form.instance.id]) - words = "发布了关于" + f"《{form.instance.book.title}》" + "的评论" - # tags = MASTODON_TAGS % {'category': '书', 'type': '评论'} - tags = '' - content = words + '\n' + url + \ - '\n' + form.cleaned_data['title'] + '\n' + tags - response = post_toot( - request.user.mastodon_site, content, visibility, request.session['oauth_token']) - if response.status_code != 200: - mastodon_logger.error( - f"CODE:{response.status_code} {response.text}") - return HttpResponseServerError("publishing mastodon status failed") + if not share_review(form.instance): + return go_relogin(request) return redirect(reverse("books:retrieve_review", args=[form.instance.id])) else: return HttpResponseBadRequest() @@ -450,22 +461,8 @@ def update_review(request, id): form.instance.edited_time = timezone.now() form.save() if form.cleaned_data['share_to_mastodon']: - if form.cleaned_data['is_private']: - visibility = TootVisibilityEnum.PRIVATE - else: - visibility = TootVisibilityEnum.UNLISTED - url = "https://" + request.get_host() + reverse("books:retrieve_review", - args=[form.instance.id]) - words = "发布了关于" + f"《{form.instance.book.title}》" + "的评论" - # tags = MASTODON_TAGS % {'category': '书', 'type': '评论'} - tags = '' - content = words + '\n' + url + \ - '\n' + form.cleaned_data['title'] + '\n' + tags - response = post_toot( - request.user.mastodon_site, content, visibility, request.session['oauth_token']) - if response.status_code != 200: - mastodon_logger.error(f"CODE:{response.status_code} {response.text}") - return HttpResponseServerError("publishing mastodon status failed") + if not share_review(form.instance): + return go_relogin(request) return redirect(reverse("books:retrieve_review", args=[form.instance.id])) else: return HttpResponseBadRequest() @@ -500,11 +497,10 @@ def delete_review(request, id): @mastodon_request_included -@login_required def retrieve_review(request, id): if request.method == 'GET': review = get_object_or_404(BookReview, pk=id) - if not check_visibility(review, request.session['oauth_token'], request.user): + if not review.is_visible_to(request.user): msg = _("你没有访问这个页面的权限😥") return render( request, @@ -539,8 +535,7 @@ def retrieve_review(request, id): def retrieve_review_list(request, book_id): if request.method == 'GET': book = get_object_or_404(Book, pk=book_id) - queryset = BookReview.get_available( - book, request.user, request.session['oauth_token']) + queryset = BookReview.get_available_for_identicals(book, request.user) paginator = Paginator(queryset, REVIEW_PER_PAGE) page_number = request.GET.get('page', default=1) reviews = paginator.get_page(page_number) diff --git a/collection/__init__.py b/collection/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/collection/admin.py b/collection/admin.py new file mode 100644 index 00000000..8c38f3f3 --- /dev/null +++ b/collection/admin.py @@ -0,0 +1,3 @@ +from django.contrib import admin + +# Register your models here. diff --git a/collection/apps.py b/collection/apps.py new file mode 100644 index 00000000..7edc77d1 --- /dev/null +++ b/collection/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class CollectionConfig(AppConfig): + default_auto_field = 'django.db.models.BigAutoField' + name = 'collection' diff --git a/collection/forms.py b/collection/forms.py new file mode 100644 index 00000000..ed84f5a1 --- /dev/null +++ b/collection/forms.py @@ -0,0 +1,45 @@ +from django import forms +from django.utils.translation import gettext_lazy as _ +from .models import Collection +from common.forms import * + + +COLLABORATIVE_CHOICES = [ + (0, _("仅限创建者")), + (1, _("创建者及其互关用户")), +] + + +class CollectionForm(forms.ModelForm): + # id = forms.IntegerField(required=False, widget=forms.HiddenInput()) + title = forms.CharField(label=_("标题")) + description = MarkdownxFormField(label=_("详细介绍 (Markdown)")) + # share_to_mastodon = forms.BooleanField(label=_("分享到联邦网络"), initial=True, required=False) + visibility = forms.TypedChoiceField( + label=_("可见性"), + initial=0, + coerce=int, + choices=VISIBILITY_CHOICES, + widget=forms.RadioSelect + ) + collaborative = forms.TypedChoiceField( + label=_("协作整理权限"), + initial=0, + coerce=int, + choices=COLLABORATIVE_CHOICES, + widget=forms.RadioSelect + ) + + class Meta: + model = Collection + fields = [ + 'title', + 'description', + 'cover', + 'visibility', + 'collaborative', + ] + + widgets = { + 'cover': PreviewImageInput(), + } diff --git a/collection/models.py b/collection/models.py new file mode 100644 index 00000000..d079ec60 --- /dev/null +++ b/collection/models.py @@ -0,0 +1,126 @@ +from django.db import models +from common.models import UserOwnedEntity +from movies.models import Movie +from books.models import Book +from music.models import Song, Album +from games.models import Game +from markdownx.models import MarkdownxField +from django.utils.translation import gettext_lazy as _ +from django.conf import settings +from common.utils import ChoicesDictGenerator, GenerateDateUUIDMediaFilePath +from django.shortcuts import reverse + + +def collection_cover_path(instance, filename): + return GenerateDateUUIDMediaFilePath(instance, filename, settings.COLLECTION_MEDIA_PATH_ROOT) + + +class Collection(UserOwnedEntity): + title = models.CharField(max_length=200) + description = MarkdownxField() + cover = models.ImageField(_("封面"), upload_to=collection_cover_path, default=settings.DEFAULT_COLLECTION_IMAGE, blank=True) + collaborative = models.PositiveSmallIntegerField(default=0) # 0: Editable by owner only / 1: Editable by bi-direction followers + + def __str__(self): + return f"Collection({self.id} {self.owner} {self.title})" + + @property + def translated_status(self): + return '创建了收藏单' + + @property + def collectionitem_list(self): + return sorted(list(self.collectionitem_set.all()), key=lambda i: i.position) + + @property + def item_list(self): + return map(lambda i: i.item, self.collectionitem_list) + + @property + def plain_description(self): + html = markdown(self.description) + return RE_HTML_TAG.sub(' ', html) + + def has_item(self, item): + return len(list(filter(lambda i: i.item == item, self.collectionitem_list))) > 0 + + def append_item(self, item, comment=""): + cl = self.collectionitem_list + if item is None or self.has_item(item): + return None + else: + i = CollectionItem(collection=self, position=cl[-1].position + 1 if len(cl) else 1, comment=comment) + i.set_item(item) + i.save() + return i + + @property + def item(self): + return self + + @property + def mark_class(self): + return CollectionMark + + @property + def url(self): + return settings.APP_WEBSITE + reverse("collection:retrieve", args=[self.id]) + + @property + def wish_url(self): + return reverse("collection:wish", args=[self.id]) + + def is_editable_by(self, viewer): + if viewer.is_staff or viewer.is_superuser or viewer == self.owner: + return True + elif self.collaborative == 1 and viewer.is_following(self.owner) and viewer.is_followed_by(self.owner): + return True + else: + return False + + +class CollectionItem(models.Model): + movie = models.ForeignKey(Movie, on_delete=models.CASCADE, null=True) + album = models.ForeignKey(Album, on_delete=models.CASCADE, null=True) + song = models.ForeignKey(Song, on_delete=models.CASCADE, null=True) + book = models.ForeignKey(Book, on_delete=models.CASCADE, null=True) + game = models.ForeignKey(Game, on_delete=models.CASCADE, null=True) + collection = models.ForeignKey(Collection, on_delete=models.CASCADE) + position = models.PositiveIntegerField() + comment = models.TextField(_("备注"), default='') + + @property + def item(self): + items = list(filter(lambda i: i is not None, [self.movie, self.book, self.album, self.song, self.game])) + return items[0] if len(items) > 0 else None + + # @item.setter + def set_item(self, new_item): + old_item = self.item + if old_item == new_item: + return + if old_item is not None: + self.movie = None + self.book = None + self.album = None + self.song = None + self.game = None + setattr(self, new_item.__class__.__name__.lower(), new_item) + + +class CollectionMark(UserOwnedEntity): + collection = models.ForeignKey( + Collection, on_delete=models.CASCADE, related_name='collection_marks', null=True) + + class Meta: + constraints = [ + models.UniqueConstraint( + fields=['owner', 'collection'], name="unique_collection_mark") + ] + + def __str__(self): + return f"CollectionMark({self.id} {self.owner} {self.collection})" + + @property + def translated_status(self): + return '关注了收藏单' diff --git a/collection/templates/add_to_list.html b/collection/templates/add_to_list.html new file mode 100644 index 00000000..8ce1cae8 --- /dev/null +++ b/collection/templates/add_to_list.html @@ -0,0 +1,45 @@ +{% load static %} +{% load i18n %} +{% load l10n %} +{% load humanize %} +{% load admin_url %} +{% load mastodon %} +{% load oauth_token %} +{% load truncate %} +{% load highlight %} +{% load thumb %} + +<div id="modal" _="on closeModal add .closing then wait for animationend then remove me"> + <div class="modal-underlay" _="on click trigger closeModal"></div> + <div class="modal-content"> + <div class="add-to-list-modal__head"> + <span class="add-to-list-modal__title">{% trans '添加到收藏单' %}</span> + <span class="add-to-list-modal__close-button modal-close" _="on click trigger closeModal"> + <span class="icon-cross"> + <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20"> + <polygon + points="20 2.61 17.39 0 10 7.39 2.61 0 0 2.61 7.39 10 0 17.39 2.61 20 10 12.61 17.39 20 20 17.39 12.61 10 20 2.61"> + </polygon> + </svg> + </span> + </span> + </div> + <div class="add-to-list-modal__body"> + <form action="/collections/add_to_list/{{ type }}/{{ id }}/" method="post"> + {% csrf_token %} + <select name="collection_id"> + {% for collection in collections %} + <option value="{{ collection.id }}">{{ collection.title }}{% if collection.visibility > 0 %}🔒{% endif %}</option> + {% endfor %} + <option value="0">新建收藏单</option> + </select> + <div> + <textarea type="text" name="comment" placeholder="条目备注"></textarea> + </div> + <div class="add-to-list-modal__confirm-button"> + <input type="submit" class="button float-right" value="{% trans '提交' %}"> + </div> + </form> + </div> + </div> +</div> diff --git a/collection/templates/create_update.html b/collection/templates/create_update.html new file mode 100644 index 00000000..43e82dcb --- /dev/null +++ b/collection/templates/create_update.html @@ -0,0 +1,71 @@ +{% load static %} +{% load i18n %} +{% load admin_url %} +{% load mastodon %} +{% load oauth_token %} +{% load truncate %} +<!DOCTYPE html> +<html lang="en"> + +<head> + <meta charset="UTF-8"> + <meta name="viewport" content="width=device-width, initial-scale=1.0"> + <title>{{ site_name }} - {{ title }}</title> + <script src="https://static.neodb.social/cdnjs.cloudflare.com/ajax/libs/jquery/3.6.0/jquery.min.js"></script> + <link rel="stylesheet" href="{% static 'css/boofilsic.min.css' %}"> + <style type="text/css"> + #id_collaborative li, #id_visibility li {display: inline-block !important;} + </style> +</head> + +<body> + <div id="page-wrapper"> + {% include "partial/_navbar.html" %} + <div id="content-wrapper"> + <section id="content" class="container"> + <div class="grid"> + <div class="single-section-wrapper" id="main"> + <form class="entity-form" action="{{ submit_url }}" method="post" enctype="multipart/form-data"> + {% csrf_token %} + {{ form }} + <input class="button" type="submit" value="{% trans '提交' %}"> + </form> + {{ form.media }} + </div> + </section> + </div> + {% include "partial/_footer.html" %} + </div> + + + <script> + // mark required + $("#content *[required]").each(function () { + $(this).prev().prepend("*"); + }); + + // when source site is this site, hide url input box and populate it with fake url + // the backend would update this field + if ($("select[name='source_site']").val() == "{{ this_site_enum_value }}") { + $("input[name='source_url']").hide(); + $("label[for='id_source_url']").hide(); + $("input[name='source_url']").val("https://www.temp.com/" + Date.now() + Math.random()); + } + $("select[name='source_site']").change(function () { + let value = $(this).val(); + if (value == "{{ this_site_enum_value }}") { + $("input[name='source_url']").hide(); + $("label[for='id_source_url']").hide(); + $("input[name='source_url']").val("https://www.temp.com/" + Date.now() + Math.random()); + } else { + $("input[name='source_url']").show(); + $("label[for='id_source_url']").show(); + $("input[name='source_url']").val(""); + } + }); + + </script> +</body> + + +</html> \ No newline at end of file diff --git a/collection/templates/delete.html b/collection/templates/delete.html new file mode 100644 index 00000000..ee6d440d --- /dev/null +++ b/collection/templates/delete.html @@ -0,0 +1,117 @@ +{% load static %} +{% load i18n %} +{% load l10n %} +{% load humanize %} +{% load admin_url %} +{% load mastodon %} +{% load oauth_token %} +{% load truncate %} +{% load thumb %} +<!DOCTYPE html> +<html lang="en"> + +<head> + <meta charset="UTF-8"> + <meta name="viewport" content="width=device-width, initial-scale=1.0"> + <meta property="og:title" content="{{ site_name }} {% trans '收藏单' %} - {{ collection.title }}"> + <meta property="og:description" content="{{ collection.description }}"> + <meta property="og:type" content="article"> + <meta property="og:article:author" content="{{ collection.owner.username }}"> + <meta property="og:url" content="{{ request.build_absolute_uri }}"> + <meta property="og:image" content="{{ collection.cover|thumb:'normal' }}"> + <title>{{ site_name }} {% trans '收藏单' %} - {{ collection.title }}</title> + <script src="https://static.neodb.social/cdnjs.cloudflare.com/ajax/libs/jquery/3.6.0/jquery.min.js"></script> + <script src="{% static 'lib/js/rating-star.js' %}"></script> + <script src="{% static 'js/rating-star-readonly.js' %}"></script> + <link rel="stylesheet" href="{% static 'lib/css/rating-star.css' %}"> + <link rel="stylesheet" href="{% static 'css/boofilsic.min.css' %}"> + <script src="https://static.neodb.social/cdnjs.cloudflare.com/ajax/libs/htmx/1.8.0/htmx.min.js"></script> +</head> + +<body> + <div id="page-wrapper"> + <div id="content-wrapper"> + {% include "partial/_navbar.html" %} + + <section id="content"> + <div class="grid"> + <div class="grid__main" id="main"> + <div class="main-section-wrapper"> + <div class="review-head"> + <h5 class="review-head__title"> + 确认删除收藏单「{{ collection.title }}」吗? + </h5> + {% if collection.visibility > 0 %} + <span class="icon-lock"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20"> + <path + d="M17,8.48h-.73V6.27a6.27,6.27,0,1,0-12.53,0V8.48H3a.67.67,0,0,0-.67.67V19.33A.67.67,0,0,0,3,20H17a.67.67,0,0,0,.67-.67V9.15A.67.67,0,0,0,17,8.48ZM6.42,6.27h0a3.57,3.57,0,0,1,7.14,0h0V8.48H6.42Z" /> + </svg></span> + {% endif %} + <div class="review-head__body"> + <div class="review-head__info"> + + <a href="{% url 'users:home' collection.owner.mastodon_username %}" class="review-head__owner-link">{{ collection.owner.mastodon_username }}</a> + + + <span class="review-head__time">{{ collection.edited_time }}</span> + + </div> + <div class="review-head__actions"> + </div> + </div> + <div id="rawContent"> + {{ form.description }} + </div> + {{ form.media }} + <div class="dividing-line"></div> + <div class="clearfix"> + <form action="{% url 'collection:delete' collection.id %}" method="post" class="float-right"> + {% csrf_token %} + <input class="button" type="submit" value="{% trans '确认' %}"> + </form> + <button onclick="history.back()" class="button button-clear float-right">{% trans '返回' %}</button> + </div> + <!-- <div class="dividing-line"></div> --> + <!-- <div class="entity-card__img-wrapper" style="text-align: center;"> + <img src="{{ collection.cover|thumb:'normal' }}" alt="" class="entity-card__img"> + </div> --> + </div> + </div> + </div> + <div class="grid__aside" id="aside"> + <div class="aside-section-wrapper"> + <div class="entity-card"> + <div class="entity-card__img-wrapper"> + <a href="{% url 'collection:retrieve' collection.id %}"> + <img src="{{ collection.cover|thumb:'normal' }}" alt="" class="entity-card__img"> + </a> + </div> + <div class="entity-card__info-wrapper"> + <h5 class="entity-card__title"> + <a href="{% url 'collection:retrieve' collection.id %}"> + {{ collection.title }} + </a> + </h5> + </div> + </div> + </div> + </div> + </div> + </section> + </div> + {% include "partial/_footer.html" %} + </div> + + + <script> + $(".markdownx textarea").hide(); + </script> + <script> + document.body.addEventListener('htmx:configRequest', (event) => { + event.detail.headers['X-CSRFToken'] = '{{ csrf_token }}'; + }) + </script> +</body> + + +</html> diff --git a/collection/templates/detail.html b/collection/templates/detail.html new file mode 100644 index 00000000..d78513b9 --- /dev/null +++ b/collection/templates/detail.html @@ -0,0 +1,147 @@ +{% load static %} +{% load i18n %} +{% load l10n %} +{% load humanize %} +{% load admin_url %} +{% load mastodon %} +{% load oauth_token %} +{% load truncate %} +{% load thumb %} +<!DOCTYPE html> +<html lang="en"> + +<head> + <meta charset="UTF-8"> + <meta name="viewport" content="width=device-width, initial-scale=1.0"> + <meta property="og:title" content="{{ site_name }} {% trans '收藏单' %} - {{ collection.title }}"> + <meta property="og:description" content="{{ collection.description }}"> + <meta property="og:type" content="article"> + <meta property="og:article:author" content="{{ collection.owner.username }}"> + <meta property="og:url" content="{{ request.build_absolute_uri }}"> + <meta property="og:image" content="{{ collection.cover|thumb:'normal' }}"> + + <title>{{ site_name }} {% trans '收藏单' %} - {{ collection.title }}</title> + + {% include "partial/_common_libs.html" with jquery=1 %} + + <script src="{% static 'lib/js/rating-star.js' %}"></script> + <script src="{% static 'js/rating-star-readonly.js' %}"></script> +</head> + +<body> + <div id="page-wrapper"> + <div id="content-wrapper"> + {% include "partial/_navbar.html" %} + + <section id="content"> + <div class="grid"> + <div class="grid__main" id="main"> + <div class="main-section-wrapper"> + <div class="review-head"> + <h5 class="review-head__title"> + {{ collection.title }} + </h5> + {% if collection.visibility > 0 %} + <span class="icon-lock"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20"> + <path + d="M17,8.48h-.73V6.27a6.27,6.27,0,1,0-12.53,0V8.48H3a.67.67,0,0,0-.67.67V19.33A.67.67,0,0,0,3,20H17a.67.67,0,0,0,.67-.67V9.15A.67.67,0,0,0,17,8.48ZM6.42,6.27h0a3.57,3.57,0,0,1,7.14,0h0V8.48H6.42Z" /> + </svg></span> + {% endif %} + <div class="review-head__body"> + <div class="review-head__info"> + + <a href="{% url 'users:home' collection.owner.mastodon_username %}" class="review-head__owner-link">{{ collection.owner.mastodon_username }}</a> + + + <span class="review-head__time">{{ collection.edited_time }}</span> + + </div> + <div class="review-head__actions"> + {% if request.user == collection.owner %} + <a class="review-head__action-link" href="{% url 'collection:update' collection.id %}">{% trans '编辑' %}</a> + <a class="review-head__action-link" href="{% url 'collection:delete' collection.id %}">{% trans '删除' %}</a> + {% elif editable %} + <span class="review-head__time">可协作整理</span> + {% endif %} + </div> + </div> + <!-- <div class="dividing-line"></div> --> + <!-- <div class="entity-card__img-wrapper" style="text-align: center;"> + <img src="{{ collection.cover|thumb:'normal' }}" alt="" class="entity-card__img"> + </div> --> + <div id="rawContent"> + {{ form.description }} + </div> + {{ form.media }} + </div> + <div class="entity-list" hx-get="{% url 'collection:retrieve_entity_list' collection.id %}" hx-trigger="load"> + </div> + </div> + </div> + <div class="grid__aside" id="aside"> + <div class="aside-section-wrapper"> + <div class="entity-card"> + <div class="entity-card__img-wrapper"> + <a href="{% url 'collection:retrieve' collection.id %}"> + <img src="{{ collection.cover|thumb:'normal' }}" alt="" class="entity-card__img"> + </a> + </div> + <div class="entity-card__info-wrapper"> + <h5 class="entity-card__title"> + <a href="{% url 'collection:retrieve' collection.id %}"> + {{ collection.title }} + </a> + </h5> + </div> + </div> + </div> + + {% if request.user != collection.owner %} + <div class="aside-section-wrapper"> + <div class="action-panel"> + <div class="action-panel__button-group action-panel__button-group--center"> + {% if following %} + <form action="{% url 'collection:unfollow' collection.id %}" method="post"> + {% csrf_token %} + <button class="action-panel__button">{% trans '取消关注' %}</button> + </form> + {% else %} + <form action="{% url 'collection:follow' collection.id %}" method="post"> + {% csrf_token %} + <button class="action-panel__button">{% trans '关注' %}</button> + </form> + {% endif %} + </div> + </div> + </div> + {% endif %} + + <div class="aside-section-wrapper"> + <div class="action-panel"> + <div class="action-panel__button-group action-panel__button-group--center"> + <form> + <button class="action-panel__button add-to-list" hx-get="{% url 'collection:share' collection.id %}" hx-target="body" hx-swap="beforeend">{% trans '分享到联邦网络' %}</button> + </form> + </div> + </div> + </div> + </div> + </div> + </section> + </div> + {% include "partial/_footer.html" %} + </div> + + + <script> + $(".markdownx textarea").hide(); + </script> + <script> + document.body.addEventListener('htmx:configRequest', (event) => { + event.detail.headers['X-CSRFToken'] = '{{ csrf_token }}'; + }) + </script> +</body> + + +</html> diff --git a/collection/templates/edit_item_comment.html b/collection/templates/edit_item_comment.html new file mode 100644 index 00000000..674985e3 --- /dev/null +++ b/collection/templates/edit_item_comment.html @@ -0,0 +1,5 @@ +<form hx-post="{% url 'collection:update_item_comment' collection.id collectionitem.id %}"> + <input name="comment" value="{{ collectionitem.comment }}"> + <input type="submit" style="width:unset;" value="修改"> + <button style="width:unset;" hx-get="{% url 'collection:show_item_comment' collection.id collectionitem.id %}">取消</button> +</form> \ No newline at end of file diff --git a/collection/templates/entity_list.html b/collection/templates/entity_list.html new file mode 100644 index 00000000..4cdc9ee2 --- /dev/null +++ b/collection/templates/entity_list.html @@ -0,0 +1,21 @@ +{% load thumb %} +{% load i18n %} +{% load l10n %} +<ul class="entity-list__entities"> + {% for collectionitem in collection.collectionitem_list %} + {% if collectionitem.item is not None %} + {% include "partial/list_item.html" with item=collectionitem.item %} + {% endif %} + {% empty %} + {% endfor %} + {% if editable %} + <li> + <form hx-target=".entity-list" hx-post="{% url 'collection:append_item' form.instance.id %}" method="POST"> + {% csrf_token %} + <input type="url" name="url" placeholder="https://neodb.social/movies/1/" style="min-width:24rem" required> + <input type="text" name="comment" placeholder="{% trans '备注' %}" style="min-width:24rem"> + <input class="button" type="submit" value="{% trans '添加' %}" > + </form> + </li> + {% endif %} +</ul> diff --git a/collection/templates/list.html b/collection/templates/list.html new file mode 100644 index 00000000..0c9c75c3 --- /dev/null +++ b/collection/templates/list.html @@ -0,0 +1,99 @@ +{% load static %} +{% load i18n %} +{% load l10n %} +{% load humanize %} +{% load admin_url %} +{% load mastodon %} +{% load oauth_token %} +{% load truncate %} +{% load highlight %} +{% load thumb %} +<!DOCTYPE html> +<html lang="en"> + +<head> + <meta charset="UTF-8"> + <meta name="viewport" content="width=device-width, initial-scale=1.0"> + <title>{{ site_name }} - {{ title }}</title> + <script src="https://static.neodb.social/cdnjs.cloudflare.com/ajax/libs/jquery/3.6.0/jquery.min.js"></script> + <script src="{% static 'lib/js/rating-star.js' %}"></script> + <script src="{% static 'js/rating-star-readonly.js' %}"></script> + <link rel="stylesheet" href="{% static 'lib/css/rating-star.css' %}"> + <link rel="stylesheet" href="{% static 'css/boofilsic.min.css' %}"> +</head> + +<body> + <div id="page-wrapper"> + <div id="content-wrapper"> + {% include "partial/_navbar.html" %} + + <section id="content"> + <div class="grid"> + <div class="grid__main" id="main"> + <div class="main-section-wrapper"> + <div class="entity-reviews"> + <h5 class="entity-reviews__title entity-reviews__title--stand-alone"> + {{ title }} + </h5> + <ul class="entity-reviews__review-list"> + + {% for collection in collections %} + + <li class="entity-reviews__review entity-reviews__review--wider"> + <img src="{{ collection.cover|thumb:'normal' }}" style="width:40px; float:right"class="entity-card__img"> + <span class="entity-reviews__review-title"><a href="{% url 'collection:retrieve' collection.id %}">{{ collection.title }}</a></span> + <span class="entity-reviews__review-time">{{ collection.edited_time }}</span> + {% if collection.visibility > 0 %} + <span class="icon-lock"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20"><path d="M17,8.48h-.73V6.27a6.27,6.27,0,1,0-12.53,0V8.48H3a.67.67,0,0,0-.67.67V19.33A.67.67,0,0,0,3,20H17a.67.67,0,0,0,.67-.67V9.15A.67.67,0,0,0,17,8.48ZM6.42,6.27h0a3.57,3.57,0,0,1,7.14,0h0V8.48H6.42Z"/></svg></span> + {% endif %} + </li> + {% empty %} + <div>{% trans '无结果' %}</div> + {% endfor %} + + </ul> + </div> + <div class="pagination"> + + {% if collections.pagination.has_prev %} + <a href="?page=1" class="pagination__nav-link pagination__nav-link">«</a> + <a href="?page={{ collections.previous_page_number }}" + class="pagination__nav-link pagination__nav-link--right-margin pagination__nav-link">‹</a> + {% endif %} + + {% for page in collections.pagination.page_range %} + + {% if page == collections.pagination.current_page %} + <a href="?page={{ page }}" class="pagination__page-link pagination__page-link--current">{{ page }}</a> + {% else %} + <a href="?page={{ page }}" class="pagination__page-link">{{ page }}</a> + {% endif %} + + {% endfor %} + + {% if collections.pagination.has_next %} + <a href="?page={{ collections.next_page_number }}" + class="pagination__nav-link pagination__nav-link--left-margin">›</a> + <a href="?page={{ collections.pagination.last_page }}" class="pagination__nav-link">»</a> + {% endif %} + + </div> + </div> + </div> + + + </div> + </section> + </div> + {% include "partial/_footer.html" %} + </div> + + + + <script> + + </script> +</body> + + +</html> diff --git a/collection/templates/share_collection.html b/collection/templates/share_collection.html new file mode 100644 index 00000000..dc8e3beb --- /dev/null +++ b/collection/templates/share_collection.html @@ -0,0 +1,56 @@ +{% load static %} +{% load i18n %} +{% load l10n %} +{% load humanize %} +{% load admin_url %} +{% load mastodon %} +{% load oauth_token %} +{% load truncate %} +{% load highlight %} +{% load thumb %} + +<div id="modal" _="on closeModal add .closing then wait for animationend then remove me"> + <div class="modal-underlay" _="on click trigger closeModal"></div> + <div class="modal-content"> + <div class="add-to-list-modal__head"> + <span class="add-to-list-modal__title">{% trans '分享收藏单' %}</span> + <span class="add-to-list-modal__close-button modal-close" _="on click trigger closeModal"> + <span class="icon-cross"> + <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20"> + <polygon + points="20 2.61 17.39 0 10 7.39 2.61 0 0 2.61 7.39 10 0 17.39 2.61 20 10 12.61 17.39 20 20 17.39 12.61 10 20 2.61"> + </polygon> + </svg> + </span> + </span> + </div> + <div class="add-to-list-modal__body"> + <form action="/collections/share/{{ id }}/" method="post"> + {% csrf_token %} + <div> + <label for="id_visibility_0">分享可见性(不同于收藏单本身的权限):</label> + <ul id="id_visibility"> + <li><label for="id_visibility_0"><input type="radio" name="visibility" value="0" required="" id="id_visibility_0" {% if visibility == 0 %}checked{% endif %}> + 公开</label> + + </li> + <li><label for="id_visibility_1"><input type="radio" name="visibility" value="1" required="" id="id_visibility_1" {% if visibility == 1 %}checked{% endif %}> + 仅关注者</label> + + </li> + <li><label for="id_visibility_2"><input type="radio" name="visibility" value="2" required="" id="id_visibility_2" {% if visibility == 2 %}checked{% endif %}> + 仅自己</label> + + </li> + </ul> + </div> + <div> + <textarea type="text" name="comment" placeholder="分享附言"></textarea> + </div> + <div class="add-to-list-modal__confirm-button"> + <input type="submit" class="button float-right" value="{% trans '提交' %}"> + </div> + </form> + </div> + </div> +</div> diff --git a/collection/templates/show_item_comment.html b/collection/templates/show_item_comment.html new file mode 100644 index 00000000..f9add2bd --- /dev/null +++ b/collection/templates/show_item_comment.html @@ -0,0 +1,4 @@ +{{ collectionitem.comment }} +{% if editable %} +<a class="action-icon" hx-get="{% url 'collection:update_item_comment' collection.id collectionitem.id %}"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><g><path d="M19,20H5a1,1,0,0,0,0,2H19a1,1,0,0,0,0-2Z"/><path d="M5,18h.09l4.17-.38a2,2,0,0,0,1.21-.57l9-9a1.92,1.92,0,0,0-.07-2.71h0L16.66,2.6A2,2,0,0,0,14,2.53l-9,9a2,2,0,0,0-.57,1.21L4,16.91a1,1,0,0,0,.29.8A1,1,0,0,0,5,18ZM15.27,4,18,6.73,16,8.68,13.32,6Zm-8.9,8.91L12,7.32l2.7,2.7-5.6,5.6-3,.28Z"/></g></svg></a> +{% endif %} \ No newline at end of file diff --git a/collection/tests.py b/collection/tests.py new file mode 100644 index 00000000..7ce503c2 --- /dev/null +++ b/collection/tests.py @@ -0,0 +1,3 @@ +from django.test import TestCase + +# Create your tests here. diff --git a/collection/urls.py b/collection/urls.py new file mode 100644 index 00000000..f3c62663 --- /dev/null +++ b/collection/urls.py @@ -0,0 +1,27 @@ +from django.urls import path, re_path +from .views import * + + +app_name = 'collection' +urlpatterns = [ + path('mine/', list, name='list'), + path('create/', create, name='create'), + path('<int:id>/', retrieve, name='retrieve'), + path('<int:id>/entity_list', retrieve_entity_list, name='retrieve_entity_list'), + path('update/<int:id>/', update, name='update'), + path('delete/<int:id>/', delete, name='delete'), + path('follow/<int:id>/', follow, name='follow'), + path('unfollow/<int:id>/', unfollow, name='unfollow'), + path('<int:id>/append_item/', append_item, name='append_item'), + path('<int:id>/delete_item/<int:item_id>', delete_item, name='delete_item'), + path('<int:id>/move_up_item/<int:item_id>', move_up_item, name='move_up_item'), + path('<int:id>/move_down_item/<int:item_id>', move_down_item, name='move_down_item'), + path('<int:id>/update_item_comment/<int:item_id>', update_item_comment, name='update_item_comment'), + path('<int:id>/show_item_comment/<int:item_id>', show_item_comment, name='show_item_comment'), + path('with/<str:type>/<int:id>/', list_with, name='list_with'), + path('add_to_list/<str:type>/<int:id>/', add_to_list, name='add_to_list'), + path('share/<int:id>/', share, name='share'), + path('follow2/<int:id>/', wish, name='wish'), + + # TODO: tag +] diff --git a/collection/views.py b/collection/views.py new file mode 100644 index 00000000..659cb586 --- /dev/null +++ b/collection/views.py @@ -0,0 +1,442 @@ +import logging +from django.shortcuts import render, get_object_or_404, redirect, reverse +from django.contrib.auth.decorators import login_required, permission_required +from django.utils.translation import gettext_lazy as _ +from django.http import HttpResponseBadRequest, HttpResponseServerError, HttpResponse +from django.core.exceptions import ObjectDoesNotExist, PermissionDenied +from django.db import IntegrityError, transaction +from django.db.models import Count +from django.utils import timezone +from django.core.paginator import Paginator +from mastodon import mastodon_request_included +from mastodon.models import MastodonApplication +from mastodon.api import post_toot, TootVisibilityEnum, share_collection +from common.utils import PageLinksGenerator +from common.views import PAGE_LINK_NUMBER, jump_or_scrape, go_relogin +from common.models import SourceSiteEnum +from .models import * +from .forms import * +from django.conf import settings +import re +from users.models import User +from django.http import HttpResponseRedirect + + +logger = logging.getLogger(__name__) +mastodon_logger = logging.getLogger("django.mastodon") + + +# how many marks showed on the detail page +MARK_NUMBER = 5 +# how many marks at the mark page +MARK_PER_PAGE = 20 +# how many reviews showed on the detail page +REVIEW_NUMBER = 5 +# how many reviews at the mark page +REVIEW_PER_PAGE = 20 +# max tags on detail page +TAG_NUMBER = 10 + + +class HTTPResponseHXRedirect(HttpResponseRedirect): + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self['HX-Redirect'] = self['Location'] + status_code = 200 + + +# public data +########################### +@login_required +def create(request): + if request.method == 'GET': + form = CollectionForm() + return render( + request, + 'create_update.html', + { + 'form': form, + 'title': _('添加收藏单'), + 'submit_url': reverse("collection:create"), + # provided for frontend js + 'this_site_enum_value': SourceSiteEnum.IN_SITE.value, + } + ) + elif request.method == 'POST': + if request.user.is_authenticated: + # only local user can alter public data + form = CollectionForm(request.POST, request.FILES) + form.instance.owner = request.user + if form.is_valid(): + form.instance.last_editor = request.user + try: + with transaction.atomic(): + form.save() + except IntegrityError as e: + logger.error(e.__str__()) + return HttpResponseServerError("integrity error") + return redirect(reverse("collection:retrieve", args=[form.instance.id])) + else: + return render( + request, + 'create_update.html', + { + 'form': form, + 'title': _('添加收藏单'), + 'submit_url': reverse("collection:create"), + # provided for frontend js + 'this_site_enum_value': SourceSiteEnum.IN_SITE.value, + } + ) + else: + return redirect(reverse("users:login")) + else: + return HttpResponseBadRequest() + + +@login_required +def update(request, id): + page_title = _("修改收藏单") + collection = get_object_or_404(Collection, pk=id) + if not collection.is_visible_to(request.user): + raise PermissionDenied() + if request.method == 'GET': + form = CollectionForm(instance=collection) + return render( + request, + 'create_update.html', + { + 'form': form, + 'is_update': True, + 'title': page_title, + 'submit_url': reverse("collection:update", args=[collection.id]), + # provided for frontend js + 'this_site_enum_value': SourceSiteEnum.IN_SITE.value, + } + ) + elif request.method == 'POST': + form = CollectionForm(request.POST, request.FILES, instance=collection) + if form.is_valid(): + form.instance.last_editor = request.user + form.instance.edited_time = timezone.now() + try: + with transaction.atomic(): + form.save() + except IntegrityError as e: + logger.error(e.__str__()) + return HttpResponseServerError("integrity error") + else: + return render( + request, + 'create_update.html', + { + 'form': form, + 'is_update': True, + 'title': page_title, + 'submit_url': reverse("collection:update", args=[collection.id]), + # provided for frontend js + 'this_site_enum_value': SourceSiteEnum.IN_SITE.value, + } + ) + return redirect(reverse("collection:retrieve", args=[form.instance.id])) + + else: + return HttpResponseBadRequest() + + +@mastodon_request_included +# @login_required +def retrieve(request, id): + if request.method == 'GET': + collection = get_object_or_404(Collection, pk=id) + if not collection.is_visible_to(request.user): + raise PermissionDenied() + form = CollectionForm(instance=collection) + + if request.user.is_authenticated: + following = True if CollectionMark.objects.filter(owner=request.user, collection=collection).first() is not None else False + followers = [] + else: + following = False + followers = [] + + return render( + request, + 'detail.html', + { + 'collection': collection, + 'form': form, + 'editable': request.user.is_authenticated and collection.is_editable_by(request.user), + 'followers': followers, + 'following': following, + } + ) + else: + logger.warning('non-GET method at /collections/<id>') + return HttpResponseBadRequest() + + +@mastodon_request_included +# @login_required +def retrieve_entity_list(request, id): + collection = get_object_or_404(Collection, pk=id) + if not collection.is_visible_to(request.user): + raise PermissionDenied() + form = CollectionForm(instance=collection) + + followers = [] + if request.user.is_authenticated: + followers = [] + + return render( + request, + 'entity_list.html', + { + 'collection': collection, + 'form': form, + 'editable': request.user.is_authenticated and collection.is_editable_by(request.user), + 'followers': followers, + + } + ) + + +@login_required +def delete(request, id): + collection = get_object_or_404(Collection, pk=id) + if request.user.is_staff or request.user == collection.owner: + if request.method == 'GET': + return render( + request, + 'delete.html', + { + 'collection': collection, + 'form': CollectionForm(instance=collection) + } + ) + elif request.method == 'POST': + collection.delete() + return redirect(reverse("common:home")) + else: + raise PermissionDenied() + + +@login_required +def wish(request, id): + try: + CollectionMark.objects.create(owner=request.user, collection=Collection.objects.get(id=id)) + except Exception: + pass + return HttpResponse("✔️") + + +@login_required +def follow(request, id): + CollectionMark.objects.create(owner=request.user, collection=Collection.objects.get(id=id)) + return redirect(reverse("collection:retrieve", args=[id])) + + +@login_required +def unfollow(request, id): + CollectionMark.objects.filter(owner=request.user, collection=Collection.objects.get(id=id)).delete() + return redirect(reverse("collection:retrieve", args=[id])) + + +@login_required +def list(request, user_id=None, marked=False): + if request.method == 'GET': + user = request.user if user_id is None else User.objects.get(id=user_id) + if marked: + title = user.mastodon_username + _('关注的收藏单') + queryset = Collection.objects.filter(pk__in=CollectionMark.objects.filter(owner=user).values_list('collection', flat=True)) + else: + title = user.mastodon_username + _('创建的收藏单') + queryset = Collection.objects.filter(owner=user) + paginator = Paginator(queryset, REVIEW_PER_PAGE) + page_number = request.GET.get('page', default=1) + collections = paginator.get_page(page_number) + collections.pagination = PageLinksGenerator( + PAGE_LINK_NUMBER, page_number, paginator.num_pages) + return render( + request, + 'list.html', + { + 'collections': collections, + 'title': title, + } + ) + else: + return HttpResponseBadRequest() + + +def get_entity_by_url(url): + m = re.findall(r'^/?(movies|books|games|music/album|music/song)/(\d+)/?', url.strip().lower().replace(settings.APP_WEBSITE.lower(), '')) + if len(m) > 0: + mapping = { + 'movies': Movie, + 'books': Book, + 'games': Game, + 'music/album': Album, + 'music/song': Song, + } + cls = mapping.get(m[0][0]) + id = int(m[0][1]) + if cls is not None: + return cls.objects.get(id=id) + return None + + +@login_required +def append_item(request, id): + collection = get_object_or_404(Collection, pk=id) + if request.method == 'POST' and collection.is_editable_by(request.user): + url = request.POST.get('url') + comment = request.POST.get('comment') + item = get_entity_by_url(url) + collection.append_item(item, comment) + collection.save() + # return redirect(reverse("collection:retrieve", args=[id])) + return retrieve_entity_list(request, id) + else: + return HttpResponseBadRequest() + + +@login_required +def delete_item(request, id, item_id): + collection = get_object_or_404(Collection, pk=id) + if request.method == 'POST' and collection.is_editable_by(request.user): + # item_id = int(request.POST.get('item_id')) + item = CollectionItem.objects.get(id=item_id) + if item is not None and item.collection == collection: + item.delete() + # collection.save() + # return HTTPResponseHXRedirect(redirect_to=reverse("collection:retrieve", args=[id])) + return retrieve_entity_list(request, id) + return HttpResponseBadRequest() + + +@login_required +def move_up_item(request, id, item_id): + collection = get_object_or_404(Collection, pk=id) + if request.method == 'POST' and collection.is_editable_by(request.user): + # item_id = int(request.POST.get('item_id')) + item = CollectionItem.objects.get(id=item_id) + if item is not None and item.collection == collection: + items = collection.collectionitem_list + idx = items.index(item) + if idx > 0: + o = items[idx - 1] + p = o.position + o.position = item.position + item.position = p + o.save() + item.save() + # collection.save() + # return HTTPResponseHXRedirect(redirect_to=reverse("collection:retrieve", args=[id])) + return retrieve_entity_list(request, id) + return HttpResponseBadRequest() + + +@login_required +def move_down_item(request, id, item_id): + collection = get_object_or_404(Collection, pk=id) + if request.method == 'POST' and collection.is_editable_by(request.user): + # item_id = int(request.POST.get('item_id')) + item = CollectionItem.objects.get(id=item_id) + if item is not None and item.collection == collection: + items = collection.collectionitem_list + idx = items.index(item) + if idx + 1 < len(items): + o = items[idx + 1] + p = o.position + o.position = item.position + item.position = p + o.save() + item.save() + # collection.save() + # return HTTPResponseHXRedirect(redirect_to=reverse("collection:retrieve", args=[id])) + return retrieve_entity_list(request, id) + return HttpResponseBadRequest() + + +def show_item_comment(request, id, item_id): + collection = get_object_or_404(Collection, pk=id) + item = CollectionItem.objects.get(id=item_id) + editable = request.user.is_authenticated and collection.is_editable_by(request.user) + return render(request, 'show_item_comment.html', {'collection': collection, 'collectionitem': item, 'editable': editable}) + + +@login_required +def update_item_comment(request, id, item_id): + collection = get_object_or_404(Collection, pk=id) + if collection.is_editable_by(request.user): + # item_id = int(request.POST.get('item_id')) + item = CollectionItem.objects.get(id=item_id) + if item is not None and item.collection == collection: + if request.method == 'POST': + item.comment = request.POST.get('comment', default='') + item.save() + return render(request, 'show_item_comment.html', {'collection': collection, 'collectionitem': item, 'editable': True}) + else: + return render(request, 'edit_item_comment.html', {'collection': collection, 'collectionitem': item}) + return retrieve_entity_list(request, id) + return HttpResponseBadRequest() + + +@login_required +def list_with(request, type, id): + pass + + +def get_entity_by_type_id(type, id): + mapping = { + 'movie': Movie, + 'book': Book, + 'game': Game, + 'album': Album, + 'song': Song, + } + cls = mapping.get(type) + if cls is not None: + return cls.objects.get(id=id) + return None + + +@login_required +def add_to_list(request, type, id): + item = get_entity_by_type_id(type, id) + if request.method == 'GET': + queryset = Collection.objects.filter(owner=request.user) + return render( + request, + 'add_to_list.html', + { + 'type': type, + 'id': id, + 'item': item, + 'collections': queryset, + } + ) + else: + cid = int(request.POST.get('collection_id', default=0)) + if not cid: + cid = Collection.objects.create(owner=request.user, title=f'{request.user.username}的收藏单').id + collection = Collection.objects.filter(owner=request.user, id=cid).first() + collection.append_item(item, request.POST.get('comment')) + return HttpResponseRedirect(request.META.get('HTTP_REFERER')) + + +@login_required +def share(request, id): + collection = Collection.objects.filter(id=id).first() + if not collection: + return HttpResponseBadRequest() + if request.method == 'GET': + return render(request, 'share_collection.html', {'id': id, 'visibility': request.user.get_preference().default_visibility}) + else: + visibility = int(request.POST.get('visibility', default=0)) + comment = request.POST.get('comment') + if share_collection(collection, comment, request.user, visibility): + return HttpResponseRedirect(request.META.get('HTTP_REFERER')) + else: + return go_relogin(request) diff --git a/common/forms.py b/common/forms.py index 5372ce12..849b31c4 100644 --- a/common/forms.py +++ b/common/forms.py @@ -1,4 +1,5 @@ from django import forms +from markdownx.fields import MarkdownxFormField import django.contrib.postgres.forms as postgres from django.utils import formats from django.core.exceptions import ValidationError @@ -45,7 +46,7 @@ class HstoreInput(forms.Widget): js = ('js/key_value_input.js',) -class JSONField(postgres.JSONField): +class JSONField(forms.fields.JSONField): widget = KeyValueInput def to_python(self, value): if not value: @@ -88,7 +89,7 @@ class RatingValidator: _('%(value)s is not an integer'), params={'value': value}, ) - if not str(value) in [str(i) for i in range(1, 11)]: + if not str(value) in [str(i) for i in range(0, 11)]: raise ValidationError( _('%(value)s is not an integer in range 1-10'), params={'value': value}, @@ -154,9 +155,9 @@ class MultiSelect(forms.SelectMultiple): class Media: css = { - 'all': ('lib/css/multiple-select.min.css',) + 'all': ('https://cdn.jsdelivr.net/npm/multiple-select@1.5.2/dist/multiple-select.min.css',) } - js = ('lib/js/multiple-select.min.js',) + js = ('https://cdn.jsdelivr.net/npm/multiple-select@1.5.2/dist/multiple-select.min.js',) class HstoreField(forms.CharField): @@ -223,22 +224,25 @@ class DurationField(forms.TimeField): ############################# # Form ############################# +VISIBILITY_CHOICES = [ + (0, _("公开")), + (1, _("仅关注者")), + (2, _("仅自己")), +] -class MarkForm(forms.ModelForm): - IS_PRIVATE_CHOICES = [ - (True, _("仅关注者")), - (False, _("公开")), - ] - + +class MarkForm(forms.ModelForm): id = forms.IntegerField(required=False, widget=forms.HiddenInput()) share_to_mastodon = forms.BooleanField( - label=_("分享到长毛象"), initial=True, required=False) + label=_("分享到联邦网络"), initial=True, required=False) rating = forms.IntegerField( - validators=[RatingValidator()], widget=forms.HiddenInput(), required=False) - is_private = RadioBooleanField( + label=_("评分"), validators=[RatingValidator()], widget=forms.HiddenInput(), required=False) + visibility = forms.TypedChoiceField( label=_("可见性"), - initial=True, - choices=IS_PRIVATE_CHOICES + initial=0, + coerce=int, + choices=VISIBILITY_CHOICES, + widget=forms.RadioSelect ) tags = TagField( required=False, @@ -259,15 +263,15 @@ class MarkForm(forms.ModelForm): class ReviewForm(forms.ModelForm): - IS_PRIVATE_CHOICES = [ - (True, _("仅关注者")), - (False, _("公开")), - ] + title = forms.CharField(label=_("标题")) + content = MarkdownxFormField(label=_("正文 (Markdown)")) share_to_mastodon = forms.BooleanField( - label=_("分享到长毛象"), initial=True, required=False) + label=_("分享到联邦网络"), initial=True, required=False) id = forms.IntegerField(required=False, widget=forms.HiddenInput()) - is_private = RadioBooleanField( + visibility = forms.TypedChoiceField( label=_("可见性"), - initial=True, - choices=IS_PRIVATE_CHOICES + initial=0, + coerce=int, + choices=VISIBILITY_CHOICES, + widget=forms.RadioSelect ) diff --git a/common/importers/douban.py b/common/importers/douban.py new file mode 100644 index 00000000..321cea8f --- /dev/null +++ b/common/importers/douban.py @@ -0,0 +1,270 @@ +import openpyxl +import requests +import re +from lxml import html +from markdownify import markdownify as md +from datetime import datetime +from common.scraper import get_scraper_by_url +import logging +import pytz +from django.conf import settings +from django.core.exceptions import ObjectDoesNotExist +from user_messages import api as msg +import django_rq +from common.utils import GenerateDateUUIDMediaFilePath +import os +from books.models import BookReview, Book, BookMark, BookTag +from movies.models import MovieReview, Movie, MovieMark, MovieTag +from music.models import AlbumReview, Album, AlbumMark, AlbumTag +from games.models import GameReview, Game, GameMark, GameTag +from common.scraper import DoubanAlbumScraper, DoubanBookScraper, DoubanGameScraper, DoubanMovieScraper +from PIL import Image +from io import BytesIO +import filetype +from common.models import MarkStatusEnum + + +logger = logging.getLogger(__name__) +tz_sh = pytz.timezone('Asia/Shanghai') + + +def fetch_remote_image(url): + try: + print(f'fetching remote image {url}') + raw_img = None + ext = None + if settings.SCRAPESTACK_KEY is not None: + dl_url = f'http://api.scrapestack.com/scrape?access_key={settings.SCRAPESTACK_KEY}&url={url}' + elif settings.SCRAPERAPI_KEY is not None: + dl_url = f'http://api.scraperapi.com?api_key={settings.SCRAPERAPI_KEY}&url={url}' + else: + dl_url = url + img_response = requests.get(dl_url, timeout=settings.SCRAPING_TIMEOUT) + raw_img = img_response.content + img = Image.open(BytesIO(raw_img)) + img.load() # corrupted image will trigger exception + content_type = img_response.headers.get('Content-Type') + ext = filetype.get_type(mime=content_type.partition(';')[0].strip()).extension + f = GenerateDateUUIDMediaFilePath(None, "x." + ext, settings.MARKDOWNX_MEDIA_PATH) + file = settings.MEDIA_ROOT + f + local_url = settings.MEDIA_URL + f + os.makedirs(os.path.dirname(file), exist_ok=True) + img.save(file) + # print(f'remote image saved as {local_url}') + return local_url + except Exception: + print(f'unable to fetch remote image {url}') + return url + + +class DoubanImporter: + total = 0 + processed = 0 + skipped = 0 + imported = 0 + failed = [] + user = None + visibility = 0 + file = None + + def __init__(self, user, visibility): + self.user = user + self.visibility = visibility + + def update_user_import_status(self, status): + self.user.preference.import_status['douban_pending'] = status + self.user.preference.import_status['douban_file'] = self.file + self.user.preference.import_status['douban_visibility'] = self.visibility + self.user.preference.import_status['douban_total'] = self.total + self.user.preference.import_status['douban_processed'] = self.processed + self.user.preference.import_status['douban_skipped'] = self.skipped + self.user.preference.import_status['douban_imported'] = self.imported + self.user.preference.import_status['douban_failed'] = self.failed + self.user.preference.save(update_fields=['import_status']) + + def import_from_file(self, uploaded_file): + try: + wb = openpyxl.open(uploaded_file, read_only=True, data_only=True, keep_links=False) + wb.close() + file = settings.MEDIA_ROOT + GenerateDateUUIDMediaFilePath(None, "x.xlsx", settings.SYNC_FILE_PATH_ROOT) + os.makedirs(os.path.dirname(file), exist_ok=True) + with open(file, 'wb') as destination: + for chunk in uploaded_file.chunks(): + destination.write(chunk) + self.file = file + self.update_user_import_status(2) + jid = f'Douban_{self.user.id}_{os.path.basename(self.file)}' + django_rq.get_queue('doufen').enqueue(self.import_from_file_task, job_id=jid) + except Exception: + return False + # self.import_from_file_task(file, user, visibility) + return True + + mark_sheet_config = { + '想读': [MarkStatusEnum.WISH, DoubanBookScraper, Book, BookMark, BookTag], + '在读': [MarkStatusEnum.DO, DoubanBookScraper, Book, BookMark, BookTag], + '读过': [MarkStatusEnum.COLLECT, DoubanBookScraper, Book, BookMark, BookTag], + '想看': [MarkStatusEnum.WISH, DoubanMovieScraper, Movie, MovieMark, MovieTag], + '在看': [MarkStatusEnum.DO, DoubanMovieScraper, Movie, MovieMark, MovieTag], + '想看': [MarkStatusEnum.COLLECT, DoubanMovieScraper, Movie, MovieMark, MovieTag], + '想听': [MarkStatusEnum.WISH, DoubanAlbumScraper, Album, AlbumMark, AlbumTag], + '在听': [MarkStatusEnum.DO, DoubanAlbumScraper, Album, AlbumMark, AlbumTag], + '听过': [MarkStatusEnum.COLLECT, DoubanAlbumScraper, Album, AlbumMark, AlbumTag], + '想玩': [MarkStatusEnum.WISH, DoubanGameScraper, Game, GameMark, GameTag], + '在玩': [MarkStatusEnum.DO, DoubanGameScraper, Game, GameMark, GameTag], + '玩过': [MarkStatusEnum.COLLECT, DoubanGameScraper, Game, GameMark, GameTag], + } + review_sheet_config = { + '书评': [DoubanBookScraper, Book, BookReview], + '影评': [DoubanMovieScraper, Movie, MovieReview], + '乐评': [DoubanAlbumScraper, Album, AlbumReview], + '游戏评论&攻略': [DoubanGameScraper, Game, GameReview], + } + mark_data = {} + review_data = {} + entity_lookup = {} + + def load_sheets(self): + f = open(self.file, 'rb') + wb = openpyxl.load_workbook(f, read_only=True, data_only=True, keep_links=False) + for data, config in [(self.mark_data, self.mark_sheet_config), (self.review_data, self.review_sheet_config)]: + for name in config: + data[name] = [] + if name in wb: + print(f'{self.user} parsing {name}') + for row in wb[name].iter_rows(min_row=2, values_only=True): + cells = [cell for cell in row] + if len(cells) > 6: + data[name].append(cells) + for sheet in self.mark_data.values(): + for cells in sheet: + # entity_lookup["title|rating"] = [(url, time), ...] + k = f'{cells[0]}|{cells[5]}' + v = (cells[3], cells[4]) + if k in self.entity_lookup: + self.entity_lookup[k].append(v) + else: + self.entity_lookup[k] = [v] + self.total = sum(map(lambda a: len(a), self.review_data.values())) + + def guess_entity_url(self, title, rating, timestamp): + k = f'{title}|{rating}' + if k not in self.entity_lookup: + return None + v = self.entity_lookup[k] + if len(v) > 1: + v.sort(key=lambda c: abs(timestamp - (datetime.strptime(c[1], "%Y-%m-%d %H:%M:%S") if type(c[1])==str else c[1]).replace(tzinfo=tz_sh))) + return v[0][0] + # for sheet in self.mark_data.values(): + # for cells in sheet: + # if cells[0] == title and cells[5] == rating: + # return cells[3] + + def import_from_file_task(self): + print(f'{self.user} import start') + msg.info(self.user, f'开始导入豆瓣评论') + self.update_user_import_status(1) + self.load_sheets() + print(f'{self.user} sheet loaded, {self.total} lines total') + self.update_user_import_status(1) + for name, param in self.review_sheet_config.items(): + self.import_review_sheet(self.review_data[name], param[0], param[1], param[2]) + self.update_user_import_status(0) + msg.success(self.user, f'豆瓣评论导入完成,共处理{self.total}篇,已存在{self.skipped}篇,新增{self.imported}篇。') + if len(self.failed): + msg.error(self.user, f'豆瓣评论导入时未能处理以下网址:\n{" , ".join(self.failed)}') + + def import_review_sheet(self, worksheet, scraper, entity_class, review_class): + prefix = f'{self.user} |' + if worksheet is None: # or worksheet.max_row < 2: + print(f'{prefix} {review_class.__name__} empty sheet') + return + for cells in worksheet: + if len(cells) < 6: + continue + title = cells[0] + entity_title = re.sub('^《', '', re.sub('》$', '', cells[1])) + review_url = cells[2] + time = cells[3] + rating = cells[4] + content = cells[6] + self.processed += 1 + if time: + if type(time) == str: + time = datetime.strptime(time, "%Y-%m-%d %H:%M:%S") + time = time.replace(tzinfo=tz_sh) + else: + time = None + if not content: + content = "" + if not title: + title = "" + r = self.import_review(entity_title, rating, title, review_url, content, time, scraper, entity_class, review_class) + if r == 1: + self.imported += 1 + elif r == 2: + self.skipped += 1 + else: + self.failed.append(review_url) + self.update_user_import_status(1) + + def import_review(self, entity_title, rating, title, review_url, content, time, scraper, entity_class, review_class): + # return 1: done / 2: skipped / None: failed + prefix = f'{self.user} |' + url = self.guess_entity_url(entity_title, rating, time) + if url is None: + print(f'{prefix} fetching {review_url}') + try: + if settings.SCRAPESTACK_KEY is not None: + _review_url = f'http://api.scrapestack.com/scrape?access_key={settings.SCRAPESTACK_KEY}&url={review_url}' + else: + _review_url = review_url + r = requests.get(_review_url, timeout=settings.SCRAPING_TIMEOUT) + if r.status_code != 200: + print(f'{prefix} fetching error {review_url} {r.status_code}') + return + h = html.fromstring(r.content.decode('utf-8')) + for u in h.xpath("//header[@class='main-hd']/a/@href"): + if '.douban.com/subject/' in u: + url = u + if not url: + print(f'{prefix} fetching error {review_url} unable to locate entity url') + return + except Exception: + print(f'{prefix} fetching exception {review_url}') + return + try: + entity = entity_class.objects.get(source_url=url) + print(f'{prefix} matched {url}') + except ObjectDoesNotExist: + try: + print(f'{prefix} scraping {url}') + scraper.scrape(url) + form = scraper.save(request_user=self.user) + entity = form.instance + except Exception as e: + print(f"{prefix} scrape failed: {url} {e}") + logger.error(f"{prefix} scrape failed: {url}", exc_info=e) + return + params = { + 'owner': self.user, + entity_class.__name__.lower(): entity + } + if review_class.objects.filter(**params).exists(): + return 2 + content = re.sub(r'<span style="font-weight: bold;">([^<]+)</span>', r'<b>\1</b>', content) + content = re.sub(r'(<img [^>]+>)', r'\1<br>', content) + content = re.sub(r'<div class="image-caption">([^<]+)</div>', r'<br><i>\1</i><br>', content) + content = md(content) + content = re.sub(r'(?<=!\[\]\()([^)]+)(?=\))', lambda x: fetch_remote_image(x[1]), content) + params = { + 'owner': self.user, + 'created_time': time, + 'edited_time': time, + 'title': title, + 'content': content, + 'visibility': self.visibility, + entity_class.__name__.lower(): entity, + } + review_class.objects.create(**params) + return 1 diff --git a/common/importers/goodreads.py b/common/importers/goodreads.py new file mode 100644 index 00000000..4e2fec58 --- /dev/null +++ b/common/importers/goodreads.py @@ -0,0 +1,202 @@ +import re +import requests +from lxml import html +from datetime import datetime +# from common.scrapers.goodreads import GoodreadsScraper +from common.scraper import get_scraper_by_url +from books.models import Book, BookMark +from collection.models import Collection +from common.models import MarkStatusEnum +from django.conf import settings +from user_messages import api as msg +import django_rq +from django.utils.timezone import make_aware + + +re_list = r'^https://www.goodreads.com/list/show/\d+' +re_shelf = r'^https://www.goodreads.com/review/list/\d+[^?]*\?shelf=[^&]+' +re_profile = r'^https://www.goodreads.com/user/show/(\d+)' +gr_rating = { + 'did not like it': 2, + 'it was ok': 4, + 'liked it': 6, + 'really liked it': 8, + 'it was amazing': 10 +} + + +class GoodreadsImporter: + @classmethod + def import_from_url(self, raw_url, user): + match_list = re.match(re_list, raw_url) + match_shelf = re.match(re_shelf, raw_url) + match_profile = re.match(re_profile, raw_url) + if match_profile or match_shelf or match_list: + django_rq.get_queue('doufen').enqueue(self.import_from_url_task, raw_url, user) + return True + else: + return False + + @classmethod + def import_from_url_task(cls, url, user): + match_list = re.match(re_list, url) + match_shelf = re.match(re_shelf, url) + match_profile = re.match(re_profile, url) + total = 0 + if match_list or match_shelf: + shelf = cls.parse_shelf(match_shelf[0], user) if match_shelf else cls.parse_list(match_list[0], user) + if shelf['title'] and shelf['books']: + collection = Collection.objects.create(title=shelf['title'], + description=shelf['description'] + '\n\nImported from [Goodreads](' + url + ')', + owner=user) + for book in shelf['books']: + collection.append_item(book['book'], book['review']) + total += 1 + collection.save() + msg.success(user, f'成功从Goodreads导入包含{total}本书的收藏单{shelf["title"]}。') + elif match_profile: + uid = match_profile[1] + shelves = { + MarkStatusEnum.WISH: f'https://www.goodreads.com/review/list/{uid}?shelf=to-read', + MarkStatusEnum.DO: f'https://www.goodreads.com/review/list/{uid}?shelf=currently-reading', + MarkStatusEnum.COLLECT: f'https://www.goodreads.com/review/list/{uid}?shelf=read', + } + for status in shelves: + shelf_url = shelves.get(status) + shelf = cls.parse_shelf(shelf_url, user) + for book in shelf['books']: + params = { + 'owner': user, + 'rating': book['rating'], + 'text': book['review'], + 'status': status, + 'visibility': 0, + 'book': book['book'], + } + if book['last_updated']: + params['created_time'] = book['last_updated'] + params['edited_time'] = book['last_updated'] + try: + mark = BookMark.objects.create(**params) + mark.book.update_rating(None, mark.rating) + except Exception: + print(f'Skip mark for {book["book"]}') + pass + total += 1 + msg.success(user, f'成功从Goodreads用户主页导入{total}个标记。') + + @classmethod + def parse_shelf(cls, url, user): # return {'title': 'abc', books: [{'book': obj, 'rating': 10, 'review': 'txt'}, ...]} + title = None + books = [] + url_shelf = url + '&view=table' + while url_shelf: + print(f'Shelf loading {url_shelf}') + r = requests.get(url_shelf, timeout=settings.SCRAPING_TIMEOUT) + if r.status_code != 200: + print(f'Shelf loading error {url_shelf}') + break + url_shelf = None + content = html.fromstring(r.content.decode('utf-8')) + title_elem = content.xpath("//span[@class='h1Shelf']/text()") + if not title_elem: + print(f'Shelf parsing error {url_shelf}') + break + title = title_elem[0].strip() + print("Shelf title: " + title) + for cell in content.xpath("//tbody[@id='booksBody']/tr"): + url_book = 'https://www.goodreads.com' + \ + cell.xpath( + ".//td[@class='field title']//a/@href")[0].strip() + # has_review = cell.xpath( + # ".//td[@class='field actions']//a/text()")[0].strip() == 'view (with text)' + rating_elem = cell.xpath( + ".//td[@class='field rating']//span/@title") + rating = gr_rating.get( + rating_elem[0].strip()) if rating_elem else None + url_review = 'https://www.goodreads.com' + \ + cell.xpath( + ".//td[@class='field actions']//a/@href")[0].strip() + review = '' + last_updated = None + try: + r2 = requests.get( + url_review, timeout=settings.SCRAPING_TIMEOUT) + if r2.status_code == 200: + c2 = html.fromstring(r2.content.decode('utf-8')) + review_elem = c2.xpath( + "//div[@itemprop='reviewBody']/text()") + review = '\n'.join( + p.strip() for p in review_elem) if review_elem else '' + date_elem = c2.xpath( + "//div[@class='readingTimeline__text']/text()") + for d in date_elem: + date_matched = re.search(r'(\w+)\s+(\d+),\s+(\d+)', d) + if date_matched: + last_updated = make_aware(datetime.strptime(date_matched[1] + ' ' + date_matched[2] + ' ' + date_matched[3], '%B %d %Y')) + else: + print(f"Error loading review{url_review}, ignored") + scraper = get_scraper_by_url(url_book) + url_book = scraper.get_effective_url(url_book) + book = Book.objects.filter(source_url=url_book).first() + if not book: + print("add new book " + url_book) + scraper.scrape(url_book) + form = scraper.save(request_user=user) + book = form.instance + books.append({ + 'url': url_book, + 'book': book, + 'rating': rating, + 'review': review, + 'last_updated': last_updated + }) + except Exception: + print("Error adding " + url_book) + pass # likely just download error + next_elem = content.xpath("//a[@class='next_page']/@href") + url_shelf = ('https://www.goodreads.com' + next_elem[0].strip()) if next_elem else None + return {'title': title, 'description': '', 'books': books} + + @classmethod + def parse_list(cls, url, user): # return {'title': 'abc', books: [{'book': obj, 'rating': 10, 'review': 'txt'}, ...]} + title = None + books = [] + url_shelf = url + while url_shelf: + print(f'List loading {url_shelf}') + r = requests.get(url_shelf, timeout=settings.SCRAPING_TIMEOUT) + if r.status_code != 200: + print(f'List loading error {url_shelf}') + break + url_shelf = None + content = html.fromstring(r.content.decode('utf-8')) + title_elem = content.xpath('//h1[@class="gr-h1 gr-h1--serif"]/text()') + if not title_elem: + print(f'List parsing error {url_shelf}') + break + title = title_elem[0].strip() + description = content.xpath('//div[@class="mediumText"]/text()')[0].strip() + print("List title: " + title) + for link in content.xpath('//a[@class="bookTitle"]/@href'): + url_book = 'https://www.goodreads.com' + link + try: + scraper = get_scraper_by_url(url_book) + url_book = scraper.get_effective_url(url_book) + book = Book.objects.filter(source_url=url_book).first() + if not book: + print("add new book " + url_book) + scraper.scrape(url_book) + form = scraper.save(request_user=user) + book = form.instance + books.append({ + 'url': url_book, + 'book': book, + 'review': '', + }) + except Exception: + print("Error adding " + url_book) + pass # likely just download error + next_elem = content.xpath("//a[@class='next_page']/@href") + url_shelf = ('https://www.goodreads.com' + next_elem[0].strip()) if next_elem else None + return {'title': title, 'description': description, 'books': books} diff --git a/common/index.py b/common/index.py new file mode 100644 index 00000000..42227b60 --- /dev/null +++ b/common/index.py @@ -0,0 +1,12 @@ +from django.conf import settings + + +if settings.SEARCH_BACKEND == 'MEILISEARCH': + from .search.meilisearch import Indexer +elif settings.SEARCH_BACKEND == 'TYPESENSE': + from .search.typesense import Indexer +else: + class Indexer: + @classmethod + def update_model_indexable(self, cls): + pass diff --git a/common/management/commands/delete_job.py b/common/management/commands/delete_job.py new file mode 100644 index 00000000..1c1d6c86 --- /dev/null +++ b/common/management/commands/delete_job.py @@ -0,0 +1,19 @@ +from django.core.management.base import BaseCommand +import pprint +from redis import Redis +from rq.job import Job +from rq import Queue + + +class Command(BaseCommand): + help = 'Delete a job' + + def add_arguments(self, parser): + parser.add_argument('job_id', type=str, help='Job ID') + + def handle(self, *args, **options): + redis = Redis() + job_id = str(options['job_id']) + job = Job.fetch(job_id, connection=redis) + job.delete() + self.stdout.write(self.style.SUCCESS(f'Deleted {job}')) diff --git a/common/management/commands/index_stats.py b/common/management/commands/index_stats.py new file mode 100644 index 00000000..28a9f07e --- /dev/null +++ b/common/management/commands/index_stats.py @@ -0,0 +1,40 @@ +from django.core.management.base import BaseCommand +from common.index import Indexer +from django.conf import settings +from movies.models import Movie +from books.models import Book +from games.models import Game +from music.models import Album, Song +from django.core.paginator import Paginator +from tqdm import tqdm +from time import sleep +from datetime import timedelta +from django.utils import timezone + + +class Command(BaseCommand): + help = 'Check search index' + + def handle(self, *args, **options): + print(f'Connecting to search server') + stats = Indexer.get_stats() + print(stats) + st = Indexer.instance().get_all_update_status() + cnt = {"enqueued": [0, 0], "processing": [0, 0], "processed": [0, 0], "failed": [0, 0]} + lastEnq = {"enqueuedAt": ""} + lastProc = {"enqueuedAt": ""} + for s in st: + n = s["type"].get("number") + cnt[s["status"]][0] += 1 + cnt[s["status"]][1] += n if n else 0 + if s["status"] == "processing": + print(s) + elif s["status"] == "enqueued": + if s["enqueuedAt"] > lastEnq["enqueuedAt"]: + lastEnq = s + elif s["status"] == "processed": + if s["enqueuedAt"] > lastProc["enqueuedAt"]: + lastProc = s + print(lastEnq) + print(lastProc) + print(cnt) diff --git a/common/management/commands/init_index.py b/common/management/commands/init_index.py new file mode 100644 index 00000000..797d32e4 --- /dev/null +++ b/common/management/commands/init_index.py @@ -0,0 +1,18 @@ +from django.core.management.base import BaseCommand +from common.index import Indexer +from django.conf import settings + + +class Command(BaseCommand): + help = 'Initialize the search index' + + def handle(self, *args, **options): + print(f'Connecting to search server') + Indexer.init() + self.stdout.write(self.style.SUCCESS('Index created.')) + # try: + # Indexer.init() + # self.stdout.write(self.style.SUCCESS('Index created.')) + # except Exception: + # Indexer.update_settings() + # self.stdout.write(self.style.SUCCESS('Index settings updated.')) diff --git a/common/management/commands/list_jobs.py b/common/management/commands/list_jobs.py new file mode 100644 index 00000000..e843f527 --- /dev/null +++ b/common/management/commands/list_jobs.py @@ -0,0 +1,24 @@ +from django.core.management.base import BaseCommand +import pprint +from redis import Redis +from rq.job import Job +from rq import Queue + + +class Command(BaseCommand): + help = 'Show jobs in queue' + + def add_arguments(self, parser): + parser.add_argument('queue', type=str, help='Queue') + + def handle(self, *args, **options): + redis = Redis() + queue = Queue(str(options['queue']), connection=redis) + for registry in [queue.started_job_registry, queue.deferred_job_registry, queue.finished_job_registry, queue.failed_job_registry, queue.scheduled_job_registry]: + self.stdout.write(self.style.SUCCESS(f'Registry {registry}')) + for job_id in registry.get_job_ids(): + try: + job = Job.fetch(job_id, connection=redis) + pprint.pp(job) + except Exception as e: + print(f'Error fetching {job_id}') diff --git a/common/management/commands/reindex.py b/common/management/commands/reindex.py new file mode 100644 index 00000000..5dcc766f --- /dev/null +++ b/common/management/commands/reindex.py @@ -0,0 +1,40 @@ +from django.core.management.base import BaseCommand +from common.index import Indexer +from django.conf import settings +from movies.models import Movie +from books.models import Book +from games.models import Game +from music.models import Album, Song +from django.core.paginator import Paginator +from tqdm import tqdm +from time import sleep +from datetime import timedelta +from django.utils import timezone + + +BATCH_SIZE = 1000 + + +class Command(BaseCommand): + help = 'Regenerate the search index' + + # def add_arguments(self, parser): + # parser.add_argument('hours', type=int, help='Re-index items modified in last N hours, 0 to reindex all') + + def handle(self, *args, **options): + # h = int(options['hours']) + print(f'Connecting to search server') + if Indexer.busy(): + print('Please wait for previous updates') + # Indexer.update_settings() + # self.stdout.write(self.style.SUCCESS('Index settings updated.')) + for c in [Book, Song, Album, Game, Movie]: + print(f'Re-indexing {c}') + qs = c.objects.all() # if h == 0 else c.objects.filter(edited_time__gt=timezone.now() - timedelta(hours=h)) + pg = Paginator(qs.order_by('id'), BATCH_SIZE) + for p in tqdm(pg.page_range): + items = list(map(lambda o: Indexer.obj_to_dict(o), pg.get_page(p).object_list)) + if items: + Indexer.replace_batch(items) + while Indexer.busy(): + sleep(0.5) diff --git a/common/management/commands/restart_sync.py b/common/management/commands/restart_sync.py new file mode 100644 index 00000000..93903c57 --- /dev/null +++ b/common/management/commands/restart_sync.py @@ -0,0 +1,28 @@ +from django.core.management.base import BaseCommand +from redis import Redis +from rq.job import Job +from sync.models import SyncTask +from sync.jobs import import_doufen_task +from django.utils import timezone +import django_rq + + +class Command(BaseCommand): + help = 'Restart a sync task' + + def add_arguments(self, parser): + parser.add_argument('synctask_id', type=int, help='Sync Task ID') + + def handle(self, *args, **options): + task = SyncTask.objects.get(id=options['synctask_id']) + task.finished_items = 0 + task.failed_urls = [] + task.success_items = 0 + task.total_items = 0 + task.is_finished = False + task.is_failed = False + task.break_point = '' + task.started_time = timezone.now() + task.save() + django_rq.get_queue('doufen').enqueue(import_doufen_task, task, job_id=f'SyncTask_{task.id}') + self.stdout.write(self.style.SUCCESS(f'Queued {task}')) diff --git a/common/management/commands/scrape.py b/common/management/commands/scrape.py new file mode 100644 index 00000000..d48898e6 --- /dev/null +++ b/common/management/commands/scrape.py @@ -0,0 +1,25 @@ +from django.core.management.base import BaseCommand +from common.scraper import get_scraper_by_url, get_normalized_url +import pprint + + +class Command(BaseCommand): + help = 'Scrape an item from URL (but not save it)' + + def add_arguments(self, parser): + parser.add_argument('url', type=str, help='URL to scrape') + + def handle(self, *args, **options): + url = str(options['url']) + url = get_normalized_url(url) + scraper = get_scraper_by_url(url) + + if scraper is None: + self.stdout.write(self.style.ERROR(f'Unable to match a scraper for {url}')) + return + + effective_url = scraper.get_effective_url(url) + self.stdout.write(f'Fetching {effective_url} via {scraper.__name__}') + data, img = scraper.scrape(effective_url) + self.stdout.write(self.style.SUCCESS(f'Done.')) + pprint.pp(data) diff --git a/common/models.py b/common/models.py index c6bf6a70..5d1b55b5 100644 --- a/common/models.py +++ b/common/models.py @@ -1,29 +1,34 @@ import re from decimal import * from markdown import markdown -from django.utils.translation import ugettext_lazy as _ +from django.utils.translation import gettext_lazy as _ from django.db import models, IntegrityError from django.core.serializers.json import DjangoJSONEncoder -from django.db.models import Q +from django.db.models import Q, Count, Sum from markdownx.models import MarkdownxField from users.models import User -from mastodon.api import get_relationships, get_cross_site_id -from boofilsic.settings import CLIENT_NAME from django.utils import timezone +from django.conf import settings RE_HTML_TAG = re.compile(r"<[^>]*>") +MAX_TOP_TAGS = 5 # abstract base classes ################################### class SourceSiteEnum(models.TextChoices): - IN_SITE = "in-site", CLIENT_NAME - DOUBAN = "douban", _("豆瓣") + IN_SITE = "in-site", settings.CLIENT_NAME + DOUBAN = "douban", _("豆瓣") SPOTIFY = "spotify", _("Spotify") IMDB = "imdb", _("IMDb") STEAM = "steam", _("STEAM") BANGUMI = 'bangumi', _("bangumi") + GOODREADS = "goodreads", _("goodreads") + TMDB = "tmdb", _("The Movie Database") + GOOGLEBOOKS = "googlebooks", _("Google Books") + BANDCAMP = "bandcamp", _("BandCamp") + IGDB = "igdb", _("IGDB") class Entity(models.Model): @@ -52,10 +57,25 @@ class Entity(models.Model): rating__lte=10), name='%(class)s_rating_upperbound'), ] - def get_absolute_url(self): raise NotImplementedError("Subclass should implement this method") + @property + def url(self): + return settings.APP_WEBSITE + self.get_absolute_url() + + def get_json(self): + return { + 'title': self.title, + 'brief': self.brief, + 'rating': self.rating, + 'url': self.url, + 'cover_url': settings.APP_WEBSITE + self.cover.url, + 'top_tags': self.tags[:5], + 'category_name': self.verbose_category_name, + 'other_info': self.other_info, + } + def save(self, *args, **kwargs): """ update rating and strip source url scheme & querystring before save to db """ if self.rating_number and self.rating_total_score: @@ -108,6 +128,15 @@ class Entity(models.Model): self.calculate_rating(old_rating, new_rating) self.save() + def refresh_rating(self): # TODO: replace update_rating() + a = self.marks.filter(rating__gt=0).aggregate(Sum('rating'), Count('rating')) + if self.rating_total_score != a['rating__sum'] or self.rating_number != a['rating__count']: + self.rating_total_score = a['rating__sum'] + self.rating_number = a['rating__count'] + self.rating = a['rating__sum'] / a['rating__count'] if a['rating__count'] > 0 else None + self.save() + return self.rating + def get_tags_manager(self): """ Since relation between tag and entity is foreign key, and related name has to be unique, @@ -115,9 +144,13 @@ class Entity(models.Model): """ raise NotImplementedError("Subclass should implement this method.") + @property + def top_tags(self): + return self.get_tags_manager().values('content').annotate(tag_frequency=Count('content')).order_by('-tag_frequency')[:MAX_TOP_TAGS] + def get_marks_manager(self): """ - Normally this won't be used. + Normally this won't be used. There is no ocassion where visitor can simply view all the marks. """ raise NotImplementedError("Subclass should implement this method.") @@ -129,6 +162,19 @@ class Entity(models.Model): """ raise NotImplementedError("Subclass should implement this method.") + @property + def all_tag_list(self): + return self.get_tags_manager().values('content').annotate(frequency=Count('content')).order_by('-frequency') + + @property + def tags(self): + return list(map(lambda t: t['content'], self.all_tag_list)) + + @property + def marks(self): + params = {self.__class__.__name__.lower() + '_id': self.id} + return self.mark_class.objects.filter(**params) + @classmethod def get_category_mapping_dict(cls): category_mapping_dict = {} @@ -144,75 +190,64 @@ class Entity(models.Model): def verbose_category_name(self): raise NotImplementedError("Subclass should implement this.") + @property + def mark_class(self): + raise NotImplementedError("Subclass should implement this.") + + @property + def tag_class(self): + raise NotImplementedError("Subclass should implement this.") + class UserOwnedEntity(models.Model): - is_private = models.BooleanField() - owner = models.ForeignKey( - User, on_delete=models.CASCADE, related_name='user_%(class)ss') + is_private = models.BooleanField(default=False, null=True) # first set allow null, then migration, finally (in a few days) remove for good + visibility = models.PositiveSmallIntegerField(default=0) # 0: Public / 1: Follower only / 2: Self only + owner = models.ForeignKey(User, on_delete=models.CASCADE, related_name='user_%(class)ss') created_time = models.DateTimeField(default=timezone.now) edited_time = models.DateTimeField(default=timezone.now) class Meta: abstract = True + def is_visible_to(self, viewer): + if not viewer.is_authenticated: + return self.visibility == 0 + owner = self.owner + if owner == viewer: + return True + if not owner.is_active: + return False + if self.visibility == 2: + return False + if viewer.is_blocking(owner) or owner.is_blocking(viewer) or viewer.is_muting(owner): + return False + if self.visibility == 1: + return viewer.is_following(owner) + else: + return True + + def is_editable_by(self, viewer): + return True if viewer.is_staff or viewer.is_superuser or viewer == self.owner else False + @classmethod - def get_available(cls, entity, request_user, token): - # TODO add amount limit for once query - """ - Returns all avaliable user-owned entities related to given entity. - This method handles mute/block relationships and private/public visibilities. - """ - # the foreign key field that points to entity - # has to be named as the lower case name of that entity + def get_available(cls, entity, request_user, following_only=False): + # e.g. SongMark.get_available(song, request.user) query_kwargs = {entity.__class__.__name__.lower(): entity} - user_owned_entities = cls.objects.filter( - **query_kwargs).order_by("-edited_time") - - # every user should only be abled to have one user owned entity for each entity - # this is guaranteed by models - id_list = [] - - # none_index tracks those failed cross site id query - none_index = [] - - for (i, entity) in enumerate(user_owned_entities): - if entity.owner.mastodon_site == request_user.mastodon_site: - id_list.append(entity.owner.mastodon_id) - else: - # TODO there could be many requests therefore make the pulling asynchronized - cross_site_id = get_cross_site_id( - entity.owner, request_user.mastodon_site, token) - if not cross_site_id is None: - id_list.append(cross_site_id) - else: - none_index.append(i) - # populate those query-failed None postions - # to ensure the consistency of the orders of - # the three(id_list, user_owned_entities, relationships) - id_list.append(request_user.mastodon_id) - - # Mastodon request - relationships = get_relationships( - request_user.mastodon_site, id_list, token) - mute_block_blocked_index = [] - following_index = [] - for i, r in enumerate(relationships): - # the order of relationships is corresponding to the id_list, - # and the order of id_list is the same as user_owned_entiies - if r['blocking'] or r['blocked_by'] or r['muting']: - mute_block_blocked_index.append(i) - if r['following']: - following_index.append(i) - available_entities = [ - e for i, e in enumerate(user_owned_entities) - if ((e.is_private == True and i in following_index) or e.is_private == False or e.owner == request_user) - and not i in mute_block_blocked_index and not i in none_index - ] - return available_entities + all_entities = cls.objects.filter(**query_kwargs).order_by("-created_time") # get all marks for song + visible_entities = list(filter(lambda _entity: _entity.is_visible_to(request_user) and (_entity.owner.mastodon_username in request_user.mastodon_following if following_only else True), all_entities)) + return visible_entities @classmethod - def get_available_by_user(cls, owner, is_following): - """ + def get_available_for_identicals(cls, entity, request_user, following_only=False): + # e.g. SongMark.get_available(song, request.user) + query_kwargs = {entity.__class__.__name__.lower() + '__in': entity.get_identicals()} + all_entities = cls.objects.filter(**query_kwargs).order_by("-created_time") # get all marks for song + visible_entities = list(filter(lambda _entity: _entity.is_visible_to(request_user) and (_entity.owner.mastodon_username in request_user.mastodon_following if following_only else True), all_entities)) + return visible_entities + + @classmethod + def get_available_by_user(cls, owner, is_following): # FIXME + """ Returns all avaliable owner's entities. Mute/Block relation is not handled in this method. @@ -220,10 +255,17 @@ class UserOwnedEntity(models.Model): :param is_following: if the current user is following the owner """ user_owned_entities = cls.objects.filter(owner=owner) - if not is_following: - user_owned_entities = user_owned_entities.exclude(is_private=True) + if is_following: + user_owned_entities = user_owned_entities.exclude(visibility=2) + else: + user_owned_entities = user_owned_entities.filter(visibility=0) return user_owned_entities + @property + def item(self): + attr = re.findall(r'[A-Z](?:[a-z]+|[A-Z]*(?=[A-Z]|$))', self.__class__.__name__)[0].lower() + return getattr(self, attr) + # commonly used entity classes ################################### @@ -236,10 +278,20 @@ class MarkStatusEnum(models.TextChoices): class Mark(UserOwnedEntity): status = models.CharField(choices=MarkStatusEnum.choices, max_length=20) rating = models.PositiveSmallIntegerField(blank=True, null=True) - text = models.CharField(max_length=500, blank=True, default='') + text = models.CharField(max_length=5000, blank=True, default='') + shared_link = models.CharField(max_length=5000, blank=True, default='') def __str__(self): - return f"({self.id}) {self.owner} {self.status.upper()}" + return f"Mark({self.id} {self.owner} {self.status.upper()})" + + @property + def translated_status(self): + raise NotImplementedError("Subclass should implement this.") + + @property + def tags(self): + tags = self.item.tag_class.objects.filter(mark_id=self.id) + return tags class Meta: abstract = True @@ -249,7 +301,7 @@ class Mark(UserOwnedEntity): models.CheckConstraint(check=models.Q( rating__lte=10), name='mark_rating_upperbound'), ] - + # TODO update entity rating when save # TODO update tags @@ -257,6 +309,7 @@ class Mark(UserOwnedEntity): class Review(UserOwnedEntity): title = models.CharField(max_length=120) content = MarkdownxField() + shared_link = models.CharField(max_length=5000, blank=True, default='') def __str__(self): return self.title @@ -271,6 +324,10 @@ class Review(UserOwnedEntity): class Meta: abstract = True + @property + def translated_status(self): + return '评论了' + class Tag(models.Model): content = models.CharField(max_length=50) @@ -278,5 +335,28 @@ class Tag(models.Model): def __str__(self): return self.content + @property + def edited_time(self): + return self.mark.edited_time + + @property + def created_time(self): + return self.mark.created_time + + @property + def text(self): + return self.mark.text + + @classmethod + def find_by_user(cls, tag, owner, viewer): + qs = cls.objects.filter(content=tag, mark__owner=owner) + if owner != viewer: + qs = qs.filter(mark__visibility__lte=owner.get_max_visibility(viewer)) + return qs + + @classmethod + def all_by_user(cls, owner): + return cls.objects.filter(mark__owner=owner).values('content').annotate(total=Count('content')).order_by('-total') + class Meta: abstract = True diff --git a/common/scraper.py b/common/scraper.py index 33bef7e4..defe6f38 100644 --- a/common/scraper.py +++ b/common/scraper.py @@ -7,23 +7,17 @@ import dateparser import datetime import time import filetype +import dns.resolver +import urllib.parse from lxml import html from threading import Thread -from boofilsic.settings import LUMINATI_USERNAME, LUMINATI_PASSWORD, DEBUG, IMDB_API_KEY, SCRAPERAPI_KEY -from boofilsic.settings import SPOTIFY_CREDENTIAL from django.utils import timezone from django.utils.translation import ugettext_lazy as _ from django.core.exceptions import ObjectDoesNotExist, ValidationError from django.core.files.uploadedfile import SimpleUploadedFile from common.models import SourceSiteEnum -from movies.models import Movie, MovieGenreEnum -from movies.forms import MovieForm -from books.models import Book -from books.forms import BookForm -from music.models import Album, Song -from music.forms import AlbumForm, SongForm -from games.models import Game -from games.forms import GameForm +from django.conf import settings +from django.core.exceptions import ValidationError RE_NUMBERS = re.compile(r"\d+\d*") @@ -43,12 +37,11 @@ DEFAULT_REQUEST_HEADERS = { 'Cache-Control': 'no-cache', } -# in seconds -TIMEOUT = 60 # luminati account credentials PORT = 22225 + logger = logging.getLogger(__name__) @@ -56,6 +49,12 @@ logger = logging.getLogger(__name__) scraper_registry = {} +def get_normalized_url(raw_url): + url = re.sub(r'//m.douban.com/(\w+)/', r'//\1.douban.com/', raw_url) + url = re.sub(r'//www.google.com/books/edition/_/([A-Za-z0-9_\-]+)[\?]*', r'//books.google.com/books?id=\1&', url) + return url + + def log_url(func): """ Catch exceptions and log then pass the exceptions. @@ -67,20 +66,23 @@ def log_url(func): return func(*args, **kwargs) except Exception as e: # log the url and trace stack - logger.error(f"Scrape Failed URL: {args[1]}") - logger.error("Expections during scraping:", exc_info=e) + logger.error(f"Scrape Failed URL: {args[1]}\n{e}") + if settings.DEBUG: + logger.error("Expections during scraping:", exc_info=e) raise e return wrapper + def parse_date(raw_str): return dateparser.parse( - raw_str, + raw_str, settings={ - "RELATIVE_BASE": datetime.datetime(1900, 1, 1) + "RELATIVE_BASE": datetime.datetime(1900, 1, 1) } ) + class AbstractScraper: """ Scrape entities. The entities means those defined in the models.py file, @@ -119,7 +121,7 @@ class AbstractScraper: # decorate the scrape method cls.scrape = classmethod(log_url(cls.scrape)) - + # register scraper if isinstance(cls.host, list): for host in cls.host: @@ -141,26 +143,30 @@ class AbstractScraper: """ The return value should be identical with that saved in DB as `source_url` """ - url = cls.regex.findall(raw_url) + url = cls.regex.findall(raw_url.replace('http:', 'https:')) # force all http to be https if not url: - raise ValueError("not valid url") + raise ValueError(f"not valid url: {raw_url}") return url[0] @classmethod def download_page(cls, url, headers): url = cls.get_effective_url(url) - session_id = random.random() - proxy_url = ('http://%s-country-cn-session-%s:%s@zproxy.lum-superproxy.io:%d' % - (LUMINATI_USERNAME, session_id, LUMINATI_PASSWORD, PORT)) - proxies = { - 'http': proxy_url, - 'https': proxy_url, - } - # if DEBUG: - # proxies = None + if settings.LUMINATI_USERNAME is None: + proxies = None + if settings.SCRAPESTACK_KEY is not None: + url = f'http://api.scrapestack.com/scrape?access_key={settings.SCRAPESTACK_KEY}&url={url}' + else: + session_id = random.random() + proxy_url = ('http://%s-country-cn-session-%s:%s@zproxy.lum-superproxy.io:%d' % + (settings.LUMINATI_USERNAME, session_id, settings.LUMINATI_PASSWORD, PORT)) + proxies = { + 'http': proxy_url, + 'https': proxy_url, + } + r = requests.get(url, proxies=proxies, - headers=headers, timeout=TIMEOUT) + headers=headers, timeout=settings.SCRAPING_TIMEOUT) if r.status_code != 200: raise RuntimeError(f"download page failed, status code {r.status_code}") @@ -169,19 +175,19 @@ class AbstractScraper: return html.fromstring(r.content.decode('utf-8')) @classmethod - def download_image(cls, url): + def download_image(cls, url, item_url=None): if url is None: - return + return None, None raw_img = None session_id = random.random() proxy_url = ('http://%s-country-cn-session-%s:%s@zproxy.lum-superproxy.io:%d' % - (LUMINATI_USERNAME, session_id, LUMINATI_PASSWORD, PORT)) + (settings.LUMINATI_USERNAME, session_id, settings.LUMINATI_PASSWORD, PORT)) proxies = { 'http': proxy_url, 'https': proxy_url, } - # if DEBUG: - # proxies = None + if settings.LUMINATI_USERNAME is None: + proxies = None if url: img_response = requests.get( url, @@ -194,7 +200,7 @@ class AbstractScraper: 'dnt': '1', }, proxies=proxies, - timeout=TIMEOUT, + timeout=settings.SCRAPING_TIMEOUT, ) if img_response.status_code == 200: raw_img = img_response.content @@ -205,13 +211,14 @@ class AbstractScraper: return raw_img, ext @classmethod - def save(cls, request_user): + def save(cls, request_user, instance=None): entity_cover = { 'cover': SimpleUploadedFile('temp.' + cls.img_ext, cls.raw_img) - } - form = cls.form_class(cls.raw_data, entity_cover) + } if cls.img_ext is not None else None + form = cls.form_class(data=cls.raw_data, files=entity_cover, instance=instance) if form.is_valid(): form.instance.last_editor = request_user + form.instance._change_reason = 'scrape' form.save() cls.instance = form.instance else: @@ -220,1145 +227,37 @@ class AbstractScraper: return form -class DoubanScrapperMixin: - @classmethod - def download_page(cls, url, headers): - url = cls.get_effective_url(url) - - scraper_api_endpoint = f'http://api.scraperapi.com?api_key={SCRAPERAPI_KEY}&url={url}' - - r = requests.get(scraper_api_endpoint, timeout=TIMEOUT) - - if r.status_code != 200: - raise RuntimeError(f"download page failed, status code {r.status_code}") - # with open('temp.html', 'w', encoding='utf-8') as fp: - # fp.write(r.content.decode('utf-8')) - return html.fromstring(r.content.decode('utf-8')) - - -class DoubanBookScraper(DoubanScrapperMixin, AbstractScraper): - site_name = SourceSiteEnum.DOUBAN.value - host = "book.douban.com" - data_class = Book - form_class = BookForm - - regex = re.compile(r"https://book\.douban\.com/subject/\d+/{0,1}") - - def scrape(self, url): - headers = DEFAULT_REQUEST_HEADERS.copy() - headers['Host'] = self.host - content = self.download_page(url, headers) - - # parsing starts here - try: - title = content.xpath("/html/body//h1/span/text()")[0].strip() - except IndexError: - raise ValueError("given url contains no book info") - - subtitle_elem = content.xpath( - "//div[@id='info']//span[text()='副标题:']/following::text()") - subtitle = subtitle_elem[0].strip() if subtitle_elem else None - - orig_title_elem = content.xpath( - "//div[@id='info']//span[text()='原作名:']/following::text()") - orig_title = orig_title_elem[0].strip() if orig_title_elem else None - - language_elem = content.xpath( - "//div[@id='info']//span[text()='语言:']/following::text()") - language = language_elem[0].strip() if language_elem else None - - pub_house_elem = content.xpath( - "//div[@id='info']//span[text()='出版社:']/following::text()") - pub_house = pub_house_elem[0].strip() if pub_house_elem else None - - pub_date_elem = content.xpath( - "//div[@id='info']//span[text()='出版年:']/following::text()") - pub_date = pub_date_elem[0].strip() if pub_date_elem else '' - year_month_day = RE_NUMBERS.findall(pub_date) - if len(year_month_day) in (2, 3): - pub_year = int(year_month_day[0]) - pub_month = int(year_month_day[1]) - elif len(year_month_day) == 1: - pub_year = int(year_month_day[0]) - pub_month = None - else: - pub_year = None - pub_month = None - if pub_year and pub_month and pub_year < pub_month: - pub_year, pub_month = pub_month, pub_year - pub_year = None if pub_year is not None and not pub_year in range( - 0, 3000) else pub_year - pub_month = None if pub_month is not None and not pub_month in range( - 1, 12) else pub_month - - binding_elem = content.xpath( - "//div[@id='info']//span[text()='装帧:']/following::text()") - binding = binding_elem[0].strip() if binding_elem else None - - price_elem = content.xpath( - "//div[@id='info']//span[text()='定价:']/following::text()") - price = price_elem[0].strip() if price_elem else None - - pages_elem = content.xpath( - "//div[@id='info']//span[text()='页数:']/following::text()") - pages = pages_elem[0].strip() if pages_elem else None - if pages is not None: - pages = int(RE_NUMBERS.findall(pages)[ - 0]) if RE_NUMBERS.findall(pages) else None - - isbn_elem = content.xpath( - "//div[@id='info']//span[text()='ISBN:']/following::text()") - isbn = isbn_elem[0].strip() if isbn_elem else None - - brief_elem = content.xpath( - "//h2/span[text()='内容简介']/../following-sibling::div[1]//div[@class='intro'][not(ancestor::span[@class='short'])]/p/text()") - brief = '\n'.join(p.strip() - for p in brief_elem) if brief_elem else None - - contents = None - try: - contents_elem = content.xpath( - "//h2/span[text()='目录']/../following-sibling::div[1]")[0] - # if next the id of next sibling contains `dir`, that would be the full contents - if "dir" in contents_elem.getnext().xpath("@id")[0]: - contents_elem = contents_elem.getnext() - contents = '\n'.join(p.strip() for p in contents_elem.xpath( - "text()")[:-2]) if contents_elem else None - else: - contents = '\n'.join(p.strip() for p in contents_elem.xpath( - "text()")) if contents_elem else None - except: - pass - - img_url_elem = content.xpath("//*[@id='mainpic']/a/img/@src") - img_url = img_url_elem[0].strip() if img_url_elem else None - raw_img, ext = self.download_image(img_url) - - # there are two html formats for authors and translators - authors_elem = content.xpath("""//div[@id='info']//span[text()='作者:']/following-sibling::br[1]/ - preceding-sibling::a[preceding-sibling::span[text()='作者:']]/text()""") - if not authors_elem: - authors_elem = content.xpath( - """//div[@id='info']//span[text()=' 作者']/following-sibling::a/text()""") - if authors_elem: - authors = [] - for author in authors_elem: - authors.append(RE_WHITESPACES.sub(' ', author.strip())) - else: - authors = None - - translators_elem = content.xpath("""//div[@id='info']//span[text()='译者:']/following-sibling::br[1]/ - preceding-sibling::a[preceding-sibling::span[text()='译者:']]/text()""") - if not translators_elem: - translators_elem = content.xpath( - """//div[@id='info']//span[text()=' 译者']/following-sibling::a/text()""") - if translators_elem: - translators = [] - for translator in translators_elem: - translators.append(RE_WHITESPACES.sub(' ', translator.strip())) - else: - translators = None - - other = {} - cncode_elem = content.xpath( - "//div[@id='info']//span[text()='统一书号:']/following::text()") - if cncode_elem: - other['统一书号'] = cncode_elem[0].strip() - series_elem = content.xpath( - "//div[@id='info']//span[text()='丛书:']/following-sibling::a[1]/text()") - if series_elem: - other['丛书'] = series_elem[0].strip() - imprint_elem = content.xpath( - "//div[@id='info']//span[text()='出品方:']/following-sibling::a[1]/text()") - if imprint_elem: - other['出品方'] = imprint_elem[0].strip() - - data = { - 'title': title, - 'subtitle': subtitle, - 'orig_title': orig_title, - 'author': authors, - 'translator': translators, - 'language': language, - 'pub_house': pub_house, - 'pub_year': pub_year, - 'pub_month': pub_month, - 'binding': binding, - 'price': price, - 'pages': pages, - 'isbn': isbn, - 'brief': brief, - 'contents': contents, - 'other_info': other, - 'source_site': self.site_name, - 'source_url': self.get_effective_url(url), - } - self.raw_data, self.raw_img, self.img_ext = data, raw_img, ext - return data, raw_img - - -class DoubanMovieScraper(DoubanScrapperMixin, AbstractScraper): - site_name = SourceSiteEnum.DOUBAN.value - host = 'movie.douban.com' - data_class = Movie - form_class = MovieForm - - regex = re.compile(r"https://movie\.douban\.com/subject/\d+/{0,1}") - - def scrape(self, url): - headers = DEFAULT_REQUEST_HEADERS.copy() - headers['Host'] = self.host - content = self.download_page(url, headers) - - # parsing starts here - try: - raw_title = content.xpath( - "//span[@property='v:itemreviewed']/text()")[0].strip() - except IndexError: - raise ValueError("given url contains no movie info") - - orig_title = content.xpath( - "//img[@rel='v:image']/@alt")[0].strip() - title = raw_title.split(orig_title)[0].strip() - # if has no chinese title - if title == '': - title = orig_title - - if title == orig_title: - orig_title = None - - # there are two html formats for authors and translators - other_title_elem = content.xpath( - "//div[@id='info']//span[text()='又名:']/following-sibling::text()[1]") - other_title = other_title_elem[0].strip().split( - ' / ') if other_title_elem else None - - imdb_elem = content.xpath( - "//div[@id='info']//span[text()='IMDb链接:']/following-sibling::a[1]/text()") - if not imdb_elem: - imdb_elem = content.xpath( - "//div[@id='info']//span[text()='IMDb:']/following-sibling::text()[1]") - imdb_code = imdb_elem[0].strip() if imdb_elem else None - - director_elem = content.xpath( - "//div[@id='info']//span[text()='导演']/following-sibling::span[1]/a/text()") - director = director_elem if director_elem else None - - playwright_elem = content.xpath( - "//div[@id='info']//span[text()='编剧']/following-sibling::span[1]/a/text()") - playwright = playwright_elem if playwright_elem else None - - actor_elem = content.xpath( - "//div[@id='info']//span[text()='主演']/following-sibling::span[1]/a/text()") - actor = actor_elem if actor_elem else None - - # construct genre translator - genre_translator = {} - attrs = [attr for attr in dir(MovieGenreEnum) if not '__' in attr] - for attr in attrs: - genre_translator[getattr(MovieGenreEnum, attr).label] = getattr( - MovieGenreEnum, attr).value - - genre_elem = content.xpath("//span[@property='v:genre']/text()") - if genre_elem: - genre = [] - for g in genre_elem: - genre.append(genre_translator[g]) - else: - genre = None - - showtime_elem = content.xpath( - "//span[@property='v:initialReleaseDate']/text()") - if showtime_elem: - showtime = [] - for st in showtime_elem: - parts = st.split('(') - if len(parts) == 1: - time = st.split('(')[0] - region = '' - else: - time = st.split('(')[0] - region = st.split('(')[1][0:-1] - showtime.append({time: region}) - else: - showtime = None - - site_elem = content.xpath( - "//div[@id='info']//span[text()='官方网站:']/following-sibling::a[1]/@href") - site = site_elem[0].strip() if site_elem else None - - area_elem = content.xpath( - "//div[@id='info']//span[text()='制片国家/地区:']/following-sibling::text()[1]") - if area_elem: - area = [a.strip() for a in area_elem[0].split(' / ')] - else: - area = None - - language_elem = content.xpath( - "//div[@id='info']//span[text()='语言:']/following-sibling::text()[1]") - if language_elem: - language = [a.strip() for a in language_elem[0].split(' / ')] - else: - language = None - - year_elem = content.xpath("//span[@class='year']/text()") - year = int(year_elem[0][1:-1]) if year_elem else None - - duration_elem = content.xpath("//span[@property='v:runtime']/text()") - other_duration_elem = content.xpath( - "//span[@property='v:runtime']/following-sibling::text()[1]") - if duration_elem: - duration = duration_elem[0].strip() - if other_duration_elem: - duration += other_duration_elem[0].rstrip() - else: - duration = None - - season_elem = content.xpath( - - "//*[@id='season']/option[@selected='selected']/text()") - if not season_elem: - season_elem = content.xpath( - "//div[@id='info']//span[text()='季数:']/following-sibling::text()[1]") - season = int(season_elem[0].strip()) if season_elem else None - else: - season = int(season_elem[0].strip()) - - episodes_elem = content.xpath( - "//div[@id='info']//span[text()='集数:']/following-sibling::text()[1]") - episodes = int(episodes_elem[0].strip()) if episodes_elem else None - - single_episode_length_elem = content.xpath( - "//div[@id='info']//span[text()='单集片长:']/following-sibling::text()[1]") - single_episode_length = single_episode_length_elem[0].strip( - ) if single_episode_length_elem else None - - # if has field `episodes` not none then must be series - is_series = True if episodes else False - - brief_elem = content.xpath("//span[@class='all hidden']") - if not brief_elem: - brief_elem = content.xpath("//span[@property='v:summary']") - brief = '\n'.join([e.strip() for e in brief_elem[0].xpath( - './text()')]) if brief_elem else None - - img_url_elem = content.xpath("//img[@rel='v:image']/@src") - img_url = img_url_elem[0].strip() if img_url_elem else None - raw_img, ext = self.download_image(img_url) - - data = { - 'title': title, - 'orig_title': orig_title, - 'other_title': other_title, - 'imdb_code': imdb_code, - 'director': director, - 'playwright': playwright, - 'actor': actor, - 'genre': genre, - 'showtime': showtime, - 'site': site, - 'area': area, - 'language': language, - 'year': year, - 'duration': duration, - 'season': season, - 'episodes': episodes, - 'single_episode_length': single_episode_length, - 'brief': brief, - 'is_series': is_series, - 'source_site': self.site_name, - 'source_url': self.get_effective_url(url), - } - self.raw_data, self.raw_img, self.img_ext = data, raw_img, ext - return data, raw_img - - -class DoubanAlbumScraper(DoubanScrapperMixin, AbstractScraper): - site_name = SourceSiteEnum.DOUBAN.value - host = 'music.douban.com' - data_class = Album - form_class = AlbumForm - - regex = re.compile(r"https://music\.douban\.com/subject/\d+/{0,1}") - - def scrape(self, url): - headers = DEFAULT_REQUEST_HEADERS.copy() - headers['Host'] = self.host - content = self.download_page(url, headers) - - # parsing starts here - try: - title = content.xpath("//h1/span/text()")[0].strip() - except IndexError: - raise ValueError("given url contains no album info") - if not title: - raise ValueError("given url contains no album info") - - - artists_elem = content.xpath("""//div[@id='info']/span/span[@class='pl']/a/text()""") - artist = None if not artists_elem else artists_elem - - genre_elem = content.xpath( - "//div[@id='info']//span[text()='流派:']/following::text()[1]") - genre = genre_elem[0].strip() if genre_elem else None - - date_elem = content.xpath( - "//div[@id='info']//span[text()='发行时间:']/following::text()[1]") - release_date = parse_date(date_elem[0].strip()) if date_elem else None - - company_elem = content.xpath( - "//div[@id='info']//span[text()='出版者:']/following::text()[1]") - company = company_elem[0].strip() if company_elem else None - - track_list_elem = content.xpath( - "//div[@class='track-list']/div[@class='indent']/div/text()" - ) - if track_list_elem: - track_list = '\n'.join([track.strip() for track in track_list_elem]) - else: - track_list = None - - brief_elem = content.xpath("//span[@class='all hidden']") - if not brief_elem: - brief_elem = content.xpath("//span[@property='v:summary']") - brief = '\n'.join([e.strip() for e in brief_elem[0].xpath( - './text()')]) if brief_elem else None - - other_info = {} - other_elem = content.xpath( - "//div[@id='info']//span[text()='又名:']/following-sibling::text()[1]") - if other_elem: - other_info['又名'] = other_elem[0].strip() - other_elem = content.xpath( - "//div[@id='info']//span[text()='专辑类型:']/following-sibling::text()[1]") - if other_elem: - other_info['专辑类型'] = other_elem[0].strip() - other_elem = content.xpath( - "//div[@id='info']//span[text()='介质:']/following-sibling::text()[1]") - if other_elem: - other_info['介质'] = other_elem[0].strip() - other_elem = content.xpath( - "//div[@id='info']//span[text()='ISRC:']/following-sibling::text()[1]") - if other_elem: - other_info['ISRC'] = other_elem[0].strip() - other_elem = content.xpath( - "//div[@id='info']//span[text()='条形码:']/following-sibling::text()[1]") - if other_elem: - other_info['条形码'] = other_elem[0].strip() - other_elem = content.xpath( - "//div[@id='info']//span[text()='碟片数:']/following-sibling::text()[1]") - if other_elem: - other_info['碟片数'] = other_elem[0].strip() - - img_url_elem = content.xpath("//div[@id='mainpic']//img/@src") - img_url = img_url_elem[0].strip() if img_url_elem else None - raw_img, ext = self.download_image(img_url) - - data = { - 'title': title, - 'artist': artist, - 'genre': genre, - 'release_date': release_date, - 'duration': None, - 'company': company, - 'track_list': track_list, - 'brief': brief, - 'other_info': other_info, - 'source_site': self.site_name, - 'source_url': self.get_effective_url(url), - } - self.raw_data, self.raw_img, self.img_ext = data, raw_img, ext - return data, raw_img - - -spotify_token = None -spotify_token_expire_time = time.time() - -class SpotifyTrackScraper(AbstractScraper): - site_name = SourceSiteEnum.SPOTIFY.value - host = 'https://open.spotify.com/track/' - data_class = Song - form_class = SongForm - - regex = re.compile(r"(?<=https://open\.spotify\.com/track/)[a-zA-Z0-9]+") - - def scrape(self, url): - """ - Request from API, not really scraping - """ - global spotify_token, spotify_token_expire_time - - if spotify_token is None or is_spotify_token_expired(): - invoke_spotify_token() - effective_url = self.get_effective_url(url) - if effective_url is None: - raise ValueError("not valid url") - - api_url = self.get_api_url(effective_url) - headers = { - 'Authorization': f"Bearer {spotify_token}" - } - r = requests.get(api_url, headers=headers) - res_data = r.json() - - artist = [] - for artist_dict in res_data['artists']: - artist.append(artist_dict['name']) - if not artist: - artist = None - - title = res_data['name'] - - release_date = parse_date(res_data['album']['release_date']) - - duration = res_data['duration_ms'] - - if res_data['external_ids'].get('isrc'): - isrc = res_data['external_ids']['isrc'] - else: - isrc = None - - raw_img, ext = self.download_image(res_data['album']['images'][0]['url']) - - data = { - 'title': title, - 'artist': artist, - 'genre': None, - 'release_date': release_date, - 'duration': duration, - 'isrc': isrc, - 'album': None, - 'brief': None, - 'other_info': None, - 'source_site': self.site_name, - 'source_url': effective_url, - } - self.raw_data, self.raw_img, self.img_ext = data, raw_img, ext - return data, raw_img - - @classmethod - def get_effective_url(cls, raw_url): - code = cls.regex.findall(raw_url) - if code: - return f"https://open.spotify.com/track/{code[0]}" - else: - return None - - @classmethod - def get_api_url(cls, url): - return "https://api.spotify.com/v1/tracks/" + cls.regex.findall(url)[0] - - -class SpotifyAlbumScraper(AbstractScraper): - site_name = SourceSiteEnum.SPOTIFY.value - # API URL - host = 'https://open.spotify.com/album/' - data_class = Album - form_class = AlbumForm - - regex = re.compile(r"(?<=https://open\.spotify\.com/album/)[a-zA-Z0-9]+") - - def scrape(self, url): - """ - Request from API, not really scraping - """ - global spotify_token, spotify_token_expire_time - - if spotify_token is None or is_spotify_token_expired(): - invoke_spotify_token() - effective_url = self.get_effective_url(url) - if effective_url is None: - raise ValueError("not valid url") - - api_url = self.get_api_url(effective_url) - headers = { - 'Authorization': f"Bearer {spotify_token}" - } - r = requests.get(api_url, headers=headers) - res_data = r.json() - - artist = [] - for artist_dict in res_data['artists']: - artist.append(artist_dict['name']) - - title = res_data['name'] - - genre = ', '.join(res_data['genres']) - - company = [] - for com in res_data['copyrights']: - company.append(com['text']) - - duration = 0 - track_list = [] - track_urls = [] - for track in res_data['tracks']['items']: - track_urls.append(track['external_urls']['spotify']) - duration += track['duration_ms'] - if res_data['tracks']['items'][-1]['disc_number'] > 1: - # more than one disc - track_list.append(str( - track['disc_number']) + '-' + str(track['track_number']) + '. ' + track['name']) - else: - track_list.append(str(track['track_number']) + '. ' + track['name']) - track_list = '\n'.join(track_list) - - release_date = parse_date(res_data['release_date']) - - other_info = {} - if res_data['external_ids'].get('upc'): - # bar code - other_info['UPC'] = res_data['external_ids']['upc'] - - raw_img, ext = self.download_image(res_data['images'][0]['url']) - - data = { - 'title': title, - 'artist': artist, - 'genre': genre, - 'track_list': track_list, - 'release_date': release_date, - 'duration': duration, - 'company': company, - 'brief': None, - 'other_info': other_info, - 'source_site': self.site_name, - 'source_url': effective_url, - } - - # set tracks_data, used for adding tracks - self.track_urls = track_urls - - self.raw_data, self.raw_img, self.img_ext = data, raw_img, ext - return data, raw_img - - @classmethod - def get_effective_url(cls, raw_url): - code = cls.regex.findall(raw_url) - if code: - return f"https://open.spotify.com/album/{code[0]}" - else: - return None - - @classmethod - def save(cls, request_user): - form = super().save(request_user) - task = Thread( - target=cls.add_tracks, - args=(form.instance, request_user), - daemon=True - ) - task.start() - return form - - @classmethod - def get_api_url(cls, url): - return "https://api.spotify.com/v1/albums/" + cls.regex.findall(url)[0] - - @classmethod - def add_tracks(cls, album: Album, request_user): - to_be_updated_tracks = [] - for track_url in cls.track_urls: - track = cls.get_track_or_none(track_url) - # seems lik if fire too many requests at the same time - # spotify would limit access - if track is None: - task = Thread( - target=cls.scrape_and_save_track, - args=(track_url, album, request_user), - daemon=True - ) - task.start() - task.join() - else: - to_be_updated_tracks.append(track) - cls.bulk_update_track_album(to_be_updated_tracks, album, request_user) - - @classmethod - def get_track_or_none(cls, track_url: str): - try: - instance = Song.objects.get(source_url=track_url) - return instance - except ObjectDoesNotExist: - return None - - @classmethod - def scrape_and_save_track(cls, url: str, album: Album, request_user): - data, img = SpotifyTrackScraper.scrape(url) - SpotifyTrackScraper.raw_data['album'] = album - SpotifyTrackScraper.save(request_user) - - @classmethod - def bulk_update_track_album(cls, tracks, album, request_user): - for track in tracks: - track.last_editor = request_user - track.edited_time = timezone.now() - track.album = album - Song.objects.bulk_update(tracks, [ - 'last_editor', - 'edited_time', - 'album' - ]) - - -def is_spotify_token_expired(): - global spotify_token_expire_time - return True if spotify_token_expire_time <= time.time() else False - - -def invoke_spotify_token(): - global spotify_token, spotify_token_expire_time - r = requests.post( - "https://accounts.spotify.com/api/token", - data={ - "grant_type": "client_credentials" - }, - headers={ - "Authorization": f"Basic {SPOTIFY_CREDENTIAL}" - } - ) - data = r.json() - if r.status_code == 401: - # token expired, try one more time - # this maybe caused by external operations, - # for example debugging using a http client - r = requests.post( - "https://accounts.spotify.com/api/token", - data={ - "grant_type": "client_credentials" - }, - headers={ - "Authorization": f"Basic {SPOTIFY_CREDENTIAL}" - } - ) - data = r.json() - elif r.status_code != 200: - raise Exception(f"Request to spotify API fails. Reason: {r.reason}") - # minus 2 for execution time error - spotify_token_expire_time = int(data['expires_in']) + time.time() - 2 - spotify_token = data['access_token'] - - -class ImdbMovieScraper(AbstractScraper): - site_name = SourceSiteEnum.IMDB.value - host = 'https://www.imdb.com/title/' - data_class = Movie - form_class = MovieForm - - regex = re.compile(r"(?<=https://www\.imdb\.com/title/)[a-zA-Z0-9]+") - - def scrape(self, url): - - effective_url = self.get_effective_url(url) - if effective_url is None: - raise ValueError("not valid url") - - api_url = self.get_api_url(effective_url) - r = requests.get(api_url) - res_data = r.json() - - if not res_data['type'] in ['Movie', 'TVSeries']: - raise ValueError("not movie/series item") - - if res_data['type'] == 'Movie': - is_series = False - elif res_data['type'] == 'TVSeries': - is_series = True - - title = res_data['title'] - orig_title = res_data['originalTitle'] - imdb_code = self.regex.findall(effective_url)[0] - director = [] - for direct_dict in res_data['directorList']: - director.append(direct_dict['name']) - playwright = [] - for writer_dict in res_data['writerList']: - playwright.append(writer_dict['name']) - actor = [] - for actor_dict in res_data['actorList']: - actor.append(actor_dict['name']) - genre = res_data['genres'].split(', ') - area = res_data['countries'].split(', ') - language = res_data['languages'].split(', ') - year = int(res_data['year']) - duration = res_data['runtimeStr'] - brief = res_data['plotLocal'] if res_data['plotLocal'] else res_data['plot'] - if res_data['releaseDate']: - showtime = [{res_data['releaseDate']: "发布日期"}] - else: - showtime = None - - other_info = {} - if res_data['contentRating']: - other_info['分级'] = res_data['contentRating'] - if res_data['imDbRating']: - other_info['IMDb评分'] = res_data['imDbRating'] - if res_data['metacriticRating']: - other_info['Metacritic评分'] = res_data['metacriticRating'] - if res_data['awards']: - other_info['奖项'] = res_data['awards'] - - raw_img, ext = self.download_image(res_data['image']) - - data = { - 'title': title, - 'orig_title': orig_title, - 'other_title': None, - 'imdb_code': imdb_code, - 'director': director, - 'playwright': playwright, - 'actor': actor, - 'genre': genre, - 'showtime': showtime, - 'site': None, - 'area': area, - 'language': language, - 'year': year, - 'duration': duration, - 'season': None, - 'episodes': None, - 'single_episode_length': None, - 'brief': brief, - 'is_series': is_series, - 'other_info': other_info, - 'source_site': self.site_name, - 'source_url': effective_url, - } - self.raw_data, self.raw_img, self.img_ext = data, raw_img, ext - return data, raw_img - - @classmethod - def get_effective_url(cls, raw_url): - code = cls.regex.findall(raw_url) - if code: - return f"https://www.imdb.com/title/{code[0]}/" - else: - return None - - @classmethod - def get_api_url(cls, url): - return f"https://imdb-api.com/zh/API/Title/{IMDB_API_KEY}/{cls.regex.findall(url)[0]}/FullActor," - - -class DoubanGameScraper(DoubanScrapperMixin, AbstractScraper): - site_name = SourceSiteEnum.DOUBAN.value - host = 'www.douban.com/game/' - data_class = Game - form_class = GameForm - - regex = re.compile(r"https://www\.douban\.com/game/\d+/{0,1}") - - def scrape(self, url): - headers = DEFAULT_REQUEST_HEADERS.copy() - headers['Host'] = 'www.douban.com' - content = self.download_page(url, headers) - - try: - raw_title = content.xpath( - "//div[@id='content']/h1/text()")[0].strip() - except IndexError: - raise ValueError("given url contains no game info") - - title = raw_title - - other_title_elem = content.xpath( - "//dl[@class='game-attr']//dt[text()='别名:']/following-sibling::dd[1]/text()") - other_title = other_title_elem[0].strip().split(' / ') if other_title_elem else None - - developer_elem = content.xpath( - "//dl[@class='game-attr']//dt[text()='开发商:']/following-sibling::dd[1]/text()") - developer = developer_elem[0].strip().split(' / ') if developer_elem else None - - publisher_elem = content.xpath( - "//dl[@class='game-attr']//dt[text()='发行商:']/following-sibling::dd[1]/text()") - publisher = publisher_elem[0].strip().split(' / ') if publisher_elem else None - - platform_elem = content.xpath( - "//dl[@class='game-attr']//dt[text()='平台:']/following-sibling::dd[1]/a/text()") - platform = platform_elem if platform_elem else None - - genre_elem = content.xpath( - "//dl[@class='game-attr']//dt[text()='类型:']/following-sibling::dd[1]/a/text()") - genre = None - if genre_elem: - genre = [g for g in genre_elem if g != '游戏'] - - date_elem = content.xpath( - "//dl[@class='game-attr']//dt[text()='发行日期:']/following-sibling::dd[1]/text()") - release_date = parse_date(date_elem[0].strip()) if date_elem else None - - brief_elem = content.xpath("//div[@class='mod item-desc']/p/text()") - brief = '\n'.join(brief_elem) if brief_elem else None - - img_url_elem = content.xpath( - "//div[@class='item-subject-info']/div[@class='pic']//img/@src") - img_url = img_url_elem[0].strip() if img_url_elem else None - raw_img, ext = self.download_image(img_url) - - data = { - 'title': title, - 'other_title': other_title, - 'developer': developer, - 'publisher': publisher, - 'release_date': release_date, - 'genre': genre, - 'platform': platform, - 'brief': brief, - 'other_info': None, - 'source_site': self.site_name, - 'source_url': self.get_effective_url(url), - } - - self.raw_data, self.raw_img, self.img_ext = data, raw_img, ext - return data, raw_img - - -class SteamGameScraper(AbstractScraper): - site_name = SourceSiteEnum.STEAM.value - host = 'store.steampowered.com' - data_class = Game - form_class = GameForm - - regex = re.compile(r"https://store\.steampowered\.com/app/\d+/{0,1}") - - def scrape(self, url): - headers = DEFAULT_REQUEST_HEADERS.copy() - headers['Host'] = self.host - headers['Cookie'] = "wants_mature_content=1; birthtime=754700401;" - content = self.download_page(url, headers) - - title = content.xpath("//div[@class='apphub_AppName']/text()")[0] - developer = content.xpath("//div[@id='developers_list']/a/text()") - publisher = content.xpath("//div[@class='glance_ctn']//div[@class='dev_row'][2]//a/text()") - release_date = parse_date( - content.xpath( - "//div[@class='release_date']/div[@class='date']/text()")[0] - ) - - genre = content.xpath( - "//div[@class='details_block']/b[2]/following-sibling::a/text()") - - platform = ['PC'] - - brief = content.xpath( - "//div[@class='game_description_snippet']/text()")[0].strip() - - img_url = content.xpath( - "//img[@class='game_header_image_full']/@src" - )[0].replace("header.jpg", "library_600x900.jpg") - raw_img, ext = self.download_image(img_url) - - # no 600x900 picture - if raw_img is None: - img_url = content.xpath("//img[@class='game_header_image_full']/@src")[0] - raw_img, ext = self.download_image(img_url) - - data = { - 'title': title, - 'other_title': None, - 'developer': developer, - 'publisher': publisher, - 'release_date': release_date, - 'genre': genre, - 'platform': platform, - 'brief': brief, - 'other_info': None, - 'source_site': self.site_name, - 'source_url': self.get_effective_url(url), - } - - self.raw_data, self.raw_img, self.img_ext = data, raw_img, ext - return data, raw_img - - -def find_entity(source_url): - """ - for bangumi - """ - # to be added when new scrape method is implemented - result = Game.objects.filter(source_url=source_url) - if result: - return result[0] - else: - raise ObjectDoesNotExist - -class BangumiScraper(AbstractScraper): - site_name = SourceSiteEnum.BANGUMI.value - host = 'bgm.tv' - - # for interface coherence - data_class = type("FakeDataClass", (object,), {})() - data_class.objects = type("FakeObjectsClass", (object,), {})() - data_class.objects.get = find_entity - # should be set at scrape_* method - form_class = '' - - - regex = re.compile(r"https{0,1}://bgm\.tv/subject/\d+") - - def scrape(self, url): - """ - This is the scraping portal - """ - headers = DEFAULT_REQUEST_HEADERS.copy() - headers['Host'] = self.host - content = self.download_page(url, headers) - - # download image - img_url = 'http:' + content.xpath("//div[@class='infobox']//img[1]/@src")[0] - raw_img, ext = self.download_image(img_url) - - # Test category - category_code = content.xpath("//div[@id='headerSearch']//option[@selected]/@value")[0] - handler_map = { - '1': self.scrape_book, - '2': self.scrape_movie, - '3': self.scrape_album, - '4': self.scrape_game - } - data = handler_map[category_code](self, content) - data['source_url'] = self.get_effective_url(url) - - self.raw_data, self.raw_img, self.img_ext = data, raw_img, ext - return data, raw_img - - - def scrape_game(self, content): - self.data_class = Game - self.form_class = GameForm - - title_elem = content.xpath("//a[@property='v:itemreviewed']/text()") - if not title_elem: - raise ValueError("no game info found on this page") - title = None - else: - title = title_elem[0].strip() - - other_title_elem = content.xpath( - "//ul[@id='infobox']/li[child::span[contains(text(),'别名')]]/text()") - if not other_title_elem: - other_title_elem = content.xpath( - "//ul[@id='infobox']/li[child::span[contains(text(),'别名')]]/a/text()") - other_title = other_title_elem if other_title_elem else [] - - chinese_name_elem = content.xpath( - "//ul[@id='infobox']/li[child::span[contains(text(),'中文')]]/text()") - if not chinese_name_elem: - chinese_name_elem = content.xpath( - "//ul[@id='infobox']/li[child::span[contains(text(),'中文')]]/a/text()") - if chinese_name_elem: - chinese_name = chinese_name_elem[0] - # switch chinese name with original name - title, chinese_name = chinese_name, title - # actually the name appended is original - other_title.append(chinese_name) - - developer_elem = content.xpath( - "//ul[@id='infobox']/li[child::span[contains(text(),'开发')]]/text()") - if not developer_elem: - developer_elem = content.xpath( - "//ul[@id='infobox']/li[child::span[contains(text(),'开发')]]/a/text()") - developer = developer_elem if developer_elem else None - - publisher_elem = content.xpath( - "//ul[@id='infobox']/li[child::span[contains(text(),'发行:')]]/text()") - if not publisher_elem: - publisher_elem = content.xpath( - "//ul[@id='infobox']/li[child::span[contains(text(),'发行:')]]/a/text()") - publisher = publisher_elem if publisher_elem else None - - platform_elem = content.xpath( - "//ul[@id='infobox']/li[child::span[contains(text(),'平台')]]/text()") - if not platform_elem: - platform_elem = content.xpath( - "//ul[@id='infobox']/li[child::span[contains(text(),'平台')]]/a/text()") - platform = platform_elem if platform_elem else None - - genre_elem = content.xpath( - "//ul[@id='infobox']/li[child::span[contains(text(),'类型')]]/text()") - if not genre_elem: - genre_elem = content.xpath( - "//ul[@id='infobox']/li[child::span[contains(text(),'类型')]]/a/text()") - genre = genre_elem if genre_elem else None - - date_elem = content.xpath( - "//ul[@id='infobox']/li[child::span[contains(text(),'发行日期')]]/text()") - if not date_elem: - date_elem = content.xpath( - "//ul[@id='infobox']/li[child::span[contains(text(),'发行日期')]]/a/text()") - release_date = parse_date(date_elem[0]) if date_elem else None - - brief = ''.join(content.xpath("//div[@property='v:summary']/text()")) - - other_info = {} - other_elem = content.xpath( - "//ul[@id='infobox']/li[child::span[contains(text(),'人数')]]/text()") - if other_elem: - other_info['游玩人数'] = other_elem[0] - other_elem = content.xpath( - "//ul[@id='infobox']/li[child::span[contains(text(),'引擎')]]/text()") - if other_elem: - other_info['引擎'] = ' '.join(other_elem) - other_elem = content.xpath( - "//ul[@id='infobox']/li[child::span[contains(text(),'售价')]]/text()") - if other_elem: - other_info['售价'] = ' '.join(other_elem) - other_elem = content.xpath( - "//ul[@id='infobox']/li[child::span[contains(text(),'官方网站')]]/text()") - if other_elem: - other_info['网站'] = other_elem[0] - other_elem = content.xpath( - "//ul[@id='infobox']/li[child::span[contains(text(),'剧本')]]/a/text()") or content.xpath( - "//ul[@id='infobox']/li[child::span[contains(text(),'剧本')]]/text()") - if other_elem: - other_info['剧本'] = ' '.join(other_elem) - other_elem = content.xpath( - "//ul[@id='infobox']/li[child::span[contains(text(),'编剧')]]/a/text()") or content.xpath( - "//ul[@id='infobox']/li[child::span[contains(text(),'编剧')]]/text()") - if other_elem: - other_info['编剧'] = ' '.join(other_elem) - other_elem = content.xpath( - "//ul[@id='infobox']/li[child::span[contains(text(),'音乐')]]/a/text()") or content.xpath( - "//ul[@id='infobox']/li[child::span[contains(text(),'音乐')]]/text()") - if other_elem: - other_info['音乐'] = ' '.join(other_elem) - other_elem = content.xpath( - "//ul[@id='infobox']/li[child::span[contains(text(),'美术')]]/a/text()") or content.xpath( - "//ul[@id='infobox']/li[child::span[contains(text(),'美术')]]/text()") - if other_elem: - other_info['美术'] = ' '.join(other_elem) - - data = { - 'title': title, - 'other_title': None, - 'developer': developer, - 'publisher': publisher, - 'release_date': release_date, - 'genre': genre, - 'platform': platform, - 'brief': brief, - 'other_info': other_info, - 'source_site': self.site_name, - } - - return data - - def scrape_movie(self, content): - self.data_class = Movie - self.form_class = MovieForm - raise NotImplementedError - - def scrape_book(self, content): - self.data_class = Book - self.form_class = BookForm - raise NotImplementedError - - def scrape_album(self, content): - self.data_class = Album - self.form_class = AlbumForm - raise NotImplementedError - - -# https://developers.google.com/youtube/v3/docs/?apix=true -# https://developers.google.com/books/docs/v1/using - +from common.scrapers.bandcamp import BandcampAlbumScraper +from common.scrapers.goodreads import GoodreadsScraper +from common.scrapers.google import GoogleBooksScraper +from common.scrapers.tmdb import TmdbMovieScraper +from common.scrapers.steam import SteamGameScraper +from common.scrapers.imdb import ImdbMovieScraper +from common.scrapers.igdb import IgdbGameScraper +from common.scrapers.spotify import SpotifyAlbumScraper, SpotifyTrackScraper +from common.scrapers.douban import DoubanAlbumScraper, DoubanBookScraper, DoubanGameScraper, DoubanMovieScraper +from common.scrapers.bangumi import BangumiScraper + + +def get_scraper_by_url(url): + parsed_url = urllib.parse.urlparse(url) + hostname = parsed_url.netloc + for host in scraper_registry: + if host in url: + return scraper_registry[host] + # TODO move this logic to scraper class + try: + answers = dns.resolver.query(hostname, 'CNAME') + for rdata in answers: + if str(rdata.target) == 'dom.bandcamp.com.': + return BandcampAlbumScraper + except Exception as e: + pass + try: + answers = dns.resolver.query(hostname, 'A') + for rdata in answers: + if str(rdata.address) == '35.241.62.186': + return BandcampAlbumScraper + except Exception as e: + pass + return None diff --git a/common/scrapers/bandcamp.py b/common/scrapers/bandcamp.py new file mode 100644 index 00000000..5f939da6 --- /dev/null +++ b/common/scrapers/bandcamp.py @@ -0,0 +1,71 @@ +import re +import dateparser +import json +from lxml import html +from common.models import SourceSiteEnum +from common.scraper import AbstractScraper +from music.models import Album +from music.forms import AlbumForm + + +class BandcampAlbumScraper(AbstractScraper): + site_name = SourceSiteEnum.BANDCAMP.value + # API URL + host = '.bandcamp.com/' + data_class = Album + form_class = AlbumForm + + regex = re.compile(r"https://[a-zA-Z0-9\-\.]+/album/[^?#]+") + + def scrape(self, url, response=None): + effective_url = self.get_effective_url(url) + if effective_url is None: + raise ValueError("not valid url") + if response is not None: + content = html.fromstring(response.content.decode('utf-8')) + else: + content = self.download_page(url, {}) + try: + title = content.xpath("//h2[@class='trackTitle']/text()")[0].strip() + artist = [content.xpath("//div[@id='name-section']/h3/span/a/text()")[0].strip()] + except IndexError: + raise ValueError("given url contains no valid info") + + genre = [] # TODO: parse tags + track_list = [] + release_nodes = content.xpath("//div[@class='tralbumData tralbum-credits']/text()") + release_date = dateparser.parse(re.sub(r'releas\w+ ', '', release_nodes[0].strip())) if release_nodes else None + duration = None + company = None + brief_nodes = content.xpath("//div[@class='tralbumData tralbum-about']/text()") + brief = "".join(brief_nodes) if brief_nodes else None + cover_url = content.xpath("//div[@id='tralbumArt']/a/@href")[0].strip() + bandcamp_page_data = json.loads(content.xpath( + "//meta[@name='bc-page-properties']/@content")[0].strip()) + other_info = {} + other_info['bandcamp_album_id'] = bandcamp_page_data['item_id'] + + raw_img, ext = self.download_image(cover_url, url) + + data = { + 'title': title, + 'artist': artist, + 'genre': genre, + 'track_list': track_list, + 'release_date': release_date, + 'duration': duration, + 'company': company, + 'brief': brief, + 'other_info': other_info, + 'source_site': self.site_name, + 'source_url': effective_url, + 'cover_url': cover_url, + } + + self.raw_data, self.raw_img, self.img_ext = data, raw_img, ext + return data, raw_img + + @classmethod + def get_effective_url(cls, raw_url): + url = cls.regex.findall(raw_url) + return url[0] if len(url) > 0 else None diff --git a/common/scrapers/bangumi.py b/common/scrapers/bangumi.py new file mode 100644 index 00000000..498ba849 --- /dev/null +++ b/common/scrapers/bangumi.py @@ -0,0 +1,199 @@ +import re +from common.models import SourceSiteEnum +from movies.models import Movie, MovieGenreEnum +from movies.forms import MovieForm +from books.models import Book +from books.forms import BookForm +from music.models import Album, Song +from music.forms import AlbumForm, SongForm +from games.models import Game +from games.forms import GameForm +from common.scraper import * +from django.core.exceptions import ObjectDoesNotExist + + +def find_entity(source_url): + """ + for bangumi + """ + # to be added when new scrape method is implemented + result = Game.objects.filter(source_url=source_url) + if result: + return result[0] + else: + raise ObjectDoesNotExist + + +class BangumiScraper(AbstractScraper): + site_name = SourceSiteEnum.BANGUMI.value + host = 'bgm.tv' + + # for interface coherence + data_class = type("FakeDataClass", (object,), {})() + data_class.objects = type("FakeObjectsClass", (object,), {})() + data_class.objects.get = find_entity + # should be set at scrape_* method + form_class = '' + + regex = re.compile(r"https{0,1}://bgm\.tv/subject/\d+") + + def scrape(self, url): + """ + This is the scraping portal + """ + headers = DEFAULT_REQUEST_HEADERS.copy() + headers['Host'] = self.host + content = self.download_page(url, headers) + + # download image + img_url = 'http:' + content.xpath("//div[@class='infobox']//img[1]/@src")[0] + raw_img, ext = self.download_image(img_url, url) + + # Test category + category_code = content.xpath("//div[@id='headerSearch']//option[@selected]/@value")[0] + handler_map = { + '1': self.scrape_book, + '2': self.scrape_movie, + '3': self.scrape_album, + '4': self.scrape_game + } + data = handler_map[category_code](self, content) + data['source_url'] = self.get_effective_url(url) + + self.raw_data, self.raw_img, self.img_ext = data, raw_img, ext + return data, raw_img + + def scrape_game(self, content): + self.data_class = Game + self.form_class = GameForm + + title_elem = content.xpath("//a[@property='v:itemreviewed']/text()") + if not title_elem: + raise ValueError("no game info found on this page") + title = None + else: + title = title_elem[0].strip() + + other_title_elem = content.xpath( + "//ul[@id='infobox']/li[child::span[contains(text(),'别名')]]/text()") + if not other_title_elem: + other_title_elem = content.xpath( + "//ul[@id='infobox']/li[child::span[contains(text(),'别名')]]/a/text()") + other_title = other_title_elem if other_title_elem else [] + + chinese_name_elem = content.xpath( + "//ul[@id='infobox']/li[child::span[contains(text(),'中文')]]/text()") + if not chinese_name_elem: + chinese_name_elem = content.xpath( + "//ul[@id='infobox']/li[child::span[contains(text(),'中文')]]/a/text()") + if chinese_name_elem: + chinese_name = chinese_name_elem[0] + # switch chinese name with original name + title, chinese_name = chinese_name, title + # actually the name appended is original + other_title.append(chinese_name) + + developer_elem = content.xpath( + "//ul[@id='infobox']/li[child::span[contains(text(),'开发')]]/text()") + if not developer_elem: + developer_elem = content.xpath( + "//ul[@id='infobox']/li[child::span[contains(text(),'开发')]]/a/text()") + developer = developer_elem if developer_elem else None + + publisher_elem = content.xpath( + "//ul[@id='infobox']/li[child::span[contains(text(),'发行:')]]/text()") + if not publisher_elem: + publisher_elem = content.xpath( + "//ul[@id='infobox']/li[child::span[contains(text(),'发行:')]]/a/text()") + publisher = publisher_elem if publisher_elem else None + + platform_elem = content.xpath( + "//ul[@id='infobox']/li[child::span[contains(text(),'平台')]]/text()") + if not platform_elem: + platform_elem = content.xpath( + "//ul[@id='infobox']/li[child::span[contains(text(),'平台')]]/a/text()") + platform = platform_elem if platform_elem else None + + genre_elem = content.xpath( + "//ul[@id='infobox']/li[child::span[contains(text(),'类型')]]/text()") + if not genre_elem: + genre_elem = content.xpath( + "//ul[@id='infobox']/li[child::span[contains(text(),'类型')]]/a/text()") + genre = genre_elem if genre_elem else None + + date_elem = content.xpath( + "//ul[@id='infobox']/li[child::span[contains(text(),'发行日期')]]/text()") + if not date_elem: + date_elem = content.xpath( + "//ul[@id='infobox']/li[child::span[contains(text(),'发行日期')]]/a/text()") + release_date = parse_date(date_elem[0]) if date_elem else None + + brief = ''.join(content.xpath("//div[@property='v:summary']/text()")) + + other_info = {} + other_elem = content.xpath( + "//ul[@id='infobox']/li[child::span[contains(text(),'人数')]]/text()") + if other_elem: + other_info['游玩人数'] = other_elem[0] + other_elem = content.xpath( + "//ul[@id='infobox']/li[child::span[contains(text(),'引擎')]]/text()") + if other_elem: + other_info['引擎'] = ' '.join(other_elem) + other_elem = content.xpath( + "//ul[@id='infobox']/li[child::span[contains(text(),'售价')]]/text()") + if other_elem: + other_info['售价'] = ' '.join(other_elem) + other_elem = content.xpath( + "//ul[@id='infobox']/li[child::span[contains(text(),'官方网站')]]/text()") + if other_elem: + other_info['网站'] = other_elem[0] + other_elem = content.xpath( + "//ul[@id='infobox']/li[child::span[contains(text(),'剧本')]]/a/text()") or content.xpath( + "//ul[@id='infobox']/li[child::span[contains(text(),'剧本')]]/text()") + if other_elem: + other_info['剧本'] = ' '.join(other_elem) + other_elem = content.xpath( + "//ul[@id='infobox']/li[child::span[contains(text(),'编剧')]]/a/text()") or content.xpath( + "//ul[@id='infobox']/li[child::span[contains(text(),'编剧')]]/text()") + if other_elem: + other_info['编剧'] = ' '.join(other_elem) + other_elem = content.xpath( + "//ul[@id='infobox']/li[child::span[contains(text(),'音乐')]]/a/text()") or content.xpath( + "//ul[@id='infobox']/li[child::span[contains(text(),'音乐')]]/text()") + if other_elem: + other_info['音乐'] = ' '.join(other_elem) + other_elem = content.xpath( + "//ul[@id='infobox']/li[child::span[contains(text(),'美术')]]/a/text()") or content.xpath( + "//ul[@id='infobox']/li[child::span[contains(text(),'美术')]]/text()") + if other_elem: + other_info['美术'] = ' '.join(other_elem) + + data = { + 'title': title, + 'other_title': None, + 'developer': developer, + 'publisher': publisher, + 'release_date': release_date, + 'genre': genre, + 'platform': platform, + 'brief': brief, + 'other_info': other_info, + 'source_site': self.site_name, + } + + return data + + def scrape_movie(self, content): + self.data_class = Movie + self.form_class = MovieForm + raise NotImplementedError + + def scrape_book(self, content): + self.data_class = Book + self.form_class = BookForm + raise NotImplementedError + + def scrape_album(self, content): + self.data_class = Album + self.form_class = AlbumForm + raise NotImplementedError diff --git a/common/scrapers/douban.py b/common/scrapers/douban.py new file mode 100644 index 00000000..6dcaff69 --- /dev/null +++ b/common/scrapers/douban.py @@ -0,0 +1,714 @@ +import requests +import re +import filetype +from lxml import html +from common.models import SourceSiteEnum +from movies.models import Movie, MovieGenreEnum +from movies.forms import MovieForm +from books.models import Book +from books.forms import BookForm +from music.models import Album +from music.forms import AlbumForm +from games.models import Game +from games.forms import GameForm +from django.core.validators import URLValidator +from django.conf import settings +from PIL import Image +from io import BytesIO +from common.scraper import * + + +class DoubanScrapperMixin: + @classmethod + def download_page(cls, url, headers): + url = cls.get_effective_url(url) + r = None + error = 'DoubanScrapper: error occured when downloading ' + url + content = None + last_error = None + + def get(url): + nonlocal r + # print('Douban GET ' + url) + try: + r = requests.get(url, timeout=settings.SCRAPING_TIMEOUT) + except Exception as e: + r = requests.Response() + r.status_code = f"Exception when GET {url} {e}" + url + # print('Douban CODE ' + str(r.status_code)) + return r + + def check_content(): + nonlocal r, error, content, last_error + content = None + last_error = None + if r.status_code == 200: + content = r.content.decode('utf-8') + if content.find('关于豆瓣') == -1: + if content.find('你的 IP 发出') == -1: + error = error + 'Content not authentic' # response is garbage + else: + error = error + 'IP banned' + content = None + last_error = 'network' + elif content.find('<title>页面不存在</title>') != -1 or content.find('呃... 你想访问的条目豆瓣不收录。') != -1: # re.search('不存在[^<]+</title>', content, re.MULTILINE): + content = None + last_error = 'censorship' + error = error + 'Not found or hidden by Douban' + elif r.status_code == 204: + content = None + last_error = 'censorship' + error = error + 'Not found or hidden by Douban' + else: + content = None + last_error = 'network' + error = error + str(r.status_code) + + def fix_wayback_links(): + nonlocal content + # fix links + content = re.sub(r'href="http[^"]+http', r'href="http', content) + # https://img9.doubanio.com/view/subject/{l|m|s}/public/s1234.jpg + content = re.sub(r'src="[^"]+/(s\d+\.\w+)"', + r'src="https://img9.doubanio.com/view/subject/m/public/\1"', content) + # https://img9.doubanio.com/view/photo/s_ratio_poster/public/p2681329386.jpg + # https://img9.doubanio.com/view/photo/{l|m|s}/public/p1234.webp + content = re.sub(r'src="[^"]+/(p\d+\.\w+)"', + r'src="https://img9.doubanio.com/view/photo/m/public/\1"', content) + + # Wayback Machine: get latest available + def wayback(): + nonlocal r, error, content + error = error + '\nWayback: ' + get('http://archive.org/wayback/available?url=' + url) + if r.status_code == 200: + w = r.json() + if w['archived_snapshots'] and w['archived_snapshots']['closest']: + get(w['archived_snapshots']['closest']['url']) + check_content() + if content is not None: + fix_wayback_links() + else: + error = error + 'No snapshot available' + else: + error = error + str(r.status_code) + + # Wayback Machine: guess via CDX API + def wayback_cdx(): + nonlocal r, error, content + error = error + '\nWayback: ' + get('http://web.archive.org/cdx/search/cdx?url=' + url) + if r.status_code == 200: + dates = re.findall(r'[^\s]+\s+(\d+)\s+[^\s]+\s+[^\s]+\s+\d+\s+[^\s]+\s+\d{5,}', + r.content.decode('utf-8')) + # assume snapshots whose size >9999 contain real content, use the latest one of them + if len(dates) > 0: + get('http://web.archive.org/web/' + dates[-1] + '/' + url) + check_content() + if content is not None: + fix_wayback_links() + else: + error = error + 'No snapshot available' + else: + error = error + str(r.status_code) + + def latest(): + nonlocal r, error, content + if settings.SCRAPESTACK_KEY is not None: + error = error + '\nScrapeStack: ' + get(f'http://api.scrapestack.com/scrape?access_key={settings.SCRAPESTACK_KEY}&url={url}') + elif settings.SCRAPERAPI_KEY is not None: + error = error + '\nScraperAPI: ' + get(f'http://api.scraperapi.com?api_key={settings.SCRAPERAPI_KEY}&url={url}') + else: + error = error + '\nDirect: ' + get(url) + check_content() + if last_error == 'network' and settings.PROXYCRAWL_KEY is not None: + error = error + '\nProxyCrawl: ' + get(f'https://api.proxycrawl.com/?token={settings.PROXYCRAWL_KEY}&url={url}') + check_content() + if last_error == 'censorship' and settings.LOCAL_PROXY is not None: + error = error + '\nLocal: ' + get(f'{settings.LOCAL_PROXY}?url={url}') + check_content() + + latest() + if content is None: + wayback_cdx() + + if content is None: + raise RuntimeError(error) + # with open('/tmp/temp.html', 'w', encoding='utf-8') as fp: + # fp.write(content) + return html.fromstring(content) + + @classmethod + def download_image(cls, url, item_url=None): + raw_img = None + ext = None + + if settings.SCRAPESTACK_KEY is not None: + dl_url = f'http://api.scrapestack.com/scrape?access_key={settings.SCRAPESTACK_KEY}&url={url}' + elif settings.SCRAPERAPI_KEY is not None: + dl_url = f'http://api.scraperapi.com?api_key={settings.SCRAPERAPI_KEY}&url={url}' + else: + dl_url = url + + try: + img_response = requests.get(dl_url, timeout=settings.SCRAPING_TIMEOUT) + if img_response.status_code == 200: + raw_img = img_response.content + img = Image.open(BytesIO(raw_img)) + img.load() # corrupted image will trigger exception + content_type = img_response.headers.get('Content-Type') + ext = filetype.get_type(mime=content_type.partition(';')[0].strip()).extension + else: + logger.error(f"Douban: download image failed {img_response.status_code} {dl_url} {item_url}") + # raise RuntimeError(f"Douban: download image failed {img_response.status_code} {dl_url}") + except Exception as e: + raw_img = None + ext = None + logger.error(f"Douban: download image failed {e} {dl_url} {item_url}") + + if raw_img is None and settings.PROXYCRAWL_KEY is not None: + try: + dl_url = f'https://api.proxycrawl.com/?token={settings.PROXYCRAWL_KEY}&url={url}' + img_response = requests.get(dl_url, timeout=settings.SCRAPING_TIMEOUT) + if img_response.status_code == 200: + raw_img = img_response.content + img = Image.open(BytesIO(raw_img)) + img.load() # corrupted image will trigger exception + content_type = img_response.headers.get('Content-Type') + ext = filetype.get_type(mime=content_type.partition(';')[0].strip()).extension + else: + logger.error(f"Douban: download image failed {img_response.status_code} {dl_url} {item_url}") + except Exception as e: + raw_img = None + ext = None + logger.error(f"Douban: download image failed {e} {dl_url} {item_url}") + return raw_img, ext + + +class DoubanBookScraper(DoubanScrapperMixin, AbstractScraper): + site_name = SourceSiteEnum.DOUBAN.value + host = "book.douban.com" + data_class = Book + form_class = BookForm + + regex = re.compile(r"https://book\.douban\.com/subject/\d+/{0,1}") + + def scrape(self, url): + headers = DEFAULT_REQUEST_HEADERS.copy() + headers['Host'] = self.host + content = self.download_page(url, headers) + + isbn_elem = content.xpath("//div[@id='info']//span[text()='ISBN:']/following::text()") + isbn = isbn_elem[0].strip() if isbn_elem else None + title_elem = content.xpath("/html/body//h1/span/text()") + title = title_elem[0].strip() if title_elem else None + if not title: + if isbn: + title = 'isbn: ' + isbn + else: + raise ValueError("given url contains no book title or isbn") + + subtitle_elem = content.xpath( + "//div[@id='info']//span[text()='副标题:']/following::text()") + subtitle = subtitle_elem[0].strip()[:500] if subtitle_elem else None + + orig_title_elem = content.xpath( + "//div[@id='info']//span[text()='原作名:']/following::text()") + orig_title = orig_title_elem[0].strip()[:500] if orig_title_elem else None + + language_elem = content.xpath( + "//div[@id='info']//span[text()='语言:']/following::text()") + language = language_elem[0].strip() if language_elem else None + + pub_house_elem = content.xpath( + "//div[@id='info']//span[text()='出版社:']/following::text()") + pub_house = pub_house_elem[0].strip() if pub_house_elem else None + + pub_date_elem = content.xpath( + "//div[@id='info']//span[text()='出版年:']/following::text()") + pub_date = pub_date_elem[0].strip() if pub_date_elem else '' + year_month_day = RE_NUMBERS.findall(pub_date) + if len(year_month_day) in (2, 3): + pub_year = int(year_month_day[0]) + pub_month = int(year_month_day[1]) + elif len(year_month_day) == 1: + pub_year = int(year_month_day[0]) + pub_month = None + else: + pub_year = None + pub_month = None + if pub_year and pub_month and pub_year < pub_month: + pub_year, pub_month = pub_month, pub_year + pub_year = None if pub_year is not None and pub_year not in range( + 0, 3000) else pub_year + pub_month = None if pub_month is not None and pub_month not in range( + 1, 12) else pub_month + + binding_elem = content.xpath( + "//div[@id='info']//span[text()='装帧:']/following::text()") + binding = binding_elem[0].strip() if binding_elem else None + + price_elem = content.xpath( + "//div[@id='info']//span[text()='定价:']/following::text()") + price = price_elem[0].strip() if price_elem else None + + pages_elem = content.xpath( + "//div[@id='info']//span[text()='页数:']/following::text()") + pages = pages_elem[0].strip() if pages_elem else None + if pages is not None: + pages = int(RE_NUMBERS.findall(pages)[ + 0]) if RE_NUMBERS.findall(pages) else None + if pages and (pages > 999999 or pages < 1): + pages = None + + brief_elem = content.xpath( + "//h2/span[text()='内容简介']/../following-sibling::div[1]//div[@class='intro'][not(ancestor::span[@class='short'])]/p/text()") + brief = '\n'.join(p.strip() + for p in brief_elem) if brief_elem else None + + contents = None + try: + contents_elem = content.xpath( + "//h2/span[text()='目录']/../following-sibling::div[1]")[0] + # if next the id of next sibling contains `dir`, that would be the full contents + if "dir" in contents_elem.getnext().xpath("@id")[0]: + contents_elem = contents_elem.getnext() + contents = '\n'.join(p.strip() for p in contents_elem.xpath( + "text()")[:-2]) if contents_elem else None + else: + contents = '\n'.join(p.strip() for p in contents_elem.xpath( + "text()")) if contents_elem else None + except Exception: + pass + + img_url_elem = content.xpath("//*[@id='mainpic']/a/img/@src") + img_url = img_url_elem[0].strip() if img_url_elem else None + raw_img, ext = self.download_image(img_url, url) + + # there are two html formats for authors and translators + authors_elem = content.xpath("""//div[@id='info']//span[text()='作者:']/following-sibling::br[1]/ + preceding-sibling::a[preceding-sibling::span[text()='作者:']]/text()""") + if not authors_elem: + authors_elem = content.xpath( + """//div[@id='info']//span[text()=' 作者']/following-sibling::a/text()""") + if authors_elem: + authors = [] + for author in authors_elem: + authors.append(RE_WHITESPACES.sub(' ', author.strip())[:200]) + else: + authors = None + + translators_elem = content.xpath("""//div[@id='info']//span[text()='译者:']/following-sibling::br[1]/ + preceding-sibling::a[preceding-sibling::span[text()='译者:']]/text()""") + if not translators_elem: + translators_elem = content.xpath( + """//div[@id='info']//span[text()=' 译者']/following-sibling::a/text()""") + if translators_elem: + translators = [] + for translator in translators_elem: + translators.append(RE_WHITESPACES.sub(' ', translator.strip())) + else: + translators = None + + other = {} + cncode_elem = content.xpath( + "//div[@id='info']//span[text()='统一书号:']/following::text()") + if cncode_elem: + other['统一书号'] = cncode_elem[0].strip() + series_elem = content.xpath( + "//div[@id='info']//span[text()='丛书:']/following-sibling::a[1]/text()") + if series_elem: + other['丛书'] = series_elem[0].strip() + imprint_elem = content.xpath( + "//div[@id='info']//span[text()='出品方:']/following-sibling::a[1]/text()") + if imprint_elem: + other['出品方'] = imprint_elem[0].strip() + + data = { + 'title': title, + 'subtitle': subtitle, + 'orig_title': orig_title, + 'author': authors, + 'translator': translators, + 'language': language, + 'pub_house': pub_house, + 'pub_year': pub_year, + 'pub_month': pub_month, + 'binding': binding, + 'price': price, + 'pages': pages, + 'isbn': isbn, + 'brief': brief, + 'contents': contents, + 'other_info': other, + 'source_site': self.site_name, + 'source_url': self.get_effective_url(url), + } + self.raw_data, self.raw_img, self.img_ext = data, raw_img, ext + return data, raw_img + + +class DoubanMovieScraper(DoubanScrapperMixin, AbstractScraper): + site_name = SourceSiteEnum.DOUBAN.value + host = 'movie.douban.com' + data_class = Movie + form_class = MovieForm + + regex = re.compile(r"https://movie\.douban\.com/subject/\d+/{0,1}") + + def scrape(self, url): + headers = DEFAULT_REQUEST_HEADERS.copy() + headers['Host'] = self.host + content = self.download_page(url, headers) + + # parsing starts here + try: + raw_title = content.xpath( + "//span[@property='v:itemreviewed']/text()")[0].strip() + except IndexError: + raise ValueError("given url contains no movie info") + + orig_title = content.xpath( + "//img[@rel='v:image']/@alt")[0].strip() + title = raw_title.split(orig_title)[0].strip() + # if has no chinese title + if title == '': + title = orig_title + + if title == orig_title: + orig_title = None + + # there are two html formats for authors and translators + other_title_elem = content.xpath( + "//div[@id='info']//span[text()='又名:']/following-sibling::text()[1]") + other_title = other_title_elem[0].strip().split( + ' / ') if other_title_elem else None + + imdb_elem = content.xpath( + "//div[@id='info']//span[text()='IMDb链接:']/following-sibling::a[1]/text()") + if not imdb_elem: + imdb_elem = content.xpath( + "//div[@id='info']//span[text()='IMDb:']/following-sibling::text()[1]") + imdb_code = imdb_elem[0].strip() if imdb_elem else None + + director_elem = content.xpath( + "//div[@id='info']//span[text()='导演']/following-sibling::span[1]/a/text()") + director = director_elem if director_elem else None + + playwright_elem = content.xpath( + "//div[@id='info']//span[text()='编剧']/following-sibling::span[1]/a/text()") + playwright = list(map(lambda a: a[:200], playwright_elem)) if playwright_elem else None + + actor_elem = content.xpath( + "//div[@id='info']//span[text()='主演']/following-sibling::span[1]/a/text()") + actor = list(map(lambda a: a[:200], actor_elem)) if actor_elem else None + + # construct genre translator + genre_translator = {} + attrs = [attr for attr in dir(MovieGenreEnum) if '__' not in attr] + for attr in attrs: + genre_translator[getattr(MovieGenreEnum, attr).label] = getattr( + MovieGenreEnum, attr).value + + genre_elem = content.xpath("//span[@property='v:genre']/text()") + if genre_elem: + genre = [] + for g in genre_elem: + g = g.split(' ')[0] + if g == '紀錄片': # likely some original data on douban was corrupted + g = '纪录片' + elif g == '鬼怪': + g = '惊悚' + if g in genre_translator: + genre.append(genre_translator[g]) + elif g in genre_translator.values(): + genre.append(g) + else: + logger.error(f'unable to map genre {g}') + else: + genre = None + + showtime_elem = content.xpath( + "//span[@property='v:initialReleaseDate']/text()") + if showtime_elem: + showtime = [] + for st in showtime_elem: + parts = st.split('(') + if len(parts) == 1: + time = st.split('(')[0] + region = '' + else: + time = st.split('(')[0] + region = st.split('(')[1][0:-1] + showtime.append({time: region}) + else: + showtime = None + + site_elem = content.xpath( + "//div[@id='info']//span[text()='官方网站:']/following-sibling::a[1]/@href") + site = site_elem[0].strip()[:200] if site_elem else None + try: + validator = URLValidator() + validator(site) + except ValidationError: + site = None + + area_elem = content.xpath( + "//div[@id='info']//span[text()='制片国家/地区:']/following-sibling::text()[1]") + if area_elem: + area = [a.strip()[:100] for a in area_elem[0].split('/')] + else: + area = None + + language_elem = content.xpath( + "//div[@id='info']//span[text()='语言:']/following-sibling::text()[1]") + if language_elem: + language = [a.strip() for a in language_elem[0].split(' / ')] + else: + language = None + + year_elem = content.xpath("//span[@class='year']/text()") + year = int(re.search(r'\d+', year_elem[0])[0]) if year_elem and re.search(r'\d+', year_elem[0]) else None + + duration_elem = content.xpath("//span[@property='v:runtime']/text()") + other_duration_elem = content.xpath( + "//span[@property='v:runtime']/following-sibling::text()[1]") + if duration_elem: + duration = duration_elem[0].strip() + if other_duration_elem: + duration += other_duration_elem[0].rstrip() + duration = duration.split('/')[0].strip() + else: + duration = None + + season_elem = content.xpath( + "//*[@id='season']/option[@selected='selected']/text()") + if not season_elem: + season_elem = content.xpath( + "//div[@id='info']//span[text()='季数:']/following-sibling::text()[1]") + season = int(season_elem[0].strip()) if season_elem else None + else: + season = int(season_elem[0].strip()) + + episodes_elem = content.xpath( + "//div[@id='info']//span[text()='集数:']/following-sibling::text()[1]") + episodes = int(episodes_elem[0].strip()) if episodes_elem and episodes_elem[0].isdigit() else None + + single_episode_length_elem = content.xpath( + "//div[@id='info']//span[text()='单集片长:']/following-sibling::text()[1]") + single_episode_length = single_episode_length_elem[0].strip( + )[:100] if single_episode_length_elem else None + + # if has field `episodes` not none then must be series + is_series = True if episodes else False + + brief_elem = content.xpath("//span[@class='all hidden']") + if not brief_elem: + brief_elem = content.xpath("//span[@property='v:summary']") + brief = '\n'.join([e.strip() for e in brief_elem[0].xpath( + './text()')]) if brief_elem else None + + img_url_elem = content.xpath("//img[@rel='v:image']/@src") + img_url = img_url_elem[0].strip() if img_url_elem else None + raw_img, ext = self.download_image(img_url, url) + + data = { + 'title': title, + 'orig_title': orig_title, + 'other_title': other_title, + 'imdb_code': imdb_code, + 'director': director, + 'playwright': playwright, + 'actor': actor, + 'genre': genre, + 'showtime': showtime, + 'site': site, + 'area': area, + 'language': language, + 'year': year, + 'duration': duration, + 'season': season, + 'episodes': episodes, + 'single_episode_length': single_episode_length, + 'brief': brief, + 'is_series': is_series, + 'source_site': self.site_name, + 'source_url': self.get_effective_url(url), + } + self.raw_data, self.raw_img, self.img_ext = data, raw_img, ext + return data, raw_img + + +class DoubanAlbumScraper(DoubanScrapperMixin, AbstractScraper): + site_name = SourceSiteEnum.DOUBAN.value + host = 'music.douban.com' + data_class = Album + form_class = AlbumForm + + regex = re.compile(r"https://music\.douban\.com/subject/\d+/{0,1}") + + def scrape(self, url): + headers = DEFAULT_REQUEST_HEADERS.copy() + headers['Host'] = self.host + content = self.download_page(url, headers) + + # parsing starts here + try: + title = content.xpath("//h1/span/text()")[0].strip() + except IndexError: + raise ValueError("given url contains no album info") + if not title: + raise ValueError("given url contains no album info") + + artists_elem = content.xpath("//div[@id='info']/span/span[@class='pl']/a/text()") + artist = None if not artists_elem else list(map(lambda a: a[:200], artists_elem)) + + genre_elem = content.xpath( + "//div[@id='info']//span[text()='流派:']/following::text()[1]") + genre = genre_elem[0].strip() if genre_elem else None + + date_elem = content.xpath( + "//div[@id='info']//span[text()='发行时间:']/following::text()[1]") + release_date = parse_date(date_elem[0].strip()) if date_elem else None + + company_elem = content.xpath( + "//div[@id='info']//span[text()='出版者:']/following::text()[1]") + company = company_elem[0].strip() if company_elem else None + + track_list_elem = content.xpath( + "//div[@class='track-list']/div[@class='indent']/div/text()" + ) + if track_list_elem: + track_list = '\n'.join([track.strip() for track in track_list_elem]) + else: + track_list = None + + brief_elem = content.xpath("//span[@class='all hidden']") + if not brief_elem: + brief_elem = content.xpath("//span[@property='v:summary']") + brief = '\n'.join([e.strip() for e in brief_elem[0].xpath( + './text()')]) if brief_elem else None + + other_info = {} + other_elem = content.xpath( + "//div[@id='info']//span[text()='又名:']/following-sibling::text()[1]") + if other_elem: + other_info['又名'] = other_elem[0].strip() + other_elem = content.xpath( + "//div[@id='info']//span[text()='专辑类型:']/following-sibling::text()[1]") + if other_elem: + other_info['专辑类型'] = other_elem[0].strip() + other_elem = content.xpath( + "//div[@id='info']//span[text()='介质:']/following-sibling::text()[1]") + if other_elem: + other_info['介质'] = other_elem[0].strip() + other_elem = content.xpath( + "//div[@id='info']//span[text()='ISRC:']/following-sibling::text()[1]") + if other_elem: + other_info['ISRC'] = other_elem[0].strip() + other_elem = content.xpath( + "//div[@id='info']//span[text()='条形码:']/following-sibling::text()[1]") + if other_elem: + other_info['条形码'] = other_elem[0].strip() + other_elem = content.xpath( + "//div[@id='info']//span[text()='碟片数:']/following-sibling::text()[1]") + if other_elem: + other_info['碟片数'] = other_elem[0].strip() + + img_url_elem = content.xpath("//div[@id='mainpic']//img/@src") + img_url = img_url_elem[0].strip() if img_url_elem else None + raw_img, ext = self.download_image(img_url, url) + + data = { + 'title': title, + 'artist': artist, + 'genre': genre, + 'release_date': release_date, + 'duration': None, + 'company': company, + 'track_list': track_list, + 'brief': brief, + 'other_info': other_info, + 'source_site': self.site_name, + 'source_url': self.get_effective_url(url), + } + self.raw_data, self.raw_img, self.img_ext = data, raw_img, ext + return data, raw_img + + +class DoubanGameScraper(DoubanScrapperMixin, AbstractScraper): + site_name = SourceSiteEnum.DOUBAN.value + host = 'www.douban.com/game/' + data_class = Game + form_class = GameForm + + regex = re.compile(r"https://www\.douban\.com/game/\d+/{0,1}") + + def scrape(self, url): + headers = DEFAULT_REQUEST_HEADERS.copy() + headers['Host'] = 'www.douban.com' + content = self.download_page(url, headers) + + try: + raw_title = content.xpath( + "//div[@id='content']/h1/text()")[0].strip() + except IndexError: + raise ValueError("given url contains no game info") + + title = raw_title + + other_title_elem = content.xpath( + "//dl[@class='game-attr']//dt[text()='别名:']/following-sibling::dd[1]/text()") + other_title = other_title_elem[0].strip().split(' / ') if other_title_elem else None + + developer_elem = content.xpath( + "//dl[@class='game-attr']//dt[text()='开发商:']/following-sibling::dd[1]/text()") + developer = developer_elem[0].strip().split(' / ') if developer_elem else None + + publisher_elem = content.xpath( + "//dl[@class='game-attr']//dt[text()='发行商:']/following-sibling::dd[1]/text()") + publisher = publisher_elem[0].strip().split(' / ') if publisher_elem else None + + platform_elem = content.xpath( + "//dl[@class='game-attr']//dt[text()='平台:']/following-sibling::dd[1]/a/text()") + platform = platform_elem if platform_elem else None + + genre_elem = content.xpath( + "//dl[@class='game-attr']//dt[text()='类型:']/following-sibling::dd[1]/a/text()") + genre = None + if genre_elem: + genre = [g for g in genre_elem if g != '游戏'] + + date_elem = content.xpath( + "//dl[@class='game-attr']//dt[text()='发行日期:']/following-sibling::dd[1]/text()") + release_date = parse_date(date_elem[0].strip()) if date_elem else None + + brief_elem = content.xpath("//div[@class='mod item-desc']/p/text()") + brief = '\n'.join(brief_elem) if brief_elem else None + + img_url_elem = content.xpath( + "//div[@class='item-subject-info']/div[@class='pic']//img/@src") + img_url = img_url_elem[0].strip() if img_url_elem else None + raw_img, ext = self.download_image(img_url, url) + + data = { + 'title': title, + 'other_title': other_title, + 'developer': developer, + 'publisher': publisher, + 'release_date': release_date, + 'genre': genre, + 'platform': platform, + 'brief': brief, + 'other_info': None, + 'source_site': self.site_name, + 'source_url': self.get_effective_url(url), + } + + self.raw_data, self.raw_img, self.img_ext = data, raw_img, ext + return data, raw_img diff --git a/common/scrapers/goodreads.py b/common/scrapers/goodreads.py new file mode 100644 index 00000000..813fd063 --- /dev/null +++ b/common/scrapers/goodreads.py @@ -0,0 +1,157 @@ +import requests +import re +import filetype +from lxml import html +from common.models import SourceSiteEnum +from movies.models import Movie, MovieGenreEnum +from movies.forms import MovieForm +from books.models import Book +from books.forms import BookForm +from music.models import Album, Song +from music.forms import AlbumForm, SongForm +from games.models import Game +from games.forms import GameForm +from django.conf import settings +from PIL import Image +from io import BytesIO +from common.scraper import * + + +class GoodreadsScraper(AbstractScraper): + site_name = SourceSiteEnum.GOODREADS.value + host = "www.goodreads.com" + data_class = Book + form_class = BookForm + regex = re.compile(r"https://www\.goodreads\.com/book/show/\d+") + + @classmethod + def get_effective_url(cls, raw_url): + u = re.match(r".+/book/show/(\d+)", raw_url) + if not u: + u = re.match(r".+book/(\d+)", raw_url) + return "https://www.goodreads.com/book/show/" + u[1] if u else None + + def scrape(self, url, response=None): + """ + This is the scraping portal + """ + if response is not None: + content = html.fromstring(response.content.decode('utf-8')) + else: + headers = None # DEFAULT_REQUEST_HEADERS.copy() + content = self.download_page(url, headers) + + try: + title = content.xpath("//h1[@id='bookTitle']/text()")[0].strip() + except IndexError: + raise ValueError("given url contains no book info") + + subtitle = None + + orig_title_elem = content.xpath("//div[@id='bookDataBox']//div[text()='Original Title']/following-sibling::div/text()") + orig_title = orig_title_elem[0].strip() if orig_title_elem else None + + language_elem = content.xpath('//div[@itemprop="inLanguage"]/text()') + language = language_elem[0].strip() if language_elem else None + + pub_house_elem = content.xpath("//div[contains(text(), 'Published') and @class='row']/text()") + try: + months = ['January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December'] + r = re.compile('.*Published.*(' + '|'.join(months) + ').*(\\d\\d\\d\\d).+by\\s*(.+)\\s*', re.DOTALL) + pub = r.match(pub_house_elem[0]) + pub_year = pub[2] + pub_month = months.index(pub[1]) + 1 + pub_house = pub[3].strip() + except Exception: + pub_year = None + pub_month = None + pub_house = None + + pub_house_elem = content.xpath("//nobr[contains(text(), 'first published')]/text()") + try: + pub = re.match(r'.*first published\s+(.+\d\d\d\d).*', pub_house_elem[0], re.DOTALL) + first_pub = pub[1] + except Exception: + first_pub = None + + binding_elem = content.xpath('//span[@itemprop="bookFormat"]/text()') + binding = binding_elem[0].strip() if binding_elem else None + + pages_elem = content.xpath('//span[@itemprop="numberOfPages"]/text()') + pages = pages_elem[0].strip() if pages_elem else None + if pages is not None: + pages = int(RE_NUMBERS.findall(pages)[ + 0]) if RE_NUMBERS.findall(pages) else None + + isbn_elem = content.xpath('//span[@itemprop="isbn"]/text()') + if not isbn_elem: + isbn_elem = content.xpath('//div[@itemprop="isbn"]/text()') # this is likely ASIN + isbn = isbn_elem[0].strip() if isbn_elem else None + + brief_elem = content.xpath('//div[@id="description"]/span[@style="display:none"]/text()') + if brief_elem: + brief = '\n'.join(p.strip() for p in brief_elem) + else: + brief_elem = content.xpath('//div[@id="description"]/span/text()') + brief = '\n'.join(p.strip() for p in brief_elem) if brief_elem else None + + genre = content.xpath('//div[@class="bigBoxBody"]/div/div/div/a/text()') + genre = genre[0] if genre else None + book_title = re.sub('\n', '', content.xpath('//h1[@id="bookTitle"]/text()')[0]).strip() + author = content.xpath('//a[@class="authorName"]/span/text()')[0] + contents = None + + img_url_elem = content.xpath("//img[@id='coverImage']/@src") + img_url = img_url_elem[0].strip() if img_url_elem else None + raw_img, ext = self.download_image(img_url, url) + + authors_elem = content.xpath("//a[@class='authorName'][not(../span[@class='authorName greyText smallText role'])]/span/text()") + if authors_elem: + authors = [] + for author in authors_elem: + authors.append(RE_WHITESPACES.sub(' ', author.strip())) + else: + authors = None + + translators = None + authors_elem = content.xpath("//a[@class='authorName'][../span/text()='(Translator)']/span/text()") + if authors_elem: + translators = [] + for translator in authors_elem: + translators.append(RE_WHITESPACES.sub(' ', translator.strip())) + else: + translators = None + + other = {} + if first_pub: + other['首版时间'] = first_pub + if genre: + other['分类'] = genre + series_elem = content.xpath("//h2[@id='bookSeries']/a/text()") + if series_elem: + other['丛书'] = re.sub(r'\(\s*(.+[^\s])\s*#.*\)', '\\1', series_elem[0].strip()) + + data = { + 'title': title, + 'subtitle': subtitle, + 'orig_title': orig_title, + 'author': authors, + 'translator': translators, + 'language': language, + 'pub_house': pub_house, + 'pub_year': pub_year, + 'pub_month': pub_month, + 'binding': binding, + 'pages': pages, + 'isbn': isbn, + 'brief': brief, + 'contents': contents, + 'other_info': other, + 'cover_url': img_url, + 'source_site': self.site_name, + 'source_url': self.get_effective_url(url), + } + data['source_url'] = self.get_effective_url(url) + + self.raw_data, self.raw_img, self.img_ext = data, raw_img, ext + return data, raw_img diff --git a/common/scrapers/google.py b/common/scrapers/google.py new file mode 100644 index 00000000..0082fb3e --- /dev/null +++ b/common/scrapers/google.py @@ -0,0 +1,102 @@ +import requests +import re +import filetype +from lxml import html +from common.models import SourceSiteEnum +from movies.models import Movie, MovieGenreEnum +from movies.forms import MovieForm +from books.models import Book +from books.forms import BookForm +from music.models import Album, Song +from music.forms import AlbumForm, SongForm +from games.models import Game +from games.forms import GameForm +from django.conf import settings +from PIL import Image +from io import BytesIO +from common.scraper import * + + +# https://developers.google.com/youtube/v3/docs/?apix=true +# https://developers.google.com/books/docs/v1/using +class GoogleBooksScraper(AbstractScraper): + site_name = SourceSiteEnum.GOOGLEBOOKS.value + host = ["books.google.com", "www.google.com/books"] + data_class = Book + form_class = BookForm + regex = re.compile(r"https://books\.google\.com/books\?id=([^&#]+)") + + @classmethod + def get_effective_url(cls, raw_url): + # https://books.google.com/books?id=wUHxzgEACAAJ + # https://books.google.com/books/about/%E7%8F%BE%E5%A0%B4%E6%AD%B7%E5%8F%B2.html?id=nvNoAAAAIAAJ + # https://www.google.com/books/edition/_/nvNoAAAAIAAJ?hl=en&gbpv=1 + u = re.match(r"https://books\.google\.com/books.*id=([^&#]+)", raw_url) + if not u: + u = re.match(r"https://www\.google\.com/books/edition/[^/]+/([^&#?]+)", raw_url) + return 'https://books.google.com/books?id=' + u[1] if u else None + + def scrape(self, url, response=None): + url = self.get_effective_url(url) + m = self.regex.match(url) + if m: + api_url = f'https://www.googleapis.com/books/v1/volumes/{m[1]}' + else: + raise ValueError("not valid url") + b = requests.get(api_url).json() + other = {} + title = b['volumeInfo']['title'] + subtitle = b['volumeInfo']['subtitle'] if 'subtitle' in b['volumeInfo'] else None + pub_year = None + pub_month = None + if 'publishedDate' in b['volumeInfo']: + pub_date = b['volumeInfo']['publishedDate'].split('-') + pub_year = pub_date[0] + pub_month = pub_date[1] if len(pub_date) > 1 else None + pub_house = b['volumeInfo']['publisher'] if 'publisher' in b['volumeInfo'] else None + language = b['volumeInfo']['language'] if 'language' in b['volumeInfo'] else None + pages = b['volumeInfo']['pageCount'] if 'pageCount' in b['volumeInfo'] else None + if 'mainCategory' in b['volumeInfo']: + other['分类'] = b['volumeInfo']['mainCategory'] + authors = b['volumeInfo']['authors'] if 'authors' in b['volumeInfo'] else None + if 'description' in b['volumeInfo']: + brief = b['volumeInfo']['description'] + elif 'textSnippet' in b['volumeInfo']: + brief = b["volumeInfo"]["textSnippet"]["searchInfo"] + else: + brief = '' + brief = re.sub(r'<.*?>', '', brief.replace('<br', '\n<br')) + img_url = b['volumeInfo']['imageLinks']['thumbnail'] if 'imageLinks' in b['volumeInfo'] else None + isbn10 = None + isbn13 = None + for iid in b['volumeInfo']['industryIdentifiers'] if 'industryIdentifiers' in b['volumeInfo'] else []: + if iid['type'] == 'ISBN_10': + isbn10 = iid['identifier'] + if iid['type'] == 'ISBN_13': + isbn13 = iid['identifier'] + isbn = isbn13 if isbn13 is not None else isbn10 + + data = { + 'title': title, + 'subtitle': subtitle, + 'orig_title': None, + 'author': authors, + 'translator': None, + 'language': language, + 'pub_house': pub_house, + 'pub_year': pub_year, + 'pub_month': pub_month, + 'binding': None, + 'pages': pages, + 'isbn': isbn, + 'brief': brief, + 'contents': None, + 'other_info': other, + 'cover_url': img_url, + 'source_site': self.site_name, + 'source_url': self.get_effective_url(url), + } + raw_img, ext = self.download_image(img_url, url) + + self.raw_data, self.raw_img, self.img_ext = data, raw_img, ext + return data, raw_img diff --git a/common/scrapers/igdb.py b/common/scrapers/igdb.py new file mode 100644 index 00000000..eb635f5c --- /dev/null +++ b/common/scrapers/igdb.py @@ -0,0 +1,88 @@ +import requests +import re +from common.models import SourceSiteEnum +from games.models import Game +from games.forms import GameForm +from django.conf import settings +from common.scraper import * +from igdb.wrapper import IGDBWrapper +import json +import datetime + + +wrapper = IGDBWrapper(settings.IGDB_CLIENT_ID, settings.IGDB_ACCESS_TOKEN) + + +class IgdbGameScraper(AbstractScraper): + site_name = SourceSiteEnum.IGDB.value + host = 'https://www.igdb.com/' + data_class = Game + form_class = GameForm + regex = re.compile(r"https://www\.igdb\.com/games/([a-zA-Z0-9\-_]+)") + + def scrape_steam(self, steam_url): + r = json.loads(wrapper.api_request('websites', f'fields *, game.*; where url = "{steam_url}";')) + if not r: + raise ValueError("Cannot find steam url in IGDB") + r = sorted(r, key=lambda w: w['game']['id']) + return self.scrape(r[0]['game']['url']) + + def scrape(self, url): + m = self.regex.match(url) + if m: + effective_url = m[0] + else: + raise ValueError("not valid url") + effective_url = m[0] + slug = m[1] + fields = '*, cover.url, genres.name, platforms.name, involved_companies.*, involved_companies.company.name' + r = json.loads(wrapper.api_request('games', f'fields {fields}; where url = "{effective_url}";'))[0] + brief = r['summary'] if 'summary' in r else '' + brief += "\n\n" + r['storyline'] if 'storyline' in r else '' + developer = None + publisher = None + release_date = None + genre = None + platform = None + if 'involved_companies' in r: + developer = next(iter([c['company']['name'] for c in r['involved_companies'] if c['developer'] == True]), None) + publisher = next(iter([c['company']['name'] for c in r['involved_companies'] if c['publisher'] == True]), None) + if 'platforms' in r: + ps = sorted(r['platforms'], key=lambda p: p['id']) + platform = [(p['name'] if p['id'] != 6 else 'Windows') for p in ps] + if 'first_release_date' in r: + release_date = datetime.datetime.fromtimestamp(r['first_release_date'], datetime.timezone.utc) + if 'genres' in r: + genre = [g['name'] for g in r['genres']] + other_info = {'igdb_id': r['id']} + websites = json.loads(wrapper.api_request('websites', f'fields *; where game.url = "{effective_url}";')) + for website in websites: + if website['category'] == 1: + other_info['official_site'] = website['url'] + elif website['category'] == 13: + other_info['steam_url'] = website['url'] + data = { + 'title': r['name'], + 'other_title': None, + 'developer': developer, + 'publisher': publisher, + 'release_date': release_date, + 'genre': genre, + 'platform': platform, + 'brief': brief, + 'other_info': other_info, + 'source_site': self.site_name, + 'source_url': self.get_effective_url(url), + } + raw_img, ext = self.download_image('https:' + r['cover']['url'].replace('t_thumb', 't_cover_big'), url) + + self.raw_data, self.raw_img, self.img_ext = data, raw_img, ext + return data, raw_img + + @classmethod + def get_effective_url(cls, raw_url): + m = cls.regex.match(raw_url) + if m: + return m[0] + else: + return None diff --git a/common/scrapers/imdb.py b/common/scrapers/imdb.py new file mode 100644 index 00000000..97c040bb --- /dev/null +++ b/common/scrapers/imdb.py @@ -0,0 +1,116 @@ +import requests +import re +from common.models import SourceSiteEnum +from movies.forms import MovieForm +from movies.models import Movie +from django.conf import settings +from common.scraper import * + + +class ImdbMovieScraper(AbstractScraper): + site_name = SourceSiteEnum.IMDB.value + host = 'https://www.imdb.com/title/' + data_class = Movie + form_class = MovieForm + + regex = re.compile(r"(?<=https://www\.imdb\.com/title/)[a-zA-Z0-9]+") + + def scrape(self, url): + effective_url = self.get_effective_url(url) + if effective_url is None: + raise ValueError("not valid url") + code = self.regex.findall(effective_url)[0] + s = TmdbMovieScraper() + s.scrape_imdb(code) + self.raw_data = s.raw_data + self.raw_img = s.raw_img + self.img_ext = s.img_ext + self.raw_data['source_site'] = self.site_name + self.raw_data['source_url'] = effective_url + return self.raw_data, self.raw_img + + api_url = self.get_api_url(effective_url) + r = requests.get(api_url) + res_data = r.json() + + if not res_data['type'] in ['Movie', 'TVSeries']: + raise ValueError("not movie/series item") + + if res_data['type'] == 'Movie': + is_series = False + elif res_data['type'] == 'TVSeries': + is_series = True + + title = res_data['title'] + orig_title = res_data['originalTitle'] + imdb_code = self.regex.findall(effective_url)[0] + director = [] + for direct_dict in res_data['directorList']: + director.append(direct_dict['name']) + playwright = [] + for writer_dict in res_data['writerList']: + playwright.append(writer_dict['name']) + actor = [] + for actor_dict in res_data['actorList']: + actor.append(actor_dict['name']) + genre = res_data['genres'].split(', ') + area = res_data['countries'].split(', ') + language = res_data['languages'].split(', ') + year = int(res_data['year']) + duration = res_data['runtimeStr'] + brief = res_data['plotLocal'] if res_data['plotLocal'] else res_data['plot'] + if res_data['releaseDate']: + showtime = [{res_data['releaseDate']: "发布日期"}] + else: + showtime = None + + other_info = {} + if res_data['contentRating']: + other_info['分级'] = res_data['contentRating'] + if res_data['imDbRating']: + other_info['IMDb评分'] = res_data['imDbRating'] + if res_data['metacriticRating']: + other_info['Metacritic评分'] = res_data['metacriticRating'] + if res_data['awards']: + other_info['奖项'] = res_data['awards'] + + raw_img, ext = self.download_image(res_data['image'], url) + + data = { + 'title': title, + 'orig_title': orig_title, + 'other_title': None, + 'imdb_code': imdb_code, + 'director': director, + 'playwright': playwright, + 'actor': actor, + 'genre': genre, + 'showtime': showtime, + 'site': None, + 'area': area, + 'language': language, + 'year': year, + 'duration': duration, + 'season': None, + 'episodes': None, + 'single_episode_length': None, + 'brief': brief, + 'is_series': is_series, + 'other_info': other_info, + 'source_site': self.site_name, + 'source_url': effective_url, + } + self.raw_data, self.raw_img, self.img_ext = data, raw_img, ext + return data, raw_img + + @classmethod + def get_effective_url(cls, raw_url): + code = cls.regex.findall(raw_url) + if code: + return f"https://www.imdb.com/title/{code[0]}/" + else: + return None + + @classmethod + def get_api_url(cls, url): + return f"https://imdb-api.com/zh/API/Title/{settings.IMDB_API_KEY}/{cls.regex.findall(url)[0]}/FullActor," diff --git a/common/scrapers/spotify.py b/common/scrapers/spotify.py new file mode 100644 index 00000000..da891d69 --- /dev/null +++ b/common/scrapers/spotify.py @@ -0,0 +1,287 @@ +import requests +import re +import time +from common.models import SourceSiteEnum +from music.models import Album, Song +from music.forms import AlbumForm, SongForm +from django.conf import settings +from common.scraper import * +from threading import Thread +from django.core.exceptions import ObjectDoesNotExist +from django.utils import timezone + + +spotify_token = None +spotify_token_expire_time = time.time() + + +class SpotifyTrackScraper(AbstractScraper): + site_name = SourceSiteEnum.SPOTIFY.value + host = 'https://open.spotify.com/track/' + data_class = Song + form_class = SongForm + + regex = re.compile(r"(?<=https://open\.spotify\.com/track/)[a-zA-Z0-9]+") + + def scrape(self, url): + """ + Request from API, not really scraping + """ + global spotify_token, spotify_token_expire_time + + if spotify_token is None or is_spotify_token_expired(): + invoke_spotify_token() + effective_url = self.get_effective_url(url) + if effective_url is None: + raise ValueError("not valid url") + + api_url = self.get_api_url(effective_url) + headers = { + 'Authorization': f"Bearer {spotify_token}" + } + r = requests.get(api_url, headers=headers) + res_data = r.json() + + artist = [] + for artist_dict in res_data['artists']: + artist.append(artist_dict['name']) + if not artist: + artist = None + + title = res_data['name'] + + release_date = parse_date(res_data['album']['release_date']) + + duration = res_data['duration_ms'] + + if res_data['external_ids'].get('isrc'): + isrc = res_data['external_ids']['isrc'] + else: + isrc = None + + raw_img, ext = self.download_image(res_data['album']['images'][0]['url'], url) + + data = { + 'title': title, + 'artist': artist, + 'genre': None, + 'release_date': release_date, + 'duration': duration, + 'isrc': isrc, + 'album': None, + 'brief': None, + 'other_info': None, + 'source_site': self.site_name, + 'source_url': effective_url, + } + self.raw_data, self.raw_img, self.img_ext = data, raw_img, ext + return data, raw_img + + @classmethod + def get_effective_url(cls, raw_url): + code = cls.regex.findall(raw_url) + if code: + return f"https://open.spotify.com/track/{code[0]}" + else: + return None + + @classmethod + def get_api_url(cls, url): + return "https://api.spotify.com/v1/tracks/" + cls.regex.findall(url)[0] + + +class SpotifyAlbumScraper(AbstractScraper): + site_name = SourceSiteEnum.SPOTIFY.value + # API URL + host = 'https://open.spotify.com/album/' + data_class = Album + form_class = AlbumForm + + regex = re.compile(r"(?<=https://open\.spotify\.com/album/)[a-zA-Z0-9]+") + + def scrape(self, url): + """ + Request from API, not really scraping + """ + global spotify_token, spotify_token_expire_time + + if spotify_token is None or is_spotify_token_expired(): + invoke_spotify_token() + effective_url = self.get_effective_url(url) + if effective_url is None: + raise ValueError("not valid url") + + api_url = self.get_api_url(effective_url) + headers = { + 'Authorization': f"Bearer {spotify_token}" + } + r = requests.get(api_url, headers=headers) + res_data = r.json() + + artist = [] + for artist_dict in res_data['artists']: + artist.append(artist_dict['name']) + + title = res_data['name'] + + genre = ', '.join(res_data['genres']) + + company = [] + for com in res_data['copyrights']: + company.append(com['text']) + + duration = 0 + track_list = [] + track_urls = [] + for track in res_data['tracks']['items']: + track_urls.append(track['external_urls']['spotify']) + duration += track['duration_ms'] + if res_data['tracks']['items'][-1]['disc_number'] > 1: + # more than one disc + track_list.append(str( + track['disc_number']) + '-' + str(track['track_number']) + '. ' + track['name']) + else: + track_list.append(str(track['track_number']) + '. ' + track['name']) + track_list = '\n'.join(track_list) + + release_date = parse_date(res_data['release_date']) + + other_info = {} + if res_data['external_ids'].get('upc'): + # bar code + other_info['UPC'] = res_data['external_ids']['upc'] + + raw_img, ext = self.download_image(res_data['images'][0]['url'], url) + + data = { + 'title': title, + 'artist': artist, + 'genre': genre, + 'track_list': track_list, + 'release_date': release_date, + 'duration': duration, + 'company': company, + 'brief': None, + 'other_info': other_info, + 'source_site': self.site_name, + 'source_url': effective_url, + } + + # set tracks_data, used for adding tracks + self.track_urls = track_urls + + self.raw_data, self.raw_img, self.img_ext = data, raw_img, ext + return data, raw_img + + @classmethod + def get_effective_url(cls, raw_url): + code = cls.regex.findall(raw_url) + if code: + return f"https://open.spotify.com/album/{code[0]}" + else: + return None + + # @classmethod + # def save(cls, request_user): + # form = super().save(request_user) + # task = Thread( + # target=cls.add_tracks, + # args=(form.instance, request_user), + # daemon=True + # ) + # task.start() + # return form + + @classmethod + def get_api_url(cls, url): + return "https://api.spotify.com/v1/albums/" + cls.regex.findall(url)[0] + + @classmethod + def add_tracks(cls, album: Album, request_user): + to_be_updated_tracks = [] + for track_url in cls.track_urls: + track = cls.get_track_or_none(track_url) + # seems lik if fire too many requests at the same time + # spotify would limit access + if track is None: + task = Thread( + target=cls.scrape_and_save_track, + args=(track_url, album, request_user), + daemon=True + ) + task.start() + task.join() + else: + to_be_updated_tracks.append(track) + cls.bulk_update_track_album(to_be_updated_tracks, album, request_user) + + @classmethod + def get_track_or_none(cls, track_url: str): + try: + instance = Song.objects.get(source_url=track_url) + return instance + except ObjectDoesNotExist: + return None + + @classmethod + def scrape_and_save_track(cls, url: str, album: Album, request_user): + data, img = SpotifyTrackScraper.scrape(url) + SpotifyTrackScraper.raw_data['album'] = album + SpotifyTrackScraper.save(request_user) + + @classmethod + def bulk_update_track_album(cls, tracks, album, request_user): + for track in tracks: + track.last_editor = request_user + track.edited_time = timezone.now() + track.album = album + Song.objects.bulk_update(tracks, [ + 'last_editor', + 'edited_time', + 'album' + ]) + + +def get_spotify_token(): + global spotify_token, spotify_token_expire_time + if spotify_token is None or is_spotify_token_expired(): + invoke_spotify_token() + return spotify_token + + +def is_spotify_token_expired(): + global spotify_token_expire_time + return True if spotify_token_expire_time <= time.time() else False + + +def invoke_spotify_token(): + global spotify_token, spotify_token_expire_time + r = requests.post( + "https://accounts.spotify.com/api/token", + data={ + "grant_type": "client_credentials" + }, + headers={ + "Authorization": f"Basic {settings.SPOTIFY_CREDENTIAL}" + } + ) + data = r.json() + if r.status_code == 401: + # token expired, try one more time + # this maybe caused by external operations, + # for example debugging using a http client + r = requests.post( + "https://accounts.spotify.com/api/token", + data={ + "grant_type": "client_credentials" + }, + headers={ + "Authorization": f"Basic {settings.SPOTIFY_CREDENTIAL}" + } + ) + data = r.json() + elif r.status_code != 200: + raise Exception(f"Request to spotify API fails. Reason: {r.reason}") + # minus 2 for execution time error + spotify_token_expire_time = int(data['expires_in']) + time.time() - 2 + spotify_token = data['access_token'] diff --git a/common/scrapers/steam.py b/common/scrapers/steam.py new file mode 100644 index 00000000..43f1c76b --- /dev/null +++ b/common/scrapers/steam.py @@ -0,0 +1,92 @@ +import re +from common.models import SourceSiteEnum +from games.models import Game +from games.forms import GameForm +from common.scraper import * +from common.scrapers.igdb import IgdbGameScraper + + +class SteamGameScraper(AbstractScraper): + site_name = SourceSiteEnum.STEAM.value + host = 'store.steampowered.com' + data_class = Game + form_class = GameForm + + regex = re.compile(r"https://store\.steampowered\.com/app/\d+") + + def scrape(self, url): + m = self.regex.match(url) + if m: + effective_url = m[0] + else: + raise ValueError("not valid url") + try: + s = IgdbGameScraper() + s.scrape_steam(effective_url) + self.raw_data = s.raw_data + self.raw_img = s.raw_img + self.img_ext = s.img_ext + self.raw_data['source_site'] = self.site_name + self.raw_data['source_url'] = effective_url + # return self.raw_data, self.raw_img + except: + self.raw_img = None + self.raw_data = {} + headers = DEFAULT_REQUEST_HEADERS.copy() + headers['Host'] = self.host + headers['Cookie'] = "wants_mature_content=1; birthtime=754700401;" + content = self.download_page(url, headers) + + title = content.xpath("//div[@class='apphub_AppName']/text()")[0] + developer = content.xpath("//div[@id='developers_list']/a/text()") + publisher = content.xpath("//div[@class='glance_ctn']//div[@class='dev_row'][2]//a/text()") + release_date = parse_date( + content.xpath( + "//div[@class='release_date']/div[@class='date']/text()")[0] + ) + + genre = content.xpath( + "//div[@class='details_block']/b[2]/following-sibling::a/text()") + + platform = ['PC'] + + brief = content.xpath( + "//div[@class='game_description_snippet']/text()")[0].strip() + + img_url = content.xpath( + "//img[@class='game_header_image_full']/@src" + )[0].replace("header.jpg", "library_600x900.jpg") + raw_img, img_ext = self.download_image(img_url, url) + + # no 600x900 picture + if raw_img is None: + img_url = content.xpath("//img[@class='game_header_image_full']/@src")[0] + raw_img, img_ext = self.download_image(img_url, url) + + if raw_img is not None: + self.raw_img = raw_img + self.img_ext = img_ext + + data = { + 'title': title if title else self.raw_data['title'], + 'other_title': None, + 'developer': developer if 'developer' not in self.raw_data else self.raw_data['developer'], + 'publisher': publisher if 'publisher' not in self.raw_data else self.raw_data['publisher'], + 'release_date': release_date if 'release_date' not in self.raw_data else self.raw_data['release_date'], + 'genre': genre if 'genre' not in self.raw_data else self.raw_data['genre'], + 'platform': platform if 'platform' not in self.raw_data else self.raw_data['platform'], + 'brief': brief if brief else self.raw_data['brief'], + 'other_info': None if 'other_info' not in self.raw_data else self.raw_data['other_info'], + 'source_site': self.site_name, + 'source_url': effective_url + } + self.raw_data = data + return self.raw_data, self.raw_img + + @classmethod + def get_effective_url(cls, raw_url): + m = cls.regex.match(raw_url) + if m: + return m[0] + else: + return None diff --git a/common/scrapers/tmdb.py b/common/scrapers/tmdb.py new file mode 100644 index 00000000..15072add --- /dev/null +++ b/common/scrapers/tmdb.py @@ -0,0 +1,150 @@ +import requests +import re +from common.models import SourceSiteEnum +from movies.models import Movie +from movies.forms import MovieForm +from django.conf import settings +from common.scraper import * + + +class TmdbMovieScraper(AbstractScraper): + site_name = SourceSiteEnum.TMDB.value + host = 'https://www.themoviedb.org/' + data_class = Movie + form_class = MovieForm + regex = re.compile(r"https://www\.themoviedb\.org/(movie|tv)/([a-zA-Z0-9]+)") + # http://api.themoviedb.org/3/genre/movie/list?api_key=&language=zh + # http://api.themoviedb.org/3/genre/tv/list?api_key=&language=zh + genre_map = { + 'Sci-Fi & Fantasy': 'Sci-Fi', + 'War & Politics': 'War', + '儿童': 'Kids', + '冒险': 'Adventure', + '剧情': 'Drama', + '动作': 'Action', + '动作冒险': 'Action', + '动画': 'Animation', + '历史': 'History', + '喜剧': 'Comedy', + '奇幻': 'Fantasy', + '家庭': 'Family', + '恐怖': 'Horror', + '悬疑': 'Mystery', + '惊悚': 'Thriller', + '战争': 'War', + '新闻': 'News', + '爱情': 'Romance', + '犯罪': 'Crime', + '电视电影': 'TV Movie', + '真人秀': 'Reality-TV', + '科幻': 'Sci-Fi', + '纪录': 'Documentary', + '肥皂剧': 'Soap', + '脱口秀': 'Talk-Show', + '西部': 'Western', + '音乐': 'Music', + } + + def scrape_imdb(self, imdb_code): + api_url = f"https://api.themoviedb.org/3/find/{imdb_code}?api_key={settings.TMDB_API3_KEY}&language=zh-CN&external_source=imdb_id" + r = requests.get(api_url) + res_data = r.json() + if 'movie_results' in res_data and len(res_data['movie_results']) > 0: + url = f"https://www.themoviedb.org/movie/{res_data['movie_results'][0]['id']}" + elif 'tv_results' in res_data and len(res_data['tv_results']) > 0: + url = f"https://www.themoviedb.org/tv/{res_data['tv_results'][0]['id']}" + else: + raise ValueError("Cannot find IMDb ID in TMDB") + return self.scrape(url) + + def scrape(self, url): + m = self.regex.match(url) + if m: + effective_url = m[0] + else: + raise ValueError("not valid url") + effective_url = m[0] + is_series = m[1] == 'tv' + id = m[2] + if is_series: + api_url = f"https://api.themoviedb.org/3/tv/{id}?api_key={settings.TMDB_API3_KEY}&language=zh-CN&append_to_response=external_ids,credits" + else: + api_url = f"https://api.themoviedb.org/3/movie/{id}?api_key={settings.TMDB_API3_KEY}&language=zh-CN&append_to_response=external_ids,credits" + r = requests.get(api_url) + res_data = r.json() + + if is_series: + title = res_data['name'] + orig_title = res_data['original_name'] + year = int(res_data['first_air_date'].split('-')[0]) if res_data['first_air_date'] else None + imdb_code = res_data['external_ids']['imdb_id'] + showtime = [{res_data['first_air_date']: "首播日期"}] if res_data['first_air_date'] else None + duration = None + else: + title = res_data['title'] + orig_title = res_data['original_title'] + year = int(res_data['release_date'].split('-')[0]) if res_data['release_date'] else None + showtime = [{res_data['release_date']: "发布日期"}] if res_data['release_date'] else None + imdb_code = res_data['imdb_id'] + duration = res_data['runtime'] if res_data['runtime'] else None # in minutes + + genre = list(map(lambda x: self.genre_map[x['name']] if x['name'] in self.genre_map else 'Other', res_data['genres'])) + language = list(map(lambda x: x['name'], res_data['spoken_languages'])) + brief = res_data['overview'] + + if is_series: + director = list(map(lambda x: x['name'], res_data['created_by'])) + else: + director = list(map(lambda x: x['name'], filter(lambda c: c['job'] == 'Director', res_data['credits']['crew']))) + playwright = list(map(lambda x: x['name'], filter(lambda c: c['job'] == 'Screenplay', res_data['credits']['crew']))) + actor = list(map(lambda x: x['name'], res_data['credits']['cast'])) + area = [] + + other_info = {} + other_info['TMDB评分'] = res_data['vote_average'] + # other_info['分级'] = res_data['contentRating'] + # other_info['Metacritic评分'] = res_data['metacriticRating'] + # other_info['奖项'] = res_data['awards'] + other_info['TMDB_ID'] = id + if is_series: + other_info['Seasons'] = res_data['number_of_seasons'] + other_info['Episodes'] = res_data['number_of_episodes'] + + img_url = ('https://image.tmdb.org/t/p/original/' + res_data['poster_path']) if res_data['poster_path'] is not None else None + # TODO: use GET /configuration to get base url + raw_img, ext = self.download_image(img_url, url) + + data = { + 'title': title, + 'orig_title': orig_title, + 'other_title': None, + 'imdb_code': imdb_code, + 'director': director, + 'playwright': playwright, + 'actor': actor, + 'genre': genre, + 'showtime': showtime, + 'site': None, + 'area': area, + 'language': language, + 'year': year, + 'duration': duration, + 'season': None, + 'episodes': None, + 'single_episode_length': None, + 'brief': brief, + 'is_series': is_series, + 'other_info': other_info, + 'source_site': self.site_name, + 'source_url': effective_url, + } + self.raw_data, self.raw_img, self.img_ext = data, raw_img, ext + return data, raw_img + + @classmethod + def get_effective_url(cls, raw_url): + m = cls.regex.match(raw_url) + if raw_url: + return m[0] + else: + return None diff --git a/common/search/meilisearch.py b/common/search/meilisearch.py new file mode 100644 index 00000000..d7a13a2f --- /dev/null +++ b/common/search/meilisearch.py @@ -0,0 +1,183 @@ +import logging +import meilisearch +from django.conf import settings +from django.db.models.signals import post_save, post_delete +import types + + +INDEX_NAME = 'items' +SEARCHABLE_ATTRIBUTES = ['title', 'orig_title', 'other_title', 'subtitle', 'artist', 'author', 'translator', 'developer', 'director', 'actor', 'playwright', 'pub_house', 'company', 'publisher', 'isbn', 'imdb_code'] +INDEXABLE_DIRECT_TYPES = ['BigAutoField', 'BooleanField', 'CharField', 'PositiveIntegerField', 'PositiveSmallIntegerField', 'TextField', 'ArrayField'] +INDEXABLE_TIME_TYPES = ['DateTimeField'] +INDEXABLE_DICT_TYPES = ['JSONField'] +INDEXABLE_FLOAT_TYPES = ['DecimalField'] +# NONINDEXABLE_TYPES = ['ForeignKey', 'FileField',] +SEARCH_PAGE_SIZE = 20 + + +logger = logging.getLogger(__name__) + + +def item_post_save_handler(sender, instance, created, **kwargs): + if not created and settings.SEARCH_INDEX_NEW_ONLY: + return + Indexer.replace_item(instance) + + +def item_post_delete_handler(sender, instance, **kwargs): + Indexer.delete_item(instance) + + +def tag_post_save_handler(sender, instance, **kwargs): + pass + + +def tag_post_delete_handler(sender, instance, **kwargs): + pass + + +class Indexer: + class_map = {} + _instance = None + + @classmethod + def instance(self): + if self._instance is None: + self._instance = meilisearch.Client(settings.MEILISEARCH_SERVER, settings.MEILISEARCH_KEY).index(INDEX_NAME) + return self._instance + + @classmethod + def init(self): + meilisearch.Client(settings.MEILISEARCH_SERVER, settings.MEILISEARCH_KEY).create_index(INDEX_NAME, {'primaryKey': '_id'}) + self.update_settings() + + @classmethod + def update_settings(self): + self.instance().update_searchable_attributes(SEARCHABLE_ATTRIBUTES) + self.instance().update_filterable_attributes(['_class', 'tags', 'source_site']) + self.instance().update_settings({'displayedAttributes': ['_id', '_class', 'id', 'title', 'tags']}) + + @classmethod + def get_stats(self): + return self.instance().get_stats() + + @classmethod + def busy(self): + return self.instance().get_stats()['isIndexing'] + + @classmethod + def update_model_indexable(self, model): + if settings.SEARCH_BACKEND is None: + return + self.class_map[model.__name__] = model + model.indexable_fields = ['tags'] + model.indexable_fields_time = [] + model.indexable_fields_dict = [] + model.indexable_fields_float = [] + for field in model._meta.get_fields(): + type = field.get_internal_type() + if type in INDEXABLE_DIRECT_TYPES: + model.indexable_fields.append(field.name) + elif type in INDEXABLE_TIME_TYPES: + model.indexable_fields_time.append(field.name) + elif type in INDEXABLE_DICT_TYPES: + model.indexable_fields_dict.append(field.name) + elif type in INDEXABLE_FLOAT_TYPES: + model.indexable_fields_float.append(field.name) + post_save.connect(item_post_save_handler, sender=model) + post_delete.connect(item_post_delete_handler, sender=model) + + @classmethod + def obj_to_dict(self, obj): + pk = f'{obj.__class__.__name__}-{obj.id}' + item = { + '_id': pk, + '_class': obj.__class__.__name__, + # 'id': obj.id + } + for field in obj.__class__.indexable_fields: + item[field] = getattr(obj, field) + for field in obj.__class__.indexable_fields_time: + item[field] = getattr(obj, field).timestamp() + for field in obj.__class__.indexable_fields_float: + item[field] = float(getattr(obj, field)) if getattr(obj, field) else None + for field in obj.__class__.indexable_fields_dict: + d = getattr(obj, field) + if d.__class__ is dict: + item.update(d) + item = {k: v for k, v in item.items() if v} + return item + + @classmethod + def replace_item(self, obj): + try: + self.instance().add_documents([self.obj_to_dict(obj)]) + except Exception as e: + logger.error(f"replace item error: \n{e}") + + @classmethod + def replace_batch(self, objects): + try: + self.instance().update_documents(documents=objects) + except Exception as e: + logger.error(f"replace batch error: \n{e}") + + @classmethod + def delete_item(self, obj): + pk = f'{obj.__class__.__name__}-{obj.id}' + try: + self.instance().delete_document(pk) + except Exception as e: + logger.error(f"delete item error: \n{e}") + + @classmethod + def patch_item(self, obj, fields): + pk = f'{obj.__class__.__name__}-{obj.id}' + data = {} + for f in fields: + data[f] = getattr(obj, f) + try: + self.instance().update_documents(documents=[data], primary_key=[pk]) + except Exception as e: + logger.error(f"patch item error: \n{e}") + + @classmethod + def search(self, q, page=1, category=None, tag=None, sort=None): + if category or tag: + f = [] + if category == 'music': + f.append("(_class = 'Album' OR _class = 'Song')") + elif category: + f.append(f"_class = '{category}'") + if tag: + t = tag.replace("'", "\'") + f.append(f"tags = '{t}'") + filter = ' AND '.join(f) + else: + filter = None + options = { + 'offset': (page - 1) * SEARCH_PAGE_SIZE, + 'limit': SEARCH_PAGE_SIZE, + 'filter': filter, + 'facetsDistribution': ['_class'], + 'sort': None + } + try: + r = self.instance().search(q, options) + except Exception as e: + logger.error(f"MeiliSearch error: \n{e}") + r = {'nbHits': 0, 'hits': []} + # print(r) + results = types.SimpleNamespace() + results.items = list([x for x in map(lambda i: self.item_to_obj(i), r['hits']) if x is not None]) + results.num_pages = (r['nbHits'] + SEARCH_PAGE_SIZE - 1) // SEARCH_PAGE_SIZE + # print(results) + return results + + @classmethod + def item_to_obj(self, item): + try: + return self.class_map[item['_class']].objects.get(id=item['id']) + except Exception as e: + logger.error(f"unable to load search result item from db:\n{item}") + return None diff --git a/common/search/typesense.py b/common/search/typesense.py new file mode 100644 index 00000000..523fb370 --- /dev/null +++ b/common/search/typesense.py @@ -0,0 +1,215 @@ +import logging +import typesense +from django.conf import settings +from django.db.models.signals import post_save, post_delete + + +INDEX_NAME = 'items' +SEARCHABLE_ATTRIBUTES = ['title', 'orig_title', 'other_title', 'subtitle', 'artist', 'author', 'translator', + 'developer', 'director', 'actor', 'playwright', 'pub_house', 'company', 'publisher', 'isbn', 'imdb_code'] +FILTERABLE_ATTRIBUTES = ['_class', 'tags', 'source_site'] +INDEXABLE_DIRECT_TYPES = ['BigAutoField', 'BooleanField', 'CharField', + 'PositiveIntegerField', 'PositiveSmallIntegerField', 'TextField', 'ArrayField'] +INDEXABLE_TIME_TYPES = ['DateTimeField'] +INDEXABLE_DICT_TYPES = ['JSONField'] +INDEXABLE_FLOAT_TYPES = ['DecimalField'] +SORTING_ATTRIBUTE = None +# NONINDEXABLE_TYPES = ['ForeignKey', 'FileField',] +SEARCH_PAGE_SIZE = 20 + + +logger = logging.getLogger(__name__) + + +def item_post_save_handler(sender, instance, created, **kwargs): + if not created and settings.SEARCH_INDEX_NEW_ONLY: + return + Indexer.replace_item(instance) + + +def item_post_delete_handler(sender, instance, **kwargs): + Indexer.delete_item(instance) + + +def tag_post_save_handler(sender, instance, **kwargs): + pass + + +def tag_post_delete_handler(sender, instance, **kwargs): + pass + + +class Indexer: + class_map = {} + _instance = None + + @classmethod + def instance(self): + if self._instance is None: + self._instance = typesense.Client(settings.TYPESENSE_CONNECTION) + return self._instance + + @classmethod + def init(self): + # self.instance().collections[INDEX_NAME].delete() + # fields = [ + # {"name": "_class", "type": "string", "facet": True}, + # {"name": "source_site", "type": "string", "facet": True}, + # {"name": ".*", "type": "auto", "locale": "zh"}, + # ] + # use dumb schema below before typesense fix a bug + fields = [ + {'name': 'id', 'type': 'string'}, + {'name': '_id', 'type': 'int64'}, + {'name': '_class', 'type': 'string', "facet": True}, + {'name': 'source_site', 'type': 'string', "facet": True}, + {'name': 'isbn', 'optional': True, 'type': 'string'}, + {'name': 'imdb_code', 'optional': True, 'type': 'string'}, + {'name': 'author', 'optional': True, 'locale': 'zh', 'type': 'string[]'}, + {'name': 'orig_title', 'optional': True, 'locale': 'zh', 'type': 'string'}, + {'name': 'pub_house', 'optional': True, 'locale': 'zh', 'type': 'string'}, + {'name': 'title', 'optional': True, 'locale': 'zh', 'type': 'string'}, + {'name': 'translator', 'optional': True, 'locale': 'zh', 'type': 'string[]'}, + {'name': 'subtitle', 'optional': True, 'locale': 'zh', 'type': 'string'}, + {'name': 'artist', 'optional': True, 'locale': 'zh', 'type': 'string[]'}, + {'name': 'company', 'optional': True, 'locale': 'zh', 'type': 'string[]'}, + {'name': 'developer', 'optional': True, 'locale': 'zh', 'type': 'string[]'}, + {'name': 'other_title', 'optional': True, 'locale': 'zh', 'type': 'string[]'}, + {'name': 'publisher', 'optional': True, 'locale': 'zh', 'type': 'string[]'}, + {'name': 'actor', 'optional': True, 'locale': 'zh', 'type': 'string[]'}, + {'name': 'director', 'optional': True, 'locale': 'zh', 'type': 'string[]'}, + {'name': 'playwright', 'optional': True, 'locale': 'zh', 'type': 'string[]'}, + {'name': 'tags', 'optional': True, 'locale': 'zh', 'type': 'string[]'}, + {'name': '.*', 'optional': True, 'locale': 'zh', 'type': 'auto'}, + ] + + self.instance().collections.create({ + "name": INDEX_NAME, + "fields": fields + }) + + @classmethod + def update_settings(self): + # https://github.com/typesense/typesense/issues/96 + print('not supported by typesense yet') + pass + + @classmethod + def get_stats(self): + return self.instance().collections[INDEX_NAME].retrieve() + + @classmethod + def busy(self): + return False + + @classmethod + def update_model_indexable(self, model): + if settings.SEARCH_BACKEND is None: + return + self.class_map[model.__name__] = model + model.indexable_fields = ['tags'] + model.indexable_fields_time = [] + model.indexable_fields_dict = [] + model.indexable_fields_float = [] + for field in model._meta.get_fields(): + type = field.get_internal_type() + if type in INDEXABLE_DIRECT_TYPES: + model.indexable_fields.append(field.name) + elif type in INDEXABLE_TIME_TYPES: + model.indexable_fields_time.append(field.name) + elif type in INDEXABLE_DICT_TYPES: + model.indexable_fields_dict.append(field.name) + elif type in INDEXABLE_FLOAT_TYPES: + model.indexable_fields_float.append(field.name) + post_save.connect(item_post_save_handler, sender=model) + post_delete.connect(item_post_delete_handler, sender=model) + + @classmethod + def obj_to_dict(self, obj): + pk = f'{obj.__class__.__name__}-{obj.id}' + item = { + '_class': obj.__class__.__name__, + } + for field in obj.__class__.indexable_fields: + item[field] = getattr(obj, field) + for field in obj.__class__.indexable_fields_time: + item[field] = getattr(obj, field).timestamp() + for field in obj.__class__.indexable_fields_float: + item[field] = float(getattr(obj, field)) if getattr( + obj, field) else None + for field in obj.__class__.indexable_fields_dict: + d = getattr(obj, field) + if d.__class__ is dict: + item.update(d) + item = {k: v for k, v in item.items() if v and ( + k in SEARCHABLE_ATTRIBUTES or k in FILTERABLE_ATTRIBUTES or k == 'id')} + item['_id'] = item['id'] + # typesense requires primary key to be named 'id', type string + item['id'] = pk + return item + + @classmethod + def replace_item(self, obj): + try: + self.instance().collections[INDEX_NAME].documents.upsert(self.obj_to_dict(obj), { + 'dirty_values': 'coerce_or_drop' + }) + except Exception as e: + logger.error(f"replace item error: \n{e}") + + @classmethod + def replace_batch(self, objects): + try: + self.instance().collections[INDEX_NAME].documents.import_( + objects, {'action': 'upsert'}) + except Exception as e: + logger.error(f"replace batch error: \n{e}") + + @classmethod + def delete_item(self, obj): + pk = f'{obj.__class__.__name__}-{obj.id}' + try: + self.instance().collections[INDEX_NAME].documents[pk].delete() + except Exception as e: + logger.error(f"delete item error: \n{e}") + + @classmethod + def search(self, q, page=1, category=None, tag=None, sort=None): + f = [] + if category == 'music': + f.append('_class:= [Album, Song]') + elif category: + f.append('_class:= ' + category) + else: + f.append('') + if tag: + f.append(f"tags:= '{tag}'") + filter = ' && '.join(f) + options = { + 'q': q, + 'page': page, + 'per_page': SEARCH_PAGE_SIZE, + 'query_by': ','.join(SEARCHABLE_ATTRIBUTES), + 'filter_by': filter, + # 'facetsDistribution': ['_class'], + # 'sort_by': None, + } + # print(q) + r = self.instance().collections[INDEX_NAME].documents.search(options) + # print(r) + import types + results = types.SimpleNamespace() + results.items = list([x for x in map(lambda i: self.item_to_obj( + i['document']), r['hits']) if x is not None]) + results.num_pages = ( + r['found'] + SEARCH_PAGE_SIZE - 1) // SEARCH_PAGE_SIZE + # print(results) + return results + + @classmethod + def item_to_obj(self, item): + try: + return self.class_map[item['_class']].objects.get(id=item['_id']) + except Exception as e: + logger.error(f"unable to load search result item from db:\n{item}") + return None diff --git a/common/searcher.py b/common/searcher.py new file mode 100644 index 00000000..48c43af9 --- /dev/null +++ b/common/searcher.py @@ -0,0 +1,209 @@ +from urllib.parse import quote_plus +from enum import Enum +from common.models import SourceSiteEnum +from django.conf import settings +from common.scrapers.goodreads import GoodreadsScraper +from common.scrapers.spotify import get_spotify_token +import requests +from lxml import html +import logging + +SEARCH_PAGE_SIZE = 5 # not all apis support page size +logger = logging.getLogger(__name__) + + +class Category(Enum): + Book = '书籍' + Movie = '电影' + Music = '音乐' + Game = '游戏' + TV = '剧集' + + +class SearchResultItem: + def __init__(self, category, source_site, source_url, title, subtitle, brief, cover_url): + self.category = category + self.source_site = source_site + self.source_url = source_url + self.title = title + self.subtitle = subtitle + self.brief = brief + self.cover_url = cover_url + + @property + def verbose_category_name(self): + return self.category.value + + @property + def link(self): + return f"/search?q={quote_plus(self.source_url)}" + + @property + def scraped(self): + return False + + +class ProxiedRequest: + @classmethod + def get(cls, url): + u = f'http://api.scraperapi.com?api_key={settings.SCRAPERAPI_KEY}&url={quote_plus(url)}' + return requests.get(u, timeout=10) + + +class Goodreads: + @classmethod + def search(self, q, page=1): + results = [] + try: + search_url = f'https://www.goodreads.com/search?page={page}&q={quote_plus(q)}' + r = requests.get(search_url) + if r.url.startswith('https://www.goodreads.com/book/show/'): + # Goodreads will 302 if only one result matches ISBN + data, img = GoodreadsScraper.scrape(r.url, r) + subtitle = f"{data['pub_year']} {', '.join(data['author'])} {', '.join(data['translator'] if data['translator'] else [])}" + results.append(SearchResultItem(Category.Book, SourceSiteEnum.GOODREADS, + data['source_url'], data['title'], subtitle, + data['brief'], data['cover_url'])) + else: + h = html.fromstring(r.content.decode('utf-8')) + for c in h.xpath('//tr[@itemtype="http://schema.org/Book"]'): + el_cover = c.xpath('.//img[@class="bookCover"]/@src') + cover = el_cover[0] if el_cover else None + el_title = c.xpath('.//a[@class="bookTitle"]//text()') + title = ''.join(el_title).strip() if el_title else None + el_url = c.xpath('.//a[@class="bookTitle"]/@href') + url = 'https://www.goodreads.com' + \ + el_url[0] if el_url else None + el_authors = c.xpath('.//a[@class="authorName"]//text()') + subtitle = ', '.join(el_authors) if el_authors else None + results.append(SearchResultItem( + Category.Book, SourceSiteEnum.GOODREADS, url, title, subtitle, '', cover)) + except Exception as e: + logger.error(f"Goodreads search '{q}' error: {e}") + return results + + +class GoogleBooks: + @classmethod + def search(self, q, page=1): + results = [] + try: + api_url = f'https://www.googleapis.com/books/v1/volumes?country=us&q={quote_plus(q)}&startIndex={SEARCH_PAGE_SIZE*(page-1)}&maxResults={SEARCH_PAGE_SIZE}&maxAllowedMaturityRating=MATURE' + j = requests.get(api_url).json() + if 'items' in j: + for b in j['items']: + if 'title' not in b['volumeInfo']: + continue + title = b['volumeInfo']['title'] + subtitle = '' + if 'publishedDate' in b['volumeInfo']: + subtitle += b['volumeInfo']['publishedDate'] + ' ' + if 'authors' in b['volumeInfo']: + subtitle += ', '.join(b['volumeInfo']['authors']) + if 'description' in b['volumeInfo']: + brief = b['volumeInfo']['description'] + elif 'textSnippet' in b['volumeInfo']: + brief = b["volumeInfo"]["textSnippet"]["searchInfo"] + else: + brief = '' + category = Category.Book + # b['volumeInfo']['infoLink'].replace('http:', 'https:') + url = 'https://books.google.com/books?id=' + b['id'] + cover = b['volumeInfo']['imageLinks']['thumbnail'] if 'imageLinks' in b['volumeInfo'] else None + results.append(SearchResultItem( + category, SourceSiteEnum.GOOGLEBOOKS, url, title, subtitle, brief, cover)) + except Exception as e: + logger.error(f"GoogleBooks search '{q}' error: {e}") + return results + + +class TheMovieDatabase: + @classmethod + def search(self, q, page=1): + results = [] + try: + api_url = f'https://api.themoviedb.org/3/search/multi?query={quote_plus(q)}&page={page}&api_key={settings.TMDB_API3_KEY}&language=zh-CN&include_adult=true' + j = requests.get(api_url).json() + for m in j['results']: + if m['media_type'] in ['tv', 'movie']: + url = f"https://www.themoviedb.org/{m['media_type']}/{m['id']}" + if m['media_type'] == 'tv': + cat = Category.TV + title = m['name'] + subtitle = f"{m.get('first_air_date')} {m.get('original_name')}" + else: + cat = Category.Movie + title = m['title'] + subtitle = f"{m.get('release_date')} {m.get('original_name')}" + cover = f"https://image.tmdb.org/t/p/w500/{m.get('poster_path')}" + results.append(SearchResultItem( + cat, SourceSiteEnum.TMDB, url, title, subtitle, m.get('overview'), cover)) + except Exception as e: + logger.error(f"TMDb search '{q}' error: {e}") + return results + + +class Spotify: + @classmethod + def search(self, q, page=1): + results = [] + try: + api_url = f"https://api.spotify.com/v1/search?q={q}&type=album&limit={SEARCH_PAGE_SIZE}&offset={page*SEARCH_PAGE_SIZE}" + headers = { + 'Authorization': f"Bearer {get_spotify_token()}" + } + j = requests.get(api_url, headers=headers).json() + for a in j['albums']['items']: + title = a['name'] + subtitle = a['release_date'] + for artist in a['artists']: + subtitle += ' ' + artist['name'] + url = a['external_urls']['spotify'] + cover = a['images'][0]['url'] + results.append(SearchResultItem( + Category.Music, SourceSiteEnum.SPOTIFY, url, title, subtitle, '', cover)) + except Exception as e: + logger.error(f"Spotify search '{q}' error: {e}") + return results + + +class Bandcamp: + @classmethod + def search(self, q, page=1): + results = [] + try: + search_url = f'https://bandcamp.com/search?from=results&item_type=a&page={page}&q={quote_plus(q)}' + r = requests.get(search_url) + h = html.fromstring(r.content.decode('utf-8')) + for c in h.xpath('//li[@class="searchresult data-search"]'): + el_cover = c.xpath('.//div[@class="art"]/img/@src') + cover = el_cover[0] if el_cover else None + el_title = c.xpath('.//div[@class="heading"]//text()') + title = ''.join(el_title).strip() if el_title else None + el_url = c.xpath('..//div[@class="itemurl"]/a/@href') + url = el_url[0] if el_url else None + el_authors = c.xpath('.//div[@class="subhead"]//text()') + subtitle = ', '.join(el_authors) if el_authors else None + results.append(SearchResultItem(Category.Music, SourceSiteEnum.BANDCAMP, url, title, subtitle, '', cover)) + except Exception as e: + logger.error(f"Goodreads search '{q}' error: {e}") + return results + + +class ExternalSources: + @classmethod + def search(self, c, q, page=1): + if not q: + return [] + results = [] + if c == '' or c is None: + c = 'all' + if c == 'all' or c == 'movie': + results.extend(TheMovieDatabase.search(q, page)) + if c == 'all' or c == 'book': + results.extend(GoogleBooks.search(q, page)) + results.extend(Goodreads.search(q, page)) + if c == 'all' or c == 'music': + results.extend(Spotify.search(q, page)) + results.extend(Bandcamp.search(q, page)) + return results diff --git a/common/static/css/boofilsic.css b/common/static/css/boofilsic.css index 514f1aba..b93d83b6 100644 --- a/common/static/css/boofilsic.css +++ b/common/static/css/boofilsic.css @@ -270,16 +270,13 @@ h6 { img { max-width: 100%; - -o-object-fit: contain; - object-fit: contain; + object-fit: contain; } img.emoji { height: 14px; - -webkit-box-sizing: border-box; - box-sizing: border-box; - -o-object-fit: contain; - object-fit: contain; + box-sizing: border-box; + object-fit: contain; position: relative; top: 3px; } @@ -315,13 +312,11 @@ img.emoji--large { *, *:after, *:before { - -webkit-box-sizing: inherit; - box-sizing: inherit; + box-sizing: inherit; } html { - -webkit-box-sizing: border-box; - box-sizing: border-box; + box-sizing: border-box; height: 100%; } @@ -379,16 +374,12 @@ input[type='time'], input[type='color'], textarea, select { - -webkit-appearance: none; - -moz-appearance: none; - appearance: none; + appearance: none; background-color: transparent; border: 0.1rem solid #ccc; border-radius: .4rem; - -webkit-box-shadow: none; - box-shadow: none; - -webkit-box-sizing: inherit; - box-sizing: inherit; + box-shadow: none; + box-sizing: inherit; padding: .6rem 1.0rem; } @@ -408,51 +399,6 @@ select:focus { outline: 0; } -input[type='email']::-webkit-input-placeholder, -input[type='number']::-webkit-input-placeholder, -input[type='password']::-webkit-input-placeholder, -input[type='search']::-webkit-input-placeholder, -input[type='tel']::-webkit-input-placeholder, -input[type='text']::-webkit-input-placeholder, -input[type='url']::-webkit-input-placeholder, -input[type='date']::-webkit-input-placeholder, -input[type='time']::-webkit-input-placeholder, -input[type='color']::-webkit-input-placeholder, -textarea::-webkit-input-placeholder, -select::-webkit-input-placeholder { - color: #ccc; -} - -input[type='email']:-ms-input-placeholder, -input[type='number']:-ms-input-placeholder, -input[type='password']:-ms-input-placeholder, -input[type='search']:-ms-input-placeholder, -input[type='tel']:-ms-input-placeholder, -input[type='text']:-ms-input-placeholder, -input[type='url']:-ms-input-placeholder, -input[type='date']:-ms-input-placeholder, -input[type='time']:-ms-input-placeholder, -input[type='color']:-ms-input-placeholder, -textarea:-ms-input-placeholder, -select:-ms-input-placeholder { - color: #ccc; -} - -input[type='email']::-ms-input-placeholder, -input[type='number']::-ms-input-placeholder, -input[type='password']::-ms-input-placeholder, -input[type='search']::-ms-input-placeholder, -input[type='tel']::-ms-input-placeholder, -input[type='text']::-ms-input-placeholder, -input[type='url']::-ms-input-placeholder, -input[type='date']::-ms-input-placeholder, -input[type='time']::-ms-input-placeholder, -input[type='color']::-ms-input-placeholder, -textarea::-ms-input-placeholder, -select::-ms-input-placeholder { - color: #ccc; -} - input[type='email']::placeholder, input[type='number']::placeholder, input[type='password']::placeholder, @@ -468,11 +414,6 @@ select::placeholder { color: #ccc; } -::-moz-selection { - color: white; - background-color: #00a1cc; -} - ::selection { color: white; background-color: #00a1cc; @@ -480,29 +421,21 @@ select::placeholder { .navbar { background-color: #f7f7f7; - -webkit-box-sizing: border-box; - box-sizing: border-box; + box-sizing: border-box; padding: 10px 0; margin-bottom: 50px; border-bottom: #ccc 0.5px solid; } .navbar .navbar__wrapper { - display: -webkit-box; - display: -ms-flexbox; display: flex; - -webkit-box-pack: justify; - -ms-flex-pack: justify; - justify-content: space-between; - -webkit-box-align: center; - -ms-flex-align: center; - align-items: center; + justify-content: space-between; + align-items: center; position: relative; } .navbar .navbar__logo { - -ms-flex-preferred-size: 100px; - flex-basis: 100px; + flex-basis: 100px; } .navbar .navbar__logo-link { @@ -511,11 +444,8 @@ select::placeholder { .navbar .navbar__link-list { margin: 0; - display: -webkit-box; - display: -ms-flexbox; display: flex; - -ms-flex-pack: distribute; - justify-content: space-around; + justify-content: space-around; } .navbar .navbar__link { @@ -533,12 +463,8 @@ select::placeholder { .navbar .navbar__search-box { margin: 0 12% 0 15px; - display: -webkit-inline-box; - display: -ms-inline-flexbox; display: inline-flex; - -webkit-box-flex: 1; - -ms-flex: 1; - flex: 1; + flex: 1; } .navbar .navbar__search-box > input[type="search"] { @@ -556,9 +482,7 @@ select::placeholder { padding: 0; padding-left: 10px; color: #606c76; - -webkit-appearance: auto; - -moz-appearance: auto; - appearance: auto; + appearance: auto; background-color: white; height: 32px; width: 80px; @@ -596,7 +520,6 @@ select::placeholder { .navbar .navbar__link-list { margin-top: 7px; max-height: 0; - -webkit-transition: max-height 0.6s ease-out; transition: max-height 0.6s ease-out; overflow: hidden; } @@ -605,12 +528,10 @@ select::placeholder { position: absolute; right: 5px; top: 3px; - -webkit-transform: scale(0.7); - transform: scale(0.7); + transform: scale(0.7); } .navbar .navbar__dropdown-btn:hover + .navbar__link-list { max-height: 500px; - -webkit-transition: max-height 0.6s ease-in; transition: max-height 0.6s ease-in; } .navbar .navbar__search-box { @@ -654,15 +575,9 @@ select::placeholder { width: 26%; float: right; position: relative; - display: -webkit-box; - display: -ms-flexbox; display: flex; - -webkit-box-orient: vertical; - -webkit-box-direction: normal; - -ms-flex-direction: column; - flex-direction: column; - -ms-flex-pack: distribute; - justify-content: space-around; + flex-direction: column; + justify-content: space-around; } .grid::after { @@ -673,10 +588,7 @@ select::placeholder { @media (max-width: 575.98px) { .grid .grid__aside { - -webkit-box-orient: vertical !important; - -webkit-box-direction: normal !important; - -ms-flex-direction: column !important; - flex-direction: column !important; + flex-direction: column !important; } } @@ -688,28 +600,19 @@ select::placeholder { .grid .grid__aside { width: 100%; float: none; - -webkit-box-orient: horizontal; - -webkit-box-direction: normal; - -ms-flex-direction: row; - flex-direction: row; + flex-direction: row; } .grid .grid__aside--tablet-column { - -webkit-box-orient: vertical; - -webkit-box-direction: normal; - -ms-flex-direction: column; - flex-direction: column; + flex-direction: column; } .grid--reverse-order { - -webkit-transform: scaleY(-1); - transform: scaleY(-1); + transform: scaleY(-1); } .grid .grid__main--reverse-order { - -webkit-transform: scaleY(-1); - transform: scaleY(-1); + transform: scaleY(-1); } .grid .grid__aside--reverse-order { - -webkit-transform: scaleY(-1); - transform: scaleY(-1); + transform: scaleY(-1); } } @@ -778,8 +681,7 @@ select::placeholder { margin-bottom: 4px !important; position: absolute !important; left: 50%; - -webkit-transform: translateX(-50%); - transform: translateX(-50%); + transform: translateX(-50%); bottom: 0; width: 100%; } @@ -839,17 +741,14 @@ select::placeholder { display: inline-block; position: relative; left: 50%; - -webkit-transform: translateX(-50%) scale(0.4); - transform: translateX(-50%) scale(0.4); + transform: translateX(-50%) scale(0.4); width: 80px; height: 80px; } .spinner div { - -webkit-transform-origin: 40px 40px; - transform-origin: 40px 40px; - -webkit-animation: spinner 1.2s linear infinite; - animation: spinner 1.2s linear infinite; + transform-origin: 40px 40px; + animation: spinner 1.2s linear infinite; } .spinner div::after { @@ -865,96 +764,63 @@ select::placeholder { } .spinner div:nth-child(1) { - -webkit-transform: rotate(0deg); - transform: rotate(0deg); - -webkit-animation-delay: -1.1s; - animation-delay: -1.1s; + transform: rotate(0deg); + animation-delay: -1.1s; } .spinner div:nth-child(2) { - -webkit-transform: rotate(30deg); - transform: rotate(30deg); - -webkit-animation-delay: -1s; - animation-delay: -1s; + transform: rotate(30deg); + animation-delay: -1s; } .spinner div:nth-child(3) { - -webkit-transform: rotate(60deg); - transform: rotate(60deg); - -webkit-animation-delay: -0.9s; - animation-delay: -0.9s; + transform: rotate(60deg); + animation-delay: -0.9s; } .spinner div:nth-child(4) { - -webkit-transform: rotate(90deg); - transform: rotate(90deg); - -webkit-animation-delay: -0.8s; - animation-delay: -0.8s; + transform: rotate(90deg); + animation-delay: -0.8s; } .spinner div:nth-child(5) { - -webkit-transform: rotate(120deg); - transform: rotate(120deg); - -webkit-animation-delay: -0.7s; - animation-delay: -0.7s; + transform: rotate(120deg); + animation-delay: -0.7s; } .spinner div:nth-child(6) { - -webkit-transform: rotate(150deg); - transform: rotate(150deg); - -webkit-animation-delay: -0.6s; - animation-delay: -0.6s; + transform: rotate(150deg); + animation-delay: -0.6s; } .spinner div:nth-child(7) { - -webkit-transform: rotate(180deg); - transform: rotate(180deg); - -webkit-animation-delay: -0.5s; - animation-delay: -0.5s; + transform: rotate(180deg); + animation-delay: -0.5s; } .spinner div:nth-child(8) { - -webkit-transform: rotate(210deg); - transform: rotate(210deg); - -webkit-animation-delay: -0.4s; - animation-delay: -0.4s; + transform: rotate(210deg); + animation-delay: -0.4s; } .spinner div:nth-child(9) { - -webkit-transform: rotate(240deg); - transform: rotate(240deg); - -webkit-animation-delay: -0.3s; - animation-delay: -0.3s; + transform: rotate(240deg); + animation-delay: -0.3s; } .spinner div:nth-child(10) { - -webkit-transform: rotate(270deg); - transform: rotate(270deg); - -webkit-animation-delay: -0.2s; - animation-delay: -0.2s; + transform: rotate(270deg); + animation-delay: -0.2s; } .spinner div:nth-child(11) { - -webkit-transform: rotate(300deg); - transform: rotate(300deg); - -webkit-animation-delay: -0.1s; - animation-delay: -0.1s; + transform: rotate(300deg); + animation-delay: -0.1s; } .spinner div:nth-child(12) { - -webkit-transform: rotate(330deg); - transform: rotate(330deg); - -webkit-animation-delay: 0s; - animation-delay: 0s; -} - -@-webkit-keyframes spinner { - 0% { - opacity: 1; - } - 100% { - opacity: 0; - } + transform: rotate(330deg); + animation-delay: 0s; } @keyframes spinner { @@ -969,8 +835,7 @@ select::placeholder { .bg-mask { background-color: black; z-index: 1; - -webkit-filter: opacity(20%); - filter: opacity(20%); + filter: opacity(20%); position: fixed; width: 100%; height: 100%; @@ -986,8 +851,7 @@ select::placeholder { width: 500px; top: 50%; left: 50%; - -webkit-transform: translate(-50%, -50%); - transform: translate(-50%, -50%); + transform: translate(-50%, -50%); background-color: #f7f7f7; padding: 20px 20px 10px 20px; color: #606c76; @@ -1107,8 +971,7 @@ select::placeholder { width: 500px; top: 50%; left: 50%; - -webkit-transform: translate(-50%, -50%); - transform: translate(-50%, -50%); + transform: translate(-50%, -50%); background-color: #f7f7f7; padding: 20px 20px 10px 20px; color: #606c76; @@ -1146,8 +1009,7 @@ select::placeholder { width: 500px; top: 50%; left: 50%; - -webkit-transform: translate(-50%, -50%); - transform: translate(-50%, -50%); + transform: translate(-50%, -50%); background-color: #f7f7f7; padding: 20px 20px 10px 20px; color: #606c76; @@ -1196,8 +1058,46 @@ select::placeholder { word-break: break-all; } +.add-to-list-modal { + z-index: 2; + display: none; + position: fixed; + width: 500px; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + background-color: #f7f7f7; + padding: 20px 20px 10px 20px; + color: #606c76; +} + +.add-to-list-modal .add-to-list-modal__head { + margin-bottom: 20px; +} + +.add-to-list-modal .add-to-list-modal__head::after { + content: ' '; + clear: both; + display: table; +} + +.add-to-list-modal .add-to-list-modal__title { + font-weight: bold; + font-size: 1.2em; + float: left; +} + +.add-to-list-modal .add-to-list-modal__close-button { + float: right; + cursor: pointer; +} + +.add-to-list-modal .add-to-list-modal__confirm-button { + float: right; +} + @media (max-width: 575.98px) { - .mark-modal, .confirm-modal, .announcement-modal { + .mark-modal, .confirm-modal, .announcement-modal .add-to-list-modal { width: 100%; } } @@ -1246,6 +1146,13 @@ select::placeholder { font-weight: bold; } +.source-label.source-label__igdb { + background-color: #323A44; + color: #DFE1E2; + border: none; + font-weight: bold; +} + .source-label.source-label__steam { background: linear-gradient(30deg, #1387b8, #111d2e); color: white; @@ -1261,6 +1168,37 @@ select::placeholder { font-weight: 600; } +.source-label.source-label__goodreads { + background: #F4F1EA; + color: #372213; + font-weight: lighter; +} + +.source-label.source-label__tmdb { + background: linear-gradient(90deg, #91CCA3, #1FB4E2); + color: white; + border: none; + font-weight: lighter; + padding-top: 2px; +} + +.source-label.source-label__googlebooks { + color: white; + background-color: #4285F4; + border-color: #4285F4; +} + +.source-label.source-label__bandcamp { + color: white; + background-color: #28A0C1; + display: inline-block; +} + +.source-label.source-label__bandcamp span { + display: inline-block; + margin: 0 4px; +} + .main-section-wrapper { padding: 32px 48px 32px 36px; background-color: #f7f7f7; @@ -1276,8 +1214,6 @@ select::placeholder { } .entity-list .entity-list__entity { - display: -webkit-box; - display: -ms-flexbox; display: flex; margin-bottom: 36px; } @@ -1289,8 +1225,7 @@ select::placeholder { } .entity-list .entity-list__entity-img { - -o-object-fit: contain; - object-fit: contain; + object-fit: contain; min-width: 130px; max-width: 130px; } @@ -1368,15 +1303,12 @@ select::placeholder { .entity-detail .entity-detail__img { height: 210px; float: left; - -o-object-fit: contain; - object-fit: contain; + object-fit: contain; max-width: 150px; - -o-object-position: top; - object-position: top; + object-position: top; } .entity-detail .entity-detail__img-origin { - cursor: -webkit-zoom-in; cursor: zoom-in; } @@ -1451,14 +1383,10 @@ select::placeholder { } .entity-desc .entity-desc__unfold-button { - display: -webkit-box; - display: -ms-flexbox; display: flex; color: #00a1cc; background-color: transparent; - -webkit-box-pack: center; - -ms-flex-pack: center; - justify-content: center; + justify-content: center; text-align: center; } @@ -1597,20 +1525,14 @@ select::placeholder { } .entity-sort .entity-sort__entity-list { - display: -webkit-box; - display: -ms-flexbox; display: flex; - -webkit-box-pack: start; - -ms-flex-pack: start; - justify-content: flex-start; - -ms-flex-wrap: wrap; - flex-wrap: wrap; + justify-content: flex-start; + flex-wrap: wrap; } .entity-sort .entity-sort__entity { padding: 0 10px; - -ms-flex-preferred-size: 20%; - flex-basis: 20%; + flex-basis: 20%; text-align: center; display: inline-block; color: #606c76; @@ -1658,12 +1580,8 @@ select::placeholder { } .entity-sort-control { - display: -webkit-box; - display: -ms-flexbox; display: flex; - -webkit-box-pack: end; - -ms-flex-pack: end; - justify-content: flex-end; + justify-content: flex-end; } .entity-sort-control__button { @@ -1693,12 +1611,8 @@ select::placeholder { } .related-user-list .related-user-list__user { - display: -webkit-box; - display: -ms-flexbox; display: flex; - -webkit-box-pack: start; - -ms-flex-pack: start; - justify-content: flex-start; + justify-content: flex-start; margin-bottom: 20px; } @@ -1791,12 +1705,9 @@ select::placeholder { overflow: auto; scroll-behavior: smooth; scrollbar-width: none; - display: -webkit-box; - display: -ms-flexbox; display: flex; margin: auto; - -webkit-box-sizing: border-box; - box-sizing: border-box; + box-sizing: border-box; padding-bottom: 10px; } @@ -1820,8 +1731,7 @@ select::placeholder { } .track-carousel__track img { - -o-object-fit: contain; - object-fit: contain; + object-fit: contain; } .track-carousel__track-title { @@ -1829,14 +1739,9 @@ select::placeholder { } .track-carousel__button { - display: -webkit-box; - display: -ms-flexbox; display: flex; - -webkit-box-pack: center; - -ms-flex-pack: center; - justify-content: center; - -ms-flex-line-pack: center; - align-content: center; + justify-content: center; + align-content: center; background: white; border: none; padding: 8px; @@ -1849,22 +1754,17 @@ select::placeholder { .track-carousel__button--prev { left: 0; - -webkit-transform: translate(50%, -50%); - transform: translate(50%, -50%); + transform: translate(50%, -50%); } .track-carousel__button--next { right: 0; - -webkit-transform: translate(-50%, -50%); - transform: translate(-50%, -50%); + transform: translate(-50%, -50%); } @media (max-width: 575.98px) { .entity-list .entity-list__entity { - -webkit-box-orient: vertical; - -webkit-box-direction: normal; - -ms-flex-direction: column; - flex-direction: column; + flex-direction: column; margin-bottom: 30px; } .entity-list .entity-list__entity-text { @@ -1883,10 +1783,7 @@ select::placeholder { -webkit-line-clamp: 5; } .entity-detail { - -webkit-box-orient: vertical; - -webkit-box-direction: normal; - -ms-flex-direction: column; - flex-direction: column; + flex-direction: column; } .entity-detail .entity-detail__title { margin-bottom: 5px; @@ -1894,13 +1791,8 @@ select::placeholder { .entity-detail .entity-detail__info { margin-left: 0; float: none; - display: -webkit-box; - display: -ms-flexbox; display: flex; - -webkit-box-orient: vertical; - -webkit-box-direction: normal; - -ms-flex-direction: column; - flex-direction: column; + flex-direction: column; width: 100%; } .entity-detail .entity-detail__img { @@ -1920,8 +1812,7 @@ select::placeholder { margin-top: 24px; } .entity-sort .entity-sort__entity { - -ms-flex-preferred-size: 50%; - flex-basis: 50%; + flex-basis: 50%; } .entity-sort .entity-sort__entity-img { height: 130px; @@ -1947,23 +1838,14 @@ select::placeholder { padding: 32px 28px 28px 28px; } .entity-detail { - display: -webkit-box; - display: -ms-flexbox; display: flex; } } .aside-section-wrapper { - display: -webkit-box; - display: -ms-flexbox; display: flex; - -webkit-box-flex: 1; - -ms-flex: 1; - flex: 1; - -webkit-box-orient: vertical; - -webkit-box-direction: normal; - -ms-flex-direction: column; - flex-direction: column; + flex: 1; + flex-direction: column; width: 100%; padding: 28px 25px 12px 25px; background-color: #f7f7f7; @@ -2005,18 +1887,12 @@ select::placeholder { } .action-panel .action-panel__button-group { - display: -webkit-box; - display: -ms-flexbox; display: flex; - -webkit-box-pack: justify; - -ms-flex-pack: justify; - justify-content: space-between; + justify-content: space-between; } .action-panel .action-panel__button-group--center { - -webkit-box-pack: center; - -ms-flex-pack: center; - justify-content: center; + justify-content: center; } .action-panel .action-panel__button { @@ -2084,12 +1960,8 @@ select::placeholder { } .user-profile .user-profile__header { - display: -webkit-box; - display: -ms-flexbox; display: flex; - -webkit-box-align: start; - -ms-flex-align: start; - align-items: flex-start; + align-items: flex-start; margin-bottom: 15px; } @@ -2118,12 +1990,8 @@ select::placeholder { } .user-relation .user-relation__related-user-list { - display: -webkit-box; - display: -ms-flexbox; display: flex; - -webkit-box-pack: start; - -ms-flex-pack: start; - justify-content: flex-start; + justify-content: flex-start; } .user-relation .user-relation__related-user-list:last-of-type { @@ -2131,8 +1999,7 @@ select::placeholder { } .user-relation .user-relation__related-user { - -ms-flex-preferred-size: 25%; - flex-basis: 25%; + flex-basis: 25%; padding: 0px 3px; text-align: center; display: inline-block; @@ -2268,7 +2135,7 @@ select::placeholder { background-color: #d5d5d5; border-radius: 0; height: 10px; - width: 65%; + width: 54%; } .import-panel .import-panel__progress progress::-webkit-progress-bar { @@ -2310,21 +2177,13 @@ select::placeholder { } .entity-card { - display: -webkit-box; - display: -ms-flexbox; display: flex; margin-bottom: 10px; - -webkit-box-orient: vertical; - -webkit-box-direction: normal; - -ms-flex-direction: column; - flex-direction: column; + flex-direction: column; } .entity-card--horizontal { - -webkit-box-orient: horizontal; - -webkit-box-direction: normal; - -ms-flex-direction: row; - flex-direction: row; + flex-direction: row; } .entity-card .entity-card__img { @@ -2353,8 +2212,7 @@ select::placeholder { } .entity-card .entity-card__img-wrapper { - -ms-flex-preferred-size: 100px; - flex-basis: 100px; + flex-basis: 100px; } @media (max-width: 575.98px) { @@ -2373,16 +2231,10 @@ select::placeholder { margin-bottom: 20px !important; } .action-panel { - -webkit-box-orient: vertical !important; - -webkit-box-direction: normal !important; - -ms-flex-direction: column !important; - flex-direction: column !important; + flex-direction: column !important; } .entity-card--horizontal { - -webkit-box-orient: vertical !important; - -webkit-box-direction: normal !important; - -ms-flex-direction: column !important; - flex-direction: column !important; + flex-direction: column !important; } .entity-card .entity-card__info-wrapper { margin-left: 10px !important; @@ -2394,11 +2246,8 @@ select::placeholder { @media (max-width: 991.98px) { .add-entity-entries { - display: -webkit-box; - display: -ms-flexbox; display: flex; - -ms-flex-pack: distribute; - justify-content: space-around; + justify-content: space-around; } .aside-section-wrapper { padding: 24px 25px 10px 25px; @@ -2419,15 +2268,10 @@ select::placeholder { margin: 0; } .action-panel { - -webkit-box-orient: horizontal; - -webkit-box-direction: normal; - -ms-flex-direction: row; - flex-direction: row; + flex-direction: row; } .action-panel .action-panel__button-group { - -webkit-box-pack: space-evenly; - -ms-flex-pack: space-evenly; - justify-content: space-evenly; + justify-content: space-evenly; } .relation-dropdown { margin-bottom: 20px; @@ -2436,54 +2280,36 @@ select::placeholder { padding-bottom: 10px; background-color: #f7f7f7; width: 100%; - display: -webkit-box; - display: -ms-flexbox; display: flex; - -webkit-box-pack: center; - -ms-flex-pack: center; - justify-content: center; - -webkit-box-align: center; - -ms-flex-align: center; - align-items: center; + justify-content: center; + align-items: center; cursor: pointer; - -webkit-transition: -webkit-transform 0.3s; - transition: -webkit-transform 0.3s; transition: transform 0.3s; - transition: transform 0.3s, -webkit-transform 0.3s; } .relation-dropdown .relation-dropdown__button:focus { background-color: red; } .relation-dropdown .relation-dropdown__button > .icon-arrow { - -webkit-transition: -webkit-transform 0.3s; - transition: -webkit-transform 0.3s; transition: transform 0.3s; - transition: transform 0.3s, -webkit-transform 0.3s; } .relation-dropdown .relation-dropdown__button:hover > .icon-arrow > svg { fill: #00a1cc; } .relation-dropdown .relation-dropdown__button > .icon-arrow--expand { - -webkit-transform: rotate(-180deg); - transform: rotate(-180deg); + transform: rotate(-180deg); } .relation-dropdown .relation-dropdown__button + .relation-dropdown__body--expand { max-height: 2000px; - -webkit-transition: max-height 1s ease-in; transition: max-height 1s ease-in; } .relation-dropdown .relation-dropdown__body { background-color: #f7f7f7; max-height: 0; - -webkit-transition: max-height 1s ease-out; transition: max-height 1s ease-out; overflow: hidden; } .entity-card { - -webkit-box-orient: horizontal; - -webkit-box-direction: normal; - -ms-flex-direction: row; - flex-direction: row; + flex-direction: row; } .entity-card .entity-card__info-wrapper { margin-left: 30px; @@ -2510,21 +2336,7 @@ select::placeholder { overflow: auto; } -.entity-form > input[type='email'], -.entity-form > input[type='number'], -.entity-form > input[type='password'], -.entity-form > input[type='search'], -.entity-form > input[type='tel'], -.entity-form > input[type='text'], -.entity-form > input[type='url'], -.entity-form textarea, .review-form > input[type='email'], -.review-form > input[type='number'], -.review-form > input[type='password'], -.review-form > input[type='search'], -.review-form > input[type='tel'], -.review-form > input[type='text'], -.review-form > input[type='url'], -.review-form textarea { +.entity-form > input[type='email'], .entity-form > input[type='number'], .entity-form > input[type='password'], .entity-form > input[type='search'], .entity-form > input[type='tel'], .entity-form > input[type='text'], .entity-form > input[type='url'], .entity-form textarea, .review-form > input[type='email'], .review-form > input[type='number'], .review-form > input[type='password'], .review-form > input[type='search'], .review-form > input[type='tel'], .review-form > input[type='text'], .review-form > input[type='url'], .review-form textarea { width: 100%; } @@ -2614,16 +2426,12 @@ select::placeholder { .ms-parent > .ms-choice { margin-bottom: 1.5rem; - -webkit-appearance: none; - -moz-appearance: none; - appearance: none; + appearance: none; background-color: transparent; border: 0.1rem solid #ccc; border-radius: .4rem; - -webkit-box-shadow: none; - box-shadow: none; - -webkit-box-sizing: inherit; - box-sizing: inherit; + box-shadow: none; + box-sizing: inherit; padding: .6rem 1.0rem; width: 100%; height: 30.126px; @@ -2664,7 +2472,9 @@ select::placeholder { } .tag-input input { - -webkit-box-flex: 1; - -ms-flex-positive: 1; - flex-grow: 1; + flex-grow: 1; +} + +.tools-section-wrapper input, .tools-section-wrapper select { + width: unset; } diff --git a/common/static/css/boofilsic.min.css b/common/static/css/boofilsic.min.css index 19ea74b6..4a5c94d6 100644 --- a/common/static/css/boofilsic.min.css +++ b/common/static/css/boofilsic.min.css @@ -1 +1 @@ -@import url(https://cdn.jsdelivr.net/npm/skeleton-css@2.0.4/css/normalize.css);.button,button,input[type='button'],input[type='reset'],input[type='submit']{background-color:#00a1cc;border:0.1rem solid #00a1cc;border-radius:.4rem;color:#fff;cursor:pointer;display:inline-block;font-size:1.1rem;font-weight:700;height:3.4rem;letter-spacing:.1rem;line-height:3.4rem;padding:0 2.8rem;text-align:center;text-decoration:none;text-transform:uppercase;white-space:nowrap}.button:focus,.button:hover,button:focus,button:hover,input[type='button']:focus,input[type='button']:hover,input[type='reset']:focus,input[type='reset']:hover,input[type='submit']:focus,input[type='submit']:hover{background-color:#606c76;border-color:#606c76;color:#fff;outline:0}.button[disabled],button[disabled],input[type='button'][disabled],input[type='reset'][disabled],input[type='submit'][disabled]{cursor:default;opacity:.5}.button[disabled]:focus,.button[disabled]:hover,button[disabled]:focus,button[disabled]:hover,input[type='button'][disabled]:focus,input[type='button'][disabled]:hover,input[type='reset'][disabled]:focus,input[type='reset'][disabled]:hover,input[type='submit'][disabled]:focus,input[type='submit'][disabled]:hover{background-color:#00a1cc;border-color:#00a1cc}.button.button-outline,button.button-outline,input[type='button'].button-outline,input[type='reset'].button-outline,input[type='submit'].button-outline{background-color:transparent;color:#00a1cc}.button.button-outline:focus,.button.button-outline:hover,button.button-outline:focus,button.button-outline:hover,input[type='button'].button-outline:focus,input[type='button'].button-outline:hover,input[type='reset'].button-outline:focus,input[type='reset'].button-outline:hover,input[type='submit'].button-outline:focus,input[type='submit'].button-outline:hover{background-color:transparent;border-color:#606c76;color:#606c76}.button.button-outline[disabled]:focus,.button.button-outline[disabled]:hover,button.button-outline[disabled]:focus,button.button-outline[disabled]:hover,input[type='button'].button-outline[disabled]:focus,input[type='button'].button-outline[disabled]:hover,input[type='reset'].button-outline[disabled]:focus,input[type='reset'].button-outline[disabled]:hover,input[type='submit'].button-outline[disabled]:focus,input[type='submit'].button-outline[disabled]:hover{border-color:inherit;color:#00a1cc}.button.button-clear,button.button-clear,input[type='button'].button-clear,input[type='reset'].button-clear,input[type='submit'].button-clear{background-color:transparent;border-color:transparent;color:#00a1cc}.button.button-clear:focus,.button.button-clear:hover,button.button-clear:focus,button.button-clear:hover,input[type='button'].button-clear:focus,input[type='button'].button-clear:hover,input[type='reset'].button-clear:focus,input[type='reset'].button-clear:hover,input[type='submit'].button-clear:focus,input[type='submit'].button-clear:hover{background-color:transparent;border-color:transparent;color:#606c76}.button.button-clear[disabled]:focus,.button.button-clear[disabled]:hover,button.button-clear[disabled]:focus,button.button-clear[disabled]:hover,input[type='button'].button-clear[disabled]:focus,input[type='button'].button-clear[disabled]:hover,input[type='reset'].button-clear[disabled]:focus,input[type='reset'].button-clear[disabled]:hover,input[type='submit'].button-clear[disabled]:focus,input[type='submit'].button-clear[disabled]:hover{color:#00a1cc}select{background:url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" height="14" viewBox="0 0 29 14" width="29"><path fill="#d1d1d1" d="M9.37727 3.625l5.08154 6.93523L19.54036 3.625"/></svg>') center right no-repeat;padding-right:3.0rem}select:focus{background-image:url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" height="14" viewBox="0 0 29 14" width="29"><path fill="#9b4dca" d="M9.37727 3.625l5.08154 6.93523L19.54036 3.625"/></svg>')}textarea{min-height:6.5rem;width:100%}select{width:100%}label,legend{display:block;margin-bottom:.5rem}fieldset{border-width:0;padding:0}input[type='checkbox'],input[type='radio']{display:inline}.label-inline{display:inline-block;font-weight:normal;margin-left:.5rem}dl,ol,ul{list-style:none;margin-top:0;padding-left:0}ol{list-style:decimal inside}ul{list-style:circle inside}.button,button,dd,dt,li{margin-bottom:1.0rem}fieldset,input,select,textarea{margin-bottom:1.5rem}blockquote,dl,figure,form,ol,p,pre,table,ul{margin-bottom:1rem}b,strong{font-weight:bold}p{margin-top:0}h1,h2,h3,h4,h5,h6{font-weight:300;letter-spacing:-.1rem;margin-bottom:2.0rem;margin-top:0}h1{font-size:4.6rem;line-height:1.2}h2{font-size:3.6rem;line-height:1.25}h3{font-size:2.8rem;line-height:1.3}h4{font-size:2.2rem;letter-spacing:-.08rem;line-height:1.35}h5{font-size:1.8rem;letter-spacing:-.05rem;line-height:1.5}h6{font-size:1.6rem;letter-spacing:0;line-height:1.4}img{max-width:100%;-o-object-fit:contain;object-fit:contain}img.emoji{height:14px;-webkit-box-sizing:border-box;box-sizing:border-box;-o-object-fit:contain;object-fit:contain;position:relative;top:3px}img.emoji--large{height:20px;position:relative;top:2px}.clearfix:after{clear:both;content:' ';display:table}.float-left{float:left}.float-right{float:right}.highlight{font-weight:bold}:root{font-size:10px}*,*:after,*:before{-webkit-box-sizing:inherit;box-sizing:inherit}html{-webkit-box-sizing:border-box;box-sizing:border-box;height:100%}body{color:#606c76;font-family:'Roboto', 'Helvetica Neue', 'Helvetica', 'Arial', 'Microsoft YaHei Light', sans-serif;font-size:1.3rem;font-weight:300;letter-spacing:.05rem;line-height:1.6;margin:0;height:100%}textarea{font-family:'Roboto', 'Helvetica Neue', 'Helvetica', 'Arial', 'Microsoft YaHei Light', sans-serif}a{color:#00a1cc;text-decoration:none}a:active,a:hover,a:hover:visited{color:#606c76}li{list-style:none}input[type=text]::-ms-clear,input[type=text]::-ms-reveal{display:none;width:0;height:0}input[type="search"]::-webkit-search-decoration,input[type="search"]::-webkit-search-cancel-button,input[type="search"]::-webkit-search-results-button,input[type="search"]::-webkit-search-results-decoration{display:none}input[type='email'],input[type='number'],input[type='password'],input[type='search'],input[type='tel'],input[type='text'],input[type='url'],input[type='date'],input[type='time'],input[type='color'],textarea,select{-webkit-appearance:none;-moz-appearance:none;appearance:none;background-color:transparent;border:0.1rem solid #ccc;border-radius:.4rem;-webkit-box-shadow:none;box-shadow:none;-webkit-box-sizing:inherit;box-sizing:inherit;padding:.6rem 1.0rem}input[type='email']:focus,input[type='number']:focus,input[type='password']:focus,input[type='search']:focus,input[type='tel']:focus,input[type='text']:focus,input[type='url']:focus,input[type='date']:focus,input[type='time']:focus,input[type='color']:focus,textarea:focus,select:focus{border-color:#00a1cc;outline:0}input[type='email']::-webkit-input-placeholder,input[type='number']::-webkit-input-placeholder,input[type='password']::-webkit-input-placeholder,input[type='search']::-webkit-input-placeholder,input[type='tel']::-webkit-input-placeholder,input[type='text']::-webkit-input-placeholder,input[type='url']::-webkit-input-placeholder,input[type='date']::-webkit-input-placeholder,input[type='time']::-webkit-input-placeholder,input[type='color']::-webkit-input-placeholder,textarea::-webkit-input-placeholder,select::-webkit-input-placeholder{color:#ccc}input[type='email']:-ms-input-placeholder,input[type='number']:-ms-input-placeholder,input[type='password']:-ms-input-placeholder,input[type='search']:-ms-input-placeholder,input[type='tel']:-ms-input-placeholder,input[type='text']:-ms-input-placeholder,input[type='url']:-ms-input-placeholder,input[type='date']:-ms-input-placeholder,input[type='time']:-ms-input-placeholder,input[type='color']:-ms-input-placeholder,textarea:-ms-input-placeholder,select:-ms-input-placeholder{color:#ccc}input[type='email']::-ms-input-placeholder,input[type='number']::-ms-input-placeholder,input[type='password']::-ms-input-placeholder,input[type='search']::-ms-input-placeholder,input[type='tel']::-ms-input-placeholder,input[type='text']::-ms-input-placeholder,input[type='url']::-ms-input-placeholder,input[type='date']::-ms-input-placeholder,input[type='time']::-ms-input-placeholder,input[type='color']::-ms-input-placeholder,textarea::-ms-input-placeholder,select::-ms-input-placeholder{color:#ccc}input[type='email']::placeholder,input[type='number']::placeholder,input[type='password']::placeholder,input[type='search']::placeholder,input[type='tel']::placeholder,input[type='text']::placeholder,input[type='url']::placeholder,input[type='date']::placeholder,input[type='time']::placeholder,input[type='color']::placeholder,textarea::placeholder,select::placeholder{color:#ccc}::-moz-selection{color:white;background-color:#00a1cc}::selection{color:white;background-color:#00a1cc}.navbar{background-color:#f7f7f7;-webkit-box-sizing:border-box;box-sizing:border-box;padding:10px 0;margin-bottom:50px;border-bottom:#ccc 0.5px solid}.navbar .navbar__wrapper{display:-webkit-box;display:-ms-flexbox;display:flex;-webkit-box-pack:justify;-ms-flex-pack:justify;justify-content:space-between;-webkit-box-align:center;-ms-flex-align:center;align-items:center;position:relative}.navbar .navbar__logo{-ms-flex-preferred-size:100px;flex-basis:100px}.navbar .navbar__logo-link{display:inline-block}.navbar .navbar__link-list{margin:0;display:-webkit-box;display:-ms-flexbox;display:flex;-ms-flex-pack:distribute;justify-content:space-around}.navbar .navbar__link{margin:9px;color:#606c76}.navbar .navbar__link:active,.navbar .navbar__link:hover,.navbar .navbar__link:hover:visited{color:#00a1cc}.navbar .navbar__link:visited{color:#606c76}.navbar .navbar__search-box{margin:0 12% 0 15px;display:-webkit-inline-box;display:-ms-inline-flexbox;display:inline-flex;-webkit-box-flex:1;-ms-flex:1;flex:1}.navbar .navbar__search-box>input[type="search"]{border-top-right-radius:0;border-bottom-right-radius:0;margin:0;height:32px;background-color:white !important;width:100%}.navbar .navbar__search-box .navbar__search-dropdown{margin:0;margin-left:-1px;padding:0;padding-left:10px;color:#606c76;-webkit-appearance:auto;-moz-appearance:auto;appearance:auto;background-color:white;height:32px;width:80px;border-top-left-radius:0;border-bottom-left-radius:0}.navbar .navbar__dropdown-btn{display:none;padding:0;margin:0;border:none;background-color:transparent;color:#00a1cc}.navbar .navbar__dropdown-btn:focus,.navbar .navbar__dropdown-btn:hover{background-color:transparent;color:#606c76}@media (max-width: 575.98px){.navbar{padding:2px 0}.navbar .navbar__wrapper{display:block}.navbar .navbar__logo-img{width:72px;margin-right:10px;position:relative;top:7px}.navbar .navbar__link-list{margin-top:7px;max-height:0;-webkit-transition:max-height 0.6s ease-out;transition:max-height 0.6s ease-out;overflow:hidden}.navbar .navbar__dropdown-btn{display:block;position:absolute;right:5px;top:3px;-webkit-transform:scale(0.7);transform:scale(0.7)}.navbar .navbar__dropdown-btn:hover+.navbar__link-list{max-height:500px;-webkit-transition:max-height 0.6s ease-in;transition:max-height 0.6s ease-in}.navbar .navbar__search-box{margin:0;width:46vw}.navbar .navbar__search-box>input[type="search"]{height:26px;padding:4px 6px;width:32vw}.navbar .navbar__search-box .navbar__search-dropdown{cursor:pointer;height:26px;width:80px;padding-left:5px}}@media (max-width: 991.98px){.navbar{margin-bottom:20px}}.grid{margin:0 auto;position:relative;max-width:110rem;padding:0 2.0rem;width:100%}.grid .grid__main{width:70%;float:left;position:relative}.grid .grid__aside{width:26%;float:right;position:relative;display:-webkit-box;display:-ms-flexbox;display:flex;-webkit-box-orient:vertical;-webkit-box-direction:normal;-ms-flex-direction:column;flex-direction:column;-ms-flex-pack:distribute;justify-content:space-around}.grid::after{content:' ';clear:both;display:table}@media (max-width: 575.98px){.grid .grid__aside{-webkit-box-orient:vertical !important;-webkit-box-direction:normal !important;-ms-flex-direction:column !important;flex-direction:column !important}}@media (max-width: 991.98px){.grid .grid__main{width:100%;float:none}.grid .grid__aside{width:100%;float:none;-webkit-box-orient:horizontal;-webkit-box-direction:normal;-ms-flex-direction:row;flex-direction:row}.grid .grid__aside--tablet-column{-webkit-box-orient:vertical;-webkit-box-direction:normal;-ms-flex-direction:column;flex-direction:column}.grid--reverse-order{-webkit-transform:scaleY(-1);transform:scaleY(-1)}.grid .grid__main--reverse-order{-webkit-transform:scaleY(-1);transform:scaleY(-1)}.grid .grid__aside--reverse-order{-webkit-transform:scaleY(-1);transform:scaleY(-1)}}.pagination{text-align:center;width:100%}.pagination .pagination__page-link{font-weight:normal;margin:0 5px}.pagination .pagination__page-link--current{font-weight:bold;font-size:1.2em;color:#606c76}.pagination .pagination__nav-link{font-size:1.4em;margin:0 2px}.pagination .pagination__nav-link--right-margin{margin-right:18px}.pagination .pagination__nav-link--left-margin{margin-left:18px}.pagination .pagination__nav-link--hidden{display:none}@media (max-width: 575.98px){.pagination .pagination__page-link{margin:0 3px}.pagination .pagination__nav-link{font-size:1.4em;margin:0 2px}.pagination .pagination__nav-link--right-margin{margin-right:10px}.pagination .pagination__nav-link--left-margin{margin-left:10px}}#page-wrapper{position:relative;min-height:100vh;z-index:0}#content-wrapper{padding-bottom:160px}.footer{padding-top:0.4em !important;text-align:center;margin-bottom:4px !important;position:absolute !important;left:50%;-webkit-transform:translateX(-50%);transform:translateX(-50%);bottom:0;width:100%}.footer__border{padding-top:4px;border-top:#f7f7f7 solid 2px}.footer__link{margin:0 12px;white-space:nowrap}@media (max-width: 575.98px){#content-wrapper{padding-bottom:120px}}.icon-lock svg{fill:#ccc;height:12px;position:relative;top:1px;margin-left:3px}.icon-edit svg{fill:#ccc;height:12px;position:relative;top:2px}.icon-save svg{fill:#ccc;height:12px;position:relative;top:2px}.icon-cross svg{fill:#ccc;height:10px;position:relative}.icon-arrow svg{fill:#606c76;height:15px;position:relative;top:3px}.spinner{display:inline-block;position:relative;left:50%;-webkit-transform:translateX(-50%) scale(0.4);transform:translateX(-50%) scale(0.4);width:80px;height:80px}.spinner div{-webkit-transform-origin:40px 40px;transform-origin:40px 40px;-webkit-animation:spinner 1.2s linear infinite;animation:spinner 1.2s linear infinite}.spinner div::after{content:" ";display:block;position:absolute;top:3px;left:37px;width:6px;height:18px;border-radius:20%;background:#606c76}.spinner div:nth-child(1){-webkit-transform:rotate(0deg);transform:rotate(0deg);-webkit-animation-delay:-1.1s;animation-delay:-1.1s}.spinner div:nth-child(2){-webkit-transform:rotate(30deg);transform:rotate(30deg);-webkit-animation-delay:-1s;animation-delay:-1s}.spinner div:nth-child(3){-webkit-transform:rotate(60deg);transform:rotate(60deg);-webkit-animation-delay:-.9s;animation-delay:-.9s}.spinner div:nth-child(4){-webkit-transform:rotate(90deg);transform:rotate(90deg);-webkit-animation-delay:-.8s;animation-delay:-.8s}.spinner div:nth-child(5){-webkit-transform:rotate(120deg);transform:rotate(120deg);-webkit-animation-delay:-.7s;animation-delay:-.7s}.spinner div:nth-child(6){-webkit-transform:rotate(150deg);transform:rotate(150deg);-webkit-animation-delay:-.6s;animation-delay:-.6s}.spinner div:nth-child(7){-webkit-transform:rotate(180deg);transform:rotate(180deg);-webkit-animation-delay:-.5s;animation-delay:-.5s}.spinner div:nth-child(8){-webkit-transform:rotate(210deg);transform:rotate(210deg);-webkit-animation-delay:-.4s;animation-delay:-.4s}.spinner div:nth-child(9){-webkit-transform:rotate(240deg);transform:rotate(240deg);-webkit-animation-delay:-.3s;animation-delay:-.3s}.spinner div:nth-child(10){-webkit-transform:rotate(270deg);transform:rotate(270deg);-webkit-animation-delay:-.2s;animation-delay:-.2s}.spinner div:nth-child(11){-webkit-transform:rotate(300deg);transform:rotate(300deg);-webkit-animation-delay:-.1s;animation-delay:-.1s}.spinner div:nth-child(12){-webkit-transform:rotate(330deg);transform:rotate(330deg);-webkit-animation-delay:0s;animation-delay:0s}@-webkit-keyframes spinner{0%{opacity:1}100%{opacity:0}}@keyframes spinner{0%{opacity:1}100%{opacity:0}}.bg-mask{background-color:black;z-index:1;-webkit-filter:opacity(20%);filter:opacity(20%);position:fixed;width:100%;height:100%;left:0;top:0;display:none}.mark-modal{z-index:2;display:none;position:fixed;width:500px;top:50%;left:50%;-webkit-transform:translate(-50%, -50%);transform:translate(-50%, -50%);background-color:#f7f7f7;padding:20px 20px 10px 20px;color:#606c76}.mark-modal .mark-modal__head{margin-bottom:20px}.mark-modal .mark-modal__head::after{content:' ';clear:both;display:table}.mark-modal .mark-modal__title{font-weight:bold;font-size:1.2em;float:left}.mark-modal .mark-modal__close-button{float:right;cursor:pointer}.mark-modal .mark-modal__confirm-button{float:right}.mark-modal input[type="radio"]{margin-right:0}.mark-modal .mark-modal__rating-star{display:inline;float:left;position:relative;left:-3px}.mark-modal .mark-modal__status-radio{float:right}.mark-modal .mark-modal__status-radio ul{margin-bottom:0}.mark-modal .mark-modal__status-radio li,.mark-modal .mark-modal__status-radio label{display:inline}.mark-modal .mark-modal__status-radio input[type="radio"]{position:relative;top:1px}.mark-modal .mark-modal__clear{content:' ';clear:both;display:table}.mark-modal .mark-modal__content-input,.mark-modal form textarea{height:200px;width:100%;margin-top:5px;margin-bottom:5px;resize:vertical}.mark-modal .mark-modal__tag{margin-bottom:20px}.mark-modal .mark-modal__option{margin-bottom:24px}.mark-modal .mark-modal__option::after{content:' ';clear:both;display:table}.mark-modal .mark-modal__visibility-radio{float:left}.mark-modal .mark-modal__visibility-radio ul,.mark-modal .mark-modal__visibility-radio li,.mark-modal .mark-modal__visibility-radio label{display:inline}.mark-modal .mark-modal__visibility-radio label{font-size:normal}.mark-modal .mark-modal__visibility-radio input[type="radio"]{position:relative;top:2px}.mark-modal .mark-modal__share-checkbox{float:right}.mark-modal .mark-modal__share-checkbox input[type="checkbox"]{position:relative;top:2px}.confirm-modal{z-index:2;display:none;position:fixed;width:500px;top:50%;left:50%;-webkit-transform:translate(-50%, -50%);transform:translate(-50%, -50%);background-color:#f7f7f7;padding:20px 20px 10px 20px;color:#606c76}.confirm-modal .confirm-modal__head{margin-bottom:20px}.confirm-modal .confirm-modal__head::after{content:' ';clear:both;display:table}.confirm-modal .confirm-modal__title{font-weight:bold;font-size:1.2em;float:left}.confirm-modal .confirm-modal__close-button{float:right;cursor:pointer}.confirm-modal .confirm-modal__confirm-button{float:right}.announcement-modal{z-index:2;display:none;position:fixed;width:500px;top:50%;left:50%;-webkit-transform:translate(-50%, -50%);transform:translate(-50%, -50%);background-color:#f7f7f7;padding:20px 20px 10px 20px;color:#606c76}.announcement-modal .announcement-modal__head{margin-bottom:20px}.announcement-modal .announcement-modal__head::after{content:' ';clear:both;display:table}.announcement-modal .announcement-modal__title{font-weight:bold;font-size:1.2em;float:left}.announcement-modal .announcement-modal__close-button{float:right;cursor:pointer}.announcement-modal .announcement-modal__confirm-button{float:right}.announcement-modal .announcement-modal__body{overflow-y:auto;max-height:64vh}.announcement-modal .announcement-modal__body .announcement__title{display:inline-block}.announcement-modal .announcement-modal__body .announcement__datetime{color:#ccc;margin-left:10px}.announcement-modal .announcement-modal__body .announcement__content{word-break:break-all}@media (max-width: 575.98px){.mark-modal,.confirm-modal,.announcement-modal{width:100%}}.source-label{display:inline;background:transparent;border-radius:.3rem;border-style:solid;border-width:.1rem;line-height:1.2rem;font-size:1.1rem;margin:3px;padding:1px 3px;padding-top:2px;font-weight:lighter;letter-spacing:0.1rem;word-break:keep-all;opacity:1;position:relative;top:-1px}.source-label.source-label__in-site{border-color:#00a1cc;color:#00a1cc}.source-label.source-label__douban{border:none;color:#fff;background-color:#319840}.source-label.source-label__spotify{background-color:#1ed760;color:#000;border:none;font-weight:bold}.source-label.source-label__imdb{background-color:#F5C518;color:#121212;border:none;font-weight:bold}.source-label.source-label__steam{background:linear-gradient(30deg, #1387b8, #111d2e);color:white;border:none;font-weight:600;padding-top:2px}.source-label.source-label__bangumi{background:#FCFCFC;color:#F09199;font-style:italic;font-weight:600}.main-section-wrapper{padding:32px 48px 32px 36px;background-color:#f7f7f7;overflow:auto}.main-section-wrapper input,.main-section-wrapper select{width:100%}.entity-list .entity-list__title{margin-bottom:20px}.entity-list .entity-list__entity{display:-webkit-box;display:-ms-flexbox;display:flex;margin-bottom:36px}.entity-list .entity-list__entity::after{content:' ';clear:both;display:table}.entity-list .entity-list__entity-img{-o-object-fit:contain;object-fit:contain;min-width:130px;max-width:130px}.entity-list .entity-list__entity-text{margin-left:20px;overflow:hidden;width:100%}.entity-list .entity-list__entity-text .tag-collection{margin-left:-3px}.entity-list .entity-list__entity-link{font-size:1.2em}.entity-list .entity-list__entity-title{display:block}.entity-list .entity-list__entity-category{color:#bbb;margin-left:5px;position:relative;top:-1px}.entity-list .entity-list__entity-info{max-width:73%;white-space:nowrap;overflow:hidden;display:inline-block;text-overflow:ellipsis;position:relative;top:0.52em}.entity-list .entity-list__entity-info--full-length{max-width:100%}.entity-list .entity-list__entity-brief{margin-top:8px;display:-webkit-box;-webkit-box-orient:vertical;-webkit-line-clamp:4;overflow:hidden;margin-bottom:0}.entity-list .entity-list__rating{display:inline-block;margin:0}.entity-list .entity-list__rating--empty{margin-right:5px}.entity-list .entity-list__rating-score{margin-right:5px;position:relative;top:1px}.entity-list .entity-list__rating-star{display:inline;position:relative;top:0.3em;left:-0.3em}.entity-detail .entity-detail__img{height:210px;float:left;-o-object-fit:contain;object-fit:contain;max-width:150px;-o-object-position:top;object-position:top}.entity-detail .entity-detail__img-origin{cursor:-webkit-zoom-in;cursor:zoom-in}.entity-detail .entity-detail__info{float:left;margin-left:20px;overflow:hidden;text-overflow:ellipsis;width:70%}.entity-detail .entity-detail__title{font-weight:bold}.entity-detail .entity-detail__title--secondary{color:#bbb}.entity-detail .entity-detail__fields{display:inline-block;vertical-align:top;width:46%;margin-left:2%}.entity-detail .entity-detail__fields div,.entity-detail .entity-detail__fields span{margin:1px 0}.entity-detail .entity-detail__fields+.tag-collection{margin-top:5px;margin-left:6px}.entity-detail .entity-detail__rating{position:relative;top:-5px}.entity-detail .entity-detail__rating-star{position:relative;left:-4px;top:3px}.entity-detail .entity-detail__rating-score{font-weight:bold}.entity-detail::after{content:' ';clear:both;display:table}.entity-desc{margin-bottom:28px}.entity-desc .entity-desc__title{display:inline-block;margin-bottom:8px}.entity-desc .entity-desc__content{overflow:hidden}.entity-desc .entity-desc__content--folded{max-height:202px}.entity-desc .entity-desc__unfold-button{display:-webkit-box;display:-ms-flexbox;display:flex;color:#00a1cc;background-color:transparent;-webkit-box-pack:center;-ms-flex-pack:center;justify-content:center;text-align:center}.entity-desc .entity-desc__unfold-button--hidden{display:none}.entity-marks{margin-bottom:28px}.entity-marks .entity-marks__title{margin-bottom:8px;display:inline-block}.entity-marks .entity-marks__title>a{margin-right:5px}.entity-marks .entity-marks__title--stand-alone{margin-bottom:20px}.entity-marks .entity-marks__more-link{margin-left:5px}.entity-marks .entity-marks__mark{margin:0;padding:3px 0;border-bottom:1px dashed #e5e5e5}.entity-marks .entity-marks__mark:last-child{border:none}.entity-marks .entity-marks__mark--wider{padding:6px 0}.entity-marks .entity-marks__mark-content{margin-bottom:0}.entity-marks .entity-marks__mark-time{color:#ccc;margin-left:2px}.entity-marks .entity-marks__rating-star{position:relative;top:4px}.entity-reviews:first-child{margin-bottom:28px}.entity-reviews .entity-reviews__title{display:inline-block;margin-bottom:8px}.entity-reviews .entity-reviews__title>a{margin-right:5px}.entity-reviews .entity-reviews__title--stand-alone{margin-bottom:20px}.entity-reviews .entity-reviews__more-link{margin-left:5px}.entity-reviews .entity-reviews__review{margin:0;padding:3px 0;border-bottom:1px dashed #e5e5e5}.entity-reviews .entity-reviews__review:last-child{border:none}.entity-reviews .entity-reviews__review--wider{padding:6px 0}.entity-reviews .entity-reviews__review-time{color:#ccc;margin-left:2px}.dividing-line{height:0;width:100%;margin:40px 0 24px 0;border-top:solid 1px #ccc}.dividing-line.dividing-line--dashed{margin:0;margin-top:10px;margin-bottom:2px;border-top:1px dashed #e5e5e5}.entity-sort{position:relative;margin-bottom:30px}.entity-sort .entity-sort__label{font-size:large;display:inline-block;margin-bottom:20px}.entity-sort .entity-sort__more-link{margin-left:8px}.entity-sort .entity-sort__count{color:#bbb}.entity-sort .entity-sort__count::before{content:'('}.entity-sort .entity-sort__count::after{content:')'}.entity-sort .entity-sort__entity-list{display:-webkit-box;display:-ms-flexbox;display:flex;-webkit-box-pack:start;-ms-flex-pack:start;justify-content:flex-start;-ms-flex-wrap:wrap;flex-wrap:wrap}.entity-sort .entity-sort__entity{padding:0 10px;-ms-flex-preferred-size:20%;flex-basis:20%;text-align:center;display:inline-block;color:#606c76}.entity-sort .entity-sort__entity:hover{color:#00a1cc}.entity-sort .entity-sort__entity>a{color:inherit}.entity-sort .entity-sort__entity-img{height:110px}.entity-sort .entity-sort__entity-name{text-overflow:ellipsis;overflow:hidden;display:-webkit-box;-webkit-box-orient:vertical;-webkit-line-clamp:2}.entity-sort--placeholder{border:dashed #bbb 4px}.entity-sort--hover{padding:10px;border:dashed #00a1cc 2px !important;border-radius:3px}.entity-sort--sortable{padding:10px;margin:10px 0;border:dashed #bbb 2px;cursor:all-scroll}.entity-sort--hidden{opacity:0.4}.entity-sort-control{display:-webkit-box;display:-ms-flexbox;display:flex;-webkit-box-pack:end;-ms-flex-pack:end;justify-content:flex-end}.entity-sort-control__button{margin-top:5px;margin-left:12px;padding:0 2px;cursor:pointer;color:#bbb}.entity-sort-control__button:hover{color:#00a1cc}.entity-sort-control__button:hover>.icon-save svg,.entity-sort-control__button:hover>.icon-edit svg{fill:#00a1cc}.entity-sort-control__button--float-right{position:absolute;top:4px;right:10px}.related-user-list .related-user-list__title{margin-bottom:20px}.related-user-list .related-user-list__user{display:-webkit-box;display:-ms-flexbox;display:flex;-webkit-box-pack:start;-ms-flex-pack:start;justify-content:flex-start;margin-bottom:20px}.related-user-list .related-user-list__user-info{margin-left:15px;overflow:auto}.related-user-list .related-user-list__user-avatar{max-height:72px;min-width:72px}.review-head .review-head__title{display:inline-block;font-weight:bold}.review-head .review-head__body{margin-bottom:10px}.review-head .review-head__body::after{content:' ';clear:both;display:table}.review-head .review-head__info{float:left}.review-head .review-head__owner-link{color:#ccc}.review-head .review-head__owner-link:hover{color:#00a1cc}.review-head .review-head__time{color:#ccc}.review-head .review-head__rating-star{position:relative;top:3px;left:-1px}.review-head .review-head__actions{float:right}.review-head .review-head__action-link:not(:first-child){margin-left:5px}.tag-collection{margin-left:-9px}.tag-collection .tag-collection__tag{position:relative;display:block;float:left;color:white;background:#ccc;padding:5px;border-radius:.3rem;line-height:1.2em;font-size:80%;margin:3px}.tag-collection .tag-collection__tag a{color:white}.tag-collection .tag-collection__tag a:hover{color:#00a1cc}.track-carousel{position:relative;margin-top:5px}.track-carousel__content{overflow:auto;scroll-behavior:smooth;scrollbar-width:none;display:-webkit-box;display:-ms-flexbox;display:flex;margin:auto;-webkit-box-sizing:border-box;box-sizing:border-box;padding-bottom:10px}.track-carousel__content::-webkit-scrollbar{height:3px;width:1px;background-color:#e5e5e5}.track-carousel__content::-webkit-scrollbar-thumb{background-color:#bbb}.track-carousel__track{text-align:center;overflow:hidden;text-overflow:ellipsis;min-width:18%;max-width:18%;margin-right:2.5%}.track-carousel__track img{-o-object-fit:contain;object-fit:contain}.track-carousel__track-title{white-space:nowrap}.track-carousel__button{display:-webkit-box;display:-ms-flexbox;display:flex;-webkit-box-pack:center;-ms-flex-pack:center;justify-content:center;-ms-flex-line-pack:center;align-content:center;background:white;border:none;padding:8px;border-radius:50%;outline:0;cursor:pointer;position:absolute;top:50%}.track-carousel__button--prev{left:0;-webkit-transform:translate(50%, -50%);transform:translate(50%, -50%)}.track-carousel__button--next{right:0;-webkit-transform:translate(-50%, -50%);transform:translate(-50%, -50%)}@media (max-width: 575.98px){.entity-list .entity-list__entity{-webkit-box-orient:vertical;-webkit-box-direction:normal;-ms-flex-direction:column;flex-direction:column;margin-bottom:30px}.entity-list .entity-list__entity-text{margin-left:0}.entity-list .entity-list__entity-img-wrapper{margin-bottom:8px}.entity-list .entity-list__entity-info{max-width:unset}.entity-list .entity-list__rating--empty+.entity-list__entity-info{max-width:70%}.entity-list .entity-list__entity-brief{-webkit-line-clamp:5}.entity-detail{-webkit-box-orient:vertical;-webkit-box-direction:normal;-ms-flex-direction:column;flex-direction:column}.entity-detail .entity-detail__title{margin-bottom:5px}.entity-detail .entity-detail__info{margin-left:0;float:none;display:-webkit-box;display:-ms-flexbox;display:flex;-webkit-box-orient:vertical;-webkit-box-direction:normal;-ms-flex-direction:column;flex-direction:column;width:100%}.entity-detail .entity-detail__img{margin-bottom:24px;float:none;height:unset;max-width:170px}.entity-detail .entity-detail__fields{width:unset;margin-left:unset}.entity-detail .entity-detail__fields+.tag-collection{margin-left:-3px}.dividing-line{margin-top:24px}.entity-sort .entity-sort__entity{-ms-flex-preferred-size:50%;flex-basis:50%}.entity-sort .entity-sort__entity-img{height:130px}.review-head .review-head__info{float:unset}.review-head .review-head__actions{float:unset}.track-carousel__content{padding-bottom:10px}.track-carousel__track{min-width:31%;max-width:31%;margin-right:4.5%}}@media (max-width: 991.98px){.main-section-wrapper{padding:32px 28px 28px 28px}.entity-detail{display:-webkit-box;display:-ms-flexbox;display:flex}}.aside-section-wrapper{display:-webkit-box;display:-ms-flexbox;display:flex;-webkit-box-flex:1;-ms-flex:1;flex:1;-webkit-box-orient:vertical;-webkit-box-direction:normal;-ms-flex-direction:column;flex-direction:column;width:100%;padding:28px 25px 12px 25px;background-color:#f7f7f7;margin-bottom:30px;overflow:auto}.aside-section-wrapper--transparent{background-color:unset}.aside-section-wrapper--collapse{padding:unset}.add-entity-entries .add-entity-entries__entry{margin-bottom:10px}.add-entity-entries .add-entity-entries__label{font-size:1.2em;margin-bottom:8px}.add-entity-entries .add-entity-entries__button{line-height:unset;height:unset;padding:4px 15px;margin:5px}.action-panel{margin-bottom:20px}.action-panel .action-panel__label{font-weight:bold;margin-bottom:12px}.action-panel .action-panel__button-group{display:-webkit-box;display:-ms-flexbox;display:flex;-webkit-box-pack:justify;-ms-flex-pack:justify;justify-content:space-between}.action-panel .action-panel__button-group--center{-webkit-box-pack:center;-ms-flex-pack:center;justify-content:center}.action-panel .action-panel__button{line-height:unset;height:unset;padding:4px 15px;margin:0 5px}.mark-panel{margin-bottom:20px}.mark-panel .mark-panel__status{font-weight:bold}.mark-panel .mark-panel__rating-star{position:relative;top:2px}.mark-panel .mark-panel__actions{float:right}.mark-panel .mark-panel__actions form{display:inline}.mark-panel .mark-panel__time{color:#ccc;margin-bottom:10px}.mark-panel .mark-panel__clear{content:' ';clear:both;display:table}.review-panel .review-panel__label{font-weight:bold}.review-panel .review-panel__actions{float:right}.review-panel .review-panel__time{color:#ccc;margin-bottom:10px}.review-panel .review-panel__review-title{display:block;margin-bottom:15px;font-weight:bold}.review-panel .review-panel__clear{content:' ';clear:both;display:table}.user-profile .user-profile__header{display:-webkit-box;display:-ms-flexbox;display:flex;-webkit-box-align:start;-ms-flex-align:start;align-items:flex-start;margin-bottom:15px}.user-profile .user-profile__avatar{width:72px}.user-profile .user-profile__username{font-size:large;margin-left:10px;margin-bottom:0}.user-profile .user-profile__report-link{color:#ccc}.user-relation .user-relation__label{display:inline-block;font-size:large;margin-bottom:10px}.user-relation .user-relation__more-link{margin-left:5px}.user-relation .user-relation__related-user-list{display:-webkit-box;display:-ms-flexbox;display:flex;-webkit-box-pack:start;-ms-flex-pack:start;justify-content:flex-start}.user-relation .user-relation__related-user-list:last-of-type{margin-bottom:0}.user-relation .user-relation__related-user{-ms-flex-preferred-size:25%;flex-basis:25%;padding:0px 3px;text-align:center;display:inline-block;overflow:hidden}.user-relation .user-relation__related-user>a:hover{color:#606c76}.user-relation .user-relation__related-user-avatar{background-image:url("data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7");width:48px;height:48px}@media (min-width: 575.98px) and (max-width: 991.98px){.user-relation .user-relation__related-user-avatar{height:unset;width:60%;max-width:96px}}.user-relation .user-relation__related-user-name{color:inherit;overflow:hidden;text-overflow:ellipsis;-webkit-box-orient:vertical;-webkit-line-clamp:2}.report-panel .report-panel__label{display:inline-block;margin-bottom:10px}.report-panel .report-panel__body{padding-left:0}.report-panel .report-panel__report{margin:2px 0}.report-panel .report-panel__user-link{margin:0 2px}.report-panel .report-panel__all-link{margin-left:5px}.import-panel{overflow-x:hidden}.import-panel .import-panel__label{display:inline-block;margin-bottom:10px}.import-panel .import-panel__body{padding-left:0;border:2px dashed #00a1cc;padding:6px 9px}.import-panel .import-panel__body form{margin:0}@media (max-width: 991.98px){.import-panel .import-panel__body{border:unset;padding-left:0}}.import-panel .import-panel__help{background-color:#e5e5e5;border-radius:100000px;display:inline-block;width:16px;height:16px;text-align:center;font-size:12px;cursor:help}.import-panel .import-panel__checkbox{display:inline-block;margin-right:10px}.import-panel .import-panel__checkbox label{display:inline}.import-panel .import-panel__checkbox input[type="checkbox"]{margin:0;position:relative;top:2px}.import-panel .import-panel__checkbox--last{margin-right:0}.import-panel .import-panel__file-input{margin-top:10px}.import-panel .import-panel__button{line-height:unset;height:unset;padding:4px 15px}.import-panel .import-panel__progress{padding-top:10px}.import-panel .import-panel__progress:not(:first-child){border-top:#bbb 1px dashed}.import-panel .import-panel__progress label{display:inline}.import-panel .import-panel__progress progress{background-color:#d5d5d5;border-radius:0;height:10px;width:65%}.import-panel .import-panel__progress progress::-webkit-progress-bar{background-color:#d5d5d5}.import-panel .import-panel__progress progress::-webkit-progress-value{background-color:#00a1cc}.import-panel .import-panel__progress progress::-moz-progress-bar{background-color:#d5d5d5}.import-panel .import-panel__last-task:not(:first-child){padding-top:4px;border-top:#bbb 1px dashed}.import-panel .import-panel__last-task .index:not(:last-of-type){margin-right:8px}.import-panel .import-panel__fail-urls{margin-top:10px}.import-panel .import-panel__fail-urls li{word-break:break-all}.import-panel .import-panel__fail-urls ul{max-height:100px;overflow-y:auto}.relation-dropdown .relation-dropdown__button{display:none}.entity-card{display:-webkit-box;display:-ms-flexbox;display:flex;margin-bottom:10px;-webkit-box-orient:vertical;-webkit-box-direction:normal;-ms-flex-direction:column;flex-direction:column}.entity-card--horizontal{-webkit-box-orient:horizontal;-webkit-box-direction:normal;-ms-flex-direction:row;flex-direction:row}.entity-card .entity-card__img{height:150px}.entity-card .entity-card__rating-star{position:relative;top:4px;left:-3px}.entity-card .entity-card__rating-score{position:relative;top:1px;margin-left:2px}.entity-card .entity-card__title{margin-bottom:10px;margin-top:5px}.entity-card .entity-card__info-wrapper--horizontal{margin-left:20px}.entity-card .entity-card__img-wrapper{-ms-flex-preferred-size:100px;flex-basis:100px}@media (max-width: 575.98px){.add-entity-entries{display:block !important}.add-entity-entries .add-entity-entries__button{width:100%;margin:5px 0 5px 0}.aside-section-wrapper:first-child{margin-right:0 !important;margin-bottom:0 !important}.aside-section-wrapper--singular:first-child{margin-bottom:20px !important}.action-panel{-webkit-box-orient:vertical !important;-webkit-box-direction:normal !important;-ms-flex-direction:column !important;flex-direction:column !important}.entity-card--horizontal{-webkit-box-orient:vertical !important;-webkit-box-direction:normal !important;-ms-flex-direction:column !important;flex-direction:column !important}.entity-card .entity-card__info-wrapper{margin-left:10px !important}.entity-card .entity-card__info-wrapper--horizontal{margin-left:0 !important}}@media (max-width: 991.98px){.add-entity-entries{display:-webkit-box;display:-ms-flexbox;display:flex;-ms-flex-pack:distribute;justify-content:space-around}.aside-section-wrapper{padding:24px 25px 10px 25px;margin-top:20px}.aside-section-wrapper:not(:last-child){margin-right:20px}.aside-section-wrapper--collapse{padding:24px 25px 10px 25px !important;margin-top:0;margin-bottom:0}.aside-section-wrapper--collapse:first-child{margin-right:0}.aside-section-wrapper--no-margin{margin:0}.action-panel{-webkit-box-orient:horizontal;-webkit-box-direction:normal;-ms-flex-direction:row;flex-direction:row}.action-panel .action-panel__button-group{-webkit-box-pack:space-evenly;-ms-flex-pack:space-evenly;justify-content:space-evenly}.relation-dropdown{margin-bottom:20px}.relation-dropdown .relation-dropdown__button{padding-bottom:10px;background-color:#f7f7f7;width:100%;display:-webkit-box;display:-ms-flexbox;display:flex;-webkit-box-pack:center;-ms-flex-pack:center;justify-content:center;-webkit-box-align:center;-ms-flex-align:center;align-items:center;cursor:pointer;-webkit-transition:-webkit-transform 0.3s;transition:-webkit-transform 0.3s;transition:transform 0.3s;transition:transform 0.3s, -webkit-transform 0.3s}.relation-dropdown .relation-dropdown__button:focus{background-color:red}.relation-dropdown .relation-dropdown__button>.icon-arrow{-webkit-transition:-webkit-transform 0.3s;transition:-webkit-transform 0.3s;transition:transform 0.3s;transition:transform 0.3s, -webkit-transform 0.3s}.relation-dropdown .relation-dropdown__button:hover>.icon-arrow>svg{fill:#00a1cc}.relation-dropdown .relation-dropdown__button>.icon-arrow--expand{-webkit-transform:rotate(-180deg);transform:rotate(-180deg)}.relation-dropdown .relation-dropdown__button+.relation-dropdown__body--expand{max-height:2000px;-webkit-transition:max-height 1s ease-in;transition:max-height 1s ease-in}.relation-dropdown .relation-dropdown__body{background-color:#f7f7f7;max-height:0;-webkit-transition:max-height 1s ease-out;transition:max-height 1s ease-out;overflow:hidden}.entity-card{-webkit-box-orient:horizontal;-webkit-box-direction:normal;-ms-flex-direction:row;flex-direction:row}.entity-card .entity-card__info-wrapper{margin-left:30px}}.single-section-wrapper{padding:32px 36px;background-color:#f7f7f7;overflow:auto}.single-section-wrapper .single-section-wrapper__link--secondary{display:inline-block;color:#ccc;margin-bottom:20px}.single-section-wrapper .single-section-wrapper__link--secondary:hover{color:#00a1cc}.entity-form,.review-form{overflow:auto}.entity-form>input[type='email'],.entity-form>input[type='number'],.entity-form>input[type='password'],.entity-form>input[type='search'],.entity-form>input[type='tel'],.entity-form>input[type='text'],.entity-form>input[type='url'],.entity-form textarea,.review-form>input[type='email'],.review-form>input[type='number'],.review-form>input[type='password'],.review-form>input[type='search'],.review-form>input[type='tel'],.review-form>input[type='text'],.review-form>input[type='url'],.review-form textarea{width:100%}.entity-form img,.review-form img{display:block}.review-form .review-form__preview-button{color:#00a1cc;font-weight:bold;cursor:pointer}.review-form .review-form__fyi{color:#ccc}.review-form .review-form__main-content,.review-form textarea{margin-bottom:5px;resize:vertical;height:400px}.review-form .review-form__option{margin-top:24px;margin-bottom:10px}.review-form .review-form__option::after{content:' ';clear:both;display:table}.review-form .review-form__visibility-radio{float:left}.review-form .review-form__visibility-radio ul,.review-form .review-form__visibility-radio li,.review-form .review-form__visibility-radio label{display:inline}.review-form .review-form__visibility-radio label{font-size:normal}.review-form .review-form__visibility-radio input[type="radio"]{position:relative;top:2px}.review-form .review-form__share-checkbox{float:right}.review-form .review-form__share-checkbox input[type="checkbox"]{position:relative;top:2px}.report-form input,.report-form select{width:100%}@media (max-width: 575.98px){.review-form .review-form__visibility-radio{float:unset}.review-form .review-form__share-checkbox{float:unset;position:relative;left:-3px}}.markdownx-preview{min-height:100px}.markdownx-preview ul li{list-style:circle inside}.rating-star .jq-star{cursor:unset !important}.ms-parent>.ms-choice{margin-bottom:1.5rem;-webkit-appearance:none;-moz-appearance:none;appearance:none;background-color:transparent;border:0.1rem solid #ccc;border-radius:.4rem;-webkit-box-shadow:none;box-shadow:none;-webkit-box-sizing:inherit;box-sizing:inherit;padding:.6rem 1.0rem;width:100%;height:30.126px}.ms-parent>.ms-choice:focus{border-color:#00a1cc}.ms-parent>.ms-choice>.icon-caret{top:15.5px}.ms-parent>.ms-choice>span{color:black;font-weight:initial;font-size:13.3333px;top:2.5px;left:2px}.ms-parent>.ms-choice>span:hover,.ms-parent>.ms-choice>span:focus{color:black}.ms-parent>.ms-drop>ul>li>label>span{margin-left:10px}.ms-parent>.ms-drop>ul>li>label>input{width:unset}.tippy-box{border:#606c76 1px solid;background-color:#f7f7f7;padding:3px 5px}.tag-input input{-webkit-box-flex:1;-ms-flex-positive:1;flex-grow:1} +@import url(https://cdn.jsdelivr.net/npm/skeleton-css@2.0.4/css/normalize.css);.button,button,input[type='button'],input[type='reset'],input[type='submit']{background-color:#00a1cc;border:0.1rem solid #00a1cc;border-radius:.4rem;color:#fff;cursor:pointer;display:inline-block;font-size:1.1rem;font-weight:700;height:3.4rem;letter-spacing:.1rem;line-height:3.4rem;padding:0 2.8rem;text-align:center;text-decoration:none;text-transform:uppercase;white-space:nowrap}.button:focus,.button:hover,button:focus,button:hover,input[type='button']:focus,input[type='button']:hover,input[type='reset']:focus,input[type='reset']:hover,input[type='submit']:focus,input[type='submit']:hover{background-color:#606c76;border-color:#606c76;color:#fff;outline:0}.button[disabled],button[disabled],input[type='button'][disabled],input[type='reset'][disabled],input[type='submit'][disabled]{cursor:default;opacity:.5}.button[disabled]:focus,.button[disabled]:hover,button[disabled]:focus,button[disabled]:hover,input[type='button'][disabled]:focus,input[type='button'][disabled]:hover,input[type='reset'][disabled]:focus,input[type='reset'][disabled]:hover,input[type='submit'][disabled]:focus,input[type='submit'][disabled]:hover{background-color:#00a1cc;border-color:#00a1cc}.button.button-outline,button.button-outline,input[type='button'].button-outline,input[type='reset'].button-outline,input[type='submit'].button-outline{background-color:transparent;color:#00a1cc}.button.button-outline:focus,.button.button-outline:hover,button.button-outline:focus,button.button-outline:hover,input[type='button'].button-outline:focus,input[type='button'].button-outline:hover,input[type='reset'].button-outline:focus,input[type='reset'].button-outline:hover,input[type='submit'].button-outline:focus,input[type='submit'].button-outline:hover{background-color:transparent;border-color:#606c76;color:#606c76}.button.button-outline[disabled]:focus,.button.button-outline[disabled]:hover,button.button-outline[disabled]:focus,button.button-outline[disabled]:hover,input[type='button'].button-outline[disabled]:focus,input[type='button'].button-outline[disabled]:hover,input[type='reset'].button-outline[disabled]:focus,input[type='reset'].button-outline[disabled]:hover,input[type='submit'].button-outline[disabled]:focus,input[type='submit'].button-outline[disabled]:hover{border-color:inherit;color:#00a1cc}.button.button-clear,button.button-clear,input[type='button'].button-clear,input[type='reset'].button-clear,input[type='submit'].button-clear{background-color:transparent;border-color:transparent;color:#00a1cc}.button.button-clear:focus,.button.button-clear:hover,button.button-clear:focus,button.button-clear:hover,input[type='button'].button-clear:focus,input[type='button'].button-clear:hover,input[type='reset'].button-clear:focus,input[type='reset'].button-clear:hover,input[type='submit'].button-clear:focus,input[type='submit'].button-clear:hover{background-color:transparent;border-color:transparent;color:#606c76}.button.button-clear[disabled]:focus,.button.button-clear[disabled]:hover,button.button-clear[disabled]:focus,button.button-clear[disabled]:hover,input[type='button'].button-clear[disabled]:focus,input[type='button'].button-clear[disabled]:hover,input[type='reset'].button-clear[disabled]:focus,input[type='reset'].button-clear[disabled]:hover,input[type='submit'].button-clear[disabled]:focus,input[type='submit'].button-clear[disabled]:hover{color:#00a1cc}select{background:url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" height="14" viewBox="0 0 29 14" width="29"><path fill="#d1d1d1" d="M9.37727 3.625l5.08154 6.93523L19.54036 3.625"/></svg>') center right no-repeat;padding-right:3.0rem}select:focus{background-image:url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" height="14" viewBox="0 0 29 14" width="29"><path fill="#9b4dca" d="M9.37727 3.625l5.08154 6.93523L19.54036 3.625"/></svg>')}textarea{min-height:6.5rem;width:100%}select{width:100%}label,legend{display:block;margin-bottom:.5rem}fieldset{border-width:0;padding:0}input[type='checkbox'],input[type='radio']{display:inline}.label-inline{display:inline-block;font-weight:normal;margin-left:.5rem}dl,ol,ul{list-style:none;margin-top:0;padding-left:0}ol{list-style:decimal inside}ul{list-style:circle inside}.button,button,dd,dt,li{margin-bottom:1.0rem}fieldset,input,select,textarea{margin-bottom:1.5rem}blockquote,dl,figure,form,ol,p,pre,table,ul{margin-bottom:1rem}b,strong{font-weight:bold}p{margin-top:0}h1,h2,h3,h4,h5,h6{font-weight:300;letter-spacing:-.1rem;margin-bottom:2.0rem;margin-top:0}h1{font-size:4.6rem;line-height:1.2}h2{font-size:3.6rem;line-height:1.25}h3{font-size:2.8rem;line-height:1.3}h4{font-size:2.2rem;letter-spacing:-.08rem;line-height:1.35}h5{font-size:1.8rem;letter-spacing:-.05rem;line-height:1.5}h6{font-size:1.6rem;letter-spacing:0;line-height:1.4}img{max-width:100%;object-fit:contain}img.emoji{height:14px;box-sizing:border-box;object-fit:contain;position:relative;top:3px}img.emoji--large{height:20px;position:relative;top:2px}.clearfix:after{clear:both;content:' ';display:table}.float-left{float:left}.float-right{float:right}.highlight{font-weight:bold}:root{font-size:10px}*,*:after,*:before{box-sizing:inherit}html{box-sizing:border-box;height:100%}body{color:#606c76;font-family:'Roboto', 'Helvetica Neue', 'Helvetica', 'Arial', 'Microsoft YaHei Light', sans-serif;font-size:1.3rem;font-weight:300;letter-spacing:.05rem;line-height:1.6;margin:0;height:100%}textarea{font-family:'Roboto', 'Helvetica Neue', 'Helvetica', 'Arial', 'Microsoft YaHei Light', sans-serif}a{color:#00a1cc;text-decoration:none}a:active,a:hover,a:hover:visited{color:#606c76}li{list-style:none}input[type=text]::-ms-clear,input[type=text]::-ms-reveal{display:none;width:0;height:0}input[type="search"]::-webkit-search-decoration,input[type="search"]::-webkit-search-cancel-button,input[type="search"]::-webkit-search-results-button,input[type="search"]::-webkit-search-results-decoration{display:none}input[type='email'],input[type='number'],input[type='password'],input[type='search'],input[type='tel'],input[type='text'],input[type='url'],input[type='date'],input[type='time'],input[type='color'],textarea,select{appearance:none;background-color:transparent;border:0.1rem solid #ccc;border-radius:.4rem;box-shadow:none;box-sizing:inherit;padding:.6rem 1.0rem}input[type='email']:focus,input[type='number']:focus,input[type='password']:focus,input[type='search']:focus,input[type='tel']:focus,input[type='text']:focus,input[type='url']:focus,input[type='date']:focus,input[type='time']:focus,input[type='color']:focus,textarea:focus,select:focus{border-color:#00a1cc;outline:0}input[type='email']::placeholder,input[type='number']::placeholder,input[type='password']::placeholder,input[type='search']::placeholder,input[type='tel']::placeholder,input[type='text']::placeholder,input[type='url']::placeholder,input[type='date']::placeholder,input[type='time']::placeholder,input[type='color']::placeholder,textarea::placeholder,select::placeholder{color:#ccc}::selection{color:white;background-color:#00a1cc}.navbar{background-color:#f7f7f7;box-sizing:border-box;padding:10px 0;margin-bottom:50px;border-bottom:#ccc 0.5px solid}.navbar .navbar__wrapper{display:flex;justify-content:space-between;align-items:center;position:relative}.navbar .navbar__logo{flex-basis:100px}.navbar .navbar__logo-link{display:inline-block}.navbar .navbar__link-list{margin:0;display:flex;justify-content:space-around}.navbar .navbar__link{margin:9px;color:#606c76}.navbar .navbar__link:active,.navbar .navbar__link:hover,.navbar .navbar__link:hover:visited{color:#00a1cc}.navbar .navbar__link:visited{color:#606c76}.navbar .navbar__search-box{margin:0 12% 0 15px;display:inline-flex;flex:1}.navbar .navbar__search-box>input[type="search"]{border-top-right-radius:0;border-bottom-right-radius:0;margin:0;height:32px;background-color:white !important;width:100%}.navbar .navbar__search-box .navbar__search-dropdown{margin:0;margin-left:-1px;padding:0;padding-left:10px;color:#606c76;appearance:auto;background-color:white;height:32px;width:80px;border-top-left-radius:0;border-bottom-left-radius:0}.navbar .navbar__dropdown-btn{display:none;padding:0;margin:0;border:none;background-color:transparent;color:#00a1cc}.navbar .navbar__dropdown-btn:focus,.navbar .navbar__dropdown-btn:hover{background-color:transparent;color:#606c76}@media (max-width: 575.98px){.navbar{padding:2px 0}.navbar .navbar__wrapper{display:block}.navbar .navbar__logo-img{width:72px;margin-right:10px;position:relative;top:7px}.navbar .navbar__link-list{margin-top:7px;max-height:0;transition:max-height 0.6s ease-out;overflow:hidden}.navbar .navbar__dropdown-btn{display:block;position:absolute;right:5px;top:3px;transform:scale(0.7)}.navbar .navbar__dropdown-btn:hover+.navbar__link-list{max-height:500px;transition:max-height 0.6s ease-in}.navbar .navbar__search-box{margin:0;width:46vw}.navbar .navbar__search-box>input[type="search"]{height:26px;padding:4px 6px;width:32vw}.navbar .navbar__search-box .navbar__search-dropdown{cursor:pointer;height:26px;width:80px;padding-left:5px}}@media (max-width: 991.98px){.navbar{margin-bottom:20px}}.grid{margin:0 auto;position:relative;max-width:110rem;padding:0 2.0rem;width:100%}.grid .grid__main{width:70%;float:left;position:relative}.grid .grid__aside{width:26%;float:right;position:relative;display:flex;flex-direction:column;justify-content:space-around}.grid::after{content:' ';clear:both;display:table}@media (max-width: 575.98px){.grid .grid__aside{flex-direction:column !important}}@media (max-width: 991.98px){.grid .grid__main{width:100%;float:none}.grid .grid__aside{width:100%;float:none;flex-direction:row}.grid .grid__aside--tablet-column{flex-direction:column}.grid--reverse-order{transform:scaleY(-1)}.grid .grid__main--reverse-order{transform:scaleY(-1)}.grid .grid__aside--reverse-order{transform:scaleY(-1)}}.pagination{text-align:center;width:100%}.pagination .pagination__page-link{font-weight:normal;margin:0 5px}.pagination .pagination__page-link--current{font-weight:bold;font-size:1.2em;color:#606c76}.pagination .pagination__nav-link{font-size:1.4em;margin:0 2px}.pagination .pagination__nav-link--right-margin{margin-right:18px}.pagination .pagination__nav-link--left-margin{margin-left:18px}.pagination .pagination__nav-link--hidden{display:none}@media (max-width: 575.98px){.pagination .pagination__page-link{margin:0 3px}.pagination .pagination__nav-link{font-size:1.4em;margin:0 2px}.pagination .pagination__nav-link--right-margin{margin-right:10px}.pagination .pagination__nav-link--left-margin{margin-left:10px}}#page-wrapper{position:relative;min-height:100vh;z-index:0}#content-wrapper{padding-bottom:160px}.footer{padding-top:0.4em !important;text-align:center;margin-bottom:4px !important;position:absolute !important;left:50%;transform:translateX(-50%);bottom:0;width:100%}.footer__border{padding-top:4px;border-top:#f7f7f7 solid 2px}.footer__link{margin:0 12px;white-space:nowrap}@media (max-width: 575.98px){#content-wrapper{padding-bottom:120px}}.icon-lock svg{fill:#ccc;height:12px;position:relative;top:1px;margin-left:3px}.icon-edit svg{fill:#ccc;height:12px;position:relative;top:2px}.icon-save svg{fill:#ccc;height:12px;position:relative;top:2px}.icon-cross svg{fill:#ccc;height:10px;position:relative}.icon-arrow svg{fill:#606c76;height:15px;position:relative;top:3px}.spinner{display:inline-block;position:relative;left:50%;transform:translateX(-50%) scale(0.4);width:80px;height:80px}.spinner div{transform-origin:40px 40px;animation:spinner 1.2s linear infinite}.spinner div::after{content:" ";display:block;position:absolute;top:3px;left:37px;width:6px;height:18px;border-radius:20%;background:#606c76}.spinner div:nth-child(1){transform:rotate(0deg);animation-delay:-1.1s}.spinner div:nth-child(2){transform:rotate(30deg);animation-delay:-1s}.spinner div:nth-child(3){transform:rotate(60deg);animation-delay:-.9s}.spinner div:nth-child(4){transform:rotate(90deg);animation-delay:-.8s}.spinner div:nth-child(5){transform:rotate(120deg);animation-delay:-.7s}.spinner div:nth-child(6){transform:rotate(150deg);animation-delay:-.6s}.spinner div:nth-child(7){transform:rotate(180deg);animation-delay:-.5s}.spinner div:nth-child(8){transform:rotate(210deg);animation-delay:-.4s}.spinner div:nth-child(9){transform:rotate(240deg);animation-delay:-.3s}.spinner div:nth-child(10){transform:rotate(270deg);animation-delay:-.2s}.spinner div:nth-child(11){transform:rotate(300deg);animation-delay:-.1s}.spinner div:nth-child(12){transform:rotate(330deg);animation-delay:0s}@keyframes spinner{0%{opacity:1}100%{opacity:0}}.bg-mask{background-color:black;z-index:1;filter:opacity(20%);position:fixed;width:100%;height:100%;left:0;top:0;display:none}.mark-modal{z-index:2;display:none;position:fixed;width:500px;top:50%;left:50%;transform:translate(-50%, -50%);background-color:#f7f7f7;padding:20px 20px 10px 20px;color:#606c76}.mark-modal .mark-modal__head{margin-bottom:20px}.mark-modal .mark-modal__head::after{content:' ';clear:both;display:table}.mark-modal .mark-modal__title{font-weight:bold;font-size:1.2em;float:left}.mark-modal .mark-modal__close-button{float:right;cursor:pointer}.mark-modal .mark-modal__confirm-button{float:right}.mark-modal input[type="radio"]{margin-right:0}.mark-modal .mark-modal__rating-star{display:inline;float:left;position:relative;left:-3px}.mark-modal .mark-modal__status-radio{float:right}.mark-modal .mark-modal__status-radio ul{margin-bottom:0}.mark-modal .mark-modal__status-radio li,.mark-modal .mark-modal__status-radio label{display:inline}.mark-modal .mark-modal__status-radio input[type="radio"]{position:relative;top:1px}.mark-modal .mark-modal__clear{content:' ';clear:both;display:table}.mark-modal .mark-modal__content-input,.mark-modal form textarea{height:200px;width:100%;margin-top:5px;margin-bottom:5px;resize:vertical}.mark-modal .mark-modal__tag{margin-bottom:20px}.mark-modal .mark-modal__option{margin-bottom:24px}.mark-modal .mark-modal__option::after{content:' ';clear:both;display:table}.mark-modal .mark-modal__visibility-radio{float:left}.mark-modal .mark-modal__visibility-radio ul,.mark-modal .mark-modal__visibility-radio li,.mark-modal .mark-modal__visibility-radio label{display:inline}.mark-modal .mark-modal__visibility-radio label{font-size:normal}.mark-modal .mark-modal__visibility-radio input[type="radio"]{position:relative;top:2px}.mark-modal .mark-modal__share-checkbox{float:right}.mark-modal .mark-modal__share-checkbox input[type="checkbox"]{position:relative;top:2px}.confirm-modal{z-index:2;display:none;position:fixed;width:500px;top:50%;left:50%;transform:translate(-50%, -50%);background-color:#f7f7f7;padding:20px 20px 10px 20px;color:#606c76}.confirm-modal .confirm-modal__head{margin-bottom:20px}.confirm-modal .confirm-modal__head::after{content:' ';clear:both;display:table}.confirm-modal .confirm-modal__title{font-weight:bold;font-size:1.2em;float:left}.confirm-modal .confirm-modal__close-button{float:right;cursor:pointer}.confirm-modal .confirm-modal__confirm-button{float:right}.announcement-modal{z-index:2;display:none;position:fixed;width:500px;top:50%;left:50%;transform:translate(-50%, -50%);background-color:#f7f7f7;padding:20px 20px 10px 20px;color:#606c76}.announcement-modal .announcement-modal__head{margin-bottom:20px}.announcement-modal .announcement-modal__head::after{content:' ';clear:both;display:table}.announcement-modal .announcement-modal__title{font-weight:bold;font-size:1.2em;float:left}.announcement-modal .announcement-modal__close-button{float:right;cursor:pointer}.announcement-modal .announcement-modal__confirm-button{float:right}.announcement-modal .announcement-modal__body{overflow-y:auto;max-height:64vh}.announcement-modal .announcement-modal__body .announcement__title{display:inline-block}.announcement-modal .announcement-modal__body .announcement__datetime{color:#ccc;margin-left:10px}.announcement-modal .announcement-modal__body .announcement__content{word-break:break-all}.add-to-list-modal{z-index:2;display:none;position:fixed;width:500px;top:50%;left:50%;transform:translate(-50%, -50%);background-color:#f7f7f7;padding:20px 20px 10px 20px;color:#606c76}.add-to-list-modal .add-to-list-modal__head{margin-bottom:20px}.add-to-list-modal .add-to-list-modal__head::after{content:' ';clear:both;display:table}.add-to-list-modal .add-to-list-modal__title{font-weight:bold;font-size:1.2em;float:left}.add-to-list-modal .add-to-list-modal__close-button{float:right;cursor:pointer}.add-to-list-modal .add-to-list-modal__confirm-button{float:right}@media (max-width: 575.98px){.mark-modal,.confirm-modal,.announcement-modal .add-to-list-modal{width:100%}}.source-label{display:inline;background:transparent;border-radius:.3rem;border-style:solid;border-width:.1rem;line-height:1.2rem;font-size:1.1rem;margin:3px;padding:1px 3px;padding-top:2px;font-weight:lighter;letter-spacing:0.1rem;word-break:keep-all;opacity:1;position:relative;top:-1px}.source-label.source-label__in-site{border-color:#00a1cc;color:#00a1cc}.source-label.source-label__douban{border:none;color:#fff;background-color:#319840}.source-label.source-label__spotify{background-color:#1ed760;color:#000;border:none;font-weight:bold}.source-label.source-label__imdb{background-color:#F5C518;color:#121212;border:none;font-weight:bold}.source-label.source-label__igdb{background-color:#323A44;color:#DFE1E2;border:none;font-weight:bold}.source-label.source-label__steam{background:linear-gradient(30deg, #1387b8, #111d2e);color:white;border:none;font-weight:600;padding-top:2px}.source-label.source-label__bangumi{background:#FCFCFC;color:#F09199;font-style:italic;font-weight:600}.source-label.source-label__goodreads{background:#F4F1EA;color:#372213;font-weight:lighter}.source-label.source-label__tmdb{background:linear-gradient(90deg, #91CCA3, #1FB4E2);color:white;border:none;font-weight:lighter;padding-top:2px}.source-label.source-label__googlebooks{color:white;background-color:#4285F4;border-color:#4285F4}.source-label.source-label__bandcamp{color:#fff;background-color:#28A0C1;display:inline-block}.source-label.source-label__bandcamp span{display:inline-block;margin:0 4px}.main-section-wrapper{padding:32px 48px 32px 36px;background-color:#f7f7f7;overflow:auto}.main-section-wrapper input,.main-section-wrapper select{width:100%}.entity-list .entity-list__title{margin-bottom:20px}.entity-list .entity-list__entity{display:flex;margin-bottom:36px}.entity-list .entity-list__entity::after{content:' ';clear:both;display:table}.entity-list .entity-list__entity-img{object-fit:contain;min-width:130px;max-width:130px}.entity-list .entity-list__entity-text{margin-left:20px;overflow:hidden;width:100%}.entity-list .entity-list__entity-text .tag-collection{margin-left:-3px}.entity-list .entity-list__entity-link{font-size:1.2em}.entity-list .entity-list__entity-title{display:block}.entity-list .entity-list__entity-category{color:#bbb;margin-left:5px;position:relative;top:-1px}.entity-list .entity-list__entity-info{max-width:73%;white-space:nowrap;overflow:hidden;display:inline-block;text-overflow:ellipsis;position:relative;top:0.52em}.entity-list .entity-list__entity-info--full-length{max-width:100%}.entity-list .entity-list__entity-brief{margin-top:8px;display:-webkit-box;-webkit-box-orient:vertical;-webkit-line-clamp:4;overflow:hidden;margin-bottom:0}.entity-list .entity-list__rating{display:inline-block;margin:0}.entity-list .entity-list__rating--empty{margin-right:5px}.entity-list .entity-list__rating-score{margin-right:5px;position:relative;top:1px}.entity-list .entity-list__rating-star{display:inline;position:relative;top:0.3em;left:-0.3em}.entity-detail .entity-detail__img{height:210px;float:left;object-fit:contain;max-width:150px;object-position:top}.entity-detail .entity-detail__img-origin{cursor:zoom-in}.entity-detail .entity-detail__info{float:left;margin-left:20px;overflow:hidden;text-overflow:ellipsis;width:70%}.entity-detail .entity-detail__title{font-weight:bold}.entity-detail .entity-detail__title--secondary{color:#bbb}.entity-detail .entity-detail__fields{display:inline-block;vertical-align:top;width:46%;margin-left:2%}.entity-detail .entity-detail__fields div,.entity-detail .entity-detail__fields span{margin:1px 0}.entity-detail .entity-detail__fields+.tag-collection{margin-top:5px;margin-left:6px}.entity-detail .entity-detail__rating{position:relative;top:-5px}.entity-detail .entity-detail__rating-star{position:relative;left:-4px;top:3px}.entity-detail .entity-detail__rating-score{font-weight:bold}.entity-detail::after{content:' ';clear:both;display:table}.entity-desc{margin-bottom:28px}.entity-desc .entity-desc__title{display:inline-block;margin-bottom:8px}.entity-desc .entity-desc__content{overflow:hidden}.entity-desc .entity-desc__content--folded{max-height:202px}.entity-desc .entity-desc__unfold-button{display:flex;color:#00a1cc;background-color:transparent;justify-content:center;text-align:center}.entity-desc .entity-desc__unfold-button--hidden{display:none}.entity-marks{margin-bottom:28px}.entity-marks .entity-marks__title{margin-bottom:8px;display:inline-block}.entity-marks .entity-marks__title>a{margin-right:5px}.entity-marks .entity-marks__title--stand-alone{margin-bottom:20px}.entity-marks .entity-marks__more-link{margin-left:5px}.entity-marks .entity-marks__mark{margin:0;padding:3px 0;border-bottom:1px dashed #e5e5e5}.entity-marks .entity-marks__mark:last-child{border:none}.entity-marks .entity-marks__mark--wider{padding:6px 0}.entity-marks .entity-marks__mark-content{margin-bottom:0}.entity-marks .entity-marks__mark-time{color:#ccc;margin-left:2px}.entity-marks .entity-marks__rating-star{position:relative;top:4px}.entity-reviews:first-child{margin-bottom:28px}.entity-reviews .entity-reviews__title{display:inline-block;margin-bottom:8px}.entity-reviews .entity-reviews__title>a{margin-right:5px}.entity-reviews .entity-reviews__title--stand-alone{margin-bottom:20px}.entity-reviews .entity-reviews__more-link{margin-left:5px}.entity-reviews .entity-reviews__review{margin:0;padding:3px 0;border-bottom:1px dashed #e5e5e5}.entity-reviews .entity-reviews__review:last-child{border:none}.entity-reviews .entity-reviews__review--wider{padding:6px 0}.entity-reviews .entity-reviews__review-time{color:#ccc;margin-left:2px}.dividing-line{height:0;width:100%;margin:40px 0 24px 0;border-top:solid 1px #ccc}.dividing-line.dividing-line--dashed{margin:0;margin-top:10px;margin-bottom:2px;border-top:1px dashed #e5e5e5}.entity-sort{position:relative;margin-bottom:30px}.entity-sort .entity-sort__label{font-size:large;display:inline-block;margin-bottom:20px}.entity-sort .entity-sort__more-link{margin-left:8px}.entity-sort .entity-sort__count{color:#bbb}.entity-sort .entity-sort__count::before{content:'('}.entity-sort .entity-sort__count::after{content:')'}.entity-sort .entity-sort__entity-list{display:flex;justify-content:flex-start;flex-wrap:wrap}.entity-sort .entity-sort__entity{padding:0 10px;flex-basis:20%;text-align:center;display:inline-block;color:#606c76}.entity-sort .entity-sort__entity:hover{color:#00a1cc}.entity-sort .entity-sort__entity>a{color:inherit}.entity-sort .entity-sort__entity-img{height:110px}.entity-sort .entity-sort__entity-name{text-overflow:ellipsis;overflow:hidden;display:-webkit-box;-webkit-box-orient:vertical;-webkit-line-clamp:2}.entity-sort--placeholder{border:dashed #bbb 4px}.entity-sort--hover{padding:10px;border:dashed #00a1cc 2px !important;border-radius:3px}.entity-sort--sortable{padding:10px;margin:10px 0;border:dashed #bbb 2px;cursor:all-scroll}.entity-sort--hidden{opacity:0.4}.entity-sort-control{display:flex;justify-content:flex-end}.entity-sort-control__button{margin-top:5px;margin-left:12px;padding:0 2px;cursor:pointer;color:#bbb}.entity-sort-control__button:hover{color:#00a1cc}.entity-sort-control__button:hover>.icon-save svg,.entity-sort-control__button:hover>.icon-edit svg{fill:#00a1cc}.entity-sort-control__button--float-right{position:absolute;top:4px;right:10px}.related-user-list .related-user-list__title{margin-bottom:20px}.related-user-list .related-user-list__user{display:flex;justify-content:flex-start;margin-bottom:20px}.related-user-list .related-user-list__user-info{margin-left:15px;overflow:auto}.related-user-list .related-user-list__user-avatar{max-height:72px;min-width:72px}.review-head .review-head__title{display:inline-block;font-weight:bold}.review-head .review-head__body{margin-bottom:10px}.review-head .review-head__body::after{content:' ';clear:both;display:table}.review-head .review-head__info{float:left}.review-head .review-head__owner-link{color:#ccc}.review-head .review-head__owner-link:hover{color:#00a1cc}.review-head .review-head__time{color:#ccc}.review-head .review-head__rating-star{position:relative;top:3px;left:-1px}.review-head .review-head__actions{float:right}.review-head .review-head__action-link:not(:first-child){margin-left:5px}.tag-collection{margin-left:-9px}.tag-collection .tag-collection__tag{position:relative;display:block;float:left;color:white;background:#ccc;padding:5px;border-radius:.3rem;line-height:1.2em;font-size:80%;margin:3px}.tag-collection .tag-collection__tag a{color:white}.tag-collection .tag-collection__tag a:hover{color:#00a1cc}.track-carousel{position:relative;margin-top:5px}.track-carousel__content{overflow:auto;scroll-behavior:smooth;scrollbar-width:none;display:flex;margin:auto;box-sizing:border-box;padding-bottom:10px}.track-carousel__content::-webkit-scrollbar{height:3px;width:1px;background-color:#e5e5e5}.track-carousel__content::-webkit-scrollbar-thumb{background-color:#bbb}.track-carousel__track{text-align:center;overflow:hidden;text-overflow:ellipsis;min-width:18%;max-width:18%;margin-right:2.5%}.track-carousel__track img{object-fit:contain}.track-carousel__track-title{white-space:nowrap}.track-carousel__button{display:flex;justify-content:center;align-content:center;background:white;border:none;padding:8px;border-radius:50%;outline:0;cursor:pointer;position:absolute;top:50%}.track-carousel__button--prev{left:0;transform:translate(50%, -50%)}.track-carousel__button--next{right:0;transform:translate(-50%, -50%)}@media (max-width: 575.98px){.entity-list .entity-list__entity{flex-direction:column;margin-bottom:30px}.entity-list .entity-list__entity-text{margin-left:0}.entity-list .entity-list__entity-img-wrapper{margin-bottom:8px}.entity-list .entity-list__entity-info{max-width:unset}.entity-list .entity-list__rating--empty+.entity-list__entity-info{max-width:70%}.entity-list .entity-list__entity-brief{-webkit-line-clamp:5}.entity-detail{flex-direction:column}.entity-detail .entity-detail__title{margin-bottom:5px}.entity-detail .entity-detail__info{margin-left:0;float:none;display:flex;flex-direction:column;width:100%}.entity-detail .entity-detail__img{margin-bottom:24px;float:none;height:unset;max-width:170px}.entity-detail .entity-detail__fields{width:unset;margin-left:unset}.entity-detail .entity-detail__fields+.tag-collection{margin-left:-3px}.dividing-line{margin-top:24px}.entity-sort .entity-sort__entity{flex-basis:50%}.entity-sort .entity-sort__entity-img{height:130px}.review-head .review-head__info{float:unset}.review-head .review-head__actions{float:unset}.track-carousel__content{padding-bottom:10px}.track-carousel__track{min-width:31%;max-width:31%;margin-right:4.5%}}@media (max-width: 991.98px){.main-section-wrapper{padding:32px 28px 28px 28px}.entity-detail{display:flex}}.aside-section-wrapper{display:flex;flex:1;flex-direction:column;width:100%;padding:28px 25px 12px 25px;background-color:#f7f7f7;margin-bottom:30px;overflow:auto}.aside-section-wrapper--transparent{background-color:unset}.aside-section-wrapper--collapse{padding:unset}.add-entity-entries .add-entity-entries__entry{margin-bottom:10px}.add-entity-entries .add-entity-entries__label{font-size:1.2em;margin-bottom:8px}.add-entity-entries .add-entity-entries__button{line-height:unset;height:unset;padding:4px 15px;margin:5px}.action-panel{margin-bottom:20px}.action-panel .action-panel__label{font-weight:bold;margin-bottom:12px}.action-panel .action-panel__button-group{display:flex;justify-content:space-between}.action-panel .action-panel__button-group--center{justify-content:center}.action-panel .action-panel__button{line-height:unset;height:unset;padding:4px 15px;margin:0 5px}.mark-panel{margin-bottom:20px}.mark-panel .mark-panel__status{font-weight:bold}.mark-panel .mark-panel__rating-star{position:relative;top:2px}.mark-panel .mark-panel__actions{float:right}.mark-panel .mark-panel__actions form{display:inline}.mark-panel .mark-panel__time{color:#ccc;margin-bottom:10px}.mark-panel .mark-panel__clear{content:' ';clear:both;display:table}.review-panel .review-panel__label{font-weight:bold}.review-panel .review-panel__actions{float:right}.review-panel .review-panel__time{color:#ccc;margin-bottom:10px}.review-panel .review-panel__review-title{display:block;margin-bottom:15px;font-weight:bold}.review-panel .review-panel__clear{content:' ';clear:both;display:table}.user-profile .user-profile__header{display:flex;align-items:flex-start;margin-bottom:15px}.user-profile .user-profile__avatar{width:72px}.user-profile .user-profile__username{font-size:large;margin-left:10px;margin-bottom:0}.user-profile .user-profile__report-link{color:#ccc}.user-relation .user-relation__label{display:inline-block;font-size:large;margin-bottom:10px}.user-relation .user-relation__more-link{margin-left:5px}.user-relation .user-relation__related-user-list{display:flex;justify-content:flex-start}.user-relation .user-relation__related-user-list:last-of-type{margin-bottom:0}.user-relation .user-relation__related-user{flex-basis:25%;padding:0px 3px;text-align:center;display:inline-block;overflow:hidden}.user-relation .user-relation__related-user>a:hover{color:#606c76}.user-relation .user-relation__related-user-avatar{background-image:url("data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7");width:48px;height:48px}@media (min-width: 575.98px) and (max-width: 991.98px){.user-relation .user-relation__related-user-avatar{height:unset;width:60%;max-width:96px}}.user-relation .user-relation__related-user-name{color:inherit;overflow:hidden;text-overflow:ellipsis;-webkit-box-orient:vertical;-webkit-line-clamp:2}.report-panel .report-panel__label{display:inline-block;margin-bottom:10px}.report-panel .report-panel__body{padding-left:0}.report-panel .report-panel__report{margin:2px 0}.report-panel .report-panel__user-link{margin:0 2px}.report-panel .report-panel__all-link{margin-left:5px}.import-panel{overflow-x:hidden}.import-panel .import-panel__label{display:inline-block;margin-bottom:10px}.import-panel .import-panel__body{padding-left:0;border:2px dashed #00a1cc;padding:6px 9px}.import-panel .import-panel__body form{margin:0}@media (max-width: 991.98px){.import-panel .import-panel__body{border:unset;padding-left:0}}.import-panel .import-panel__help{background-color:#e5e5e5;border-radius:100000px;display:inline-block;width:16px;height:16px;text-align:center;font-size:12px;cursor:help}.import-panel .import-panel__checkbox{display:inline-block;margin-right:10px}.import-panel .import-panel__checkbox label{display:inline}.import-panel .import-panel__checkbox input[type="checkbox"]{margin:0;position:relative;top:2px}.import-panel .import-panel__checkbox--last{margin-right:0}.import-panel .import-panel__file-input{margin-top:10px}.import-panel .import-panel__button{line-height:unset;height:unset;padding:4px 15px}.import-panel .import-panel__progress{padding-top:10px}.import-panel .import-panel__progress:not(:first-child){border-top:#bbb 1px dashed}.import-panel .import-panel__progress label{display:inline}.import-panel .import-panel__progress progress{background-color:#d5d5d5;border-radius:0;height:10px;width:54%}.import-panel .import-panel__progress progress::-webkit-progress-bar{background-color:#d5d5d5}.import-panel .import-panel__progress progress::-webkit-progress-value{background-color:#00a1cc}.import-panel .import-panel__progress progress::-moz-progress-bar{background-color:#d5d5d5}.import-panel .import-panel__last-task:not(:first-child){padding-top:4px;border-top:#bbb 1px dashed}.import-panel .import-panel__last-task .index:not(:last-of-type){margin-right:8px}.import-panel .import-panel__fail-urls{margin-top:10px}.import-panel .import-panel__fail-urls li{word-break:break-all}.import-panel .import-panel__fail-urls ul{max-height:100px;overflow-y:auto}.relation-dropdown .relation-dropdown__button{display:none}.entity-card{display:flex;margin-bottom:10px;flex-direction:column}.entity-card--horizontal{flex-direction:row}.entity-card .entity-card__img{height:150px}.entity-card .entity-card__rating-star{position:relative;top:4px;left:-3px}.entity-card .entity-card__rating-score{position:relative;top:1px;margin-left:2px}.entity-card .entity-card__title{margin-bottom:10px;margin-top:5px}.entity-card .entity-card__info-wrapper--horizontal{margin-left:20px}.entity-card .entity-card__img-wrapper{flex-basis:100px}@media (max-width: 575.98px){.add-entity-entries{display:block !important}.add-entity-entries .add-entity-entries__button{width:100%;margin:5px 0 5px 0}.aside-section-wrapper:first-child{margin-right:0 !important;margin-bottom:0 !important}.aside-section-wrapper--singular:first-child{margin-bottom:20px !important}.action-panel{flex-direction:column !important}.entity-card--horizontal{flex-direction:column !important}.entity-card .entity-card__info-wrapper{margin-left:10px !important}.entity-card .entity-card__info-wrapper--horizontal{margin-left:0 !important}}@media (max-width: 991.98px){.add-entity-entries{display:flex;justify-content:space-around}.aside-section-wrapper{padding:24px 25px 10px 25px;margin-top:20px}.aside-section-wrapper:not(:last-child){margin-right:20px}.aside-section-wrapper--collapse{padding:24px 25px 10px 25px !important;margin-top:0;margin-bottom:0}.aside-section-wrapper--collapse:first-child{margin-right:0}.aside-section-wrapper--no-margin{margin:0}.action-panel{flex-direction:row}.action-panel .action-panel__button-group{justify-content:space-evenly}.relation-dropdown{margin-bottom:20px}.relation-dropdown .relation-dropdown__button{padding-bottom:10px;background-color:#f7f7f7;width:100%;display:flex;justify-content:center;align-items:center;cursor:pointer;transition:transform 0.3s}.relation-dropdown .relation-dropdown__button:focus{background-color:red}.relation-dropdown .relation-dropdown__button>.icon-arrow{transition:transform 0.3s}.relation-dropdown .relation-dropdown__button:hover>.icon-arrow>svg{fill:#00a1cc}.relation-dropdown .relation-dropdown__button>.icon-arrow--expand{transform:rotate(-180deg)}.relation-dropdown .relation-dropdown__button+.relation-dropdown__body--expand{max-height:2000px;transition:max-height 1s ease-in}.relation-dropdown .relation-dropdown__body{background-color:#f7f7f7;max-height:0;transition:max-height 1s ease-out;overflow:hidden}.entity-card{flex-direction:row}.entity-card .entity-card__info-wrapper{margin-left:30px}}.single-section-wrapper{padding:32px 36px;background-color:#f7f7f7;overflow:auto}.single-section-wrapper .single-section-wrapper__link--secondary{display:inline-block;color:#ccc;margin-bottom:20px}.single-section-wrapper .single-section-wrapper__link--secondary:hover{color:#00a1cc}.entity-form,.review-form{overflow:auto}.entity-form>input[type='email'],.entity-form>input[type='number'],.entity-form>input[type='password'],.entity-form>input[type='search'],.entity-form>input[type='tel'],.entity-form>input[type='text'],.entity-form>input[type='url'],.entity-form textarea,.review-form>input[type='email'],.review-form>input[type='number'],.review-form>input[type='password'],.review-form>input[type='search'],.review-form>input[type='tel'],.review-form>input[type='text'],.review-form>input[type='url'],.review-form textarea{width:100%}.entity-form img,.review-form img{display:block}.review-form .review-form__preview-button{color:#00a1cc;font-weight:bold;cursor:pointer}.review-form .review-form__fyi{color:#ccc}.review-form .review-form__main-content,.review-form textarea{margin-bottom:5px;resize:vertical;height:400px}.review-form .review-form__option{margin-top:24px;margin-bottom:10px}.review-form .review-form__option::after{content:' ';clear:both;display:table}.review-form .review-form__visibility-radio{float:left}.review-form .review-form__visibility-radio ul,.review-form .review-form__visibility-radio li,.review-form .review-form__visibility-radio label{display:inline}.review-form .review-form__visibility-radio label{font-size:normal}.review-form .review-form__visibility-radio input[type="radio"]{position:relative;top:2px}.review-form .review-form__share-checkbox{float:right}.review-form .review-form__share-checkbox input[type="checkbox"]{position:relative;top:2px}.report-form input,.report-form select{width:100%}@media (max-width: 575.98px){.review-form .review-form__visibility-radio{float:unset}.review-form .review-form__share-checkbox{float:unset;position:relative;left:-3px}}.markdownx-preview{min-height:100px}.markdownx-preview ul li{list-style:circle inside}.rating-star .jq-star{cursor:unset !important}.ms-parent>.ms-choice{margin-bottom:1.5rem;appearance:none;background-color:transparent;border:0.1rem solid #ccc;border-radius:.4rem;box-shadow:none;box-sizing:inherit;padding:.6rem 1.0rem;width:100%;height:30.126px}.ms-parent>.ms-choice:focus{border-color:#00a1cc}.ms-parent>.ms-choice>.icon-caret{top:15.5px}.ms-parent>.ms-choice>span{color:black;font-weight:initial;font-size:13.3333px;top:2.5px;left:2px}.ms-parent>.ms-choice>span:hover,.ms-parent>.ms-choice>span:focus{color:black}.ms-parent>.ms-drop>ul>li>label>span{margin-left:10px}.ms-parent>.ms-drop>ul>li>label>input{width:unset}.tippy-box{border:#606c76 1px solid;background-color:#f7f7f7;padding:3px 5px}.tag-input input{flex-grow:1}.tools-section-wrapper input,.tools-section-wrapper select{width:unset} diff --git a/common/static/img/fediverse.svg b/common/static/img/fediverse.svg new file mode 100644 index 00000000..c3ab108c --- /dev/null +++ b/common/static/img/fediverse.svg @@ -0,0 +1,5 @@ +<svg width="850" height="850" xmlns="http://www.w3.org/2000/svg" version="1.1"> + <g> + <path d="m464.16327,0q-32,0 -55,22t-25,55t20.5,58t56,27t58.5,-20.5t27,-56t-20.5,-59t-56.5,-26.5l-5,0zm-87,95l-232,118q20,20 25,48l231,-118q-19,-20 -24,-48zm167,27q-13,25 -38,38l183,184q13,-25 39,-38l-184,-184zm-142,22l-135,265l40,40l143,-280q-28,-5 -48,-25zm104,16q-22,11 -46,10l-8,-1l21,132l56,9l-23,-150zm-426,34q-32,0 -55,22.5t-25,55t20.5,58t56.5,27t59,-21t26.5,-56t-21,-58.5t-55.5,-27l-6,0zm90,68q1,9 1,18q-1,19 -10,35l132,21l26,-50l-149,-24zm225,36l-26,51l311,49q-1,-8 -1,-17q1,-19 10,-36l-294,-47zm372,6q-32,1 -55,23t-24.5,55t21,58t56,27t58.5,-20.5t27,-56.5t-20.5,-59t-56.5,-27l-6,0zm-606,13q-13,25 -39,38l210,210l51,-25l-222,-223zm-40,38q-21,11 -44,10l-9,-1l40,256q21,-10 45,-9l8,1l-40,-257zm364,22l48,311q21,-10 44,-9l10,1l-46,-294l-56,-9zm195,23l-118,60l8,56l135,-68q-20,-20 -25,-48zm26,49l-119,231q28,5 48,25l119,-231q-28,-5 -48,-25zm-475,29l-68,134q28,5 48,25l60,-119l-40,-40zm262,17l-281,143q19,20 24,48l265,-135l-8,-56zm-55,100l-51,25l106,107q13,-25 39,-38l-94,-94zm-291,24q-32,0 -55.5,22.5t-25,55t21,57.5t56,27t58.5,-20.5t27,-56t-20.5,-58.5t-56.5,-27l-5,0zm89,68q2,9 1,18q-1,19 -9,35l256,41q-1,-9 -1,-18q1,-18 10,-35l-257,-41zm335,0q-32,0 -55,22.5t-24.5,55t20.5,58t56,27t59,-21t27,-56t-20.5,-58.5t-56.5,-27l-6,0z"/> + </g> +</svg> \ No newline at end of file diff --git a/common/static/img/logo.svg b/common/static/img/logo.svg index 0570333b..cc10cff8 100644 --- a/common/static/img/logo.svg +++ b/common/static/img/logo.svg @@ -1 +1,140 @@ -<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 101.25 37.29"><title>logo</title><path d="M1.55,30.26H2.78l2.1,3.81.7,1.44h0c-.06-.7-.16-1.56-.16-2.31V30.26H6.61V37H5.38l-2.1-3.81-.7-1.44h0c.06.72.15,1.53.15,2.29v3H1.55Z" style="fill:#606c76"/><path d="M13.09,30.26h1.2V37h-1.2Z" style="fill:#606c76"/><path d="M20.41,33.67c0-2.22,1.38-3.53,3.1-3.53a2.66,2.66,0,0,1,1.95.86l-.64.77a1.78,1.78,0,0,0-1.29-.59c-1.1,0-1.89.93-1.89,2.45s.73,2.48,1.86,2.48A1.94,1.94,0,0,0,25,35.42l.64.75a2.68,2.68,0,0,1-2.13,1C21.76,37.15,20.41,35.91,20.41,33.67Z" style="fill:#606c76"/><path d="M31.48,30.26h4.07v1H32.68V33h2.43v1H32.68v2h3v1H31.48Z" style="fill:#606c76"/><path d="M41.79,30.26H43.6a3,3,0,0,1,3.3,3.36c0,2.24-1.23,3.41-3.24,3.41H41.79Zm1.73,5.8c1.36,0,2.14-.78,2.14-2.44s-.78-2.38-2.14-2.38H43v4.82Z" style="fill:#606c76"/><path d="M53,30.26h2.17c1.39,0,2.41.43,2.41,1.67a1.53,1.53,0,0,1-.94,1.47v0A1.55,1.55,0,0,1,58,35c0,1.36-1.11,2-2.6,2H53Zm2.06,2.8c.94,0,1.35-.37,1.35-1s-.45-.9-1.32-.9h-.89v1.86Zm.16,3c1,0,1.55-.35,1.55-1.11s-.54-1-1.55-1h-1v2.14Z" style="fill:#606c76"/><path d="M63.73,36.35a.77.77,0,1,1,1.54,0,.77.77,0,1,1-1.54,0Z" style="fill:#606c76"/><path d="M71.19,33.62c0-2.19,1.22-3.48,3-3.48s3,1.3,3,3.48-1.22,3.53-3,3.53S71.19,35.81,71.19,33.62Zm4.73,0c0-1.52-.69-2.44-1.75-2.44s-1.75.92-1.75,2.44.69,2.49,1.75,2.49S75.92,35.14,75.92,33.62Z" style="fill:#606c76"/><path d="M83.27,30.26h2.28c1.41,0,2.49.5,2.49,2S87,34.39,85.55,34.39H84.47V37h-1.2Zm2.16,3.16c.92,0,1.43-.38,1.43-1.15s-.51-1.05-1.43-1.05h-1v2.2Zm-.1.64.86-.71L88.31,37H87Z" style="fill:#606c76"/><path d="M93.79,33.67A3.2,3.2,0,0,1,97,30.14,2.78,2.78,0,0,1,99,31l-.65.77A1.81,1.81,0,0,0,97,31.18c-1.2,0-2,.93-2,2.45s.72,2.48,2.07,2.48a1.54,1.54,0,0,0,1-.3V34.35H96.78v-1h2.36v3a3.08,3.08,0,0,1-2.16.8C95.15,37.15,93.79,35.91,93.79,33.67Z" style="fill:#606c76"/><path d="M15.48,26.35h-.15L1.39,23.41a.71.71,0,0,1-.56-.69V2.32a.74.74,0,0,1,.26-.55.73.73,0,0,1,.59-.14L15.62,4.56a.71.71,0,0,1,.56.69v20.4a.7.7,0,0,1-.26.54A.66.66,0,0,1,15.48,26.35ZM2.23,22.15l12.55,2.64v-19L2.23,3.18Z" style="fill:#00a1cc"/><path d="M15.48,26.35a.65.65,0,0,1-.44-.16.67.67,0,0,1-.26-.54V5.25a.7.7,0,0,1,.55-.69l14-2.93a.7.7,0,0,1,.84.69v20.4a.71.71,0,0,1-.55.69L15.62,26.34Zm.7-20.54v19l12.54-2.64v-19ZM29.42,22.72h0Z" style="fill:#00a1cc"/><path d="M87.55,26.48a13.17,13.17,0,1,1,13.17-13.17A13.19,13.19,0,0,1,87.55,26.48Zm0-24.94A11.77,11.77,0,1,0,99.32,13.31,11.78,11.78,0,0,0,87.55,1.54Z" style="fill:#00a1cc"/><path d="M87.55,18.38a5.08,5.08,0,1,1,5.08-5.07A5.08,5.08,0,0,1,87.55,18.38Zm0-8.75a3.68,3.68,0,1,0,3.67,3.68A3.68,3.68,0,0,0,87.55,9.63Z" style="fill:#00a1cc"/><path d="M60.63,22.63H36.21a.7.7,0,0,1-.7-.7V4.69a.7.7,0,0,1,.7-.7H60.63a.7.7,0,0,1,.7.7V21.93A.7.7,0,0,1,60.63,22.63Zm-23.72-1.4h23V5.39h-23Z" style="fill:#00a1cc"/><path d="M69.17,22.63a.68.68,0,0,1-.31-.08l-8.55-4.29a.71.71,0,0,1-.38-.62V9a.71.71,0,0,1,.38-.63l8.55-4.29a.7.7,0,0,1,1,.63V21.93a.68.68,0,0,1-.33.59A.7.7,0,0,1,69.17,22.63Zm-7.84-5.42,7.14,3.58v-15L61.33,9.41Z" style="fill:#00a1cc"/></svg> \ No newline at end of file +<?xml version="1.0" encoding="UTF-8" standalone="no"?> +<svg + width="220.10335" + height="107.60636" + viewBox="0 0 220.10335 107.60636" + data-svgdocument="" + id="_3tPmdtZQ0LGlljQGEkT-3" + class="fl-svgdocument" + x="0" + y="0" + version="1.1" + sodipodi:docname="logo.svg" + inkscape:version="1.1 (c4e8f9e, 2021-05-24)" + xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape" + xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd" + xmlns="http://www.w3.org/2000/svg" + xmlns:svg="http://www.w3.org/2000/svg"> + <sodipodi:namedview + id="namedview21" + pagecolor="#ffffff" + bordercolor="#666666" + borderopacity="1.0" + inkscape:pageshadow="2" + inkscape:pageopacity="0.0" + inkscape:pagecheckerboard="0" + showgrid="false" + units="px" + fit-margin-top="0" + fit-margin-left="0" + fit-margin-right="0" + fit-margin-bottom="0" + inkscape:zoom="1.4707031" + inkscape:cx="298.83666" + inkscape:cy="77.853919" + inkscape:window-width="1792" + inkscape:window-height="1067" + inkscape:window-x="0" + inkscape:window-y="25" + inkscape:window-maximized="0" + inkscape:current-layer="_3tPmdtZQ0LGlljQGEkT-3" + inkscape:document-units="mm" + inkscape:snap-global="false" /> + <defs + id="_SabXoCmWUJmxvMwSINRGH" + transform="matrix(1.1728515134752755, 0, 0, 1.1728515134752755, -34.46172819025348, -40.976997984526726)"> + <rect + x="86.353256" + y="25.158035" + width="217.58301" + height="60.515274" + id="rect4965" /> + </defs> + <g + id="_WTrHMt3eZfxcQKrYJirRK" + transform="matrix(0.95294163,0,0,0.95294163,-45.741197,-190.14988)"> + <path + id="_1MYZsbAYvxe5D-50H3pho" + fill="#000000" + transform="matrix(1.2602785,0,0,1.2602785,27.519653,192.79657)" + data-type="polygon" + d="M 82.1,74 75,81.1 v 0 L 82.1,74 74,65.9 v 0 z" /> + <path + id="_oL3QJlwkbW1Nmdh3qAlDC" + fill="#000000" + transform="matrix(1.2602785,0,0,1.2602785,27.45702,193.17499)" + data-type="rect" + data-x="64.5" + data-y="51.5" + data-width="0" + data-height="9.8" + d="M 64.5,51.5" /> + <path + id="_sK1htog-8Op55rgRI5hDg" + d="m 61.1,52.9 v 0 0 l -4.9,4.9 c 2.5,1.2 4.3,3.8 4.3,6.9 0,4.2 -3.4,7.6 -7.6,7.6 -3,0 -5.6,-1.8 -6.9,-4.3 L 40.1,73.9 40,74 61,95 69.1,86.9 v 0 c -3.2,0 -5.9,-2.6 -5.9,-5.9 0,-3.3 2.6,-5.9 5.9,-5.9 3.2,0 5.9,2.6 5.9,5.9 l 7.1,-7.1 -8.1,-8 v 0 l -0.6,-0.6 c 0.1,0 0.3,0 0.4,0 3.2,0 5.9,-2.6 5.9,-5.9 0,-1.5 -0.6,-2.8 -1.5,-3.9 -1,-0.9 -2.4,-1.5 -3.9,-1.5 -3.2,0 -5.9,2.6 -5.9,5.9 0,0.1 0,0.3 0,0.4 L 68,59.9 v 0 z" + fill="#083b66" + transform="matrix(1.2602785,0,0,1.2602785,27.51993,192.73364)" + style="fill:#ffb380" /> + <path + id="_OMWHsyuaoclMfPE9tvM7V" + d="m 46.5,49.5 c -4.2,0 -7.6,-3.4 -7.6,-7.6 0,-3 1.7,-5.6 4.2,-6.8 l -5.9,-5.9 v 0 l -21,21 22.2,22.2 9.5,-9.5 c -0.3,0.6 -0.4,1.3 -0.4,2 0,2.9 2.4,5.3 5.3,5.3 2.9,0 5.3,-2.4 5.3,-5.3 0,-2.9 -2.4,-5.3 -5.3,-5.3 -0.7,0 -1.4,0.1 -2,0.4 l 8.6,-8.6 -6,-6 c -1.3,2.3 -3.9,4.1 -6.9,4.1 z" + fill="#8dcaff" + transform="matrix(1.2602785,0,0,1.2602785,27.583487,192.79709)" /> + <path + id="_jAk8zpl6O80_21e1JT4hk" + d="m 83.3,27.4 -8.7,-8.7 c 0.1,0 0.3,0 0.4,0 3.2,0 5.9,-2.6 5.9,-5.9 C 80.9,9.6 78.3,7 75,7 c -3.2,0 -5.9,2.6 -5.9,5.9 0,0.1 0,0.3 0,0.4 L 61,5.2 l -21,21 -0.7,0.8 -0.4,0.4 9.5,9.5 c -0.6,-0.2 -1.2,-0.3 -1.8,-0.3 -2.9,0 -5.3,2.4 -5.3,5.3 0,2.9 2.4,5.3 5.3,5.3 2.9,0 5.3,-2.4 5.3,-5.3 0,-0.5 -0.1,-0.9 -0.2,-1.4 0,-0.2 -0.1,-0.3 -0.1,-0.5 l 0.5,0.5 9.2,9.2 0.4,-0.4 0.8,-0.8 6.8,-6.8 c 0,0 0,0 0,0 -3.2,0 -5.9,-2.6 -5.9,-5.9 0,-3.3 2.6,-5.9 5.9,-5.9 3.2,0 5.9,2.6 5.9,5.9 0,0 0,0 0,0 z" + fill="#c0d9b4" + transform="matrix(1.2602785,0,0,1.2602785,27.709267,192.98645)" /> + <path + id="_GDSYpBtYoW2GJpRZoPCot" + fill="#000000" + transform="matrix(1.2602785,0,0,1.2602785,27.456932,192.67065)" + data-type="rect" + data-x="61.1" + data-y="52.9" + data-width="0" + data-height="0" + d="M 61.1,52.9" /> + <path + id="_NldltLD5fZQlQrgFsakaC" + fill="#000000" + transform="matrix(1.2602785,0,0,1.2602785,27.456932,192.67065)" + data-type="rect" + data-x="61.1" + data-y="52.9" + data-width="0" + data-height="0" + d="M 61.1,52.9" /> + <path + id="_TbX_b0jxU8J0Cb3YWVDy6" + d="m 51.6,40.5 h 0.3 L 51.5,40 c 0,0.1 0,0.3 0.1,0.5 z" + fill="#000000" + transform="matrix(1.2602785,0,0,1.2602785,27.708767,192.98587)" /> + </g> + <g + aria-label="NeoDB" + transform="matrix(1.5308318,0,0,1.7424549,-43.057047,-45.264217)" + id="text4963" + style="font-size:40px;line-height:1.25;white-space:pre;shape-inside:url(#rect4965);fill:#083b66"> + <path + d="m 107.58017,70.426948 c 0,-1.866665 -0.85333,-6.026663 -1.28,-7.786662 -0.10667,-0.426666 0,-0.853333 -0.10667,-1.279999 -0.90666,-3.786665 -1.44,-7.733329 -1.92,-11.573327 -0.15999,-1.546665 -0.58666,-3.039998 -0.58666,-4.586664 0,-0.266666 -0.10667,-1.919998 0.42667,-1.919998 0.74666,0 0.90666,1.226666 1.59999,1.226666 0.26667,0 0.64,-0.106667 0.64,-0.426667 0,-1.013333 -1.92,-1.866665 -2.82666,-1.866665 -3.09333,0 -2.93333,3.626664 -2.93333,5.919996 0,2.079999 0.32,4.373331 0.58666,6.45333 0.16,1.279999 0.10667,2.559998 0.32,3.839997 0.16,1.12 0.64,2.719999 0.64,3.839998 0,0.906666 0.16,1.706666 0.26667,2.559998 -0.48,-0.746666 -0.69333,-1.493332 -1.12,-2.239998 -0.64,-1.066666 -1.28,-2.186666 -1.813332,-3.306665 -0.373333,-0.746666 -0.8,-1.439999 -1.226666,-2.186665 -0.426667,-0.746667 -0.693333,-1.546666 -1.119999,-2.293332 -1.919999,-3.253332 -3.626665,-6.666663 -5.493331,-9.919994 -0.213333,-0.373333 -0.693332,-0.426667 -1.066666,-0.426667 -0.693332,0 -2.933331,0.533333 -3.039998,1.333333 -0.16,1.333332 -0.16,2.719998 -0.16,4.053331 v 4.53333 c 0,1.013333 0.106667,1.973332 0.106667,2.933332 0,1.386666 0.16,2.719998 0.16,4.053331 0,0.799999 0.16,1.546666 0.16,2.346665 0,1.333333 0.05333,2.826665 0.266666,4.159998 0.106667,0.533333 0.213333,1.279999 0.213333,1.866665 0,2.399999 -0.266666,4.053331 2.826665,4.053331 0.8,0 3.093332,-0.426666 3.093332,-1.439999 0,-0.48 -0.16,-0.959999 -0.16,-1.493333 0,-2.666665 -0.213333,-5.759996 -0.64,-8.426661 -0.159999,-1.119999 -0.426666,-2.559999 -0.426666,-3.679998 0,-2.559998 -0.693333,-4.746664 -0.693333,-7.413329 1.279999,2.933332 9.599998,20.853321 11.786658,20.853321 0.8,0 3.52,-0.799999 3.52,-1.759999 z M 90.246847,53.573625 c 0.266666,0.106666 0.266666,1.333332 0.266666,1.546666 0,1.119999 -0.16,2.239998 -0.16,3.359998 -0.16,-1.493333 -0.16,-2.986665 -0.16,-4.479998 0,-0.16 0,-0.266666 0.05333,-0.426666 z m 0.266666,18.453322 0.05333,-0.106666 c 0,-0.373334 -0.106666,-0.746667 -0.106666,-1.12 v -0.48 c 0.05333,0.266667 0.106666,0.906667 0.32,1.12 0,-0.64 -0.16,-1.226666 -0.16,-1.866666 0,-0.213333 0,-0.639999 0.213333,-0.799999 0,1.119999 0.05333,2.293332 0.05333,3.413331 l -0.16,-0.16 v 0.106667 h -0.213333 z m -0.266666,-7.199996 c -0.05333,-0.266666 -0.05333,-0.479999 -0.05333,-0.746666 v -0.693333 c 0.213334,-0.266666 0.16,-2.453332 0.16,-2.719998 0.106667,0.426666 0.16,1.813332 0.16,2.346665 0,0.266667 0,1.653333 -0.266666,1.813332 z m -1.28,-15.83999 c 0.106667,-0.64 0.05333,-1.333333 0.05333,-1.973332 0.213333,1.706665 0,3.413331 0.426666,5.066663 l -0.106666,0.05333 -0.213334,-1.066666 c 0,0.533333 0.05333,1.119999 0.05333,1.653332 l -0.05333,-0.05333 v -0.533333 c -0.05333,-0.106667 -0.16,-0.32 -0.16,-0.426667 0,-0.32 0.05333,-0.639999 0.05333,-0.959999 l 0.106666,0.32 c -0.05333,-0.693333 0,-1.386666 -0.16,-2.079999 z m 0.266667,12.373326 c 0.05333,-0.373333 -0.05333,-2.239999 -0.16,-2.559999 0.16,-0.266666 0.16,-1.493332 0.16,-1.813332 0.106667,0.213333 0.106667,1.813332 0.106667,2.133332 0,0.426667 0,1.866666 -0.106667,2.239999 z m 14.613326,-0.373333 c -0.0533,0 -0.10667,-0.693333 -0.10667,-0.746667 -0.0533,0.106667 -0.0533,0.266667 -0.0533,0.373334 0,0.106666 0.0533,0.213333 0.0533,0.319999 l -0.0533,0.05333 h 0.0533 c 0,0.32 0.0533,0.639999 -0.0533,0.959999 0,-0.533333 -0.0533,-1.066666 -0.0533,-1.599999 v -0.64 0.266667 c 0.0533,-0.213333 0.0533,-0.426666 0.0533,-0.64 0.16,0.05333 0.16,1.173333 0.16,1.386666 z m -0.10667,-3.466665 c 0,0.213333 -0.0533,0.373333 0,0.586666 v -0.319999 c 0.0533,0.16 0.10667,0.373333 0.10667,0.586666 0,0.213333 -0.0533,0.48 -0.16,0.693333 0,-0.213333 0,-0.426667 -0.0533,-0.64 v 0.266667 -1.973332 c 0,0.05333 0.10666,0.213333 0.10666,0.266666 z m -13.546657,1.493333 c -0.106666,-0.373334 -0.16,-0.853333 -0.106666,-1.226666 l -0.05333,0.16 c 0.106667,-0.586667 -0.05333,-1.12 -0.05333,-1.706666 0,-0.213333 0,-0.426667 0.106667,-0.64 0.05333,1.12 0.05333,2.293332 0.106666,3.413332 z m 0.16,12.586659 c -0.05333,0.16 -0.05333,0.32 -0.05333,0.479999 l -0.16,-0.05333 c 0,-0.266666 -0.05333,-1.119999 0.16,-1.333332 0,0.159999 -0.05333,0.426666 0.05333,0.533333 0.05333,0.266666 0.106667,0.586666 0.106667,0.853332 v 0.05333 L 90.353513,72.08028 Z M 89.926847,57.733622 c 0,0.16 0,0.266667 0.05333,0.426667 -0.16,0.799999 -0.05333,1.759999 -0.05333,2.506665 -0.106667,-0.48 -0.106667,-0.96 -0.106667,-1.493333 0,-0.479999 0,-0.959999 0.106667,-1.439999 z m 0.05333,4.053331 c 0,-0.426666 0.106667,-0.799999 0,-1.279999 l 0.16,0.16 v 0.959999 c 0,0.373333 0,0.8 -0.05333,1.173333 -0.05333,-0.32 -0.106667,-0.693333 -0.106667,-1.013333 z m 0.586666,-11.093326 c 0,0.266666 -0.05333,0.479999 -0.106666,0.746666 -0.05333,-0.05333 -0.05333,-0.16 -0.05333,-0.266667 V 50.58696 c 0,-0.266667 0,-0.48 0.05333,-0.746666 0.05333,0.213333 0.05333,0.479999 0.05333,0.693333 h 0.05333 z m -0.05333,17.173323 c 0,0.213333 0,0.426666 -0.05333,0.639999 -0.05333,-0.213333 -0.05333,-0.479999 -0.05333,-0.693333 V 67.22695 c 0,-0.106667 0,-0.213333 0.05333,-0.32 0.05333,0.32 0.106666,0.64 0.106666,0.96 z m 13.706657,-3.679998 0.16,0.32 c 0,0.213333 -0.10667,0.373333 -0.21333,0.533333 0,-0.106667 -0.0533,-0.266667 -0.0533,-0.373333 0,-0.16 0,-0.266667 0.0533,-0.426667 z m -0.32,-0.16 c 0,-0.106667 -0.0533,-0.266667 -0.0533,-0.373333 l 0.0533,-0.106667 -0.0533,0.05333 c 0,-0.106666 -0.0533,-0.159999 -0.0533,-0.266666 v -0.64 l 0.10666,-0.05333 c 0,0.48 0,0.906666 0,1.386666 z M 89.180181,54.213624 c -0.05333,-0.16 -0.106667,-0.319999 -0.106667,-0.533333 0,-0.266666 0.05333,-0.586666 0.05333,-0.906666 0.05333,0.16 0.05333,0.373333 0.05333,0.533333 z M 102.78017,51.22696 c -0.0533,0.373333 0.21334,0.853332 0.21334,1.226665 v 0.05333 c -0.16,-0.373333 -0.21334,-0.8 -0.37334,-1.226666 z m -11.359991,8.799994 c -0.05333,-0.32 -0.05333,-0.639999 -0.05333,-0.959999 v -0.373333 c 0.05333,0.213333 0.106667,0.586666 0.106667,0.799999 0,0.16 -0.05333,0.373333 -0.05333,0.533333 z m -2.239998,-4.319997 -0.106667,0.266666 v -0.266666 c 0,-0.213333 0,-0.373333 0.05333,-0.533333 0.05333,0.106666 0.05333,0.213333 0.05333,0.32 l 0.106666,-0.106667 v 0.426666 l -0.05333,-0.05333 -0.05333,0.213333 z m 0.213333,-1.226666 c 0,-0.05333 -0.106667,-0.586666 0,-0.48 -0.05333,-0.266666 0,-0.693333 -0.106667,-0.906666 l 0.106667,0.05333 c 0,0.32 0.05333,0.586667 0.05333,0.906666 0,0.16 0,0.32 -0.05333,0.426667 z m 0.906666,-6.399996 c 0,0.32 0,0.639999 0,0.959999 l -0.106667,-0.48 c 0,-0.106666 0.05333,-0.319999 0.106667,-0.479999 z m 12.74666,5.599996 c 0,0.213334 -0.0533,0.373333 -0.0533,0.586667 -0.0533,-0.213334 -0.0533,-0.48 -0.0533,-0.693333 v -0.32 c 0.0533,0.106667 0.10667,0.266666 0.10667,0.426666 z m 1.06667,8.693329 0.0533,-0.05333 V 62.05362 l 0.10666,0.32 c -0.0533,0.106666 -0.0533,0.266666 -0.0533,0.373333 l -0.10666,-0.213334 v 0.32 c 0,-0.05333 -0.0533,-0.16 -0.0533,-0.213333 0,-0.16 0,-0.373333 0.0533,-0.533333 z m -13.81333,6.613329 c 0,0.266666 -0.05333,0.906666 0.05333,1.226666 l -0.05333,0.106666 c -0.106667,-0.106666 -0.05333,-1.226665 0,-1.333332 z m 0.32,-2.399999 c -0.106667,0 -0.16,0.106667 -0.16,0.213334 0.05333,-0.266667 -0.05333,-0.586667 0,-0.746667 l 0.106666,0.05333 v 0.213333 h 0.05333 z m 12.53333,-8.746661 c 0.0533,0.266666 0.0533,0.586666 0,0.853333 -0.0533,-0.32 -0.0533,-0.533333 0,-0.853333 z m -0.53334,-7.253329 c 0.0533,-0.213333 0.0533,-0.426667 0.0533,-0.64 l 0.0533,0.05333 v 0.693333 z m -13.386656,6.346663 c 0,-0.16 0,-0.373333 -0.106667,-0.533333 l 0.106667,-0.266667 c 0,0.16 0,0.373333 0,0.533333 z m 1.333332,-4.853331 c -0.05333,-0.16 -0.106666,-0.319999 -0.106666,-0.479999 0,0.106666 0.05333,0.16 0.16,0.16 0,0.106666 -0.05333,0.159999 -0.05333,0.319999 0,0 0.05333,-0.05333 0.05333,-0.05333 v 0.05333 z m -0.319999,-4.479997 c -0.106667,-0.32 -0.106667,-0.693333 -0.106667,-1.066666 z m 0.16,11.839993 c 0.159999,0.05333 0.05333,0.373333 0,0.48 z m 0.213333,-6.45333 c 0,0.106667 -0.05333,0.266667 -0.05333,0.373334 v -0.48 z m -0.213333,5.493331 c 0,0.05333 0.159999,0.426666 0,0.426666 z m 0.159999,6.666662 c 0.05333,0.05333 0.05333,0.16 0.05333,0.266667 l -0.05333,0.05333 z m 13.866654,-2.773331 0.0533,0.05333 c 0,0.213333 0,0.426666 -0.0533,0.639999 z M 90.353513,53.200292 c 0,0.106666 0,0.16 -0.05333,0.266666 0,-0.106666 -0.05333,-0.266666 -0.05333,-0.373333 z m 0.16,2.879998 v -0.586666 c 0.05333,0.213333 0.05333,0.373333 0.05333,0.586666 z M 104.9135,70.160282 c 0,0.05333 0.0533,0.106666 0.10667,0.159999 l -0.10667,0.05333 h -0.0533 l -0.0533,-0.05333 z m -0.69333,-9.066662 c 0,0.16 0.10667,0.32 -0.0533,0.48 0,-0.16 0,-0.32 0.0533,-0.48 z m -13.973323,4.159998 c 0,-0.106667 0,-0.213333 0.05333,-0.32 l 0.05333,0.16 c 0,0.106667 -0.05333,0.16 -0.106666,0.16 z m 0.319999,-2.933332 c 0,0.16 0,0.373333 -0.05333,0.533333 v -0.533333 z m 0,-10.18666 v 0.106666 l 0.05333,0.106667 -0.05333,0.05333 v -0.16 l -0.106666,0.05333 v -0.05333 z m 14.079994,11.466659 c 0,-0.106666 0,-0.159999 0.0533,-0.266666 v 0.32 z m -2.13333,-11.893326 c 0,-0.106666 0,-0.16 0.0533,-0.266666 0,0.106666 0,0.16 0.0533,0.266666 z M 90.46018,69.840282 c 0,-0.106667 0,-0.213333 0.05333,-0.32 v 0.213333 z m 0.32,-1.333333 c 0,0.05333 0.05333,0.106667 0.05333,0.106667 0,0.05333 -0.05333,0.106666 -0.05333,0.106666 l -0.05333,-0.05333 c 0,-0.05333 0,-0.106666 0.05333,-0.16 z m -1.386666,-3.466664 c -0.05333,-0.106667 -0.05333,-0.266667 -0.05333,-0.373333 0.05333,0.106666 0.05333,0.266666 0.05333,0.373333 z m 14.933326,0.586666 0.0533,-0.106667 v 0.266667 z m -0.21333,-5.119997 c -0.0533,-0.16 -0.0533,-0.266667 0,-0.373333 z m 0,4.746664 h 0.0533 v 0.16 l -0.0533,-0.05333 z m -13.386664,6.293329 c 0,0.05333 0,0.16 0.05333,0.213334 0,-0.05333 0,-0.16 -0.05333,-0.213334 z m -0.213333,-14.773324 0.05333,0.106666 c 0,0.05333 0,0.05333 -0.05333,0.106667 z m -1.333332,-2.186665 c -0.05333,-0.05333 -0.05333,-0.16 0,-0.266667 z m 15.199989,11.946659 c -0.0533,-0.05333 -0.0533,-0.16 -0.0533,-0.213333 0.0533,0.05333 0.0533,0.16 0.0533,0.213333 z m 0.0533,-2.613332 c 0,0 -0.0533,-0.05333 -0.0533,-0.05333 0,-0.05333 0.0533,-0.05333 0.0533,-0.05333 z m -0.21333,-3.199998 -0.0533,-0.05333 0.0533,-0.106667 z m 0.16,5.33333 c -0.0533,-0.05333 -0.0533,-0.16 0,-0.213333 z m -1.44,-13.173325 c -0.0533,-0.05333 -0.0533,-0.106667 -0.0533,-0.16 0.0533,0.05333 0.0533,0.106667 0.0533,0.16 z m -13.599989,0 c 0.05333,0.05333 0.05333,0.106666 0.05333,0.16 -0.05333,-0.05333 -0.05333,-0.106667 -0.05333,-0.16 z m 14.773329,8.373328 -0.0533,-0.106666 c 0,0 0.0533,-0.05333 0.0533,-0.05333 z m -13.599997,7.679996 v -0.106667 h 0.05333 z m -1.119999,-4.906664 v -0.106667 z m 1.173332,1.973332 v 0.106667 z m 13.119994,-8.533328 c 0,0 -0.0533,-0.05333 -0.0533,-0.05333 v 0.16 z m -14.613326,-0.16 v -0.05333 h 0.05333 v 0.05333 z m 1.493332,-0.693333 c 0,0 -0.05333,-0.05333 -0.05333,-0.05333 0,0 0.05333,-0.05333 0.05333,-0.05333 z m 13.599994,7.413329 v -0.05333 c 0.0533,0.05333 0.0533,0.05333 0.0533,0.106666 z M 89.446847,63.653619 h -0.05333 v -0.05333 z m 15.253323,-0.533333 h -0.0533 l 0.0533,-0.05333 z m -13.91999,4.959997 0.05333,0.05333 h -0.05333 z m 13.38666,-7.146663 0.0533,-0.05333 v 0.05333 z M 91.366846,71.653614 h 0.05333 l -0.05333,0.05333 z M 104.06017,61.57362 v -0.05333 h 0.0533 z m 0.0533,-1.546666 c 0,-0.05333 0,-0.05333 0.0533,-0.106666 z m -14.719996,-5.33333 0.05333,0.05333 h -0.05333 z m 15.626656,15.626657 v -0.05333 l 0.0533,0.05333 z m -2.02666,-15.306657 c 0,-0.05333 0,-0.05333 0.0533,-0.106667 0,0.05333 0,0.05333 -0.0533,0.106667 z m -12.479997,6.826663 0.05333,-0.05333 v 0.05333 z M 103.63351,60.93362 c 0,0 0.0533,-0.05333 0.0533,-0.05333 0,0 -0.0533,-0.05333 -0.0533,-0.05333 z m 1.33333,8.906662 c 0,-0.05333 0,-0.05333 0.0533,-0.106667 0,0.05333 0,0.05333 -0.0533,0.106667 z" + style="font-size:53.3333px;font-family:'Wildemount Rough';-inkscape-font-specification:'Wildemount Rough, Normal'" + id="path920" /> + <path + d="m 126.07266,60.666954 c 0,-0.266667 -0.16,-0.586666 -0.48,-0.586666 -0.58667,0 -0.64,0.693332 -0.69333,1.119999 -0.42667,2.506665 -2.77334,7.306662 -5.70667,7.306662 -1.76,0 -1.97333,-0.479999 -3.14666,-1.706665 -0.32,-0.96 -0.96,-1.919999 -0.96,-2.986665 v -0.106667 c 0.37333,-0.106667 0.69333,-0.373333 1.01333,-0.586666 1.97333,-1.12 3.94667,-2.613332 3.94667,-5.119997 0,-1.706666 -1.44,-3.413331 -3.2,-3.413331 -4.16,0 -5.6,2.773331 -6.29333,6.293329 -0.10667,0.533333 -0.32,1.173333 -0.32,1.706666 0,1.706665 0.53333,3.253331 1.01333,4.85333 0.16,0.64 2.08,2.506665 2.66667,2.879998 0.8,0.533333 2.02666,0.8 2.98666,0.8 0.64,0 2.82667,-0.266667 3.30667,-0.746666 3.30666,-1.066666 5.86666,-6.45333 5.86666,-9.706661 z m -9.28,-4.213331 c 0.74667,0 1.33334,0.746666 1.33334,1.439999 0,1.973332 -1.65334,3.306665 -3.25333,4.159998 -0.0533,-0.213333 -0.10667,-0.96 -0.10667,-1.173333 v -0.64 c 0,-1.226665 0.42667,-3.786664 2.02666,-3.786664 z m -3.67999,4.266664 c 0,-0.16 0,-0.32 -0.0533,-0.48 v 0.48 l 0.0533,0.05333 v 0.266667 c -0.0533,-0.106667 -0.0533,-0.213333 -0.0533,-0.32 v 0.586667 l 0.0533,-0.106667 c 0.10666,0.213333 0.16,0.426666 0.16,0.64 0,0.159999 -0.0533,0.319999 -0.0533,0.426666 -0.0533,-0.106667 -0.0533,-0.266667 -0.0533,-0.373333 0,-0.106667 0.0533,-0.426667 -0.10667,-0.48 -0.0533,0.16 -0.0533,0.32 -0.0533,0.48 -0.10667,-0.586666 -0.16,-1.279999 -0.21333,-1.973332 -0.0533,0.426666 -0.0533,0.959999 -0.10667,1.013332 0,-0.16 -0.0533,-0.319999 -0.10667,-0.479999 l 0.0533,-0.106667 c -0.0533,0 -0.10667,-0.05333 -0.10667,-0.16 v -0.05333 h 0.16 v -0.213333 c 0,-0.106667 0,-0.266667 -0.0533,-0.373333 l -0.0533,0.213333 v -0.266667 l -0.0533,-0.106666 v -0.05333 l 0.16,-0.05333 c 0,-0.106667 0,-0.213333 -0.0533,-0.32 l 0.0533,-0.106666 c 0,0 0.0533,0.106666 0.0533,0.213333 0,-0.32 0.0533,-0.64 0.32,-0.906666 v 0.479999 l 0.0533,-0.05333 v 0.213333 l -0.0533,-0.05333 v 0.16 l 0.0533,-0.05333 c 0.0533,0.213333 0.10666,0.426666 0.10666,0.639999 0,0.32 0,0.906666 -0.10666,1.226666 z m 1.54666,7.626662 c -0.0533,0.16 -0.0533,0.32 -0.10666,0.48 l -0.16,-0.16 c 0,-0.373333 0.0533,-0.853333 0.16,-1.173333 0.0533,0.213334 0.10666,0.586667 0.10666,0.8 L 114.766,68.02695 v -0.213334 c 0.16,0.16 0.32,0.32 0.42667,0.48 -0.0533,0.16 -0.0533,0.373333 -0.21334,0.533333 0,0.106667 0.0533,0.213333 0.0533,0.266667 l -0.10667,-0.05333 v -0.479999 c -0.0533,0.159999 -0.0533,0.319999 -0.0533,0.479999 L 114.766,68.933616 c 0.0533,-0.106667 0.0533,-0.16 0.0533,-0.266667 l -0.0533,-0.05333 -0.0533,0.16 c 0,-0.16 0,-0.32 -0.0533,-0.426667 z m -1.76,-1.439999 c -0.0533,-0.32 -0.0533,-0.693333 -0.0533,-1.013333 v -1.866665 l -0.0533,0.106666 v -0.106666 l -0.0533,-0.106667 h 0.10667 v -0.639999 l 0.0533,-0.106667 c 0,0.64 0.0533,1.226666 0.0533,1.866666 v 1.013332 c 0,0.266667 0,0.586667 -0.0533,0.853333 z m 1.17334,1.066666 c 0,-0.106666 0,-0.16 0.0533,-0.266666 v -0.48 l -0.0533,0.05333 c 0,-0.266666 0.0533,-0.479999 0.0533,-0.746666 0.0533,0.05333 0.16,0.32 0.16,0.373333 v 0.64 c 0,0.213333 -0.0533,0.48 -0.10667,0.693333 z M 113.006,59.546955 c 0.0533,-0.16 0,-0.32 -0.10667,-0.48 v 0.213333 c 0,0.32 0,0.64 0.0533,0.959999 0.0533,-0.106666 0.10666,-0.159999 0.10666,-0.266666 l 0.0533,0.16 c 0,-0.05333 0.0533,-0.16 0.0533,-0.213333 0,-0.16 -0.10667,-0.373333 -0.10667,-0.533333 z m -0.32,3.519997 c -0.0533,-0.159999 -0.10667,-1.013332 0,-1.173332 z m 0.90667,-5.49333 c 0,0.16 0,0.32 -0.0533,0.426667 l -0.0533,0.05333 v -0.373333 z m -1.49334,6.346663 c 0.0533,0.32 0.0533,0.586667 0.0533,0.906666 l -0.0533,-0.106666 z m 0.69334,-3.999997 c 0,-0.16 0,-0.32 0,-0.426667 0,-0.05333 0,-0.266666 -0.0533,-0.426666 0,0.266666 0,0.533333 0.0533,0.853333 z M 112.206,61.41362 c -0.0533,-0.16 -0.0533,-0.32 -0.0533,-0.48 l 0.0533,-0.106666 z m 0.32,-0.64 c 0.0533,0.106667 0.0533,0.266667 0,0.373334 l -0.0533,-0.213334 z m 0.10667,1.12 c 0,-0.106667 0,-0.266667 0.0533,-0.373333 v 0.32 z m -0.74667,0.426666 c 0,0.106667 0.0533,0.213333 0,0.32 z m 2.08,4.586664 -0.0533,-0.106666 0.0533,-0.05333 z m 0,0.373333 -0.0533,-0.106666 0.0533,-0.05333 z" + style="font-size:53.3333px;font-family:'Wildemount Rough';-inkscape-font-specification:'Wildemount Rough, Normal'" + id="path922" /> + <path + d="m 134.04765,67.86695 c 1.17334,-0.64 1.92,-1.866666 2.4,-3.093332 1.38667,-0.533333 4.69333,-2.399998 4.69333,-4.106664 0,-0.213333 -0.21333,-0.426667 -0.42666,-0.426667 -0.32,0 -0.58667,0.16 -0.74667,0.426667 -0.8,1.226666 -1.92,2.239999 -3.30667,2.773332 0,-2.079999 -0.63999,-4.159998 -2.13333,-5.65333 -0.48,-0.48 -2.50666,-1.706666 -3.2,-1.706666 -0.26666,0 -0.58666,0 -0.85333,0 -0.96,0 -1.97333,0.05333 -2.77333,0.586666 -0.42667,0.266667 -0.53333,0.586667 -0.85333,0.906666 -1.44,1.599999 -2.18667,2.133332 -2.18667,4.533331 0,3.946664 3.09333,6.45333 6.82666,6.45333 0.32,0 2.24,-0.533333 2.56,-0.693333 z m -4.95999,-9.386661 c 0,-0.373334 0.26666,-1.44 0.8,-1.44 2.29333,0 2.93333,4.906664 3.14666,6.506663 -1.97333,-0.746666 -2.88,-1.706665 -3.52,-3.733331 -0.10666,-0.426666 -0.42666,-0.906666 -0.42666,-1.333332 z m 2.55999,8.159995 c -1.65333,0 -1.91999,-2.826665 -2.34666,-3.946665 1.22667,1.546666 1.86666,1.706666 3.68,2.293332 -0.21334,0.746667 -0.32,1.653333 -1.33334,1.653333 z m 2.98667,-2.986665 c 0,-0.426667 -0.10667,-0.8 -0.10667,-1.226666 v -0.106667 c 0.10667,-0.106666 0.0533,-0.799999 0,-0.906666 h -0.0533 v 0.05333 l -0.0533,0.05333 c 0.0533,-0.16 0.10666,-0.373333 0.16,-0.533333 h 0.10666 c 0.0533,0.373333 0.10667,0.746666 0.10667,1.066666 0,0.213333 -0.0533,0.426666 -0.10667,0.586666 0,0.373333 0.10667,0.693333 -0.0533,1.013333 z m -7.2,0.106666 c 0.21334,0.213334 0.16,0.64 0.16,0.906667 v 0.853332 c -0.10666,-0.373333 -0.16,-0.799999 -0.16,-1.226666 0.10667,-0.106666 -0.0533,-0.319999 -0.0533,-0.426666 0,-0.05333 0.0533,-0.106667 0.0533,-0.106667 z m 7.25333,1.066666 c 0,-0.213333 0.0533,-1.333332 0.10667,-1.439999 0,0.05333 0.0533,0.106667 0.0533,0.16 0,-0.05333 0,-0.05333 0.0533,-0.106666 v 0.213333 c 0,0.373333 -0.0533,0.693333 -0.10667,1.013333 z m 0.26667,-0.693333 c 0,-0.266666 0.0533,-0.533333 -0.0533,-0.799999 0.10666,-0.426666 0.0533,-0.959999 0.0533,-1.386666 0.0533,0.213333 0.10667,0.426667 0.10667,0.64 0,0.373333 0,1.386666 -0.10667,1.546665 z m -0.16,-0.799999 v -0.05333 l 0.0533,0.05333 z m -0.21333,-2.079999 0.0533,0.05333 v -0.05333 z m 0.0533,1.439999 v -0.106666 z m -7.14666,-0.639999 v -0.106667 z" + style="font-size:53.3333px;font-family:'Wildemount Rough';-inkscape-font-specification:'Wildemount Rough, Normal'" + id="path924" /> + <path + d="m 155.71431,60.400287 c 0,-1.653332 -0.21334,-5.386663 -0.8,-6.879996 -0.16,-0.373333 0,-0.853332 -0.16,-1.226665 -0.26667,-0.8 -0.42667,-1.599999 -0.74667,-2.399999 -1.17333,-2.986665 -3.46666,-8.159995 -7.30666,-8.159995 -1.44,0 -3.30666,0.746666 -4,2.133332 -0.26666,-0.266667 -0.69333,-0.533333 -1.06666,-0.533333 -1.97334,0 -1.86667,0.906666 -1.86667,2.346665 0,0.426667 -0.10667,0.853333 -0.10667,1.279999 v 11.679993 c 0,0.853333 0.16,1.706666 0.21334,2.559999 0.16,2.613332 0.26666,5.119997 0.58666,7.733329 -0.26666,0.16 -0.48,0.426666 -0.48,0.746666 0,0.48 0.37334,0.533333 0.69334,0.799999 0.42666,1.333333 -0.16,1.653333 1.33333,2.239999 0.37333,0.16 0.8,0.32 1.22667,0.32 1.43999,0 2.61333,-0.32 2.61333,-2.026666 6.34666,-0.799999 9.86666,-4.21333 9.86666,-10.613327 z m -9.81333,9.013328 c 0,-2.079998 0.0533,-4.21333 -0.0533,-6.293329 -0.0533,-0.586667 -0.16,-1.12 -0.16,-1.706666 0,-2.933331 -0.64,-5.919996 -0.64,-8.853328 0,-1.546666 -0.21334,-3.093331 -0.21334,-4.639997 0,-1.226666 -0.0533,-4.853331 1.70667,-4.853331 0.64,0 1.12,0.64 1.44,1.173333 2.24,3.679998 3.25333,10.399994 3.30666,14.613325 0,0.426666 0.16,0.906666 0.16,1.333332 0,3.039998 -0.53333,6.45333 -3.14666,8.266662 -0.53333,0.373333 -0.53333,0.586666 -1.17333,0.746666 -0.42667,0.106667 -0.85334,0.05333 -1.22667,0.213333 z m -4.42666,-6.879996 c 0,-1.333332 -0.21334,-2.613331 -0.21334,-3.946664 v -4.85333 c 0.37334,0.639999 0.37334,3.679997 0.37334,4.479997 0,1.226666 -0.0533,2.506665 -0.10667,3.733331 h 0.0533 v 0.48 z m 2.4,-4.266664 v 1.066666 l -0.0533,0.05333 h 0.0533 c 0,0.586666 0,1.173332 0.10666,1.759999 -0.0533,0.106666 -0.10666,0.213333 -0.10666,0.373333 0,0.426666 0.10666,0.799999 0.10666,1.226666 0,0.106666 0,0.319999 -0.16,0.319999 0,-0.266666 -0.0533,-0.533333 -0.0533,-0.799999 0,-0.853333 0.26667,-2.079999 -0.0533,-2.933332 v 0.746667 l -0.0533,-0.106667 v -0.32 c -0.26666,-0.426666 -0.26666,-1.173332 -0.26666,-1.653332 0,-0.693333 0.10666,-1.439999 0.10666,-2.133332 l -0.10666,-0.213333 v -0.05333 c 0.10666,-0.213333 0.10666,-0.479999 0.16,-0.693333 v 0.106667 l 0.0533,0.106667 c 0,0.693332 0.16,1.493332 0.16,2.186665 l -0.0533,0.05333 h 0.0533 c 0,0.106667 0.0533,0.16 0.0533,0.266667 v 0.373333 l -0.10666,0.106666 0.0533,-0.159999 c -0.0533,0.106666 -0.0533,0.266666 -0.0533,0.373333 h 0.0533 v 0.106666 l -0.0533,-0.05333 v 0.16 l 0.0533,-0.05333 c 0,0.106666 0,0.213333 0.0533,0.319999 0,-0.106666 0,-0.213333 0.0533,-0.319999 l -0.0533,-0.16 z m -2.13334,6.77333 0.0533,0.05333 v -0.05333 c 0.0533,0.106666 0.0533,0.213333 0.16,0.319999 -0.16,-0.586666 0.0533,-1.119999 0.0533,-1.653332 l 0.0533,-2.666665 c 0.10667,0.106667 0.10667,0.32 0.10667,0.48 0,0.16 -0.0533,0.373333 0,0.533333 0.16,-0.32 0.0533,-0.426667 0.0533,-0.746666 0.0533,0.426666 0.0533,0.906666 0.0533,1.333332 0,1.013333 -0.16,2.079999 -0.16,3.093332 l -0.0533,0.05333 h -0.0533 l -0.10667,-0.16 v 0.426666 l -0.0533,-0.266666 -0.0533,0.16 -0.0533,-0.106667 z m -0.42666,-12.10666 c -0.16,-1.493332 0,-3.306665 0,-4.799997 l -0.0533,0.05333 v -0.479999 c 0.0533,0.106666 0.0533,0.213333 0.0533,0.319999 l 0.10666,-0.373333 c 0,0.586667 -0.16,1.066666 0.0533,1.653333 0.0533,-0.16 0.0533,-0.266667 0.0533,-0.426667 l 0.0533,0.05333 c 0,0.159999 0,0.319999 0.0533,0.479999 -0.0533,0.106667 -0.0533,0.16 -0.0533,0.266667 l -0.0533,-0.213333 -0.0533,0.106666 c 0,0.106667 0,0.213333 0.0533,0.32 -0.10667,0.05333 0,0.426666 0,0.533333 0,-0.106667 0.0533,-0.32 0.10667,-0.426666 0,0.159999 0,0.373333 0.0533,0.533333 -0.0533,0.16 -0.0533,0.373333 -0.0533,0.533333 l -0.10667,-0.373333 c -0.0533,0.266666 -0.0533,0.586666 -0.0533,0.853332 v 0.05333 h 0.0533 l 0.0533,-0.106667 c 0,0.05333 0,0.16 0.0533,0.213333 -0.0533,0.16 -0.0533,0.266667 -0.0533,0.426667 l -0.0533,0.106666 -0.0533,-0.266666 c -0.10667,0.479999 -0.0533,0.959999 -0.0533,1.439999 l -0.10666,-0.373333 -0.0533,0.32 v -0.32 z m 12.58666,8.426662 c -0.21334,-0.906666 0,-2.079999 -0.26667,-2.879998 0.10667,-0.533333 0.0533,-1.013333 0.0533,-1.546666 0.16,0.959999 0,1.973332 0.16,2.933331 0,-0.106666 0.0533,-0.213333 0.0533,-0.319999 0,-0.48 -0.10667,-1.013333 -0.10667,-1.546666 0,-0.05333 0,-0.266667 0.0533,-0.266667 0.0533,0.746667 0.10667,1.493333 0.10667,2.239999 0,0.213333 -0.0533,0.48 -0.0533,0.693333 l -0.0533,-0.05333 c 0.0533,0.213333 0.0533,0.479999 0.0533,0.746666 z m -0.74667,2.986665 c -0.0533,-0.373333 -0.10667,-0.746667 -0.10667,-1.12 v -0.106666 l 0.0533,-0.05333 h -0.0533 V 61.89362 c 0,-0.373333 0,-0.693333 0.0533,-1.013333 0,0.853333 0.16,1.759999 0.16,2.613332 0,0.213333 0.0533,0.693333 -0.10667,0.853333 z m -1.22667,-15.253325 c 0.16,0.373334 0.21334,0.533333 0.21334,0.96 v 1.013333 c 0,0.16 0,0.319999 -0.0533,0.479999 -0.10666,-0.266666 -0.21333,-0.639999 -0.21333,-0.959999 v -0.8 l 0.0533,0.16 z m 0.53334,4.426664 c -0.0533,-0.266666 -0.16,-0.799999 -0.16,-1.066666 v -2.559998 c 0.0533,0.106667 0.16,0.373333 0.16,0.426666 z m 1.17333,4.959998 c 0,0.426666 -0.0533,0.853332 -0.0533,1.279999 -0.10667,-0.693333 -0.16,-1.386666 -0.16,-2.079999 0,-0.213333 0,-0.8 0.21333,-0.959999 -0.0533,0.586666 0,1.173332 0,1.759999 z m -0.74667,-6.879996 c 0,0.213333 -0.0533,0.479999 -0.16,0.693333 0.0533,0.159999 0.0533,0.319999 0.0533,0.479999 l -0.10667,0.16 c -0.0533,-0.799999 -0.10667,-1.546666 -0.10667,-2.346665 0.16,0.266666 0.16,0.64 0.26667,0.959999 z m -11.62666,-4.586664 c 0,-0.693333 0.10667,-1.333333 0.10667,-2.026666 l 0.10667,-0.05333 0.10666,0.16 c -0.0533,0.639999 -0.0533,1.653332 -0.32,2.239999 z m 2.13334,5.119997 -0.0533,-0.213334 c -0.0533,0.16 0,0.266667 0,0.426667 h 0.0533 l -0.0533,0.16 v -0.16 c -0.10666,-0.32 -0.16,-1.493333 -0.16,-1.813332 0,-0.106667 0.0533,-0.213334 0.0533,-0.32 v 0.266666 c 0,-0.32 -0.0533,-1.439999 0.10666,-1.706665 0,0.853332 0.0533,1.653332 0.0533,2.506665 z m 0.53333,13.439992 c 0,0.05333 -0.0533,0.426666 -0.0533,0.426666 0,-0.32 -0.16,-0.64 -0.16,-0.959999 0,-0.16 0,-0.48 0.10666,-0.586667 0.10667,0.373333 0.10667,0.746667 0.10667,1.12 z m 9.75999,-9.226662 c 0,-0.213333 0,-0.479999 0.0533,-0.639999 0,0.48 0.10666,0.959999 0.10666,1.493332 0,0.16 0,0.32 0,0.48 0,-0.32 -0.10666,-0.64 -0.10666,-0.959999 l -0.0533,0.159999 v -0.213333 h 0.0533 l -0.0533,-0.266666 v 0.106666 c 0,-0.05333 0,-0.106666 0,-0.16 z m -11.78666,5.65333 c -0.0533,-0.319999 -0.0533,-0.639999 -0.0533,-0.959999 v -0.373333 c 0.0533,0.106666 0.10667,0.266666 0.10667,0.373333 0,0.32 -0.0533,0.64 -0.0533,0.959999 z m 0.53334,4.053331 c -0.0533,-0.106666 -0.0533,-0.266666 -0.0533,-0.373333 l -0.0533,0.106667 h -0.10667 c 0,-0.16 0.0533,-0.373333 0.10667,-0.533333 h 0.16 c -0.0533,0.32 0,0.586666 0,0.853333 z m 11.14666,-4.53333 c 0.0533,0.426666 0.16,0.853333 0.26666,1.226666 l -0.10666,0.373333 c -0.0533,-0.373333 -0.16,-0.853333 -0.16,-1.226666 z m -1.22667,4.799997 c -0.10667,0.05333 -0.10667,0.16 -0.16,0.266666 v -0.639999 l 0.10667,-0.266667 c 0,0.213333 0.0533,0.426667 0.0533,0.64 z m 0.10667,-1.333333 c 0,0.373333 0.0533,0.746667 0.0533,1.12 -0.0533,0 -0.0533,0.05333 -0.0533,0.106666 -0.10667,-0.32 -0.0533,-0.799999 -0.0533,-1.119999 z m -9.44,-15.146657 c 0.10667,0.479999 0.10667,0.959999 0,1.386666 z m 10.61333,5.01333 c 0.0533,0.213333 0.10667,0.373333 0.10667,0.586666 v 0.16 l -0.10667,0.05333 c 0,-0.266667 0,-0.586667 -0.0533,-0.853333 z m -0.0533,5.066664 c 0.0533,0.373333 0.10666,0.906666 0.10666,1.279999 l -0.0533,-0.106667 -0.0533,0.106667 v -0.533333 0.05333 z m -0.10667,1.119999 c -0.10667,-0.266667 -0.0533,-0.853333 -0.0533,-1.119999 0.0533,0.213333 0.10666,0.906666 0.0533,1.119999 z m -0.26667,3.893331 -0.10666,0.32 v -0.16 c 0,-0.16 0,-0.32 0.0533,-0.426666 z m -10.77332,1.866666 c 0,-0.05333 -0.10667,-0.213334 -0.10667,-0.266667 0,-0.106667 0.0533,-0.213333 0.10667,-0.32 z m -0.85334,-3.306665 c -0.0533,-0.266667 -0.0533,-0.533333 0,-0.8 z m 11.41333,-1.279999 v 0.693333 c -0.0533,-0.213334 -0.0533,-0.48 -0.0533,-0.693333 z m -9.65333,-9.653328 v 0.106667 l 0.10667,-0.05333 v 0.05333 l -0.0533,0.213333 -0.10667,-0.05333 c 0.0533,-0.106667 0,-0.16 0,-0.266667 z m 10.61333,5.279997 v -0.693333 c 0.0533,0.213333 0.0533,0.586666 0.0533,0.8 z m -2.98667,-10.559994 0.0533,-0.213333 0.0533,0.05333 v 0.373333 z m -9.49332,0.853333 c 0.10666,0.106667 0.0533,0.426666 0.0533,0.586666 -0.0533,-0.213333 -0.0533,-0.373333 -0.0533,-0.586666 z m 0,4.85333 c 0,-0.213333 0,-0.426666 0.0533,-0.586666 0,0.213333 0.0533,0.426667 -0.0533,0.586666 z m 0.69333,15.893324 c 0,0.16 -0.0533,0.32 0,0.48 0,-0.16 0.0533,-0.32 0,-0.48 z m -0.53333,-7.946662 c 0,0.213334 0,0.48 -0.0533,0.693333 0,-0.213333 0,-0.479999 0.0533,-0.693333 z m 0.58666,1.653333 c 0,-0.106667 -0.0533,-0.106667 -0.10666,-0.16 v 0.32 z m 1.49334,-0.48 c -0.0533,0.213333 -0.0533,0.48 -0.0533,0.746666 -0.0533,-0.266666 -0.0533,-0.533333 0,-0.799999 z m -1.49334,6.186663 c -0.0533,0.213333 -0.0533,0.373333 -0.0533,0.586666 0.0533,-0.213333 0.0533,-0.373333 0.0533,-0.586666 z m 1.76,-0.586666 v -0.05333 l 0.0533,-0.106666 c 0,-0.05333 0,-0.106667 0.0533,-0.106667 0,0.106667 0,0.213333 -0.0533,0.32 z m -1.44,-0.853333 c 0,-0.16 -0.0533,-0.266667 -0.0533,-0.426667 0.10667,0.106667 0.10667,0.266667 0.0533,0.426667 z m -0.37333,-4.639997 c 0.0533,0.05333 0.10667,0 0.0533,-0.05333 -0.0533,0.05333 -0.10666,0.16 -0.10666,0.213334 h 0.10666 z m 10.82666,-2.826665 c -0.0533,-0.106667 -0.0533,-0.213334 -0.0533,-0.32 l 0.0533,-0.106667 z m 0.53333,3.733331 c 0,-0.16 -0.0533,-0.266667 0,-0.426667 0,0.16 0.0533,0.266667 0,0.426667 z m -10.71999,7.146662 -0.0533,0.106667 0.0533,0.16 z m 10.18666,-8.959994 -0.0533,0.05333 v -0.213333 l 0.0533,-0.05333 z m 0.58667,-4.906664 c -0.0533,-0.106667 -0.0533,-0.213333 0,-0.32 0.0533,0.106667 0,0.213333 0,0.32 z m -11.52,9.599994 v 0.16 l -0.0533,0.05333 0.0533,-0.16 c -0.0533,0.05333 -0.10666,0 -0.10666,-0.05333 z m 0.64,2.026666 c 0.0533,-0.106667 0.0533,-0.213334 0,-0.32 z m 1.17334,-1.6 c 0,0 -0.0533,-0.05333 -0.0533,-0.106666 0,0 0.0533,-0.05333 0.0533,-0.106667 l 0.0533,0.106667 c 0,0.05333 0,0.05333 -0.0533,0.106666 z m 9.11999,-4.53333 v 0.16 c -0.0533,-0.05333 -0.0533,-0.05333 -0.0533,-0.16 z m 0,1.439999 h -0.0533 v -0.16 h 0.0533 z m -9.22666,1.013333 0.0533,0.106666 c -0.0533,0 -0.10666,-0.05333 -0.10666,-0.106666 z m -0.10667,-4.053331 -0.0533,-0.106667 c 0,-0.05333 0,-0.05333 0.0533,-0.106666 z m -0.53333,-7.093329 c 0,-0.05333 0,-0.16 0.0533,-0.213334 0,0.05333 0,0.16 -0.0533,0.213334 z m -1.22667,12.906659 c 0.0533,0.106666 0.0533,0.213333 0,0.266666 z m 11.57333,-11.359994 c 0,0.106667 0.0533,0.213334 0,0.32 z m -10.13333,15.039991 c 0,0.05333 0,0.106667 -0.0533,0.16 v -0.106666 z m 0.37334,-10.50666 0.0533,-0.05333 -0.0533,-0.106667 z m -1.12,8.533328 -0.0533,-0.106666 v 0.16 z m 1.06666,3.573332 v -0.16 l 0.0533,0.16 z m 9.06666,-17.813323 v 0.16 c 0,-0.05333 -0.0533,-0.106667 -0.0533,-0.106667 z m -9.54666,-6.026663 c -0.0533,-0.106667 -0.0533,-0.16 0,-0.213333 z m -1.01333,15.573324 c 0,-0.05333 0,-0.106667 -0.0533,-0.16 0,0.05333 0,0.106666 0.0533,0.16 z m 1.01333,-14.986658 c 0,-0.05333 -0.0533,-0.16 -0.0533,-0.213333 0.0533,0.05333 0.0533,0.16 0.0533,0.213333 z m 9.92,16.426657 0.0533,-0.106667 c 0,0.05333 0,0.106667 -0.0533,0.16 z m 0.37333,-4.853331 c 0,0 -0.0533,0.05333 -0.10667,0.05333 l 0.0533,-0.05333 z m -11.67999,5.546664 c 0,0 -0.0533,0.05333 -0.0533,0.05333 0,0 0.0533,0.05333 0.0533,0.05333 0,0 0.0533,-0.05333 0.0533,-0.05333 0,0 -0.0533,-0.05333 -0.0533,-0.05333 z m -0.26667,-2.933332 0.0533,-0.106666 c 0,0 -0.0533,-0.05333 -0.0533,-0.05333 z m 0.90667,3.093332 c 0,0 -0.0533,-0.05333 -0.0533,-0.05333 v 0.16 z m -0.16,1.706665 0.0533,0.05333 c 0,0.05333 -0.0533,0.106666 -0.0533,0.106666 z m 0.96,-16.47999 0.0533,-0.106666 c 0,0 -0.0533,-0.05333 -0.0533,-0.05333 z m -1.70667,13.333326 v -0.106667 c 0.0533,0.05333 0.0533,0.05333 0.0533,0.106667 z m 1.81333,6.186663 -0.0533,-0.106667 c 0,0 0.0533,-0.05333 0.0533,-0.05333 z m 0.53334,-3.573332 0.0533,0.05333 c 0,0.05333 -0.0533,0.106666 -0.0533,0.106666 z m -0.90667,2.133332 c 0.0533,0 0.0533,0.05333 0.0533,0.106667 -0.0533,0 -0.0533,-0.05333 -0.0533,-0.106667 z m -1.28,-8.479995 c 0,0.05333 0,0.106667 -0.0533,0.16 z m 0.69333,5.97333 h -0.0533 v -0.05333 z m -0.8,-16.906656 h -0.0533 v 0.05333 z m 2.08,11.359993 0.0533,-0.05333 v 0.05333 z m -0.0533,10.18666 c -0.0533,-0.05333 -0.0533,-0.05333 -0.0533,-0.106666 z M 143.50098,55.76029 c 0.0533,-0.05333 0.0533,-0.05333 0.0533,-0.106666 v 0.05333 z m -1.97333,-3.999997 v 0.05333 l 0.0533,-0.05333 z m 2.13333,8.959994 0.0533,0.05333 h -0.0533 z m -0.90666,7.519996 -0.0533,0.05333 h 0.0533 z m 11.14666,-8.266662 c -0.0533,0.05333 -0.0533,0.05333 -0.0533,0.106667 0.0533,-0.05333 0.0533,-0.05333 0.0533,-0.106667 z m -12.58666,-9.919994 h 0.0533 v -0.05333 z m 0.53333,10.133327 c 0,0 0.0533,0.05333 0.0533,0.05333 0,0 -0.0533,0.05333 -0.0533,0.05333 z m 1.12,8.213329 h -0.0533 v -0.05333 z m 10.45333,-8.959995 h -0.0533 l 0.0533,-0.05333 z m 0,0.106667 0.0533,0.05333 h -0.0533 z" + style="font-size:53.3333px;font-family:'Wildemount Rough';-inkscape-font-specification:'Wildemount Rough, Normal'" + id="path926" /> + <path + d="m 171.9068,62.533619 c 0,-3.199998 -2.82667,-4.586663 -5.38667,-5.439996 1.65334,-1.653333 2.82667,-3.999998 2.82667,-6.399996 0,-1.226666 0.0533,-2.719999 -0.58667,-3.839998 -0.90666,-1.546666 -2.08,-3.039998 -4.10666,-3.039998 -1.01333,0 -1.86667,0.319999 -2.61333,0.959999 -0.21334,-1.546666 0.10666,-3.039998 -2.08,-3.039998 -1.81333,0 -1.97333,0.906666 -1.97333,2.453332 0,1.813332 0.16,3.679998 0.16,5.546663 0,3.039998 0.16,6.079996 0.16,9.173328 0,1.919999 0.21333,3.839998 0.21333,5.759997 0,0.586666 0.16,1.119999 0.16,1.706665 0,0.16 -0.0533,0.266667 -0.10667,0.426667 -0.53333,0.159999 -0.74666,0.586666 -0.74666,1.119999 0,0.426666 0.16,1.173333 0.69333,1.226666 l 0.16,-0.05333 v 0.266666 c 0,1.013333 -0.37333,2.773332 0.8,3.253331 0.37333,0.16 0.85333,0.426667 1.22666,0.426667 0.8,0 3.2,-0.106667 3.2,-1.279999 -0.32,-0.693333 0,-1.546666 -0.21333,-2.239999 3.84,0 8.21333,-2.773332 8.21333,-6.986663 z m -7.62667,-4.426664 c 2.24,0 5.06667,1.546666 5.06667,4.053331 0,2.826665 -3.52,4.319998 -5.81333,4.639998 -0.10667,-0.8 -0.26667,-1.653333 -0.26667,-2.453332 0,-1.546666 -0.10666,-4.693331 -0.32,-6.186663 0.16,-0.05333 0.32,-0.05333 0.48,-0.05333 z M 164.8668,45.25363 c 1.33333,0 1.97333,1.173332 1.97333,2.453332 0,3.199998 -1.70666,6.559996 -3.94666,8.799994 -0.21333,-0.426666 -0.53333,-6.719996 -0.53333,-7.413329 0,-1.386665 0.37333,-2.133332 1.33333,-3.093331 0.21333,-0.213333 0.85333,-0.746666 1.17333,-0.746666 z m -3.62666,14.506658 c 0,0.586666 0,1.173332 0.10666,1.759999 -0.0533,0.106666 -0.10666,0.213333 -0.10666,0.373333 0,0.426666 0.10666,0.799999 0.10666,1.226666 0,0.106666 0,0.32 -0.16,0.32 0,-0.266667 -0.0533,-0.533333 -0.0533,-0.8 0,-0.959999 0.26667,-1.973332 -0.0533,-2.933332 v 0.746667 l -0.0533,-0.106667 v -0.32 c -0.26666,-0.426666 -0.26666,-1.173332 -0.26666,-1.653332 0,-0.693333 0.10666,-1.439999 0.10666,-2.133332 l -0.10666,-0.213333 v -0.05333 c 0.21333,-0.373333 0.10666,-0.853332 0.21333,-1.279999 0,0.16 0,0.32 0,0.48 l 0.0533,-0.106667 c 0.0533,0.48 0.0533,0.96 0.0533,1.439999 l 0.0533,0.05333 0.0533,-0.213334 c 0.10667,0.533333 0.16,1.013333 0.16,1.546666 -0.0533,0.213333 0.0533,0.693333 0.0533,0.906666 l -0.0533,-0.05333 c 0.0533,0.106667 0.0533,0.373333 0.0533,0.533333 -0.0533,-0.213333 -0.10667,-0.426666 -0.21334,-0.586666 0.10667,-0.32 0.16,-1.599999 -0.0533,-1.813333 0,0.373334 0,0.693333 -0.0533,1.066667 l 0.0533,-0.213334 c 0.0533,0.213334 0.0533,0.426667 0.0533,0.64 l -0.10666,0.106667 0.0533,-0.16 c -0.0533,0.106666 -0.0533,0.266666 -0.0533,0.373333 h 0.0533 v 0.106666 l -0.0533,-0.05333 v 0.16 l 0.0533,-0.05333 c 0,0.106666 0,0.213333 0.0533,0.319999 0,-0.106666 0,-0.213333 0.0533,-0.319999 v 0.853332 l -0.0533,0.05333 z m -0.32,-8.693328 c -0.0533,0.479999 0.0533,0.906666 0.0533,1.386665 l 0.0533,0.106667 v 0.8 l -0.0533,0.05333 v -0.533333 c -0.0533,0.16 -0.0533,0.32 -0.0533,0.48 l -0.0533,0.05333 c 0,-0.373333 -0.0533,-0.906666 0.0533,-1.279999 h -0.0533 c 0,-0.106667 0,-0.266667 0.0533,-0.373333 l -0.0533,-0.05333 c -0.16,0.16 -0.10666,0.586667 -0.10666,0.8 l -0.0533,-0.213333 v 0.426666 h 0.0533 l -0.0533,0.16 v -0.16 c -0.10666,-0.32 -0.16,-1.493332 -0.16,-1.813332 0,-0.48 0.10667,-0.96 0.10667,-1.439999 l 0.0533,0.106666 0.0533,-0.106666 c 0,0.479999 0.16,0.853332 0.16,1.279999 v 0.266666 l -0.0533,0.05333 z m -0.64,20.159988 c 0,-0.32 -0.0533,-0.586667 -0.0533,-0.906667 v -0.479999 l 0.0533,-0.106667 v 0.213333 l 0.0533,-0.106666 v -0.106667 l 0.0533,0.05333 v 0.853333 c 0.0533,-0.05333 0.0533,-0.16 0.0533,-0.213333 0.0533,0.32 0.0533,0.586666 0.0533,0.906666 z m 1.01333,-5.279997 c 0,0.05333 -0.0533,0.426666 -0.0533,0.426666 0,-0.32 -0.16,-0.639999 -0.16,-0.959999 0,-0.16 0,-0.48 0.10666,-0.586667 0.10667,0.373334 0.10667,0.746667 0.10667,1.12 z m -1.28,1.813332 c 0,0.16 0.26667,1.119999 0.32,1.173333 l -0.0533,0.106666 c 0,-0.213333 -0.10667,-0.533333 -0.21334,-0.693333 v -0.266666 0.266666 c -0.0533,-0.106666 -0.10666,-0.266666 -0.21333,-0.319999 l 0.10667,-0.213334 v -0.05333 z m 0.37333,-17.546656 c 0.10667,0.48 0.10667,0.959999 0,1.386666 z m 0.32,-1.386666 v 0.106667 h -0.0533 c 0,-0.05333 -0.0533,-0.106667 -0.0533,-0.16 0,-0.106667 0.10666,-0.16 0.16,-0.16 v 0.213333 z m -0.0533,4.373331 c 0.0533,-0.106667 0,-0.16 0,-0.266667 h 0.0533 v 0.106667 l 0.10667,-0.05333 -0.0533,0.266667 z m 0.69333,6.559996 c 0,-0.05333 0.0533,-0.16 0.0533,-0.213333 0,0.05333 0.0533,0.159999 0.0533,0.213333 v 0.106666 l -0.10667,0.05333 z m 4.58667,-4.426664 c 0.0533,-0.213333 0.21333,-0.373333 0.32,-0.533333 v 0.106666 z m -4.85333,7.146662 c -0.0533,0.213333 -0.0533,0.48 -0.0533,0.746666 -0.0533,-0.266666 -0.0533,-0.533333 0,-0.799999 z m 0.26666,5.546664 0.0533,-0.106667 c 0,-0.05333 0,-0.106667 0.0533,-0.106667 0,0.106667 0,0.213334 -0.0533,0.32 l -0.0533,-0.05333 z m -0.32,-14.399992 c -0.0533,-0.05333 -0.0533,-0.106667 -0.0533,-0.213333 l 0.0533,0.05333 z m 0.26667,13.066659 c 0,0.05333 0,0.05333 -0.0533,0.106667 0,0 -0.0533,-0.05333 -0.0533,-0.106667 0,0 0.0533,-0.05333 0.0533,-0.106667 z m 0.10667,-6.45333 -0.0533,-0.213333 c 0.0533,0.05333 0.0533,0.106667 0.10667,0.16 z m -0.26667,-4.959997 c -0.0533,-0.106666 -0.0533,-0.213333 -0.0533,-0.319999 0.0533,0.106666 0.0533,0.213333 0.0533,0.319999 z m -0.48,-5.919996 c 0,-0.106667 0,-0.16 0.0533,-0.266667 0,0.106667 0,0.16 -0.0533,0.266667 z m 0.48,15.359991 0.0533,0.106666 c -0.0533,0 -0.10666,-0.05333 -0.10666,-0.106666 z m -0.64,-11.14666 c 0,-0.05333 0,-0.16 0.0533,-0.213333 0,0.05333 0,0.159999 -0.0533,0.213333 z m 0.53333,7.093329 -0.0533,-0.106667 c 0,-0.05333 0,-0.05333 0.0533,-0.106666 z m -0.0533,-6.45333 v -0.16 l 0.0533,0.05333 z m -0.26667,15.946658 c 0,0.05333 0,0.106666 -0.0533,0.159999 v -0.106666 z m 0.42667,-10.559994 -0.0533,-0.106667 v 0.16 z m -0.85333,8.639995 c 0,0 -0.0533,0 -0.0533,-0.05333 0,0 0.0533,-0.05333 0.0533,-0.05333 z m 0.26666,-20.319988 c -0.0533,-0.106667 -0.0533,-0.16 0,-0.213333 z m 0,0.586666 c 0,-0.05333 -0.0533,-0.16 -0.0533,-0.213333 0.0533,0.05333 0.0533,0.16 0.0533,0.213333 z m 0.16,22.026654 -0.0533,-0.106667 c 0,0 0.0533,-0.05333 0.0533,-0.05333 z m 0,-21.439988 v -0.106666 l 0.0533,0.16 z m 0.53334,17.866656 0.0533,0.05333 c 0,0.05333 -0.0533,0.106666 -0.0533,0.106666 z m -0.64,-15.946657 0.0533,-0.106666 c 0,0 -0.0533,-0.05333 -0.0533,-0.05333 z m -0.26667,18.07999 c 0.0533,0 0.0533,0.05333 0.0533,0.106666 -0.0533,0 -0.0533,-0.05333 -0.0533,-0.106666 z m 0,0.373333 h -0.0533 v -0.106667 z m 0.53333,-13.333326 c 0.0533,-0.05333 0.0533,-0.05333 0.0533,-0.106666 v 0.05333 z m 0.21334,5.013331 h -0.0533 v -0.05333 z m 0.32,-1.813333 0.0533,0.05333 h -0.0533 z m -1.06667,9.439995 h -0.0533 v -0.05333 z m 0.74667,-7.733329 h -0.0533 l 0.0533,-0.05333 z m -0.10667,10.186661 c -0.0533,-0.05333 -0.0533,-0.05333 -0.0533,-0.106667 z" + style="font-size:53.3333px;font-family:'Wildemount Rough';-inkscape-font-specification:'Wildemount Rough, Normal'" + id="path928" /> + </g> +</svg> diff --git a/common/static/img/logo_square.jpg b/common/static/img/logo_square.jpg index 37ed7679cdf0eb96115865560d3386e3af8970c8..073cc571293d57ac64773cb0251cd4f38f4d2839 100644 GIT binary patch literal 32105 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 ZLH)leC;mIeLH+-6<?dg(KGeUa{s*dci825H literal 42490 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 diff --git a/common/static/img/logo_square.svg b/common/static/img/logo_square.svg index ccc0ff8d..76251fed 100644 --- a/common/static/img/logo_square.svg +++ b/common/static/img/logo_square.svg @@ -1 +1,193 @@ -<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 30 30"><title>logo</title><path d="M2.46,18h.32l.55,1,.18.38h0a5.66,5.66,0,0,1,0-.6V18h.29v1.75H3.45l-.54-1-.18-.38h0c0,.19,0,.4,0,.59v.77h-.3Z" style="fill:#606c76"/><path d="M5.45,18h.31v1.75H5.45Z" style="fill:#606c76"/><path d="M7.34,18.88a.82.82,0,0,1,.8-.91.67.67,0,0,1,.5.22l-.16.2a.47.47,0,0,0-.34-.15c-.28,0-.48.24-.48.63s.18.64.48.64a.49.49,0,0,0,.37-.18l.17.2a.73.73,0,0,1-.56.25A.8.8,0,0,1,7.34,18.88Z" style="fill:#606c76"/><path d="M10.2,18h1.05v.26h-.74v.45h.63V19h-.63v.52h.77v.26H10.2Z" style="fill:#606c76"/><path d="M12.86,18h.47a.78.78,0,0,1,.85.87.78.78,0,0,1-.84.88h-.48Zm.45,1.5c.35,0,.55-.2.55-.63s-.2-.62-.55-.62h-.14V19.5Z" style="fill:#606c76"/><path d="M15.76,18h.56c.36,0,.62.11.62.43a.39.39,0,0,1-.24.38h0a.41.41,0,0,1,.34.42c0,.35-.29.51-.67.51h-.61Zm.53.72c.25,0,.35-.09.35-.24s-.11-.24-.34-.24h-.23v.48Zm0,.79c.26,0,.4-.09.4-.29s-.14-.26-.4-.26h-.26v.55Z" style="fill:#606c76"/><path d="M18.53,19.57a.2.2,0,1,1,.4,0,.2.2,0,1,1-.4,0Z" style="fill:#606c76"/><path d="M20.46,18.87c0-.57.31-.9.77-.9s.77.34.77.9-.32.91-.77.91S20.46,19.43,20.46,18.87Zm1.22,0c0-.39-.18-.63-.45-.63s-.45.24-.45.63.17.64.45.64S21.68,19.26,21.68,18.87Z" style="fill:#606c76"/><path d="M23.58,18h.59c.36,0,.64.13.64.52s-.28.55-.64.55h-.28v.68h-.31Zm.56.82c.24,0,.37-.1.37-.3s-.13-.27-.37-.27h-.25v.57Zm0,.16.23-.18.54.95h-.35Z" style="fill:#606c76"/><path d="M26.3,18.88a.83.83,0,0,1,.83-.91.72.72,0,0,1,.52.22l-.16.2a.5.5,0,0,0-.36-.15c-.31,0-.51.24-.51.63s.18.64.53.64a.44.44,0,0,0,.25-.07v-.38h-.33V18.8h.61v.77a.8.8,0,0,1-.56.21A.81.81,0,0,1,26.3,18.88Z" style="fill:#606c76"/><path d="M6.06,17H6l-3.61-.76a.18.18,0,0,1-.14-.18V10.78a.16.16,0,0,1,.07-.14.19.19,0,0,1,.15,0l3.6.76a.18.18,0,0,1,.14.18v5.27a.18.18,0,0,1-.06.14A.19.19,0,0,1,6.06,17ZM2.64,15.91l3.24.68V11.68L2.64,11Z" style="fill:#00a1cc"/><path d="M6.06,17A.15.15,0,0,1,6,17a.19.19,0,0,1-.07-.14V11.54A.18.18,0,0,1,6,11.36l3.6-.76a.2.2,0,0,1,.15,0,.19.19,0,0,1,.07.14v5.27a.18.18,0,0,1-.15.18L6.1,17Zm.18-5.31v4.91l3.24-.68V11Zm3.43,4.37Z" style="fill:#00a1cc"/><path d="M24.69,17a3.4,3.4,0,1,1,3.4-3.4A3.41,3.41,0,0,1,24.69,17Zm0-6.44a3,3,0,1,0,3,3A3,3,0,0,0,24.69,10.58Z" style="fill:#00a1cc"/><path d="M24.69,14.93A1.31,1.31,0,1,1,26,13.62,1.31,1.31,0,0,1,24.69,14.93Zm0-2.26a1,1,0,1,0,.95.95A.95.95,0,0,0,24.69,12.67Z" style="fill:#00a1cc"/><path d="M17.73,16H11.42a.18.18,0,0,1-.18-.18V11.39a.18.18,0,0,1,.18-.18h6.31a.18.18,0,0,1,.18.18v4.46A.18.18,0,0,1,17.73,16Zm-6.13-.36h6v-4.1h-6Z" style="fill:#00a1cc"/><path d="M19.94,16l-.08,0L17.65,14.9a.18.18,0,0,1-.1-.16V12.5a.18.18,0,0,1,.1-.16l2.21-1.11a.17.17,0,0,1,.17,0,.19.19,0,0,1,.09.15v4.46A.19.19,0,0,1,20,16Zm-2-1.4,1.85.92V11.69l-1.85.92Z" style="fill:#00a1cc"/></svg> \ No newline at end of file +<?xml version="1.0" encoding="UTF-8" standalone="no"?> +<svg + width="220.10335" + height="220.69331" + viewBox="0 0 220.10334 220.69331" + data-svgdocument="" + id="_3tPmdtZQ0LGlljQGEkT-3" + class="fl-svgdocument" + x="0" + y="0" + version="1.1" + sodipodi:docname="logo-square.svg" + inkscape:version="1.1 (c4e8f9e, 2021-05-24)" + xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape" + xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd" + xmlns="http://www.w3.org/2000/svg" + xmlns:svg="http://www.w3.org/2000/svg"> + <sodipodi:namedview + id="namedview21" + pagecolor="#ffffff" + bordercolor="#666666" + borderopacity="1.0" + inkscape:pageshadow="2" + inkscape:pageopacity="0.0" + inkscape:pagecheckerboard="0" + showgrid="false" + units="px" + fit-margin-top="42" + fit-margin-left="0" + fit-margin-right="0" + fit-margin-bottom="42" + inkscape:zoom="1.4707031" + inkscape:cx="282.51793" + inkscape:cy="94.172644" + inkscape:window-width="1792" + inkscape:window-height="1067" + inkscape:window-x="0" + inkscape:window-y="25" + inkscape:window-maximized="0" + inkscape:current-layer="_3tPmdtZQ0LGlljQGEkT-3" + inkscape:document-units="mm" + inkscape:snap-global="false" /> + <defs + id="_SabXoCmWUJmxvMwSINRGH" + transform="matrix(1.1728515134752755, 0, 0, 1.1728515134752755, -34.46172819025348, -40.976997984526726)"> + <rect + x="86.353256" + y="25.158035" + width="217.58301" + height="60.515274" + id="rect4965" /> + </defs> + <g + id="_WTrHMt3eZfxcQKrYJirRK" + transform="matrix(0.95294163,0,0,0.95294163,-45.741197,-148.14988)"> + <path + id="_1MYZsbAYvxe5D-50H3pho" + fill="#000000" + transform="matrix(1.2602785,0,0,1.2602785,27.519653,192.79657)" + data-type="polygon" + d="M 82.1,74 75,81.1 v 0 L 82.1,74 74,65.9 v 0 z" /> + <path + id="_oL3QJlwkbW1Nmdh3qAlDC" + fill="#000000" + transform="matrix(1.2602785,0,0,1.2602785,27.45702,193.17499)" + data-type="rect" + data-x="64.5" + data-y="51.5" + data-width="0" + data-height="9.8" + d="M 64.5,51.5" /> + <path + id="_sK1htog-8Op55rgRI5hDg" + d="m 61.1,52.9 v 0 0 l -4.9,4.9 c 2.5,1.2 4.3,3.8 4.3,6.9 0,4.2 -3.4,7.6 -7.6,7.6 -3,0 -5.6,-1.8 -6.9,-4.3 L 40.1,73.9 40,74 61,95 69.1,86.9 v 0 c -3.2,0 -5.9,-2.6 -5.9,-5.9 0,-3.3 2.6,-5.9 5.9,-5.9 3.2,0 5.9,2.6 5.9,5.9 l 7.1,-7.1 -8.1,-8 v 0 l -0.6,-0.6 c 0.1,0 0.3,0 0.4,0 3.2,0 5.9,-2.6 5.9,-5.9 0,-1.5 -0.6,-2.8 -1.5,-3.9 -1,-0.9 -2.4,-1.5 -3.9,-1.5 -3.2,0 -5.9,2.6 -5.9,5.9 0,0.1 0,0.3 0,0.4 L 68,59.9 v 0 z" + fill="#083b66" + transform="matrix(1.2602785,0,0,1.2602785,27.51993,192.73364)" + style="fill:#ffb380" /> + <path + id="_OMWHsyuaoclMfPE9tvM7V" + d="m 46.5,49.5 c -4.2,0 -7.6,-3.4 -7.6,-7.6 0,-3 1.7,-5.6 4.2,-6.8 l -5.9,-5.9 v 0 l -21,21 22.2,22.2 9.5,-9.5 c -0.3,0.6 -0.4,1.3 -0.4,2 0,2.9 2.4,5.3 5.3,5.3 2.9,0 5.3,-2.4 5.3,-5.3 0,-2.9 -2.4,-5.3 -5.3,-5.3 -0.7,0 -1.4,0.1 -2,0.4 l 8.6,-8.6 -6,-6 c -1.3,2.3 -3.9,4.1 -6.9,4.1 z" + fill="#8dcaff" + transform="matrix(1.2602785,0,0,1.2602785,27.583487,192.79709)" /> + <path + id="_jAk8zpl6O80_21e1JT4hk" + d="m 83.3,27.4 -8.7,-8.7 c 0.1,0 0.3,0 0.4,0 3.2,0 5.9,-2.6 5.9,-5.9 C 80.9,9.6 78.3,7 75,7 c -3.2,0 -5.9,2.6 -5.9,5.9 0,0.1 0,0.3 0,0.4 L 61,5.2 l -21,21 -0.7,0.8 -0.4,0.4 9.5,9.5 c -0.6,-0.2 -1.2,-0.3 -1.8,-0.3 -2.9,0 -5.3,2.4 -5.3,5.3 0,2.9 2.4,5.3 5.3,5.3 2.9,0 5.3,-2.4 5.3,-5.3 0,-0.5 -0.1,-0.9 -0.2,-1.4 0,-0.2 -0.1,-0.3 -0.1,-0.5 l 0.5,0.5 9.2,9.2 0.4,-0.4 0.8,-0.8 6.8,-6.8 c 0,0 0,0 0,0 -3.2,0 -5.9,-2.6 -5.9,-5.9 0,-3.3 2.6,-5.9 5.9,-5.9 3.2,0 5.9,2.6 5.9,5.9 0,0 0,0 0,0 z" + fill="#c0d9b4" + transform="matrix(1.2602785,0,0,1.2602785,27.709267,192.98645)" /> + <path + id="_GDSYpBtYoW2GJpRZoPCot" + fill="#000000" + transform="matrix(1.2602785,0,0,1.2602785,27.456932,192.67065)" + data-type="rect" + data-x="61.1" + data-y="52.9" + data-width="0" + data-height="0" + d="M 61.1,52.9" /> + <path + id="_NldltLD5fZQlQrgFsakaC" + fill="#000000" + transform="matrix(1.2602785,0,0,1.2602785,27.456932,192.67065)" + data-type="rect" + data-x="61.1" + data-y="52.9" + data-width="0" + data-height="0" + d="M 61.1,52.9" /> + <path + id="_TbX_b0jxU8J0Cb3YWVDy6" + d="m 51.6,40.5 h 0.3 L 51.5,40 c 0,0.1 0,0.3 0.1,0.5 z" + fill="#000000" + transform="matrix(1.2602785,0,0,1.2602785,27.708767,192.98587)" /> + </g> + <g + aria-label="NeoDB" + transform="matrix(1.5308318,0,0,1.7424549,-43.057047,-3.2641996)" + id="text4963" + style="font-size:40px;line-height:1.25;white-space:pre;shape-inside:url(#rect4965);fill:#083b66"> + <path + d="m 107.58017,70.426948 c 0,-1.866665 -0.85333,-6.026663 -1.28,-7.786662 -0.10667,-0.426666 0,-0.853333 -0.10667,-1.279999 -0.90666,-3.786665 -1.44,-7.733329 -1.92,-11.573327 -0.15999,-1.546665 -0.58666,-3.039998 -0.58666,-4.586664 0,-0.266666 -0.10667,-1.919998 0.42667,-1.919998 0.74666,0 0.90666,1.226666 1.59999,1.226666 0.26667,0 0.64,-0.106667 0.64,-0.426667 0,-1.013333 -1.92,-1.866665 -2.82666,-1.866665 -3.09333,0 -2.93333,3.626664 -2.93333,5.919996 0,2.079999 0.32,4.373331 0.58666,6.45333 0.16,1.279999 0.10667,2.559998 0.32,3.839997 0.16,1.12 0.64,2.719999 0.64,3.839998 0,0.906666 0.16,1.706666 0.26667,2.559998 -0.48,-0.746666 -0.69333,-1.493332 -1.12,-2.239998 -0.64,-1.066666 -1.28,-2.186666 -1.813332,-3.306665 -0.373333,-0.746666 -0.8,-1.439999 -1.226666,-2.186665 -0.426667,-0.746667 -0.693333,-1.546666 -1.119999,-2.293332 -1.919999,-3.253332 -3.626665,-6.666663 -5.493331,-9.919994 -0.213333,-0.373333 -0.693332,-0.426667 -1.066666,-0.426667 -0.693332,0 -2.933331,0.533333 -3.039998,1.333333 -0.16,1.333332 -0.16,2.719998 -0.16,4.053331 v 4.53333 c 0,1.013333 0.106667,1.973332 0.106667,2.933332 0,1.386666 0.16,2.719998 0.16,4.053331 0,0.799999 0.16,1.546666 0.16,2.346665 0,1.333333 0.05333,2.826665 0.266666,4.159998 0.106667,0.533333 0.213333,1.279999 0.213333,1.866665 0,2.399999 -0.266666,4.053331 2.826665,4.053331 0.8,0 3.093332,-0.426666 3.093332,-1.439999 0,-0.48 -0.16,-0.959999 -0.16,-1.493333 0,-2.666665 -0.213333,-5.759996 -0.64,-8.426661 -0.159999,-1.119999 -0.426666,-2.559999 -0.426666,-3.679998 0,-2.559998 -0.693333,-4.746664 -0.693333,-7.413329 1.279999,2.933332 9.599998,20.853321 11.786658,20.853321 0.8,0 3.52,-0.799999 3.52,-1.759999 z M 90.246847,53.573625 c 0.266666,0.106666 0.266666,1.333332 0.266666,1.546666 0,1.119999 -0.16,2.239998 -0.16,3.359998 -0.16,-1.493333 -0.16,-2.986665 -0.16,-4.479998 0,-0.16 0,-0.266666 0.05333,-0.426666 z m 0.266666,18.453322 0.05333,-0.106666 c 0,-0.373334 -0.106666,-0.746667 -0.106666,-1.12 v -0.48 c 0.05333,0.266667 0.106666,0.906667 0.32,1.12 0,-0.64 -0.16,-1.226666 -0.16,-1.866666 0,-0.213333 0,-0.639999 0.213333,-0.799999 0,1.119999 0.05333,2.293332 0.05333,3.413331 l -0.16,-0.16 v 0.106667 h -0.213333 z m -0.266666,-7.199996 c -0.05333,-0.266666 -0.05333,-0.479999 -0.05333,-0.746666 v -0.693333 c 0.213334,-0.266666 0.16,-2.453332 0.16,-2.719998 0.106667,0.426666 0.16,1.813332 0.16,2.346665 0,0.266667 0,1.653333 -0.266666,1.813332 z m -1.28,-15.83999 c 0.106667,-0.64 0.05333,-1.333333 0.05333,-1.973332 0.213333,1.706665 0,3.413331 0.426666,5.066663 l -0.106666,0.05333 -0.213334,-1.066666 c 0,0.533333 0.05333,1.119999 0.05333,1.653332 l -0.05333,-0.05333 v -0.533333 c -0.05333,-0.106667 -0.16,-0.32 -0.16,-0.426667 0,-0.32 0.05333,-0.639999 0.05333,-0.959999 l 0.106666,0.32 c -0.05333,-0.693333 0,-1.386666 -0.16,-2.079999 z m 0.266667,12.373326 c 0.05333,-0.373333 -0.05333,-2.239999 -0.16,-2.559999 0.16,-0.266666 0.16,-1.493332 0.16,-1.813332 0.106667,0.213333 0.106667,1.813332 0.106667,2.133332 0,0.426667 0,1.866666 -0.106667,2.239999 z m 14.613326,-0.373333 c -0.0533,0 -0.10667,-0.693333 -0.10667,-0.746667 -0.0533,0.106667 -0.0533,0.266667 -0.0533,0.373334 0,0.106666 0.0533,0.213333 0.0533,0.319999 l -0.0533,0.05333 h 0.0533 c 0,0.32 0.0533,0.639999 -0.0533,0.959999 0,-0.533333 -0.0533,-1.066666 -0.0533,-1.599999 v -0.64 0.266667 c 0.0533,-0.213333 0.0533,-0.426666 0.0533,-0.64 0.16,0.05333 0.16,1.173333 0.16,1.386666 z m -0.10667,-3.466665 c 0,0.213333 -0.0533,0.373333 0,0.586666 v -0.319999 c 0.0533,0.16 0.10667,0.373333 0.10667,0.586666 0,0.213333 -0.0533,0.48 -0.16,0.693333 0,-0.213333 0,-0.426667 -0.0533,-0.64 v 0.266667 -1.973332 c 0,0.05333 0.10666,0.213333 0.10666,0.266666 z m -13.546657,1.493333 c -0.106666,-0.373334 -0.16,-0.853333 -0.106666,-1.226666 l -0.05333,0.16 c 0.106667,-0.586667 -0.05333,-1.12 -0.05333,-1.706666 0,-0.213333 0,-0.426667 0.106667,-0.64 0.05333,1.12 0.05333,2.293332 0.106666,3.413332 z m 0.16,12.586659 c -0.05333,0.16 -0.05333,0.32 -0.05333,0.479999 l -0.16,-0.05333 c 0,-0.266666 -0.05333,-1.119999 0.16,-1.333332 0,0.159999 -0.05333,0.426666 0.05333,0.533333 0.05333,0.266666 0.106667,0.586666 0.106667,0.853332 v 0.05333 L 90.353513,72.08028 Z M 89.926847,57.733622 c 0,0.16 0,0.266667 0.05333,0.426667 -0.16,0.799999 -0.05333,1.759999 -0.05333,2.506665 -0.106667,-0.48 -0.106667,-0.96 -0.106667,-1.493333 0,-0.479999 0,-0.959999 0.106667,-1.439999 z m 0.05333,4.053331 c 0,-0.426666 0.106667,-0.799999 0,-1.279999 l 0.16,0.16 v 0.959999 c 0,0.373333 0,0.8 -0.05333,1.173333 -0.05333,-0.32 -0.106667,-0.693333 -0.106667,-1.013333 z m 0.586666,-11.093326 c 0,0.266666 -0.05333,0.479999 -0.106666,0.746666 -0.05333,-0.05333 -0.05333,-0.16 -0.05333,-0.266667 V 50.58696 c 0,-0.266667 0,-0.48 0.05333,-0.746666 0.05333,0.213333 0.05333,0.479999 0.05333,0.693333 h 0.05333 z m -0.05333,17.173323 c 0,0.213333 0,0.426666 -0.05333,0.639999 -0.05333,-0.213333 -0.05333,-0.479999 -0.05333,-0.693333 V 67.22695 c 0,-0.106667 0,-0.213333 0.05333,-0.32 0.05333,0.32 0.106666,0.64 0.106666,0.96 z m 13.706657,-3.679998 0.16,0.32 c 0,0.213333 -0.10667,0.373333 -0.21333,0.533333 0,-0.106667 -0.0533,-0.266667 -0.0533,-0.373333 0,-0.16 0,-0.266667 0.0533,-0.426667 z m -0.32,-0.16 c 0,-0.106667 -0.0533,-0.266667 -0.0533,-0.373333 l 0.0533,-0.106667 -0.0533,0.05333 c 0,-0.106666 -0.0533,-0.159999 -0.0533,-0.266666 v -0.64 l 0.10666,-0.05333 c 0,0.48 0,0.906666 0,1.386666 z M 89.180181,54.213624 c -0.05333,-0.16 -0.106667,-0.319999 -0.106667,-0.533333 0,-0.266666 0.05333,-0.586666 0.05333,-0.906666 0.05333,0.16 0.05333,0.373333 0.05333,0.533333 z M 102.78017,51.22696 c -0.0533,0.373333 0.21334,0.853332 0.21334,1.226665 v 0.05333 c -0.16,-0.373333 -0.21334,-0.8 -0.37334,-1.226666 z m -11.359991,8.799994 c -0.05333,-0.32 -0.05333,-0.639999 -0.05333,-0.959999 v -0.373333 c 0.05333,0.213333 0.106667,0.586666 0.106667,0.799999 0,0.16 -0.05333,0.373333 -0.05333,0.533333 z m -2.239998,-4.319997 -0.106667,0.266666 v -0.266666 c 0,-0.213333 0,-0.373333 0.05333,-0.533333 0.05333,0.106666 0.05333,0.213333 0.05333,0.32 l 0.106666,-0.106667 v 0.426666 l -0.05333,-0.05333 -0.05333,0.213333 z m 0.213333,-1.226666 c 0,-0.05333 -0.106667,-0.586666 0,-0.48 -0.05333,-0.266666 0,-0.693333 -0.106667,-0.906666 l 0.106667,0.05333 c 0,0.32 0.05333,0.586667 0.05333,0.906666 0,0.16 0,0.32 -0.05333,0.426667 z m 0.906666,-6.399996 c 0,0.32 0,0.639999 0,0.959999 l -0.106667,-0.48 c 0,-0.106666 0.05333,-0.319999 0.106667,-0.479999 z m 12.74666,5.599996 c 0,0.213334 -0.0533,0.373333 -0.0533,0.586667 -0.0533,-0.213334 -0.0533,-0.48 -0.0533,-0.693333 v -0.32 c 0.0533,0.106667 0.10667,0.266666 0.10667,0.426666 z m 1.06667,8.693329 0.0533,-0.05333 v -0.26667 l 0.10666,0.32 c -0.0533,0.106666 -0.0533,0.266666 -0.0533,0.373333 l -0.10666,-0.213334 v 0.32 c 0,-0.05333 -0.0533,-0.16 -0.0533,-0.213333 0,-0.16 0,-0.373333 0.0533,-0.533333 z m -13.81333,6.613329 c 0,0.266666 -0.05333,0.906666 0.05333,1.226666 l -0.05333,0.106666 c -0.106667,-0.106666 -0.05333,-1.226665 0,-1.333332 z m 0.32,-2.399999 c -0.106667,0 -0.16,0.106667 -0.16,0.213334 0.05333,-0.266667 -0.05333,-0.586667 0,-0.746667 l 0.106666,0.05333 v 0.213333 h 0.05333 z m 12.53333,-8.746661 c 0.0533,0.266666 0.0533,0.586666 0,0.853333 -0.0533,-0.32 -0.0533,-0.533333 0,-0.853333 z m -0.53334,-7.253329 c 0.0533,-0.213333 0.0533,-0.426667 0.0533,-0.64 l 0.0533,0.05333 v 0.693333 z m -13.386656,6.346663 c 0,-0.16 0,-0.373333 -0.106667,-0.533333 l 0.106667,-0.266667 c 0,0.16 0,0.373333 0,0.533333 z m 1.333332,-4.853331 c -0.05333,-0.16 -0.106666,-0.319999 -0.106666,-0.479999 0,0.106666 0.05333,0.16 0.16,0.16 0,0.106666 -0.05333,0.159999 -0.05333,0.319999 0,0 0.05333,-0.05333 0.05333,-0.05333 v 0.05333 z m -0.319999,-4.479997 c -0.106667,-0.32 -0.106667,-0.693333 -0.106667,-1.066666 z m 0.16,11.839993 c 0.159999,0.05333 0.05333,0.373333 0,0.48 z m 0.213333,-6.45333 c 0,0.106667 -0.05333,0.266667 -0.05333,0.373334 v -0.48 z m -0.213333,5.493331 c 0,0.05333 0.159999,0.426666 0,0.426666 z m 0.159999,6.666662 c 0.05333,0.05333 0.05333,0.16 0.05333,0.266667 l -0.05333,0.05333 z m 13.866654,-2.773331 0.0533,0.05333 c 0,0.213333 0,0.426666 -0.0533,0.639999 z M 90.353513,53.200292 c 0,0.106666 0,0.16 -0.05333,0.266666 0,-0.106666 -0.05333,-0.266666 -0.05333,-0.373333 z m 0.16,2.879998 v -0.586666 c 0.05333,0.213333 0.05333,0.373333 0.05333,0.586666 z M 104.9135,70.160282 c 0,0.05333 0.0533,0.106666 0.10667,0.159999 l -0.10667,0.05333 h -0.0533 l -0.0533,-0.05333 z m -0.69333,-9.066662 c 0,0.16 0.10667,0.32 -0.0533,0.48 0,-0.16 0,-0.32 0.0533,-0.48 z m -13.973323,4.159998 c 0,-0.106667 0,-0.213333 0.05333,-0.32 l 0.05333,0.16 c 0,0.106667 -0.05333,0.16 -0.106666,0.16 z m 0.319999,-2.933332 c 0,0.16 0,0.373333 -0.05333,0.533333 v -0.533333 z m 0,-10.18666 v 0.106666 l 0.05333,0.106667 -0.05333,0.05333 v -0.16 l -0.106666,0.05333 v -0.05333 z m 14.079994,11.466659 c 0,-0.106666 0,-0.159999 0.0533,-0.266666 v 0.32 z m -2.13333,-11.893326 c 0,-0.106666 0,-0.16 0.0533,-0.266666 0,0.106666 0,0.16 0.0533,0.266666 z M 90.46018,69.840282 c 0,-0.106667 0,-0.213333 0.05333,-0.32 v 0.213333 z m 0.32,-1.333333 c 0,0.05333 0.05333,0.106667 0.05333,0.106667 0,0.05333 -0.05333,0.106666 -0.05333,0.106666 l -0.05333,-0.05333 c 0,-0.05333 0,-0.106666 0.05333,-0.16 z m -1.386666,-3.466664 c -0.05333,-0.106667 -0.05333,-0.266667 -0.05333,-0.373333 0.05333,0.106666 0.05333,0.266666 0.05333,0.373333 z m 14.933326,0.586666 0.0533,-0.106667 v 0.266667 z m -0.21333,-5.119997 c -0.0533,-0.16 -0.0533,-0.266667 0,-0.373333 z m 0,4.746664 h 0.0533 v 0.16 l -0.0533,-0.05333 z m -13.386664,6.293329 c 0,0.05333 0,0.16 0.05333,0.213334 0,-0.05333 0,-0.16 -0.05333,-0.213334 z m -0.213333,-14.773324 0.05333,0.106666 c 0,0.05333 0,0.05333 -0.05333,0.106667 z m -1.333332,-2.186665 c -0.05333,-0.05333 -0.05333,-0.16 0,-0.266667 z m 15.199989,11.946659 c -0.0533,-0.05333 -0.0533,-0.16 -0.0533,-0.213333 0.0533,0.05333 0.0533,0.16 0.0533,0.213333 z m 0.0533,-2.613332 c 0,0 -0.0533,-0.05333 -0.0533,-0.05333 0,-0.05333 0.0533,-0.05333 0.0533,-0.05333 z m -0.21333,-3.199998 -0.0533,-0.05333 0.0533,-0.106667 z m 0.16,5.33333 c -0.0533,-0.05333 -0.0533,-0.16 0,-0.213333 z m -1.44,-13.173325 c -0.0533,-0.05333 -0.0533,-0.106667 -0.0533,-0.16 0.0533,0.05333 0.0533,0.106667 0.0533,0.16 z m -13.599989,0 c 0.05333,0.05333 0.05333,0.106666 0.05333,0.16 -0.05333,-0.05333 -0.05333,-0.106667 -0.05333,-0.16 z m 14.773329,8.373328 -0.0533,-0.106666 c 0,0 0.0533,-0.05333 0.0533,-0.05333 z m -13.599997,7.679996 v -0.106667 h 0.05333 z m -1.119999,-4.906664 v -0.106667 z m 1.173332,1.973332 v 0.106667 z m 13.119994,-8.533328 c 0,0 -0.0533,-0.05333 -0.0533,-0.05333 v 0.16 z m -14.613326,-0.16 v -0.05333 h 0.05333 v 0.05333 z m 1.493332,-0.693333 c 0,0 -0.05333,-0.05333 -0.05333,-0.05333 0,0 0.05333,-0.05333 0.05333,-0.05333 z m 13.599994,7.413329 v -0.05333 c 0.0533,0.05333 0.0533,0.05333 0.0533,0.106666 z M 89.446847,63.653619 h -0.05333 v -0.05333 z m 15.253323,-0.533333 h -0.0533 l 0.0533,-0.05333 z m -13.91999,4.959997 0.05333,0.05333 h -0.05333 z m 13.38666,-7.146663 0.0533,-0.05333 v 0.05333 z M 91.366846,71.653614 h 0.05333 l -0.05333,0.05333 z M 104.06017,61.57362 v -0.05333 h 0.0533 z m 0.0533,-1.546666 c 0,-0.05333 0,-0.05333 0.0533,-0.106666 z m -14.719996,-5.33333 0.05333,0.05333 h -0.05333 z m 15.626656,15.626657 v -0.05333 l 0.0533,0.05333 z m -2.02666,-15.306657 c 0,-0.05333 0,-0.05333 0.0533,-0.106667 0,0.05333 0,0.05333 -0.0533,0.106667 z m -12.479997,6.826663 0.05333,-0.05333 v 0.05333 z M 103.63351,60.93362 c 0,0 0.0533,-0.05333 0.0533,-0.05333 0,0 -0.0533,-0.05333 -0.0533,-0.05333 z m 1.33333,8.906662 c 0,-0.05333 0,-0.05333 0.0533,-0.106667 0,0.05333 0,0.05333 -0.0533,0.106667 z" + style="font-size:53.3333px;font-family:'Wildemount Rough';-inkscape-font-specification:'Wildemount Rough, Normal'" + id="path920" /> + <path + d="m 126.07266,60.666954 c 0,-0.266667 -0.16,-0.586666 -0.48,-0.586666 -0.58667,0 -0.64,0.693332 -0.69333,1.119999 -0.42667,2.506665 -2.77334,7.306662 -5.70667,7.306662 -1.76,0 -1.97333,-0.479999 -3.14666,-1.706665 -0.32,-0.96 -0.96,-1.919999 -0.96,-2.986665 v -0.106667 c 0.37333,-0.106667 0.69333,-0.373333 1.01333,-0.586666 1.97333,-1.12 3.94667,-2.613332 3.94667,-5.119997 0,-1.706666 -1.44,-3.413331 -3.2,-3.413331 -4.16,0 -5.6,2.773331 -6.29333,6.293329 -0.10667,0.533333 -0.32,1.173333 -0.32,1.706666 0,1.706665 0.53333,3.253331 1.01333,4.85333 0.16,0.64 2.08,2.506665 2.66667,2.879998 0.8,0.533333 2.02666,0.8 2.98666,0.8 0.64,0 2.82667,-0.266667 3.30667,-0.746666 3.30666,-1.066666 5.86666,-6.45333 5.86666,-9.706661 z m -9.28,-4.213331 c 0.74667,0 1.33334,0.746666 1.33334,1.439999 0,1.973332 -1.65334,3.306665 -3.25333,4.159998 -0.0533,-0.213333 -0.10667,-0.96 -0.10667,-1.173333 v -0.64 c 0,-1.226665 0.42667,-3.786664 2.02666,-3.786664 z m -3.67999,4.266664 c 0,-0.16 0,-0.32 -0.0533,-0.48 v 0.48 l 0.0533,0.05333 v 0.266667 c -0.0533,-0.106667 -0.0533,-0.213333 -0.0533,-0.32 v 0.586667 l 0.0533,-0.106667 c 0.10666,0.213333 0.16,0.426666 0.16,0.64 0,0.159999 -0.0533,0.319999 -0.0533,0.426666 -0.0533,-0.106667 -0.0533,-0.266667 -0.0533,-0.373333 0,-0.106667 0.0533,-0.426667 -0.10667,-0.48 -0.0533,0.16 -0.0533,0.32 -0.0533,0.48 -0.10667,-0.586666 -0.16,-1.279999 -0.21333,-1.973332 -0.0533,0.426666 -0.0533,0.959999 -0.10667,1.013332 0,-0.16 -0.0533,-0.319999 -0.10667,-0.479999 l 0.0533,-0.106667 c -0.0533,0 -0.10667,-0.05333 -0.10667,-0.16 v -0.05333 h 0.16 v -0.213333 c 0,-0.106667 0,-0.266667 -0.0533,-0.373333 l -0.0533,0.213333 v -0.266667 l -0.0533,-0.106666 v -0.05333 l 0.16,-0.05333 c 0,-0.106667 0,-0.213333 -0.0533,-0.32 l 0.0533,-0.106666 c 0,0 0.0533,0.106666 0.0533,0.213333 0,-0.32 0.0533,-0.64 0.32,-0.906666 v 0.479999 l 0.0533,-0.05333 v 0.213333 l -0.0533,-0.05333 v 0.16 l 0.0533,-0.05333 c 0.0533,0.213333 0.10666,0.426666 0.10666,0.639999 0,0.32 0,0.906666 -0.10666,1.226666 z m 1.54666,7.626662 c -0.0533,0.16 -0.0533,0.32 -0.10666,0.48 l -0.16,-0.16 c 0,-0.373333 0.0533,-0.853333 0.16,-1.173333 0.0533,0.213334 0.10666,0.586667 0.10666,0.8 L 114.766,68.02695 v -0.213334 c 0.16,0.16 0.32,0.32 0.42667,0.48 -0.0533,0.16 -0.0533,0.373333 -0.21334,0.533333 0,0.106667 0.0533,0.213333 0.0533,0.266667 l -0.10667,-0.05333 v -0.479999 c -0.0533,0.159999 -0.0533,0.319999 -0.0533,0.479999 l -0.10666,-0.10667 c 0.0533,-0.106667 0.0533,-0.16 0.0533,-0.266667 l -0.0533,-0.05333 -0.0533,0.16 c 0,-0.16 0,-0.32 -0.0533,-0.426667 z m -1.76,-1.439999 c -0.0533,-0.32 -0.0533,-0.693333 -0.0533,-1.013333 v -1.866665 l -0.0533,0.106666 v -0.106666 l -0.0533,-0.106667 h 0.10667 v -0.639999 l 0.0533,-0.106667 c 0,0.64 0.0533,1.226666 0.0533,1.866666 v 1.013332 c 0,0.266667 0,0.586667 -0.0533,0.853333 z m 1.17334,1.066666 c 0,-0.106666 0,-0.16 0.0533,-0.266666 v -0.48 l -0.0533,0.05333 c 0,-0.266666 0.0533,-0.479999 0.0533,-0.746666 0.0533,0.05333 0.16,0.32 0.16,0.373333 v 0.64 c 0,0.213333 -0.0533,0.48 -0.10667,0.693333 z M 113.006,59.546955 c 0.0533,-0.16 0,-0.32 -0.10667,-0.48 v 0.213333 c 0,0.32 0,0.64 0.0533,0.959999 0.0533,-0.106666 0.10666,-0.159999 0.10666,-0.266666 l 0.0533,0.16 c 0,-0.05333 0.0533,-0.16 0.0533,-0.213333 0,-0.16 -0.10667,-0.373333 -0.10667,-0.533333 z m -0.32,3.519997 c -0.0533,-0.159999 -0.10667,-1.013332 0,-1.173332 z m 0.90667,-5.49333 c 0,0.16 0,0.32 -0.0533,0.426667 l -0.0533,0.05333 v -0.373333 z m -1.49334,6.346663 c 0.0533,0.32 0.0533,0.586667 0.0533,0.906666 l -0.0533,-0.106666 z m 0.69334,-3.999997 c 0,-0.16 0,-0.32 0,-0.426667 0,-0.05333 0,-0.266666 -0.0533,-0.426666 0,0.266666 0,0.533333 0.0533,0.853333 z M 112.206,61.41362 c -0.0533,-0.16 -0.0533,-0.32 -0.0533,-0.48 l 0.0533,-0.106666 z m 0.32,-0.64 c 0.0533,0.106667 0.0533,0.266667 0,0.373334 l -0.0533,-0.213334 z m 0.10667,1.12 c 0,-0.106667 0,-0.266667 0.0533,-0.373333 v 0.32 z m -0.74667,0.426666 c 0,0.106667 0.0533,0.213333 0,0.32 z m 2.08,4.586664 -0.0533,-0.106666 0.0533,-0.05333 z m 0,0.373333 -0.0533,-0.106666 0.0533,-0.05333 z" + style="font-size:53.3333px;font-family:'Wildemount Rough';-inkscape-font-specification:'Wildemount Rough, Normal'" + id="path922" /> + <path + d="m 134.04765,67.86695 c 1.17334,-0.64 1.92,-1.866666 2.4,-3.093332 1.38667,-0.533333 4.69333,-2.399998 4.69333,-4.106664 0,-0.213333 -0.21333,-0.426667 -0.42666,-0.426667 -0.32,0 -0.58667,0.16 -0.74667,0.426667 -0.8,1.226666 -1.92,2.239999 -3.30667,2.773332 0,-2.079999 -0.63999,-4.159998 -2.13333,-5.65333 -0.48,-0.48 -2.50666,-1.706666 -3.2,-1.706666 -0.26666,0 -0.58666,0 -0.85333,0 -0.96,0 -1.97333,0.05333 -2.77333,0.586666 -0.42667,0.266667 -0.53333,0.586667 -0.85333,0.906666 -1.44,1.599999 -2.18667,2.133332 -2.18667,4.533331 0,3.946664 3.09333,6.45333 6.82666,6.45333 0.32,0 2.24,-0.533333 2.56,-0.693333 z m -4.95999,-9.386661 c 0,-0.373334 0.26666,-1.44 0.8,-1.44 2.29333,0 2.93333,4.906664 3.14666,6.506663 -1.97333,-0.746666 -2.88,-1.706665 -3.52,-3.733331 -0.10666,-0.426666 -0.42666,-0.906666 -0.42666,-1.333332 z m 2.55999,8.159995 c -1.65333,0 -1.91999,-2.826665 -2.34666,-3.946665 1.22667,1.546666 1.86666,1.706666 3.68,2.293332 -0.21334,0.746667 -0.32,1.653333 -1.33334,1.653333 z m 2.98667,-2.986665 c 0,-0.426667 -0.10667,-0.8 -0.10667,-1.226666 v -0.106667 c 0.10667,-0.106666 0.0533,-0.799999 0,-0.906666 h -0.0533 v 0.05333 l -0.0533,0.05333 c 0.0533,-0.16 0.10666,-0.373333 0.16,-0.533333 h 0.10666 c 0.0533,0.373333 0.10667,0.746666 0.10667,1.066666 0,0.213333 -0.0533,0.426666 -0.10667,0.586666 0,0.373333 0.10667,0.693333 -0.0533,1.013333 z m -7.2,0.106666 c 0.21334,0.213334 0.16,0.64 0.16,0.906667 v 0.853332 c -0.10666,-0.373333 -0.16,-0.799999 -0.16,-1.226666 0.10667,-0.106666 -0.0533,-0.319999 -0.0533,-0.426666 0,-0.05333 0.0533,-0.106667 0.0533,-0.106667 z m 7.25333,1.066666 c 0,-0.213333 0.0533,-1.333332 0.10667,-1.439999 0,0.05333 0.0533,0.106667 0.0533,0.16 0,-0.05333 0,-0.05333 0.0533,-0.106666 v 0.213333 c 0,0.373333 -0.0533,0.693333 -0.10667,1.013333 z m 0.26667,-0.693333 c 0,-0.266666 0.0533,-0.533333 -0.0533,-0.799999 0.10666,-0.426666 0.0533,-0.959999 0.0533,-1.386666 0.0533,0.213333 0.10667,0.426667 0.10667,0.64 0,0.373333 0,1.386666 -0.10667,1.546665 z m -0.16,-0.799999 v -0.05333 l 0.0533,0.05333 z m -0.21333,-2.079999 0.0533,0.05333 v -0.05333 z m 0.0533,1.439999 v -0.106666 z m -7.14666,-0.639999 v -0.106667 z" + style="font-size:53.3333px;font-family:'Wildemount Rough';-inkscape-font-specification:'Wildemount Rough, Normal'" + id="path924" /> + <path + d="m 155.71431,60.400287 c 0,-1.653332 -0.21334,-5.386663 -0.8,-6.879996 -0.16,-0.373333 0,-0.853332 -0.16,-1.226665 -0.26667,-0.8 -0.42667,-1.599999 -0.74667,-2.399999 -1.17333,-2.986665 -3.46666,-8.159995 -7.30666,-8.159995 -1.44,0 -3.30666,0.746666 -4,2.133332 -0.26666,-0.266667 -0.69333,-0.533333 -1.06666,-0.533333 -1.97334,0 -1.86667,0.906666 -1.86667,2.346665 0,0.426667 -0.10667,0.853333 -0.10667,1.279999 v 11.679993 c 0,0.853333 0.16,1.706666 0.21334,2.559999 0.16,2.613332 0.26666,5.119997 0.58666,7.733329 -0.26666,0.16 -0.48,0.426666 -0.48,0.746666 0,0.48 0.37334,0.533333 0.69334,0.799999 0.42666,1.333333 -0.16,1.653333 1.33333,2.239999 0.37333,0.16 0.8,0.32 1.22667,0.32 1.43999,0 2.61333,-0.32 2.61333,-2.026666 6.34666,-0.799999 9.86666,-4.21333 9.86666,-10.613327 z m -9.81333,9.013328 c 0,-2.079998 0.0533,-4.21333 -0.0533,-6.293329 -0.0533,-0.586667 -0.16,-1.12 -0.16,-1.706666 0,-2.933331 -0.64,-5.919996 -0.64,-8.853328 0,-1.546666 -0.21334,-3.093331 -0.21334,-4.639997 0,-1.226666 -0.0533,-4.853331 1.70667,-4.853331 0.64,0 1.12,0.64 1.44,1.173333 2.24,3.679998 3.25333,10.399994 3.30666,14.613325 0,0.426666 0.16,0.906666 0.16,1.333332 0,3.039998 -0.53333,6.45333 -3.14666,8.266662 -0.53333,0.373333 -0.53333,0.586666 -1.17333,0.746666 -0.42667,0.106667 -0.85334,0.05333 -1.22667,0.213333 z m -4.42666,-6.879996 c 0,-1.333332 -0.21334,-2.613331 -0.21334,-3.946664 v -4.85333 c 0.37334,0.639999 0.37334,3.679997 0.37334,4.479997 0,1.226666 -0.0533,2.506665 -0.10667,3.733331 h 0.0533 v 0.48 z m 2.4,-4.266664 v 1.066666 l -0.0533,0.05333 h 0.0533 c 0,0.586666 0,1.173332 0.10666,1.759999 -0.0533,0.106666 -0.10666,0.213333 -0.10666,0.373333 0,0.426666 0.10666,0.799999 0.10666,1.226666 0,0.106666 0,0.319999 -0.16,0.319999 0,-0.266666 -0.0533,-0.533333 -0.0533,-0.799999 0,-0.853333 0.26667,-2.079999 -0.0533,-2.933332 v 0.746667 l -0.0533,-0.106667 v -0.32 c -0.26666,-0.426666 -0.26666,-1.173332 -0.26666,-1.653332 0,-0.693333 0.10666,-1.439999 0.10666,-2.133332 l -0.10666,-0.213333 v -0.05333 c 0.10666,-0.213333 0.10666,-0.479999 0.16,-0.693333 v 0.106667 l 0.0533,0.106667 c 0,0.693332 0.16,1.493332 0.16,2.186665 l -0.0533,0.05333 h 0.0533 c 0,0.106667 0.0533,0.16 0.0533,0.266667 v 0.373333 l -0.10666,0.106666 0.0533,-0.159999 c -0.0533,0.106666 -0.0533,0.266666 -0.0533,0.373333 h 0.0533 v 0.106666 l -0.0533,-0.05333 v 0.16 l 0.0533,-0.05333 c 0,0.106666 0,0.213333 0.0533,0.319999 0,-0.106666 0,-0.213333 0.0533,-0.319999 l -0.0533,-0.16 z m -2.13334,6.77333 0.0533,0.05333 v -0.05333 c 0.0533,0.106666 0.0533,0.213333 0.16,0.319999 -0.16,-0.586666 0.0533,-1.119999 0.0533,-1.653332 l 0.0533,-2.666665 c 0.10667,0.106667 0.10667,0.32 0.10667,0.48 0,0.16 -0.0533,0.373333 0,0.533333 0.16,-0.32 0.0533,-0.426667 0.0533,-0.746666 0.0533,0.426666 0.0533,0.906666 0.0533,1.333332 0,1.013333 -0.16,2.079999 -0.16,3.093332 l -0.0533,0.05333 h -0.0533 l -0.10667,-0.16 v 0.426666 l -0.0533,-0.266666 -0.0533,0.16 -0.0533,-0.106667 z m -0.42666,-12.10666 c -0.16,-1.493332 0,-3.306665 0,-4.799997 l -0.0533,0.05333 v -0.479999 c 0.0533,0.106666 0.0533,0.213333 0.0533,0.319999 l 0.10666,-0.373333 c 0,0.586667 -0.16,1.066666 0.0533,1.653333 0.0533,-0.16 0.0533,-0.266667 0.0533,-0.426667 l 0.0533,0.05333 c 0,0.159999 0,0.319999 0.0533,0.479999 -0.0533,0.106667 -0.0533,0.16 -0.0533,0.266667 l -0.0533,-0.213333 -0.0533,0.106666 c 0,0.106667 0,0.213333 0.0533,0.32 -0.10667,0.05333 0,0.426666 0,0.533333 0,-0.106667 0.0533,-0.32 0.10667,-0.426666 0,0.159999 0,0.373333 0.0533,0.533333 -0.0533,0.16 -0.0533,0.373333 -0.0533,0.533333 l -0.10667,-0.373333 c -0.0533,0.266666 -0.0533,0.586666 -0.0533,0.853332 v 0.05333 h 0.0533 l 0.0533,-0.106667 c 0,0.05333 0,0.16 0.0533,0.213333 -0.0533,0.16 -0.0533,0.266667 -0.0533,0.426667 l -0.0533,0.106666 -0.0533,-0.266666 c -0.10667,0.479999 -0.0533,0.959999 -0.0533,1.439999 l -0.10666,-0.373333 -0.0533,0.32 v -0.32 z m 12.58666,8.426662 c -0.21334,-0.906666 0,-2.079999 -0.26667,-2.879998 0.10667,-0.533333 0.0533,-1.013333 0.0533,-1.546666 0.16,0.959999 0,1.973332 0.16,2.933331 0,-0.106666 0.0533,-0.213333 0.0533,-0.319999 0,-0.48 -0.10667,-1.013333 -0.10667,-1.546666 0,-0.05333 0,-0.266667 0.0533,-0.266667 0.0533,0.746667 0.10667,1.493333 0.10667,2.239999 0,0.213333 -0.0533,0.48 -0.0533,0.693333 l -0.0533,-0.05333 c 0.0533,0.213333 0.0533,0.479999 0.0533,0.746666 z m -0.74667,2.986665 c -0.0533,-0.373333 -0.10667,-0.746667 -0.10667,-1.12 v -0.106666 l 0.0533,-0.05333 h -0.0533 V 61.89362 c 0,-0.373333 0,-0.693333 0.0533,-1.013333 0,0.853333 0.16,1.759999 0.16,2.613332 0,0.213333 0.0533,0.693333 -0.10667,0.853333 z m -1.22667,-15.253325 c 0.16,0.373334 0.21334,0.533333 0.21334,0.96 v 1.013333 c 0,0.16 0,0.319999 -0.0533,0.479999 -0.10666,-0.266666 -0.21333,-0.639999 -0.21333,-0.959999 v -0.8 l 0.0533,0.16 z m 0.53334,4.426664 c -0.0533,-0.266666 -0.16,-0.799999 -0.16,-1.066666 v -2.559998 c 0.0533,0.106667 0.16,0.373333 0.16,0.426666 z m 1.17333,4.959998 c 0,0.426666 -0.0533,0.853332 -0.0533,1.279999 -0.10667,-0.693333 -0.16,-1.386666 -0.16,-2.079999 0,-0.213333 0,-0.8 0.21333,-0.959999 -0.0533,0.586666 0,1.173332 0,1.759999 z m -0.74667,-6.879996 c 0,0.213333 -0.0533,0.479999 -0.16,0.693333 0.0533,0.159999 0.0533,0.319999 0.0533,0.479999 l -0.10667,0.16 c -0.0533,-0.799999 -0.10667,-1.546666 -0.10667,-2.346665 0.16,0.266666 0.16,0.64 0.26667,0.959999 z m -11.62666,-4.586664 c 0,-0.693333 0.10667,-1.333333 0.10667,-2.026666 l 0.10667,-0.05333 0.10666,0.16 c -0.0533,0.639999 -0.0533,1.653332 -0.32,2.239999 z m 2.13334,5.119997 -0.0533,-0.213334 c -0.0533,0.16 0,0.266667 0,0.426667 h 0.0533 l -0.0533,0.16 v -0.16 c -0.10666,-0.32 -0.16,-1.493333 -0.16,-1.813332 0,-0.106667 0.0533,-0.213334 0.0533,-0.32 v 0.266666 c 0,-0.32 -0.0533,-1.439999 0.10666,-1.706665 0,0.853332 0.0533,1.653332 0.0533,2.506665 z m 0.53333,13.439992 c 0,0.05333 -0.0533,0.426666 -0.0533,0.426666 0,-0.32 -0.16,-0.64 -0.16,-0.959999 0,-0.16 0,-0.48 0.10666,-0.586667 0.10667,0.373333 0.10667,0.746667 0.10667,1.12 z m 9.75999,-9.226662 c 0,-0.213333 0,-0.479999 0.0533,-0.639999 0,0.48 0.10666,0.959999 0.10666,1.493332 0,0.16 0,0.32 0,0.48 0,-0.32 -0.10666,-0.64 -0.10666,-0.959999 l -0.0533,0.159999 v -0.213333 h 0.0533 l -0.0533,-0.266666 v 0.106666 c 0,-0.05333 0,-0.106666 0,-0.16 z m -11.78666,5.65333 c -0.0533,-0.319999 -0.0533,-0.639999 -0.0533,-0.959999 v -0.373333 c 0.0533,0.106666 0.10667,0.266666 0.10667,0.373333 0,0.32 -0.0533,0.64 -0.0533,0.959999 z m 0.53334,4.053331 c -0.0533,-0.106666 -0.0533,-0.266666 -0.0533,-0.373333 l -0.0533,0.106667 h -0.10667 c 0,-0.16 0.0533,-0.373333 0.10667,-0.533333 h 0.16 c -0.0533,0.32 0,0.586666 0,0.853333 z m 11.14666,-4.53333 c 0.0533,0.426666 0.16,0.853333 0.26666,1.226666 l -0.10666,0.373333 c -0.0533,-0.373333 -0.16,-0.853333 -0.16,-1.226666 z m -1.22667,4.799997 c -0.10667,0.05333 -0.10667,0.16 -0.16,0.266666 v -0.639999 l 0.10667,-0.266667 c 0,0.213333 0.0533,0.426667 0.0533,0.64 z m 0.10667,-1.333333 c 0,0.373333 0.0533,0.746667 0.0533,1.12 -0.0533,0 -0.0533,0.05333 -0.0533,0.106666 -0.10667,-0.32 -0.0533,-0.799999 -0.0533,-1.119999 z m -9.44,-15.146657 c 0.10667,0.479999 0.10667,0.959999 0,1.386666 z m 10.61333,5.01333 c 0.0533,0.213333 0.10667,0.373333 0.10667,0.586666 v 0.16 l -0.10667,0.05333 c 0,-0.266667 0,-0.586667 -0.0533,-0.853333 z m -0.0533,5.066664 c 0.0533,0.373333 0.10666,0.906666 0.10666,1.279999 l -0.0533,-0.106667 -0.0533,0.106667 v -0.533333 0.05333 z m -0.10667,1.119999 c -0.10667,-0.266667 -0.0533,-0.853333 -0.0533,-1.119999 0.0533,0.213333 0.10666,0.906666 0.0533,1.119999 z m -0.26667,3.893331 -0.10666,0.32 v -0.16 c 0,-0.16 0,-0.32 0.0533,-0.426666 z m -10.77332,1.866666 c 0,-0.05333 -0.10667,-0.213334 -0.10667,-0.266667 0,-0.106667 0.0533,-0.213333 0.10667,-0.32 z m -0.85334,-3.306665 c -0.0533,-0.266667 -0.0533,-0.533333 0,-0.8 z m 11.41333,-1.279999 v 0.693333 c -0.0533,-0.213334 -0.0533,-0.48 -0.0533,-0.693333 z m -9.65333,-9.653328 v 0.106667 l 0.10667,-0.05333 v 0.05333 l -0.0533,0.213333 -0.10667,-0.05333 c 0.0533,-0.106667 0,-0.16 0,-0.266667 z m 10.61333,5.279997 v -0.693333 c 0.0533,0.213333 0.0533,0.586666 0.0533,0.8 z m -2.98667,-10.559994 0.0533,-0.213333 0.0533,0.05333 v 0.373333 z m -9.49332,0.853333 c 0.10666,0.106667 0.0533,0.426666 0.0533,0.586666 -0.0533,-0.213333 -0.0533,-0.373333 -0.0533,-0.586666 z m 0,4.85333 c 0,-0.213333 0,-0.426666 0.0533,-0.586666 0,0.213333 0.0533,0.426667 -0.0533,0.586666 z m 0.69333,15.893324 c 0,0.16 -0.0533,0.32 0,0.48 0,-0.16 0.0533,-0.32 0,-0.48 z m -0.53333,-7.946662 c 0,0.213334 0,0.48 -0.0533,0.693333 0,-0.213333 0,-0.479999 0.0533,-0.693333 z m 0.58666,1.653333 c 0,-0.106667 -0.0533,-0.106667 -0.10666,-0.16 v 0.32 z m 1.49334,-0.48 c -0.0533,0.213333 -0.0533,0.48 -0.0533,0.746666 -0.0533,-0.266666 -0.0533,-0.533333 0,-0.799999 z m -1.49334,6.186663 c -0.0533,0.213333 -0.0533,0.373333 -0.0533,0.586666 0.0533,-0.213333 0.0533,-0.373333 0.0533,-0.586666 z m 1.76,-0.586666 v -0.05333 l 0.0533,-0.106666 c 0,-0.05333 0,-0.106667 0.0533,-0.106667 0,0.106667 0,0.213333 -0.0533,0.32 z m -1.44,-0.853333 c 0,-0.16 -0.0533,-0.266667 -0.0533,-0.426667 0.10667,0.106667 0.10667,0.266667 0.0533,0.426667 z m -0.37333,-4.639997 c 0.0533,0.05333 0.10667,0 0.0533,-0.05333 -0.0533,0.05333 -0.10666,0.16 -0.10666,0.213334 h 0.10666 z m 10.82666,-2.826665 c -0.0533,-0.106667 -0.0533,-0.213334 -0.0533,-0.32 l 0.0533,-0.106667 z m 0.53333,3.733331 c 0,-0.16 -0.0533,-0.266667 0,-0.426667 0,0.16 0.0533,0.266667 0,0.426667 z m -10.71999,7.146662 -0.0533,0.106667 0.0533,0.16 z m 10.18666,-8.959994 -0.0533,0.05333 v -0.213333 l 0.0533,-0.05333 z m 0.58667,-4.906664 c -0.0533,-0.106667 -0.0533,-0.213333 0,-0.32 0.0533,0.106667 0,0.213333 0,0.32 z m -11.52,9.599994 v 0.16 l -0.0533,0.05333 0.0533,-0.16 c -0.0533,0.05333 -0.10666,0 -0.10666,-0.05333 z m 0.64,2.026666 c 0.0533,-0.106667 0.0533,-0.213334 0,-0.32 z m 1.17334,-1.6 c 0,0 -0.0533,-0.05333 -0.0533,-0.106666 0,0 0.0533,-0.05333 0.0533,-0.106667 l 0.0533,0.106667 c 0,0.05333 0,0.05333 -0.0533,0.106666 z m 9.11999,-4.53333 v 0.16 c -0.0533,-0.05333 -0.0533,-0.05333 -0.0533,-0.16 z m 0,1.439999 h -0.0533 v -0.16 h 0.0533 z m -9.22666,1.013333 0.0533,0.106666 c -0.0533,0 -0.10666,-0.05333 -0.10666,-0.106666 z m -0.10667,-4.053331 -0.0533,-0.106667 c 0,-0.05333 0,-0.05333 0.0533,-0.106666 z m -0.53333,-7.093329 c 0,-0.05333 0,-0.16 0.0533,-0.213334 0,0.05333 0,0.16 -0.0533,0.213334 z m -1.22667,12.906659 c 0.0533,0.106666 0.0533,0.213333 0,0.266666 z m 11.57333,-11.359994 c 0,0.106667 0.0533,0.213334 0,0.32 z m -10.13333,15.039991 c 0,0.05333 0,0.106667 -0.0533,0.16 v -0.106666 z m 0.37334,-10.50666 0.0533,-0.05333 -0.0533,-0.106667 z m -1.12,8.533328 -0.0533,-0.106666 v 0.16 z m 1.06666,3.573332 v -0.16 l 0.0533,0.16 z m 9.06666,-17.813323 v 0.16 c 0,-0.05333 -0.0533,-0.106667 -0.0533,-0.106667 z m -9.54666,-6.026663 c -0.0533,-0.106667 -0.0533,-0.16 0,-0.213333 z m -1.01333,15.573324 c 0,-0.05333 0,-0.106667 -0.0533,-0.16 0,0.05333 0,0.106666 0.0533,0.16 z m 1.01333,-14.986658 c 0,-0.05333 -0.0533,-0.16 -0.0533,-0.213333 0.0533,0.05333 0.0533,0.16 0.0533,0.213333 z m 9.92,16.426657 0.0533,-0.106667 c 0,0.05333 0,0.106667 -0.0533,0.16 z m 0.37333,-4.853331 c 0,0 -0.0533,0.05333 -0.10667,0.05333 l 0.0533,-0.05333 z m -11.67999,5.546664 c 0,0 -0.0533,0.05333 -0.0533,0.05333 0,0 0.0533,0.05333 0.0533,0.05333 0,0 0.0533,-0.05333 0.0533,-0.05333 0,0 -0.0533,-0.05333 -0.0533,-0.05333 z m -0.26667,-2.933332 0.0533,-0.106666 c 0,0 -0.0533,-0.05333 -0.0533,-0.05333 z m 0.90667,3.093332 c 0,0 -0.0533,-0.05333 -0.0533,-0.05333 v 0.16 z m -0.16,1.706665 0.0533,0.05333 c 0,0.05333 -0.0533,0.106666 -0.0533,0.106666 z m 0.96,-16.47999 0.0533,-0.106666 c 0,0 -0.0533,-0.05333 -0.0533,-0.05333 z m -1.70667,13.333326 v -0.106667 c 0.0533,0.05333 0.0533,0.05333 0.0533,0.106667 z m 1.81333,6.186663 -0.0533,-0.106667 c 0,0 0.0533,-0.05333 0.0533,-0.05333 z m 0.53334,-3.573332 0.0533,0.05333 c 0,0.05333 -0.0533,0.106666 -0.0533,0.106666 z m -0.90667,2.133332 c 0.0533,0 0.0533,0.05333 0.0533,0.106667 -0.0533,0 -0.0533,-0.05333 -0.0533,-0.106667 z m -1.28,-8.479995 c 0,0.05333 0,0.106667 -0.0533,0.16 z m 0.69333,5.97333 h -0.0533 v -0.05333 z m -0.8,-16.906656 h -0.0533 v 0.05333 z m 2.08,11.359993 0.0533,-0.05333 v 0.05333 z m -0.0533,10.18666 c -0.0533,-0.05333 -0.0533,-0.05333 -0.0533,-0.106666 z M 143.50098,55.76029 c 0.0533,-0.05333 0.0533,-0.05333 0.0533,-0.106666 v 0.05333 z m -1.97333,-3.999997 v 0.05333 l 0.0533,-0.05333 z m 2.13333,8.959994 0.0533,0.05333 h -0.0533 z m -0.90666,7.519996 -0.0533,0.05333 h 0.0533 z m 11.14666,-8.266662 c -0.0533,0.05333 -0.0533,0.05333 -0.0533,0.106667 0.0533,-0.05333 0.0533,-0.05333 0.0533,-0.106667 z m -12.58666,-9.919994 h 0.0533 v -0.05333 z m 0.53333,10.133327 c 0,0 0.0533,0.05333 0.0533,0.05333 0,0 -0.0533,0.05333 -0.0533,0.05333 z m 1.12,8.213329 h -0.0533 v -0.05333 z m 10.45333,-8.959995 h -0.0533 l 0.0533,-0.05333 z m 0,0.106667 0.0533,0.05333 h -0.0533 z" + style="font-size:53.3333px;font-family:'Wildemount Rough';-inkscape-font-specification:'Wildemount Rough, Normal'" + id="path926" /> + <path + d="m 171.9068,62.533619 c 0,-3.199998 -2.82667,-4.586663 -5.38667,-5.439996 1.65334,-1.653333 2.82667,-3.999998 2.82667,-6.399996 0,-1.226666 0.0533,-2.719999 -0.58667,-3.839998 -0.90666,-1.546666 -2.08,-3.039998 -4.10666,-3.039998 -1.01333,0 -1.86667,0.319999 -2.61333,0.959999 -0.21334,-1.546666 0.10666,-3.039998 -2.08,-3.039998 -1.81333,0 -1.97333,0.906666 -1.97333,2.453332 0,1.813332 0.16,3.679998 0.16,5.546663 0,3.039998 0.16,6.079996 0.16,9.173328 0,1.919999 0.21333,3.839998 0.21333,5.759997 0,0.586666 0.16,1.119999 0.16,1.706665 0,0.16 -0.0533,0.266667 -0.10667,0.426667 -0.53333,0.159999 -0.74666,0.586666 -0.74666,1.119999 0,0.426666 0.16,1.173333 0.69333,1.226666 l 0.16,-0.05333 v 0.266666 c 0,1.013333 -0.37333,2.773332 0.8,3.253331 0.37333,0.16 0.85333,0.426667 1.22666,0.426667 0.8,0 3.2,-0.106667 3.2,-1.279999 -0.32,-0.693333 0,-1.546666 -0.21333,-2.239999 3.84,0 8.21333,-2.773332 8.21333,-6.986663 z m -7.62667,-4.426664 c 2.24,0 5.06667,1.546666 5.06667,4.053331 0,2.826665 -3.52,4.319998 -5.81333,4.639998 -0.10667,-0.8 -0.26667,-1.653333 -0.26667,-2.453332 0,-1.546666 -0.10666,-4.693331 -0.32,-6.186663 0.16,-0.05333 0.32,-0.05333 0.48,-0.05333 z M 164.8668,45.25363 c 1.33333,0 1.97333,1.173332 1.97333,2.453332 0,3.199998 -1.70666,6.559996 -3.94666,8.799994 -0.21333,-0.426666 -0.53333,-6.719996 -0.53333,-7.413329 0,-1.386665 0.37333,-2.133332 1.33333,-3.093331 0.21333,-0.213333 0.85333,-0.746666 1.17333,-0.746666 z m -3.62666,14.506658 c 0,0.586666 0,1.173332 0.10666,1.759999 -0.0533,0.106666 -0.10666,0.213333 -0.10666,0.373333 0,0.426666 0.10666,0.799999 0.10666,1.226666 0,0.106666 0,0.32 -0.16,0.32 0,-0.266667 -0.0533,-0.533333 -0.0533,-0.8 0,-0.959999 0.26667,-1.973332 -0.0533,-2.933332 v 0.746667 l -0.0533,-0.106667 v -0.32 c -0.26666,-0.426666 -0.26666,-1.173332 -0.26666,-1.653332 0,-0.693333 0.10666,-1.439999 0.10666,-2.133332 l -0.10666,-0.213333 v -0.05333 c 0.21333,-0.373333 0.10666,-0.853332 0.21333,-1.279999 0,0.16 0,0.32 0,0.48 l 0.0533,-0.106667 c 0.0533,0.48 0.0533,0.96 0.0533,1.439999 l 0.0533,0.05333 0.0533,-0.213334 c 0.10667,0.533333 0.16,1.013333 0.16,1.546666 -0.0533,0.213333 0.0533,0.693333 0.0533,0.906666 l -0.0533,-0.05333 c 0.0533,0.106667 0.0533,0.373333 0.0533,0.533333 -0.0533,-0.213333 -0.10667,-0.426666 -0.21334,-0.586666 0.10667,-0.32 0.16,-1.599999 -0.0533,-1.813333 0,0.373334 0,0.693333 -0.0533,1.066667 l 0.0533,-0.213334 c 0.0533,0.213334 0.0533,0.426667 0.0533,0.64 l -0.10666,0.106667 0.0533,-0.16 c -0.0533,0.106666 -0.0533,0.266666 -0.0533,0.373333 h 0.0533 v 0.106666 l -0.0533,-0.05333 v 0.16 l 0.0533,-0.05333 c 0,0.106666 0,0.213333 0.0533,0.319999 0,-0.106666 0,-0.213333 0.0533,-0.319999 v 0.853332 l -0.0533,0.05333 z m -0.32,-8.693328 c -0.0533,0.479999 0.0533,0.906666 0.0533,1.386665 l 0.0533,0.106667 v 0.8 l -0.0533,0.05333 v -0.533333 c -0.0533,0.16 -0.0533,0.32 -0.0533,0.48 l -0.0533,0.05333 c 0,-0.373333 -0.0533,-0.906666 0.0533,-1.279999 h -0.0533 c 0,-0.106667 0,-0.266667 0.0533,-0.373333 l -0.0533,-0.05333 c -0.16,0.16 -0.10666,0.586667 -0.10666,0.8 l -0.0533,-0.213333 v 0.426666 h 0.0533 l -0.0533,0.16 v -0.16 c -0.10666,-0.32 -0.16,-1.493332 -0.16,-1.813332 0,-0.48 0.10667,-0.96 0.10667,-1.439999 l 0.0533,0.106666 0.0533,-0.106666 c 0,0.479999 0.16,0.853332 0.16,1.279999 v 0.266666 l -0.0533,0.05333 z m -0.64,20.159988 c 0,-0.32 -0.0533,-0.586667 -0.0533,-0.906667 v -0.479999 l 0.0533,-0.106667 v 0.213333 l 0.0533,-0.106666 v -0.106667 l 0.0533,0.05333 v 0.853333 c 0.0533,-0.05333 0.0533,-0.16 0.0533,-0.213333 0.0533,0.32 0.0533,0.586666 0.0533,0.906666 z m 1.01333,-5.279997 c 0,0.05333 -0.0533,0.426666 -0.0533,0.426666 0,-0.32 -0.16,-0.639999 -0.16,-0.959999 0,-0.16 0,-0.48 0.10666,-0.586667 0.10667,0.373334 0.10667,0.746667 0.10667,1.12 z m -1.28,1.813332 c 0,0.16 0.26667,1.119999 0.32,1.173333 l -0.0533,0.106666 c 0,-0.213333 -0.10667,-0.533333 -0.21334,-0.693333 v -0.266666 0.266666 c -0.0533,-0.106666 -0.10666,-0.266666 -0.21333,-0.319999 l 0.10667,-0.213334 v -0.05333 z m 0.37333,-17.546656 c 0.10667,0.48 0.10667,0.959999 0,1.386666 z m 0.32,-1.386666 v 0.106667 h -0.0533 c 0,-0.05333 -0.0533,-0.106667 -0.0533,-0.16 0,-0.106667 0.10666,-0.16 0.16,-0.16 v 0.213333 z m -0.0533,4.373331 c 0.0533,-0.106667 0,-0.16 0,-0.266667 h 0.0533 v 0.106667 l 0.10667,-0.05333 -0.0533,0.266667 z m 0.69333,6.559996 c 0,-0.05333 0.0533,-0.16 0.0533,-0.213333 0,0.05333 0.0533,0.159999 0.0533,0.213333 v 0.106666 l -0.10667,0.05333 z m 4.58667,-4.426664 c 0.0533,-0.213333 0.21333,-0.373333 0.32,-0.533333 v 0.106666 z m -4.85333,7.146662 c -0.0533,0.213333 -0.0533,0.48 -0.0533,0.746666 -0.0533,-0.266666 -0.0533,-0.533333 0,-0.799999 z m 0.26666,5.546664 0.0533,-0.106667 c 0,-0.05333 0,-0.106667 0.0533,-0.106667 0,0.106667 0,0.213334 -0.0533,0.32 l -0.0533,-0.05333 z m -0.32,-14.399992 c -0.0533,-0.05333 -0.0533,-0.106667 -0.0533,-0.213333 l 0.0533,0.05333 z m 0.26667,13.066659 c 0,0.05333 0,0.05333 -0.0533,0.106667 0,0 -0.0533,-0.05333 -0.0533,-0.106667 0,0 0.0533,-0.05333 0.0533,-0.106667 z m 0.10667,-6.45333 -0.0533,-0.213333 c 0.0533,0.05333 0.0533,0.106667 0.10667,0.16 z M 161.1335,55.28029 c -0.0533,-0.106666 -0.0533,-0.213333 -0.0533,-0.319999 0.0533,0.106666 0.0533,0.213333 0.0533,0.319999 z m -0.48,-5.919996 c 0,-0.106667 0,-0.16 0.0533,-0.266667 0,0.106667 0,0.16 -0.0533,0.266667 z m 0.48,15.359991 0.0533,0.106666 c -0.0533,0 -0.10666,-0.05333 -0.10666,-0.106666 z m -0.64,-11.14666 c 0,-0.05333 0,-0.16 0.0533,-0.213333 0,0.05333 0,0.159999 -0.0533,0.213333 z m 0.53333,7.093329 -0.0533,-0.106667 c 0,-0.05333 0,-0.05333 0.0533,-0.106666 z m -0.0533,-6.45333 v -0.16 l 0.0533,0.05333 z m -0.26667,15.946658 c 0,0.05333 0,0.106666 -0.0533,0.159999 v -0.106666 z m 0.42667,-10.559994 -0.0533,-0.106667 v 0.16 z m -0.85333,8.639995 c 0,0 -0.0533,0 -0.0533,-0.05333 0,0 0.0533,-0.05333 0.0533,-0.05333 z m 0.26666,-20.319988 c -0.0533,-0.106667 -0.0533,-0.16 0,-0.213333 z m 0,0.586666 c 0,-0.05333 -0.0533,-0.16 -0.0533,-0.213333 0.0533,0.05333 0.0533,0.16 0.0533,0.213333 z m 0.16,22.026654 -0.0533,-0.106667 c 0,0 0.0533,-0.05333 0.0533,-0.05333 z m 0,-21.439988 v -0.106666 l 0.0533,0.16 z m 0.53334,17.866656 0.0533,0.05333 c 0,0.05333 -0.0533,0.106666 -0.0533,0.106666 z m -0.64,-15.946657 0.0533,-0.106666 c 0,0 -0.0533,-0.05333 -0.0533,-0.05333 z m -0.26667,18.07999 c 0.0533,0 0.0533,0.05333 0.0533,0.106666 -0.0533,0 -0.0533,-0.05333 -0.0533,-0.106666 z m 0,0.373333 h -0.0533 v -0.106667 z m 0.53333,-13.333326 c 0.0533,-0.05333 0.0533,-0.05333 0.0533,-0.106666 v 0.05333 z m 0.21334,5.013331 h -0.0533 v -0.05333 z m 0.32,-1.813333 0.0533,0.05333 h -0.0533 z m -1.06667,9.439995 h -0.0533 v -0.05333 z m 0.74667,-7.733329 h -0.0533 l 0.0533,-0.05333 z m -0.10667,10.186661 c -0.0533,-0.05333 -0.0533,-0.05333 -0.0533,-0.106667 z" + style="font-size:53.3333px;font-family:'Wildemount Rough';-inkscape-font-specification:'Wildemount Rough, Normal'" + id="path928" /> + </g> + <g + aria-label="neodb.social" + id="text2391" + style="font-size:40px;line-height:1.25;fill:#b3b3b3"> + <path + d="m 32.766354,166.81331 q 2.856,0 4.248,1.488 1.2,1.272 1.2,3.48 v 6.888 h -1.824 v -7.152 q 0,-1.536 -1.008,-2.376 -0.984,-0.864 -2.616,-0.864 h -4.776 q -1.632,0 -2.664,0.888 -1.008,0.888 -1.008,2.352 v 7.152 h -1.8 v -11.856 h 1.8 v 1.92 q 0.456,-0.96 1.32,-1.44 0.888,-0.48 2.352,-0.48 z" + style="font-size:24px;font-family:Federation;-inkscape-font-specification:'Federation, Normal'" + id="path5924" /> + <path + d="m 46.123104,178.66931 q -5.328,0 -5.328,-4.44 v -2.832 q 0,-1.92 1.344,-3.216 1.464,-1.416 3.936,-1.416 h 5.232 q 2.568,0 4.008,1.248 1.344,1.152 1.344,2.928 v 1.728 h -14.04 v 1.344 q 0,1.704 0.984,2.496 0.864,0.696 2.472,0.696 h 5.928 q 1.416,0 2.112,-0.576 0.72,-0.6 0.72,-1.512 v -0.24 h 1.824 v 0.24 q 0,1.368 -0.912,2.328 -1.176,1.224 -3.528,1.224 z m 5.376,-10.416 h -5.712 q -1.392,0 -2.28,0.816 -0.888,0.816 -0.888,2.136 h 12.24 q 0,-1.296 -0.912,-2.112 -0.888,-0.84 -2.448,-0.84 z" + style="font-size:24px;font-family:Federation;-inkscape-font-specification:'Federation, Normal'" + id="path5926" /> + <path + d="m 64.309479,166.76531 h 5.016 q 2.88,0 4.248,1.416 1.176,1.2 1.176,3.264 v 2.592 q 0,2.064 -1.2,3.264 -1.392,1.392 -4.224,1.392 h -5.016 q -5.448,0 -5.448,-4.656 v -2.592 q 0,-2.16 1.248,-3.36 1.368,-1.32 4.2,-1.32 z m -3.6,7.272 q 0,3.192 3.768,3.192 h 4.68 q 1.8,0 2.736,-0.72 1.032,-0.792 1.032,-2.472 v -2.616 q 0,-1.536 -0.984,-2.352 -0.96,-0.816 -2.784,-0.816 h -4.68 q -1.92,0 -2.856,0.744 -0.912,0.744 -0.912,2.448 z" + style="font-size:24px;font-family:Federation;-inkscape-font-specification:'Federation, Normal'" + id="path5928" /> + <path + d="m 76.910604,174.03731 v -2.592 q 0,-2.136 1.272,-3.36 1.392,-1.32 4.032,-1.32 h 4.896 q 1.776,0 2.616,0.408 0.504,0.24 0.984,0.936 v -6.096 h 1.824 v 16.656 h -1.824 v -1.152 q -0.48,0.6 -1.08,0.84 -0.816,0.336 -2.52,0.336 h -4.896 q -5.304,0 -5.304,-4.656 z m 9.96,-5.784 h -4.032 q -2.136,0 -3.12,0.816 -0.984,0.792 -0.984,2.376 v 2.592 q 0,3.192 4.104,3.192 h 4.032 q 1.92,0 2.904,-0.888 1.008,-0.888 1.008,-2.304 v -2.592 q 0,-1.44 -0.936,-2.28 -1.032,-0.912 -2.976,-0.912 z" + style="font-size:24px;font-family:Federation;-inkscape-font-specification:'Federation, Normal'" + id="path5930" /> + <path + d="m 96.877479,177.37331 v 1.296 h -1.8 v -16.656 h 1.8 v 6.096 q 0.432,-0.624 1.152,-0.936 0.936,-0.408 2.472001,-0.408 h 4.896 q 2.592,0 3.936,1.248 1.368,1.248 1.368,3.432 v 2.592 q 0,2.256 -1.296,3.456 -1.296,1.2 -4.008,1.2 h -4.896 q -1.464001,0 -2.328001,-0.384 -0.576,-0.264 -1.296,-0.936 z m 3.864001,-0.144 h 4.032 q 2.16,0 3.12,-0.744 0.984,-0.768 0.984,-2.448 v -2.592 q 0,-1.56 -1.008,-2.376 -1.008,-0.816 -3.096,-0.816 h -4.032 q -1.920001,0 -2.928001,0.888 -0.984,0.864 -0.984,2.304 v 2.592 q 0,1.44 0.96,2.328 0.984,0.864 2.952001,0.864 z" + style="font-size:24px;font-family:Federation;-inkscape-font-specification:'Federation, Normal'" + id="path5932" /> + <path + d="m 113.31635,176.79731 h 1.968 v 1.872 h -1.968 z" + style="font-size:24px;font-family:Federation;-inkscape-font-specification:'Federation, Normal'" + id="path5934" /> + <path + d="m 117.6221,175.74131 v -0.84 h 1.824 v 0.624 q 0,0.864 0.552,1.296 0.552,0.408 2.04,0.408 h 6.792 q 2.712,0 2.712,-1.632 v -0.504 q 0,-0.768 -0.648,-1.224 -0.624,-0.456 -1.728,-0.456 l -7.224,-0.144 q -2.136,0 -3.144,-0.624 -1.152,-0.72 -1.152,-2.304 v -0.552 q 0,-1.368 1.08,-2.184 1.104,-0.84 3.264,-0.84 h 6.648 q 2.664,0 3.864,0.984 0.912,0.744 0.912,1.968 v 0.816 h -1.824 v -0.6 q 0,-0.792 -0.432,-1.152 -0.624,-0.528 -2.256,-0.528 h -7.08 q -2.376,0 -2.376,1.584 v 0.504 q 0,0.768 0.6,1.128 0.6,0.336 1.776,0.336 l 7.248,0.144 q 2.088,0 3.168,0.768 1.104,0.768 1.104,2.376 v 0.672 q 0,1.344 -1.104,2.136 -1.104,0.768 -3.36,0.768 h -7.08 q -2.328,0 -3.336,-0.888 -0.84,-0.744 -0.84,-2.04 z" + style="font-size:24px;font-family:Federation;-inkscape-font-specification:'Federation, Normal'" + id="path5936" /> + <path + d="m 141.09073,166.76531 h 5.016 q 2.88,0 4.248,1.416 1.176,1.2 1.176,3.264 v 2.592 q 0,2.064 -1.2,3.264 -1.392,1.392 -4.224,1.392 h -5.016 q -5.448,0 -5.448,-4.656 v -2.592 q 0,-2.16 1.248,-3.36 1.368,-1.32 4.2,-1.32 z m -3.6,7.272 q 0,3.192 3.768,3.192 h 4.68 q 1.8,0 2.736,-0.72 1.032,-0.792 1.032,-2.472 v -2.616 q 0,-1.536 -0.984,-2.352 -0.96,-0.816 -2.784,-0.816 h -4.68 q -1.92,0 -2.856,0.744 -0.912,0.744 -0.912,2.448 z" + style="font-size:24px;font-family:Federation;-inkscape-font-specification:'Federation, Normal'" + id="path5938" /> + <path + d="m 167.63585,174.46931 h 1.824 q 0,1.872 -1.08,2.928 -1.32,1.272 -4.2,1.272 h -5.592 q -2.52,0 -3.768,-1.152 -1.248,-1.152 -1.248,-3.24 v -2.952 q 0,-2.04 1.248,-3.24 1.368,-1.32 3.912,-1.32 h 5.448 q 2.592,0 3.936,1.104 1.344,1.08 1.344,3.096 h -1.824 q 0,-1.296 -0.768,-1.968 -0.84,-0.744 -2.688,-0.744 h -5.448 q -1.608,0 -2.472,0.768 -0.864,0.744 -0.864,2.304 v 2.952 q 0,1.368 0.96,2.184 0.888,0.744 2.232,0.744 h 5.592 q 3.456,0 3.456,-2.736 z" + style="font-size:24px;font-family:Federation;-inkscape-font-specification:'Federation, Normal'" + id="path5940" /> + <path + d="m 174.07235,166.81331 v 11.856 h -1.8 v -11.856 z m 0,-4.8 v 1.728 h -1.8 v -1.728 z" + style="font-size:24px;font-family:Federation;-inkscape-font-specification:'Federation, Normal'" + id="path5942" /> + <path + d="m 180.75448,171.42131 h 7.752 q 0.984,0 1.56,0.288 0.6,0.264 0.744,0.696 v -1.68 q 0,-1.104 -0.696,-1.728 -0.792,-0.72 -2.376,-0.72 h -5.28 q -1.536,0 -2.328,0.456 -0.768,0.456 -0.768,1.224 v 0.288 h -1.872 v -0.288 q 0,-1.464 1.176,-2.304 1.176,-0.84 3.312,-0.84 h 5.952 q 2.448,0 3.624,1.128 1.08,1.032 1.08,3.048 v 7.68 h -1.8 v -0.936 q -0.384,0.432 -0.912,0.696 -0.528,0.24 -1.344,0.24 h -7.92 q -3.816,0 -3.816,-3.024 v -1.44 q 0,-1.272 0.912,-1.992 1.008,-0.792 3,-0.792 z m 7.272,1.464 h -6.648 q -1.608,0 -2.184,0.408 -0.552,0.408 -0.552,1.32 v 0.864 q 0,0.84 0.6,1.296 0.624,0.432 2.136,0.432 h 6.648 q 1.464,0 2.088,-0.504 0.648,-0.504 0.648,-1.656 0,-2.16 -2.736,-2.16 z" + style="font-size:24px;font-family:Federation;-inkscape-font-specification:'Federation, Normal'" + id="path5944" /> + <path + d="m 198.05623,162.01331 v 16.656 h -1.824 v -16.656 z" + style="font-size:24px;font-family:Federation;-inkscape-font-specification:'Federation, Normal'" + id="path5946" /> + </g> +</svg> diff --git a/common/static/js/create_update_review.js b/common/static/js/create_update_review.js index 9dad74dc..60ab409d 100644 --- a/common/static/js/create_update_review.js +++ b/common/static/js/create_update_review.js @@ -1,9 +1,9 @@ $(document).ready( function() { $(".markdownx-preview").hide(); - $(".markdownx textarea").attr("placeholder", "拖拽图片至编辑框即可插入哦~"); + $(".markdownx textarea").attr("placeholder", "从剪贴板粘贴或者拖拽文件至编辑框即可插入图片"); - $(".review-form__preview-button").click(function() { + $(".review-form__preview-button").on('click', function() { if ($(".markdownx-preview").is(":visible")) { $(".review-form__preview-button").text("预览"); $(".markdownx-preview").hide(); diff --git a/common/static/js/detail.js b/common/static/js/detail.js index 01feac46..fe61e7c9 100644 --- a/common/static/js/detail.js +++ b/common/static/js/detail.js @@ -7,7 +7,7 @@ $(document).ready( function() { // pop up new rating modal $("#addMarkPanel button").each(function() { - $(this).click(function(e) { + $(this).on('click', function(e) { e.preventDefault(); let title = $(this).text().trim(); $(".mark-modal__title").text(title); @@ -29,7 +29,7 @@ $(document).ready( function() { }) // pop up modify mark modal - $(".mark-panel a.edit").click(function(e) { + $(".mark-panel a.edit").on('click', function(e) { e.preventDefault(); let title = $(".mark-panel__status").text().trim(); $(".mark-modal__title").text(title); @@ -79,7 +79,7 @@ $(document).ready( function() { if ($("#statusSelection input[type='radio']:checked").val() == WISH_CODE) { $(".mark-modal .rating-star-edit").hide(); } - $("#statusSelection input[type='radio']").click(function() { + $("#statusSelection input[type='radio']").on('click', function() { if ($(this).val() == WISH_CODE) { $(".mark-modal .rating-star-edit").hide(); } else { @@ -89,14 +89,14 @@ $(document).ready( function() { }); // show confirm modal - $(".mark-panel a.delete").click(function(e) { + $(".mark-panel a.delete").on('click', function(e) { e.preventDefault(); $(".confirm-modal").show(); $(".bg-mask").show(); }); // confirm modal - $(".confirm-modal input[type='submit']").click(function(e) { + $(".confirm-modal input[type='submit']").on('click', function(e) { e.preventDefault(); $(".mark-panel form").submit(); }); @@ -116,20 +116,20 @@ $(document).ready( function() { }); // expand hidden long text - $(".entity-desc__unfold-button a").click(function() { + $(".entity-desc__unfold-button a").on('click', function() { $(this).parent().siblings(".entity-desc__content").removeClass('entity-desc__content--folded'); $(this).parent(".entity-desc__unfold-button").remove(); }); // disable delete mark button after click const confirmDeleteMarkButton = $('.confirm-modal__confirm-button > input'); - confirmDeleteMarkButton.click(function() { + confirmDeleteMarkButton.on('click', function() { confirmDeleteMarkButton.prop("disabled", true); }); // disable sumbit button after click const confirmSumbitMarkButton = $('.mark-modal__confirm-button > input'); - confirmSumbitMarkButton.click(function() { + confirmSumbitMarkButton.on('click', function() { confirmSumbitMarkButton.prop("disabled", true); confirmSumbitMarkButton.closest('form')[0].submit(); }); diff --git a/common/static/js/home.js b/common/static/js/home.js index 456c637b..ca1cb32b 100644 --- a/common/static/js/home.js +++ b/common/static/js/home.js @@ -1,114 +1,98 @@ - $(document).ready( function() { + $("#userInfoCard .mast-brief").text($("<div>"+$("#userInfoCard .mast-brief").text().replace(/\<br/g,'\n<br').replace(/\<p/g,'\n<p')+"</div>").text()); + $("#userInfoCard .mast-brief").html($("#userInfoCard .mast-brief").html().replace(/\n/g,'<br/>')); let token = $("#oauth2Token").text(); - let mast_uri = $("#mastodonURI").text(); - let mast_domain = new URL(mast_uri); - mast_domain = mast_domain.hostname; + let mast_domain = $("#mastodonURI").text(); + let mast_uri = 'https://' + mast_domain let id = $("#userMastodonID").text(); - let userInfoSpinner = $("#spinner").clone().removeAttr("hidden"); - let followersSpinner = $("#spinner").clone().removeAttr("hidden"); - let followingSpinner = $("#spinner").clone().removeAttr("hidden"); - $("#userInfoCard").append(userInfoSpinner); - $("#followings h5").after(followingSpinner); - $("#followers h5").after(followersSpinner); - $(".mast-following-more").hide(); - $(".mast-followers-more").hide(); + if (id && id != 'None' && mast_domain != 'twitter.com') { + // let userInfoSpinner = $("#spinner").clone().removeAttr("hidden"); + let followersSpinner = $("#spinner").clone().removeAttr("hidden"); + let followingSpinner = $("#spinner").clone().removeAttr("hidden"); + // $("#userInfoCard").append(userInfoSpinner); + $("#followings h5").after(followingSpinner); + $("#followers h5").after(followersSpinner); + $(".mast-following-more").hide(); + $(".mast-followers-more").hide(); - getUserInfo( - id, - mast_uri, - token, - function(userData) { - let userName; - if (userData.display_name) { - userName = translateEmojis(userData.display_name, userData.emojis, true); - } else { - userName = userData.username; - } - $("#userInfoCard .mast-avatar").attr("src", userData.avatar); - $("#userInfoCard .mast-displayname").html(userName); - $("#userInfoCard .mast-brief").text($(userData.note).text()); - $(userInfoSpinner).remove(); - } - ); + getFollowers( + id, + mast_uri, + token, + function(userList, request) { + if (userList.length == 0) { + $(".mast-followers").hide(); + $(".mast-followers").before('<div style="margin-bottom: 20px;">暂无</div>'); - getFollowers( - id, - mast_uri, - token, - function(userList, request) { - if (userList.length == 0) { - $(".mast-followers").hide(); - $(".mast-followers").before('<div style="margin-bottom: 20px;">暂无</div>'); - - } else { - if (userList.length > 4){ - userList = userList.slice(0, 4); - $(".mast-followers-more").show(); + } else { + if (userList.length > 4){ + userList = userList.slice(0, 4); + $(".mast-followers-more").show(); + } + let template = $(".mast-followers li").clone(); + $(".mast-followers").html(""); + userList.forEach(data => { + temp = $(template).clone(); + temp.find("img").attr("src", data.avatar); + if (data.display_name) { + temp.find(".mast-displayname").html(translateEmojis(data.display_name, data.emojis)); + } else { + temp.find(".mast-displayname").text(data.username); + } + let url; + if (data.acct.includes('@')) { + url = $("#userPageURL").text().replace('0', data.acct); + } else { + url = $("#userPageURL").text().replace('0', data.acct + '@' + mast_domain); + } + temp.find("a").attr('href', url); + $(".mast-followers").append(temp); + }); } - let template = $(".mast-followers li").clone(); - $(".mast-followers").html(""); - userList.forEach(data => { - temp = $(template).clone(); - temp.find("img").attr("src", data.avatar); - if (data.display_name) { - temp.find(".mast-displayname").html(translateEmojis(data.display_name, data.emojis)); - } else { - temp.find(".mast-displayname").text(data.username); - } - let url; - if (data.acct.includes('@')) { - url = $("#userPageURL").text().replace('0', data.acct); - } else { - url = $("#userPageURL").text().replace('0', data.acct + '@' + mast_domain); - } - temp.find("a").attr('href', url); - $(".mast-followers").append(temp); - }); + $(followersSpinner).remove(); } - $(followersSpinner).remove(); - } - ); + ); - getFollowing( - id, - mast_uri, - token, - function(userList, request) { - if (userList.length == 0) { - $(".mast-following").hide(); - $(".mast-following").before('<div style="margin-bottom: 20px;">暂无</div>'); - } else { - if (userList.length > 4){ - userList = userList.slice(0, 4); - $(".mast-following-more").show(); + getFollowing( + id, + mast_uri, + token, + function(userList, request) { + if (userList.length == 0) { + $(".mast-following").hide(); + $(".mast-following").before('<div style="margin-bottom: 20px;">暂无</div>'); + } else { + if (userList.length > 4){ + userList = userList.slice(0, 4); + $(".mast-following-more").show(); + } + let template = $(".mast-following li").clone(); + $(".mast-following").html(""); + userList.forEach(data => { + temp = $(template).clone() + temp.find("img").attr("src", data.avatar); + if (data.display_name) { + temp.find(".mast-displayname").html(translateEmojis(data.display_name, data.emojis)); + } else { + temp.find(".mast-displayname").text(data.username); + } + let url; + if (data.acct.includes('@')) { + url = $("#userPageURL").text().replace('0', data.acct); + } else { + url = $("#userPageURL").text().replace('0', data.acct + '@' + mast_domain); + } + temp.find("a").attr('href', url); + $(".mast-following").append(temp); + }); } - let template = $(".mast-following li").clone(); - $(".mast-following").html(""); - userList.forEach(data => { - temp = $(template).clone() - temp.find("img").attr("src", data.avatar); - if (data.display_name) { - temp.find(".mast-displayname").html(translateEmojis(data.display_name, data.emojis)); - } else { - temp.find(".mast-displayname").text(data.username); - } - let url; - if (data.acct.includes('@')) { - url = $("#userPageURL").text().replace('0', data.acct); - } else { - url = $("#userPageURL").text().replace('0', data.acct + '@' + mast_domain); - } - temp.find("a").attr('href', url); - $(".mast-following").append(temp); - }); - } - $(followingSpinner).remove(); + $(followingSpinner).remove(); - } - ); + } + ); + } // mobile dropdown $(".relation-dropdown__button").data("collapse", true); @@ -118,7 +102,7 @@ $(document).ready( function() { button.children('.icon-arrow').toggleClass("icon-arrow--expand"); button.siblings('.relation-dropdown__body').toggleClass("relation-dropdown__body--expand"); } - $(".relation-dropdown__button").click(onClickDropdownButton) + $(".relation-dropdown__button").on('click', onClickDropdownButton); // close when click outside window.onclick = evt => { @@ -129,7 +113,7 @@ $(document).ready( function() { }; // import panel - $("#uploadBtn").click(e => { + $("#uploadBtn").on('click', e => { const btn = $("#uploadBtn") const form = $(".import-panel__body form") @@ -201,7 +185,8 @@ $(document).ready( function() { if (!data.total_items == 0) { progress.attr("max", data.total_items); progress.attr("value", data.finished_items); - percent.text(Math.floor(100 * data.finished_items / data.total_items) + '%'); + progress.attr("value", data.finished_items); + percent.text("" + data.finished_items + "/" + data.total_items); } setTimeout(() => { poll(); diff --git a/common/static/js/mastodon.js b/common/static/js/mastodon.js index 91defcbe..02a54679 100644 --- a/common/static/js/mastodon.js +++ b/common/static/js/mastodon.js @@ -54,38 +54,50 @@ const NUMBER_PER_REQUEST = 20 // "fields": [] // } // ] -function getFollowers(id, mastodonURI, token, callback) { - let url = mastodonURI + API_FOLLOWERS.replace(":id", id); - $.ajax({ - url: url, - method: 'GET', - headers: { - 'Authorization': 'Bearer ' + token, - }, - data: { - 'limit': NUMBER_PER_REQUEST - }, - success: function(data, status, request){ - callback(data, request); - }, - }); +async function getFollowers(id, mastodonURI, token, callback) { + const url = mastodonURI + API_FOLLOWERS.replace(":id", id); + var response; + try { + response = await fetch(url+'?limit='+NUMBER_PER_REQUEST, {headers: {'Authorization': 'Bearer ' + token}}); + } catch (e) { + console.error('loading followers failed.'); + return; + } + const json = await response.json(); + let nextUrl = null; + let links = response.headers.get('link'); + if (links) { + links.split(',').forEach(link => { + if (link.includes('next')) { + let regex = /<(.*?)>/; + nextUrl = link.match(regex)[1]; + } + }); + } + callback(json, nextUrl); } -function getFollowing(id, mastodonURI, token, callback) { - let url = mastodonURI + API_FOLLOWING.replace(":id", id); - $.ajax({ - url: url, - method: 'GET', - headers: { - 'Authorization': 'Bearer ' + token, - }, - data: { - 'limit': NUMBER_PER_REQUEST - }, - success: function(data, status, request){ - callback(data, request); - }, - }); +async function getFollowing(id, mastodonURI, token, callback) { + const url = mastodonURI + API_FOLLOWING.replace(":id", id); + var response; + try { + response = await fetch(url+'?limit='+NUMBER_PER_REQUEST, {headers: {'Authorization': 'Bearer ' + token}}); + } catch (e) { + console.error('loading following failed.'); + return; + } + const json = await response.json(); + let nextUrl = null; + let links = response.headers.get('link'); + if (links) { + links.split(',').forEach(link => { + if (link.includes('next')) { + let regex = /<(.*?)>/; + nextUrl = link.match(regex)[1]; + } + }); + } + callback(json, nextUrl); } // { diff --git a/common/static/js/rating-star-readonly.js b/common/static/js/rating-star-readonly.js index 76802ea8..a552a6b1 100644 --- a/common/static/js/rating-star-readonly.js +++ b/common/static/js/rating-star-readonly.js @@ -1,5 +1,5 @@ $(document).ready( function() { - +let render = function() { let ratingLabels = $(".rating-star"); $(ratingLabels).each( function(index, value) { let ratingScore = $(this).data("rating-score") / 2; @@ -8,5 +8,9 @@ $(document).ready( function() { readOnly: true }); }); - +}; +document.body.addEventListener('htmx:load', function(evt) { + render(); +}); +render(); }); \ No newline at end of file diff --git a/common/static/js/scrape.js b/common/static/js/scrape.js index 67ce3e3a..bd05bbd8 100644 --- a/common/static/js/scrape.js +++ b/common/static/js/scrape.js @@ -1,6 +1,6 @@ $(document).ready( function() { - $(".submit").click(function(e) { + $(".submit").on('click', function(e) { e.preventDefault(); let form = $("#scrapeForm form"); if (form.data('submitted') === true) { diff --git a/common/static/js/sort_layout.js b/common/static/js/sort_layout.js index 1ac72eba..c20e370d 100644 --- a/common/static/js/sort_layout.js +++ b/common/static/js/sort_layout.js @@ -8,7 +8,7 @@ $(() => { $(e).data("visibility", true); } let btn = $("#toggleDisplayButtonTemplate").clone().removeAttr("id"); - btn.click(e => { + btn.on('click', e => { if ($(e.currentTarget).parent().data('visibility') === true) { // flip text $(e.currentTarget).children("span.showText").show(); @@ -72,7 +72,7 @@ $(() => { }); // activate sorting - $("#sortEditButton").click(evt => { + $("#sortEditButton").on('click', evt => { // test if edit mode is activated isActivated = $("#sortSaveIcon").is(":visible"); @@ -134,7 +134,7 @@ $(() => { }); // exit edit mode - $("#sortExitButton").click(evt => { + $("#sortExitButton").on('click', evt => { initialLayoutData.forEach(elem => { // set visiblity $('#' + elem.id).data('visibility', elem.visibility); diff --git a/common/static/lib/css/milligram.css b/common/static/lib/css/milligram.css deleted file mode 100644 index be254df8..00000000 --- a/common/static/lib/css/milligram.css +++ /dev/null @@ -1,605 +0,0 @@ -/*! - * Milligram v1.3.0 - * https://milligram.github.io - * - * Copyright (c) 2017 CJ Patoilo - * Licensed under the MIT license - */ - -*, -*:after, -*:before { - box-sizing: inherit; -} - -html { - box-sizing: border-box; - font-size: 62.5%; -} - -body { - color: #606c76; - font-family: 'Roboto', 'Helvetica Neue', 'Helvetica', 'Arial', sans-serif; - font-size: 1.6em; - font-weight: 300; - letter-spacing: .01em; - line-height: 1.6; -} - -textarea { - font-family: 'Roboto', 'Helvetica Neue', 'Helvetica', 'Arial', sans-serif; -} - -blockquote { - border-left: 0.3rem solid #d1d1d1; - margin-left: 0; - margin-right: 0; - padding: 1rem 1.5rem; -} - -blockquote *:last-child { - margin-bottom: 0; -} - -.button, -button, -input[type='button'], -input[type='reset'], -input[type='submit'] { - background-color: #00a1cc; - border: 0.1rem solid #00a1cc; - border-radius: .4rem; - color: #fff; - cursor: pointer; - display: inline-block; - font-size: 1.1rem; - font-weight: 700; - height: 3.8rem; - letter-spacing: .1rem; - line-height: 3.8rem; - padding: 0 3.0rem; - text-align: center; - text-decoration: none; - text-transform: uppercase; - white-space: nowrap; -} - -.button:focus, .button:hover, -button:focus, -button:hover, -input[type='button']:focus, -input[type='button']:hover, -input[type='reset']:focus, -input[type='reset']:hover, -input[type='submit']:focus, -input[type='submit']:hover { - background-color: #606c76; - border-color: #606c76; - color: #fff; - outline: 0; -} - -.button[disabled], -button[disabled], -input[type='button'][disabled], -input[type='reset'][disabled], -input[type='submit'][disabled] { - cursor: default; - opacity: .5; -} - -.button[disabled]:focus, .button[disabled]:hover, -button[disabled]:focus, -button[disabled]:hover, -input[type='button'][disabled]:focus, -input[type='button'][disabled]:hover, -input[type='reset'][disabled]:focus, -input[type='reset'][disabled]:hover, -input[type='submit'][disabled]:focus, -input[type='submit'][disabled]:hover { - background-color: #00a1cc; - border-color: #00a1cc; -} - -.button.button-outline, -button.button-outline, -input[type='button'].button-outline, -input[type='reset'].button-outline, -input[type='submit'].button-outline { - background-color: transparent; - color: #00a1cc; -} - -.button.button-outline:focus, .button.button-outline:hover, -button.button-outline:focus, -button.button-outline:hover, -input[type='button'].button-outline:focus, -input[type='button'].button-outline:hover, -input[type='reset'].button-outline:focus, -input[type='reset'].button-outline:hover, -input[type='submit'].button-outline:focus, -input[type='submit'].button-outline:hover { - background-color: transparent; - border-color: #606c76; - color: #606c76; -} - -.button.button-outline[disabled]:focus, .button.button-outline[disabled]:hover, -button.button-outline[disabled]:focus, -button.button-outline[disabled]:hover, -input[type='button'].button-outline[disabled]:focus, -input[type='button'].button-outline[disabled]:hover, -input[type='reset'].button-outline[disabled]:focus, -input[type='reset'].button-outline[disabled]:hover, -input[type='submit'].button-outline[disabled]:focus, -input[type='submit'].button-outline[disabled]:hover { - border-color: inherit; - color: #00a1cc; -} - -.button.button-clear, -button.button-clear, -input[type='button'].button-clear, -input[type='reset'].button-clear, -input[type='submit'].button-clear { - background-color: transparent; - border-color: transparent; - color: #00a1cc; -} - -.button.button-clear:focus, .button.button-clear:hover, -button.button-clear:focus, -button.button-clear:hover, -input[type='button'].button-clear:focus, -input[type='button'].button-clear:hover, -input[type='reset'].button-clear:focus, -input[type='reset'].button-clear:hover, -input[type='submit'].button-clear:focus, -input[type='submit'].button-clear:hover { - background-color: transparent; - border-color: transparent; - color: #606c76; -} - -.button.button-clear[disabled]:focus, .button.button-clear[disabled]:hover, -button.button-clear[disabled]:focus, -button.button-clear[disabled]:hover, -input[type='button'].button-clear[disabled]:focus, -input[type='button'].button-clear[disabled]:hover, -input[type='reset'].button-clear[disabled]:focus, -input[type='reset'].button-clear[disabled]:hover, -input[type='submit'].button-clear[disabled]:focus, -input[type='submit'].button-clear[disabled]:hover { - color: #00a1cc; -} - -code { - background: #f4f5f6; - border-radius: .4rem; - font-size: 86%; - margin: 0 .2rem; - padding: .2rem .5rem; - white-space: nowrap; -} - -pre { - background: #f4f5f6; - border-left: 0.3rem solid #00a1cc; - overflow-y: hidden; -} - -pre > code { - border-radius: 0; - display: block; - padding: 1rem 1.5rem; - white-space: pre; -} - -hr { - border: 0; - border-top: 0.1rem solid #f4f5f6; - margin: 3.0rem 0; -} - -input[type='email'], -input[type='number'], -input[type='password'], -input[type='search'], -input[type='tel'], -input[type='text'], -input[type='url'], -textarea, -select { - -webkit-appearance: none; - -moz-appearance: none; - appearance: none; - background-color: transparent; - border: 0.1rem solid #d1d1d1; - border-radius: .4rem; - box-shadow: none; - box-sizing: inherit; - height: 3.8rem; - padding: .6rem 1.0rem; - width: 100%; -} - -input[type='email']:focus, -input[type='number']:focus, -input[type='password']:focus, -input[type='search']:focus, -input[type='tel']:focus, -input[type='text']:focus, -input[type='url']:focus, -textarea:focus, -select:focus { - border-color: #00a1cc; - outline: 0; -} - -select { - background: url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" height="14" viewBox="0 0 29 14" width="29"><path fill="#d1d1d1" d="M9.37727 3.625l5.08154 6.93523L19.54036 3.625"/></svg>') center right no-repeat; - padding-right: 3.0rem; -} - -select:focus { - background-image: url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" height="14" viewBox="0 0 29 14" width="29"><path fill="#00a1cc" d="M9.37727 3.625l5.08154 6.93523L19.54036 3.625"/></svg>'); -} - -textarea { - min-height: 6.5rem; -} - -label, -legend { - display: block; - font-size: 1.6rem; - font-weight: 700; - margin-bottom: .5rem; -} - -fieldset { - border-width: 0; - padding: 0; -} - -input[type='checkbox'], -input[type='radio'] { - display: inline; -} - -.label-inline { - display: inline-block; - font-weight: normal; - margin-left: .5rem; -} - -.container { - margin: 0 auto; - max-width: 112.0rem; - padding: 0 2.0rem; - position: relative; - width: 100%; -} - -.row { - display: flex; - flex-direction: column; - padding: 0; - width: 100%; -} - -.row.row-no-padding { - padding: 0; -} - -.row.row-no-padding > .column { - padding: 0; -} - -.row.row-wrap { - flex-wrap: wrap; -} - -.row.row-top { - align-items: flex-start; -} - -.row.row-bottom { - align-items: flex-end; -} - -.row.row-center { - align-items: center; -} - -.row.row-stretch { - align-items: stretch; -} - -.row.row-baseline { - align-items: baseline; -} - -.row .column { - display: block; - flex: 1 1 auto; - margin-left: 0; - max-width: 100%; - width: 100%; -} - -.row .column.column-offset-10 { - margin-left: 10%; -} - -.row .column.column-offset-20 { - margin-left: 20%; -} - -.row .column.column-offset-25 { - margin-left: 25%; -} - -.row .column.column-offset-33, .row .column.column-offset-34 { - margin-left: 33.3333%; -} - -.row .column.column-offset-50 { - margin-left: 50%; -} - -.row .column.column-offset-66, .row .column.column-offset-67 { - margin-left: 66.6666%; -} - -.row .column.column-offset-75 { - margin-left: 75%; -} - -.row .column.column-offset-80 { - margin-left: 80%; -} - -.row .column.column-offset-90 { - margin-left: 90%; -} - -.row .column.column-10 { - flex: 0 0 10%; - max-width: 10%; -} - -.row .column.column-20 { - flex: 0 0 20%; - max-width: 20%; -} - -.row .column.column-25 { - flex: 0 0 25%; - max-width: 25%; -} - -.row .column.column-33, .row .column.column-34 { - flex: 0 0 33.3333%; - max-width: 33.3333%; -} - -.row .column.column-40 { - flex: 0 0 40%; - max-width: 40%; -} - -.row .column.column-50 { - flex: 0 0 50%; - max-width: 50%; -} - -.row .column.column-60 { - flex: 0 0 60%; - max-width: 60%; -} - -.row .column.column-66, .row .column.column-67 { - flex: 0 0 66.6666%; - max-width: 66.6666%; -} - -.row .column.column-75 { - flex: 0 0 75%; - max-width: 75%; -} - -.row .column.column-80 { - flex: 0 0 80%; - max-width: 80%; -} - -.row .column.column-90 { - flex: 0 0 90%; - max-width: 90%; -} - -.row .column .column-top { - align-self: flex-start; -} - -.row .column .column-bottom { - align-self: flex-end; -} - -.row .column .column-center { - -ms-grid-row-align: center; - align-self: center; -} - -@media (min-width: 40rem) { - .row { - flex-direction: row; - margin-left: -1.0rem; - width: calc(100% + 2.0rem); - } - .row .column { - margin-bottom: inherit; - padding: 0 1.0rem; - } -} - -a { - color: #00a1cc; - text-decoration: none; -} - -a:focus, a:hover { - color: #606c76; -} - -dl, -ol, -ul { - list-style: none; - margin-top: 0; - padding-left: 0; -} - -dl dl, -dl ol, -dl ul, -ol dl, -ol ol, -ol ul, -ul dl, -ul ol, -ul ul { - font-size: 90%; - margin: 1.5rem 0 1.5rem 3.0rem; -} - -ol { - list-style: decimal inside; -} - -ul { - list-style: circle inside; -} - -.button, -button, -dd, -dt, -li { - margin-bottom: 1.0rem; -} - -fieldset, -input, -select, -textarea { - margin-bottom: 1.5rem; -} - -blockquote, -dl, -figure, -form, -ol, -p, -pre, -table, -ul { - margin-bottom: 2.5rem; -} - -table { - border-spacing: 0; - width: 100%; -} - -td, -th { - border-bottom: 0.1rem solid #e1e1e1; - padding: 1.2rem 1.5rem; - text-align: left; -} - -td:first-child, -th:first-child { - padding-left: 0; -} - -td:last-child, -th:last-child { - padding-right: 0; -} - -b, -strong { - font-weight: bold; -} - -p { - margin-top: 0; -} - -h1, -h2, -h3, -h4, -h5, -h6 { - font-weight: 300; - letter-spacing: -.1rem; - margin-bottom: 2.0rem; - margin-top: 0; -} - -h1 { - font-size: 4.6rem; - line-height: 1.2; -} - -h2 { - font-size: 3.6rem; - line-height: 1.25; -} - -h3 { - font-size: 2.8rem; - line-height: 1.3; -} - -h4 { - font-size: 2.2rem; - letter-spacing: -.08rem; - line-height: 1.35; -} - -h5 { - font-size: 1.8rem; - letter-spacing: -.05rem; - line-height: 1.5; -} - -h6 { - font-size: 1.6rem; - letter-spacing: 0; - line-height: 1.4; -} - -img { - max-width: 100%; -} - -.clearfix:after { - clear: both; - content: ' '; - display: table; -} - -.float-left { - float: left; -} - -.float-right { - float: right; -} - diff --git a/common/static/lib/css/multiple-select.min.css b/common/static/lib/css/multiple-select.min.css deleted file mode 100644 index 5a6f1b19..00000000 --- a/common/static/lib/css/multiple-select.min.css +++ /dev/null @@ -1,10 +0,0 @@ -/** - * multiple-select - Multiple select is a jQuery plugin to select multiple elements with checkboxes :). - * - * @version v1.5.2 - * @homepage http://multiple-select.wenzhixin.net.cn - * @author wenzhixin <wenzhixin2010@gmail.com> (http://wenzhixin.net.cn/) - * @license MIT - */ - -@charset "UTF-8";.ms-offscreen{clip:rect(0 0 0 0)!important;width:1px!important;height:1px!important;border:0!important;margin:0!important;padding:0!important;overflow:hidden!important;position:absolute!important;outline:0!important;left:auto!important;top:auto!important}.ms-parent{display:inline-block;position:relative;vertical-align:middle}.ms-choice{display:block;width:100%;height:26px;padding:0;overflow:hidden;cursor:pointer;border:1px solid #aaa;text-align:left;white-space:nowrap;line-height:26px;color:#444;text-decoration:none;border-radius:4px;background-color:#fff}.ms-choice.disabled{background-color:#f4f4f4;background-image:none;border:1px solid #ddd;cursor:default}.ms-choice>span{position:absolute;top:0;left:0;right:20px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;display:block;padding-left:8px}.ms-choice>span.placeholder{color:#999}.ms-choice>div.icon-close{position:absolute;top:0;right:16px;height:100%;width:16px}.ms-choice>div.icon-close:before{content:'×';color:#888;font-weight:bold;position:absolute;top:50%;margin-top:-14px}.ms-choice>div.icon-close:hover:before{color:#333}.ms-choice>div.icon-caret{position:absolute;width:0;height:0;top:50%;right:8px;margin-top:-2px;border-color:#888 transparent transparent transparent;border-style:solid;border-width:5px 4px 0 4px}.ms-choice>div.icon-caret.open{border-color:transparent transparent #888 transparent;border-width:0 4px 5px 4px}.ms-drop{width:auto;min-width:100%;overflow:hidden;display:none;margin-top:-1px;padding:0;position:absolute;z-index:1000;background:#fff;color:#000;border:1px solid #aaa;border-radius:4px}.ms-drop.bottom{top:100%;box-shadow:0 4px 5px rgba(0,0,0,0.15)}.ms-drop.top{bottom:100%;box-shadow:0 -4px 5px rgba(0,0,0,0.15)}.ms-search{display:inline-block;margin:0;min-height:26px;padding:2px;position:relative;white-space:nowrap;width:100%;z-index:10000;box-sizing:border-box}.ms-search input{width:100%;height:auto!important;min-height:24px;padding:0 5px;margin:0;outline:0;font-family:sans-serif;border:1px solid #aaa;border-radius:5px;box-shadow:none}.ms-drop ul{overflow:auto;margin:0;padding:0}.ms-drop ul>li{list-style:none;display:list-item;background-image:none;position:static;padding:.25rem 8px}.ms-drop ul>li .disabled{font-weight:normal!important;opacity:.35;filter:Alpha(Opacity=35);cursor:default}.ms-drop ul>li.multiple{display:block;float:left}.ms-drop ul>li.group{clear:both}.ms-drop ul>li.multiple label{width:100%;display:block;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.ms-drop ul>li label{position:relative;padding-left:1.25rem;margin-bottom:0;font-weight:normal;display:block;white-space:nowrap;cursor:pointer}.ms-drop ul>li label.optgroup{font-weight:bold}.ms-drop ul>li.hide-radio{padding:0}.ms-drop ul>li.hide-radio:focus,.ms-drop ul>li.hide-radio:hover{background-color:#f8f9fa}.ms-drop ul>li.hide-radio.selected{color:#fff;background-color:#007bff}.ms-drop ul>li.hide-radio label{margin-bottom:0;padding:5px 8px}.ms-drop ul>li.hide-radio input{display:none}.ms-drop ul>li.option-level-1 label{padding-left:28px}.ms-drop input[type="radio"],.ms-drop input[type="checkbox"]{position:absolute;margin-top:.3rem;margin-left:-1.25rem}.ms-drop .ms-no-results{display:none} \ No newline at end of file diff --git a/common/static/lib/css/neo.css b/common/static/lib/css/neo.css new file mode 100644 index 00000000..2f2fcbce --- /dev/null +++ b/common/static/lib/css/neo.css @@ -0,0 +1,166 @@ +.markdownx-preview h1 { + font-size: 2.5em; +} + +.markdownx-preview h2 { + font-size: 2.0em; +} + +.markdownx-preview h3 { + font-size: 1.6em; +} + +.markdownx-preview blockquote { + border-left: lightgray solid 0.4em; + padding-left: 0.4em; +} + +.collection-item-position-edit { + float: right; +} + +.collection-item-position-edit a { + cursor: pointer; + color: #ccc; +} + +.action-icon svg { + cursor: pointer; + fill: #ccc; + height: 12px; + vertical-align: text-bottom; +} + +.entity-list__entity-img-wrapper { + position: relative; +} + +.entity-list__entity-action-icon { + position: absolute; + top:0; + right:0; + mix-blend-mode: hard-light; + text-stroke: 1px black; + background-color: lightgray; + border-radius: 0 0 0 8px; + padding: 0 4px; + cursor: pointer; +} + + +/***** MODAL DIALOG ****/ +#modal { + /* Underlay covers entire screen. */ + position: fixed; + top:0px; + bottom: 0px; + left:0px; + right:0px; + background-color:rgba(0,0,0,0.5); + z-index:1000; + + /* Flexbox centers the .modal-content vertically and horizontally */ + display:flex; + flex-direction:column; + align-items:center; + + /* Animate when opening */ + animation-name: fadeIn; + animation-duration:150ms; + animation-timing-function: ease; +} + +#modal > .modal-underlay { + /* underlay takes up the entire viewport. This is only + required if you want to click to dismiss the popup */ + position: absolute; + z-index: -1; + .collection_list_position_edittop:0px; + + + bottom:0px; + left: 0px; + right: 0px; +} + +#modal > .modal-content { + /* Position visible dialog near the top of the window */ + margin-top:10vh; + + /* Sizing for visible dialog */ + width:80%; + max-width:600px; + + /* Display properties for visible dialog*/ + background-color: #f7f7f7; + padding: 20px 20px 10px 20px; + color: #606c76; + + /* Animate when opening */ + animation-name:zoomIn; + animation-duration:150ms; + animation-timing-function: ease; +} + +#modal.closing { + /* Animate when closing */ + animation-name: fadeOut; + animation-duration:150ms; + animation-timing-function: ease; +} + +#modal.closing > .modal-content { + /* Aniate when closing */ + animation-name: zoomOut; + animation-duration:150ms; + animation-timing-function: ease; +} + +@keyframes fadeIn { + 0% {opacity: 0;} + 100% {opacity: 1;} +} + +@keyframes fadeOut { + 0% {opacity: 1;} + 100% {opacity: 0;} +} + +@keyframes zoomIn { + 0% {transform: scale(0.9);} + 100% {transform: scale(1);} +} + +@keyframes zoomOut { + 0% {transform: scale(1);} + 100% {transform: scale(0.9);} +} + +#modal .add-to-list-modal__head { + margin-bottom: 20px; +} + +#modal .add-to-list-modal__head::after { + content: ' '; + clear: both; + display: table; +} + +#modal .add-to-list-modal__title { + font-weight: bold; + font-size: 1.2em; + float: left; +} + +#modal .add-to-list-modal__close-button { + float: right; + cursor: pointer; +} + +#modal .add-to-list-modal__confirm-button { + float: right; +} + +#modal li, #modal ul, #modal label { + display: inline; +} diff --git a/common/static/lib/js/hyperscript-0.9.5.min.js b/common/static/lib/js/hyperscript-0.9.5.min.js new file mode 100644 index 00000000..a17cb5e1 --- /dev/null +++ b/common/static/lib/js/hyperscript-0.9.5.min.js @@ -0,0 +1,2 @@ +!function(e,t){"object"==typeof exports&&"undefined"!=typeof module?module.exports=t():"function"==typeof define&&define.amd?define(t):(e||self)._hyperscript=t()}(this,function(){function e(e,t){for(var n=0;n<t.length;n++){var r=t[n];r.enumerable=r.enumerable||!1,r.configurable=!0,"value"in r&&(r.writable=!0),Object.defineProperty(e,r.key,r)}}function t(t,n,r){return n&&e(t.prototype,n),r&&e(t,r),t}function n(e,t){e.prototype=Object.create(t.prototype),e.prototype.constructor=e,o(e,t)}function r(e){return r=Object.setPrototypeOf?Object.getPrototypeOf:function(e){return e.__proto__||Object.getPrototypeOf(e)},r(e)}function o(e,t){return o=Object.setPrototypeOf||function(e,t){return e.__proto__=t,e},o(e,t)}function a(){if("undefined"==typeof Reflect||!Reflect.construct)return!1;if(Reflect.construct.sham)return!1;if("function"==typeof Proxy)return!0;try{return Boolean.prototype.valueOf.call(Reflect.construct(Boolean,[],function(){})),!0}catch(e){return!1}}function i(e,t,n){return i=a()?Reflect.construct:function(e,t,n){var r=[null];r.push.apply(r,t);var a=new(Function.bind.apply(e,r));return n&&o(a,n.prototype),a},i.apply(null,arguments)}function u(e){var t="function"==typeof Map?new Map:void 0;return u=function(e){if(null===e||-1===Function.toString.call(e).indexOf("[native code]"))return e;if("function"!=typeof e)throw new TypeError("Super expression must either be null or a function");if(void 0!==t){if(t.has(e))return t.get(e);t.set(e,n)}function n(){return i(e,arguments,r(this).constructor)}return n.prototype=Object.create(e.prototype,{constructor:{value:n,enumerable:!1,writable:!0,configurable:!0}}),o(n,e)},u(e)}function l(e,t){(null==t||t>e.length)&&(t=e.length);for(var n=0,r=new Array(t);n<t;n++)r[n]=e[n];return r}function s(e,t){var n="undefined"!=typeof Symbol&&e[Symbol.iterator]||e["@@iterator"];if(n)return(n=n.call(e)).next.bind(n);if(Array.isArray(e)||(n=function(e,t){if(e){if("string"==typeof e)return l(e,t);var n=Object.prototype.toString.call(e).slice(8,-1);return"Object"===n&&e.constructor&&(n=e.constructor.name),"Map"===n||"Set"===n?Array.from(e):"Arguments"===n||/^(?:Ui|I)nt(?:8|16|32)(?:Clamped)?Array$/.test(n)?l(e,t):void 0}}(e))||t&&e&&"number"==typeof e.length){n&&(e=n);var r=0;return function(){return r>=e.length?{done:!0}:{done:!1,value:e[r++]}}}throw new TypeError("Invalid attempt to iterate non-iterable instance.\nIn order to be iterable, non-array objects must have a [Symbol.iterator]() method.")}function c(e,t){for(var n in t)t.hasOwnProperty(n)&&(e[n]=t[n]);return e}function f(e,t){var n=e[t];if(n)return n;var r={};return e[t]=r,r}function m(e,t){return new(e.bind.apply(e,[e].concat(t)))}var p,d=globalThis,v=function(e){function n(e,t,n){this._css=e,this.relativeToElement=t,this.escape=n}var r=n.prototype;return r.contains=function(e){for(var t,n=s(this);!(t=n()).done;)if(t.value.contains(e))return!0;return!1},r[e]=function(){return this.selectMatches()[Symbol.iterator]()},r.selectMatches=function(){return T.getRootNode(this.relativeToElement).querySelectorAll(this.css)},t(n,[{key:"css",get:function(){return this.escape?T.escapeSelector(this._css):this._css}},{key:"className",get:function(){return this._css.substr(1)}},{key:"id",get:function(){return this.className()}},{key:"length",get:function(){return this.selectMatches().length}}]),n}(Symbol.iterator),h=function(){var e={"+":"PLUS","-":"MINUS","*":"MULTIPLY","/":"DIVIDE",".":"PERIOD","..":"ELLIPSIS","\\":"BACKSLASH",":":"COLON","%":"PERCENT","|":"PIPE","!":"EXCLAMATION","?":"QUESTION","#":"POUND","&":"AMPERSAND",$:"DOLLAR",";":"SEMI",",":"COMMA","(":"L_PAREN",")":"R_PAREN","<":"L_ANG",">":"R_ANG","<=":"LTE_ANG",">=":"GTE_ANG","==":"EQ","===":"EQQ","!=":"NEQ","!==":"NEQQ","{":"L_BRACE","}":"R_BRACE","[":"L_BRACKET","]":"R_BRACKET","=":"EQUALS"};function t(e){return i(e)||a(e)||"-"===e||"_"===e||":"===e}function n(e){return i(e)||a(e)||"-"===e||"_"===e||":"===e}function r(e){return" "===e||"\t"===e||o(e)}function o(e){return"\r"===e||"\n"===e}function a(e){return e>="0"&&e<="9"}function i(e){return e>="a"&&e<="z"||e>="A"&&e<="Z"}function u(e,t){return"_"===e||"$"===e}function l(e,t,n){o();var r=null;function o(){for(;"WHITESPACE"===f(0,!0).type;)t.push(e.shift())}function a(e,t){E.raiseParseError(e,t)}function i(e){if(m()&&m().op&&m().value===e)return s()}function u(e,t,n,r){if(m()&&m().type&&[e,t,n,r].indexOf(m().type)>=0)return s()}function l(e,t){if(-1===p.indexOf(e))return t=t||"IDENTIFIER",m()&&m().value===e&&m().type===t?s():void 0}function s(){var n=e.shift();return t.push(n),r=n,o(),n}function c(n,r){for(var a=[],i=f(0,!0);!(null!=r&&i.type===r||null!=n&&i.value===n||"EOF"===i.type);){var u=e.shift();t.push(u),a.push(i),i=f(0,!0)}return o(),a}function f(t,n){var r,o=0;do{if(!n)for(;e[o]&&"WHITESPACE"===e[o].type;)o++;r=e[o],t--,o++}while(t>-1);return r||{type:"EOF",value:"<<<EOF>>>"}}function m(){return f(0)}var p=[];return{pushFollow:function(e){p.push(e)},popFollow:function(){p.pop()},clearFollow:function(){var e=p;return p=[],e},restoreFollow:function(e){p=e},matchAnyToken:function(e,t,n){for(var r=0;r<arguments.length;r++){var o=arguments[r],a=l(o);if(a)return a}},matchAnyOpToken:function(e,t,n){for(var r=0;r<arguments.length;r++){var o=arguments[r],a=i(o);if(a)return a}},matchOpToken:i,requireOpToken:function(e){var t=i(e);if(t)return t;a(this,"Expected '"+e+"' but found '"+m().value+"'")},matchTokenType:u,requireTokenType:function(e,t,n,r){var o=u(e,t,n,r);if(o)return o;a(this,"Expected one of "+JSON.stringify([e,t,n]))},consumeToken:s,peekToken:function(t,n,r){return e[n]&&e[n].value===t&&e[n].type===r},matchToken:l,requireToken:function(e,t){var n=l(e,t);if(n)return n;a(this,"Expected '"+e+"' but found '"+m().value+"'")},list:e,consumed:t,source:n,hasMore:function(){return e.length>0},currentToken:m,lastMatch:function(){return r},token:f,consumeUntil:c,consumeUntilWhitespace:function(){return c(null,"WHITESPACE")},lastWhitespace:function(){return t[t.length-1]&&"WHITESPACE"===t[t.length-1].type?t[t.length-1].value:""},sourceFor:function(){return n.substring(this.startToken.start,this.endToken.end)},lineFor:function(){return n.split("\n")[this.startToken.line-1]}}}function s(e){if(e.length>0){var t=e[e.length-1];if("IDENTIFIER"===t.type||"CLASS_REF"===t.type||"ID_REF"===t.type)return!1;if(t.op&&(">"===t.value||")"===t.value))return!1}return!0}return{tokenize:function(c,f){var m,p=[],d=c,v=0,h=0,E=1,y="<START>",T=0;function k(){return f&&0===T}for(;v<d.length;)if("-"!==C()||"-"!==A()||!r(F())&&""!==F())if(r(C()))p.push(D());else if(M()||"."!==C()||!i(A())&&"{"!==A())if(M()||"#"!==C()||!i(A())&&"{"!==A())if("["===C()&&"@"===A())p.push(q());else if("@"===C())p.push(w());else if("*"===C()&&i(A()))p.push(S());else if(i(C())||!k()&&u(C()))p.push(I());else if(a(C()))p.push(R());else if(k()||'"'!==C()&&"`"!==C())if(k()||"'"!==C()){if(e[C()])"$"===y&&"{"===C()&&T++,"}"===C()&&T--,p.push(O());else if(k()||"`"===(m=C())||"^"===m)p.push(g("RESERVED",P()));else if(v<d.length)throw Error("Unknown token: "+C()+" ")}else s(p)?p.push(L()):p.push(O());else p.push(L());else p.push(N());else p.push(b());else x();return l(p,[],d);function g(e,t){return{type:e,value:t,start:v,end:v+1,column:h,line:E}}function x(){for(;C()&&!o(C());)P();P()}function b(){var e=g("CLASS_REF"),n=P();if("{"===C()){for(e.template=!0,n+=P();C()&&"}"!==C();)n+=P();if("}"!==C())throw Error("Unterminated class reference");n+=P()}else for(;t(C());)n+=P();return e.value=n,e.end=v,e}function q(){for(var e=g("ATTRIBUTE_REF"),t=P();v<d.length&&"]"!==C();)t+=P();return"]"===C()&&(t+=P()),e.value=t,e.end=v,e}function w(){for(var e=g("ATTRIBUTE_REF"),t=P();n(C());)t+=P();return e.value=t,e.end=v,e}function S(){for(var e=g("STYLE_REF"),t=P();i(C())||"-"===C();)t+=P();return e.value=t,e.end=v,e}function N(){var e=g("ID_REF"),t=P();if("{"===C()){for(e.template=!0,t+=P();C()&&"}"!==C();)t+=P();if("}"!==C())throw Error("Unterminated id reference");P()}else for(;n(C());)t+=P();return e.value=t,e.end=v,e}function I(){for(var e=g("IDENTIFIER"),t=P();i(C())||a(C())||u(C());)t+=P();return"!"===C()&&"beep"===t&&(t+=P()),e.value=t,e.end=v,e}function R(){for(var e=g("NUMBER"),t=P();a(C());)t+=P();for("."===C()&&a(A())&&(t+=P());a(C());)t+=P();return e.value=t,e.end=v,e}function O(){for(var t=(r=void 0,(r=g(void 0,void 0)).op=!0,r),n=P();C()&&e[n+C()];)n+=P();var r;return t.type=e[n],t.value=n,t.end=v,t}function L(){for(var e,t=g("STRING"),n=P(),r="";C()&&C()!==n;)if("\\"===C()){P();var o=P();r+="b"===o?"\b":"f"===o?"\f":"n"===o?"\n":"r"===o?"\r":"t"===o?"\t":"v"===o?"\v":o}else r+=P();if(C()!==n)throw Error("Unterminated string at [Line: "+(e=t).line+", Column: "+e.column+"]");return P(),t.value=r,t.end=v,t.template="`"===n,t}function C(){return d.charAt(v)}function A(){return d.charAt(v+1)}function F(){return d.charAt(v+2)}function P(){return y=C(),v++,h++,y}function M(){return i(y)||a(y)||")"===y||'"'===y||"'"===y||"`"===y||"}"===y||"]"===y}function D(){for(var e=g("WHITESPACE"),t="";C()&&r(C());)o(C())&&(h=0,E++),t+=P();return e.value=t,e.end=v,e}},makeTokensObject:l}}(),E=function(){var e={},t={},n={},r=[],o=[];function a(e,t,n){e.startToken=t,e.sourceFor=n.sourceFor,e.lineFor=n.lineFor,e.programSource=n.source}function i(t,n,r){return void 0===r&&(r=void 0),function(r){var o=e[t];if(o){var i=n.currentToken(),u=o(E,T,n,r);if(u)for(a(u,i,n),u.endToken=u.endToken||n.lastMatch(),r=u.root;null!=r;)a(r,i,n),r=r.root;return u}}(r)}function u(e,t,n,r){var o=i(e,t,r);return o||c(t,n||"Expected "+e),o}function l(e,t){for(var n=0;n<e.length;n++){var r=i(e[n],t);if(r)return r}}function s(t,n){e[t]=n}function c(e,t){t=(t||"Unexpected Token : "+e.currentToken().value)+"\n\n"+function(e){var t=e.currentToken(),n=e.source.split("\n"),r=n[t&&t.line?t.line-1:n.length-1];return r+"\n"+" ".repeat(t&&t.line?t.column:r.length-1)+"^^\n\n"}(e);var n=new Error(t);throw n.tokens=e,n}function f(e){return t[e.value]}function m(e){return n[e.value]}return s("feature",function(e,t,r){if(r.matchOpToken("(")){var o=e.requireElement("feature",r);return r.requireOpToken(")"),o}var a=n[r.currentToken().value];if(a)return a(e,t,r)}),s("command",function(e,n,r){if(r.matchOpToken("(")){var o=e.requireElement("command",r);return r.requireOpToken(")"),o}var a,i=t[r.currentToken().value];return i?a=i(e,n,r):"IDENTIFIER"===r.currentToken().type&&(a=e.parseElement("pseudoCommand",r)),a?e.parseElement("indirectStatement",r,a):a}),s("commandList",function(e,t,n){var r=e.parseElement("command",n);if(r){n.matchToken("then");var o=e.parseElement("commandList",n);return o&&(r.next=o),r}}),s("leaf",function(e,t,n){var o=l(r,n);return null==o?i("symbol",n):o}),s("indirectExpression",function(e,t,n,r){for(var a=0;a<o.length;a++){var i=o[a];r.endToken=n.lastMatch();var u=e.parseElement(i,n,r);if(u)return u}return r}),s("indirectStatement",function(e,t,n,r){if(n.matchToken("unless")){r.endToken=n.lastMatch();var o={type:"unlessStatementModifier",args:[e.requireElement("expression",n)],op:function(e,t){return t?this.next:r},execute:function(e){return t.unifiedExec(this,e)}};return r.parent=o,o}return r}),s("primaryExpression",function(e,t,n){var r=e.parseElement("leaf",n);if(r)return e.parseElement("indirectExpression",n,r);e.raiseParseError(n,"Unexpected value: "+n.currentToken().value)}),{setParent:function e(t,n){"object"==typeof t&&(t.parent=n,"object"==typeof n&&(n.children=n.children||new Set,n.children.add(t)),e(t.next,n))},requireElement:u,parseElement:i,featureStart:m,commandStart:f,commandBoundary:function(e){return!("end"!=e.value&&"then"!=e.value&&"else"!=e.value&&"otherwise"!=e.value&&")"!=e.value&&!f(e)&&!m(e)&&"EOF"!=e.type)},parseAnyOf:l,parseHyperScript:function(e){var t=i("hyperscript",e);if(e.hasMore()&&c(e),t)return t},raiseParseError:c,addGrammarElement:s,addCommand:function(n,r){var o=n+"Command",a=function(e,t,n){var a=r(e,t,n);if(a)return a.type=o,a.execute=function(e){return e.meta.command=a,t.unifiedExec(this,e)},a};e[o]=a,t[n]=a},addFeature:function(t,r){var o=t+"Feature",a=function(e,n,a){var i=r(e,n,a);if(i)return i.isFeature=!0,i.keyword=t,i.type=o,i};e[o]=a,n[t]=a},addLeafExpression:function(e,t){r.push(e),s(e,t)},addIndirectExpression:function(e,t){o.push(e),s(e,t)},parseStringTemplate:function(e){var t=[""];do{if(t.push(e.lastWhitespace()),"$"===e.currentToken().value){e.consumeToken();var n=e.matchOpToken("{");t.push(u("expression",e)),n&&e.requireOpToken("}"),t.push("")}else if("\\"===e.currentToken().value)e.consumeToken(),e.consumeToken();else{var r=e.consumeToken();t[t.length-1]+=r?r.value:""}}while(e.hasMore());return t.push(e.lastWhitespace()),t},ensureTerminated:function(e){for(var t={type:"implicitReturn",op:function(e){return e.meta.returned=!0,e.meta.resolve&&e.meta.resolve(),T.HALT},execute:function(e){}},n=e;n.next;)n=n.next;n.next=t}}}(),y={dynamicResolvers:[function(e,t){if("Fixed"===e)return Number(t).toFixed();if(0===e.indexOf("Fixed:")){var n=e.split(":")[1];return Number(t).toFixed(parseInt(n))}}],String:function(e){return e.toString?e.toString():""+e},Int:function(e){return parseInt(e)},Float:function(e){return parseFloat(e)},Number:function(e){function t(t){return e.apply(this,arguments)}return t.toString=function(){return e.toString()},t}(function(e){return Number(e)}),Date:function(e){function t(t){return e.apply(this,arguments)}return t.toString=function(){return e.toString()},t}(function(e){return new Date(e)}),Array:function(e){function t(t){return e.apply(this,arguments)}return t.toString=function(){return e.toString()},t}(function(e){return Array.from(e)}),JSON:function(e){function t(t){return e.apply(this,arguments)}return t.toString=function(){return e.toString()},t}(function(e){return JSON.stringify(e)}),Object:function(e){return e instanceof String&&(e=e.toString()),"string"==typeof e?JSON.parse(e):c({},e)}},T=function(){function e(e,t){var n=e.matches||e.matchesSelector||e.msMatchesSelector||e.mozMatchesSelector||e.webkitMatchesSelector||e.oMatchesSelector;return n&&n.call(e,t)}function t(e,t,n,r){(n=n||{}).sender=r;var o=function(e,t){var n;return d.Event&&"function"==typeof d.Event?(n=new Event(e,{bubbles:!0,cancelable:!0})).detail=t:(n=document.createEvent("CustomEvent")).initCustomEvent(e,!0,!0,t),n}(t,n);return e.dispatchEvent(o)}function r(e){return Array.isArray(e)||"undefined"!=typeof NodeList&&(e instanceof NodeList||e instanceof HTMLCollection)}function o(e){return e instanceof v||r(e)}function a(e,t){if(null==e);else if(function(e){return"object"==typeof e&&Symbol.iterator in e&&"function"==typeof e[Symbol.iterator]}(e))for(var n,o=s(e);!(n=o()).done;)t(n.value);else if(r(e))for(var a=0;a<e.length;a++)t(e[a]);else t(e)}function i(e){for(var t=0;t<e.length;t++){var n=e[t];if(n.asyncWrapper&&(e[t]=n.value),Array.isArray(n))for(var r=0;r<n.length;r++){var o=n[r];o.asyncWrapper&&(n[r]=o.value)}}}var l={};function m(e,t){var n=[t],r=!1,o=!1;if(e.args)for(var a=0;a<e.args.length;a++){var u=e.args[a];if(null==u)n.push(null);else if(Array.isArray(u)){for(var l=[],s=0;s<u.length;s++){var c=u[s];(f=c?c.evaluate(t):null)&&(f.then?r=!0:f.asyncWrapper&&(o=!0)),l.push(f)}n.push(l)}else if(u.evaluate){var f;(f=u.evaluate(t))&&(f.then?r=!0:f.asyncWrapper&&(o=!0)),n.push(f)}else n.push(u)}return r?new Promise(function(t,r){n=function(e){for(var t=[],n=0;n<e.length;n++){var r=e[n];Array.isArray(r)?t.push(Promise.all(r)):t.push(r)}return t}(n),Promise.all(n).then(function(n){o&&i(n);try{var a=e.op.apply(e,n);t(a)}catch(e){r(e)}}).catch(function(e){r(e)})}):(o&&i(n),e.op.apply(e,n))}var k=null;function g(){return null==k&&(k=p.config.attributes.replace(/ /g,"").split(",")),k}function x(e){for(var t=0;t<g().length;t++){var n=g()[t];if(e.hasAttribute&&e.hasAttribute(n))return e.getAttribute(n)}return e instanceof HTMLScriptElement&&"text/hyperscript"===e.type?e.innerText:null}var b=new WeakMap;function q(e){var t=b.get(e);return void 0===t&&b.set(e,t={}),t}function w(e,t){e&&(c(t,q(e)),w(e.parentElement,t))}function S(e,t,n,r){var o={meta:{parser:E,lexer:h,runtime:T,owner:e,feature:t,iterators:{}},me:n,event:r,target:r?r.target:null,detail:r?r.detail:null,sender:r&&r.detail?r.detail.sender:null,body:"document"in d?document.body:null};return o.meta.ctx=o,w(e,o),o}function N(e){var t=h.tokenize(e);if(E.commandStart(t.currentToken())){var n=E.requireElement("commandList",t);return t.hasMore()&&E.raiseParseError(t),E.ensureTerminated(n),n}if(E.featureStart(t.currentToken())){var r=E.requireElement("hyperscript",t);return t.hasMore()&&E.raiseParseError(t),r}var o=E.requireElement("expression",t);return t.hasMore()&&E.raiseParseError(t),o}function I(e,n){if(!e.closest||!e.closest(p.config.disableSelector)){var r=O(e);if(!r.initialized){var o=x(e);if(o)try{r.initialized=!0,r.script=o;var a=h.tokenize(o),i=E.parseHyperScript(a);if(!i)return;i.apply(n||e,e),setTimeout(function(){t(n||e,"load",{hyperscript:!0})},1)}catch(t){T.triggerEvent(e,"exception",{error:t}),console.error("hyperscript errors were found on the following element:",e,"\n\n",t.message,t.stack)}}}}var R=new WeakMap;function O(e){var t=R.get(e);return void 0===t&&R.set(e,t={}),t}function L(e){var t=e.meta&&e.meta.owner;if(t){var n=O(t),r="elementScope";return e.meta.feature&&e.meta.feature.behavior&&(r=e.meta.feature.behavior+"Scope"),f(n,r)}return{}}function C(e,t,n){if(null!=e){var r=n(e,t);if(void 0!==r)return r;if(o(e)){for(var a,i=[],u=s(e);!(a=u()).done;){var l=n(a.value,t);l&&i.push(l)}return i}}}return{typeCheck:function(e,t,n){return!(null!=e||!n)||Object.prototype.toString.call(e).slice(8,-1)===t},forEach:a,implicitLoop:function(e,t){if(o(e))for(var n,r=s(e);!(n=r()).done;)t(n.value);else t(e)},shouldAutoIterate:o,triggerEvent:t,matchesSelector:e,getScript:x,processNode:function(t){var n=T.getScriptSelector();e(t,n)&&I(t,t),t instanceof HTMLScriptElement&&"text/hyperscript"===t.type&&I(t,document.body),t.querySelectorAll&&a(t.querySelectorAll(n+", [type='text/hyperscript']"),function(e){I(e,e instanceof HTMLScriptElement&&"text/hyperscript"===e.type?document.body:e)})},evaluate:function(e,t,r){var o=function(e){function t(t){var n;return(n=e.call(this)||this).module=t,n}return n(t,e),t.prototype.toString=function(){return this.module.id},t}(u(EventTarget)),a="document"in d?d.document.body:new o(r&&r.module);t=c(S(a,null,a,null),t||{});var i=N(e);return i.execute?(i.execute(t),t.result):i.apply?(i.apply(a,a,r),q(a)):i.evaluate(t)},evaluateNoPromise:function(e,t){var n=e.evaluate(t);if(n.next)throw new Error(e.sourceFor()+" returned a Promise in a context that they are not allowed.");return n},parse:N,getScriptSelector:function(){return g().map(function(e){return"["+e+"]"}).join(", ")},resolveSymbol:function(e,t,n){if("me"===e||"my"===e||"I"===e)return t.me;if("it"===e||"its"===e)return t.result;if("you"===e||"your"===e||"yourself"===e)return t.beingTold;if("global"===n)return d[e];if("element"===n)return L(t)[e];if("local"===n)return t[e];if(t.meta&&t.meta.context){var r=t.meta.context[e];if(void 0!==r)return r}var o=t[e];return void 0!==o||void 0!==(o=L(t)[e])?o:d[e]},setSymbol:function(e,t,n,r){if("global"===n)d[e]=r;else if("element"===n)(o=L(t))[e]=r;else if("local"===n)t[e]=r;else{var o,a=t[e];void 0!==a?t[e]=r:void 0!==(a=(o=L(t))[e])?o[e]=r:t[e]=r}},makeContext:S,findNext:function e(t,n){if(t)return t.resolveNext?t.resolveNext(n):t.next?t.next:e(t.parent,n)},unifiedEval:m,convertValue:function(e,t){for(var n=y.dynamicResolvers,r=0;r<n.length;r++){var o=(0,n[r])(t,e);if(void 0!==o)return o}if(null==e)return null;var a=y[t];if(a)return a(e);throw"Unknown conversion : "+t},unifiedExec:function e(t,n){for(;;){try{var r=m(t,n)}catch(e){if(n.meta.handlingFinally)console.error(" Exception in finally block: ",e),r=l;else{if(T.registerHyperTrace(n,e),n.meta.errorHandler&&!n.meta.handlingError){n.meta.handlingError=!0,n[n.meta.errorSymbol]=e,t=n.meta.errorHandler;continue}n.meta.currentException=e,r=l}}if(null==r)return void console.error(t," did not return a next element to execute! context: ",n);if(r.then)return void r.then(function(t){e(t,n)}).catch(function(t){e({op:function(){throw t}},n)});if(r===l){if(!n.meta.finallyHandler||n.meta.handlingFinally){if(n.meta.onHalt&&n.meta.onHalt(),n.meta.currentException){if(n.meta.reject)return void n.meta.reject(n.meta.currentException);throw n.meta.currentException}return}n.meta.handlingFinally=!0,t=n.meta.finallyHandler}else t=r}},resolveProperty:function(e,t){return C(e,t,function(e,t){return e[t]})},resolveAttribute:function(e,t){return C(e,t,function(e,t){return e.getAttribute&&e.getAttribute(t)})},resolveStyle:function(e,t){return C(e,t,function(e,t){return e.style&&e.style[t]})},resolveComputedStyle:function(e,t){return C(e,t,function(e,t){return getComputedStyle(e).getPropertyValue(t)})},assignToNamespace:function(e,t,n,r){var o;for(o="undefined"!=typeof document&&e===document.body?d:q(e);t.length>0;){var a=t.shift(),i=o[a];null==i&&(o[a]=i={}),o=i}o[n]=r},registerHyperTrace:function(e,t){for(var n=[],r=null;null!=e;)n.push(e),r=e,e=e.meta.caller;null==r.meta.traceMap&&(r.meta.traceMap=new Map),r.meta.traceMap.get(t)||r.meta.traceMap.set(t,{trace:n,print:function(e){(e=e||console.error)("hypertrace /// ");for(var t=0,r=0;r<n.length;r++)t=Math.max(t,n[r].meta.feature.displayName.length);for(r=0;r<n.length;r++){var o=n[r];e(" ->",o.meta.feature.displayName.padEnd(t+2),"-",o.meta.owner)}}})},getHyperTrace:function(e,t){for(var n=e;n.meta.caller;)n=n.meta.caller;if(n.meta.traceMap)return n.meta.traceMap.get(t,[])},getInternalData:O,getHyperscriptFeatures:q,escapeSelector:function(e){return e.replace(/:/g,function(e){return"\\"+e})},nullCheck:function(e,t){if(null==e)throw new Error("'"+t.sourceFor()+"' is null")},isEmpty:function(e){return null==e||0===e.length},doesExist:function(e){if(null==e)return!1;if(o(e))for(var t=s(e);!t().done;)return!0;return!1},getRootNode:function(e){if(e&&e instanceof Node){var t=e.getRootNode();if(t instanceof Document||t instanceof ShadowRoot)return t}return document},getEventQueueFor:function(e,t){var n=O(e),r=n.eventQueues;null==r&&(r=new Map,n.eventQueues=r);var o=r.get(t);return null==o&&r.set(t,o={queue:[],executing:!1}),o},hyperscriptUrl:"document"in d?"undefined"==typeof document&&"undefined"==typeof location?new(require("url").URL)("file:"+__filename).href:"undefined"==typeof document?location.href:document.currentScript&&document.currentScript.src||new URL("_hyperscript_web.min.js",document.baseURI).href:null,HALT:l}}(),k=function(e,t,n){if(t.contains)return t.contains(n);if(t.includes)return t.includes(n);throw Error("The value of "+e.sourceFor()+" does not have a contains or includes method on it")},g=function(e,t,n){if(t.match)return!!t.match(n);if(t.matches)return t.matches(n);throw Error("The value of "+e.sourceFor()+" does not have a match or matches method on it")},x=function(e,t,n,r){var o=t.requireElement("eventName",r),a=t.parseElement("namedArgumentList",r);if("send"===e&&r.matchToken("to")||"trigger"===e&&r.matchToken("on"))var i=t.requireElement("expression",r);else i=t.requireElement("implicitMeTarget",r);var u={eventName:o,details:a,to:i,args:[i,o,a],op:function(e,t,r,o){return n.nullCheck(t,i),n.forEach(t,function(t){n.triggerEvent(t,r,o,e.me)}),n.findNext(u,e)}};return u},b=function(e,t){var n,r="text";return e.matchToken("a")||e.matchToken("an"),e.matchToken("json")||e.matchToken("Object")?r="json":e.matchToken("response")?r="response":e.matchToken("html")?r="html":e.matchToken("text")||(n=t.requireElement("dotOrColonPath",e).evaluate()),{type:r,conversion:n}};E.addLeafExpression("parenthesized",function(e,t,n){if(n.matchOpToken("(")){var r=n.clearFollow();try{var o=e.requireElement("expression",n)}finally{n.restoreFollow(r)}return n.requireOpToken(")"),o}}),E.addLeafExpression("string",function(e,t,n){var r=n.matchTokenType("STRING");if(r){var o,a=r.value;if(r.template){var i=h.tokenize(a,!0);o=e.parseStringTemplate(i)}else o=[];return{type:"string",token:r,args:o,op:function(e){for(var t="",n=1;n<arguments.length;n++){var r=arguments[n];void 0!==r&&(t+=r)}return t},evaluate:function(e){return 0===o.length?a:t.unifiedEval(this,e)}}}}),E.addGrammarElement("nakedString",function(e,t,n){if(n.hasMore()){var r=n.consumeUntilWhitespace();return n.matchTokenType("WHITESPACE"),{type:"nakedString",tokens:r,evaluate:function(e){return r.map(function(e){return e.value}).join("")}}}}),E.addLeafExpression("number",function(e,t,n){var r=n.matchTokenType("NUMBER");if(r){var o=r,a=parseFloat(r.value);return{type:"number",value:a,numberToken:o,evaluate:function(){return a}}}}),E.addLeafExpression("idRef",function(e,t,n){var r=n.matchTokenType("ID_REF");if(r){if(r.template){var o=r.value.substr(2,r.value.length-2),a=h.tokenize(o);return{type:"idRefTemplate",args:[e.requireElement("expression",a)],op:function(e,n){return t.getRootNode(e.me).getElementById(n)},evaluate:function(e){return t.unifiedEval(this,e)}}}var i=r.value.substr(1);return{type:"idRef",css:r.value,value:i,evaluate:function(e){return t.getRootNode(e.me).getElementById(i)}}}}),E.addLeafExpression("classRef",function(e,t,n){var r=n.matchTokenType("CLASS_REF");if(r){if(r.template){var o=r.value.substr(2,r.value.length-2),a=h.tokenize(o);return{type:"classRefTemplate",args:[e.requireElement("expression",a)],op:function(e,t){return new v("."+t,e.me,!0)},evaluate:function(e){return t.unifiedEval(this,e)}}}var i=r.value;return{type:"classRef",css:i,evaluate:function(e){return new v(i,e.me,!0)}}}});var q=function(e,r){function o(t,n,r){var o;return(o=e.call(this,t,n)||this).templateParts=r,o.elements=r.filter(function(e){return e instanceof Element}),o}return n(o,e),o.prototype[r]=function(){this.elements.forEach(function(e,t){return e.dataset.hsQueryId=t});var t=e.prototype[Symbol.iterator].call(this);return this.elements.forEach(function(e){return e.removeAttribute("data-hs-query-id")}),t},t(o,[{key:"css",get:function(){for(var e,t="",n=0,r=s(this.templateParts);!(e=r()).done;){var o=e.value;o instanceof Element?t+="[data-hs-query-id='"+n+++"']":t+=o}return t}}]),o}(v,Symbol.iterator);E.addLeafExpression("queryRef",function(e,t,n){if(n.matchOpToken("<")){var r=n.consumeUntil("/");n.requireOpToken("/"),n.requireOpToken(">");var o=r.map(function(e){return"STRING"===e.type?'"'+e.value+'"':e.value}).join("");if(o.indexOf("$")>=0)var a=!0,i=h.tokenize(o,!0),u=e.parseStringTemplate(i);return{type:"queryRef",css:o,args:u,op:function(e){return a?new q(o,e.me,[].slice.call(arguments,1)):new v(o,e.me)},evaluate:function(e){return t.unifiedEval(this,e)}}}}),E.addLeafExpression("attributeRef",function(e,t,n){var r=n.matchTokenType("ATTRIBUTE_REF");if(r){var o=r.value;if(0===o.indexOf("["))var a=o.substring(2,o.length-1);else a=o.substring(1);var i="["+a+"]",u=a.split("="),l=u[0],s=u[1];return s&&0===s.indexOf('"')&&(s=s.substring(1,s.length-1)),{type:"attributeRef",name:l,css:i,value:s,op:function(e){var t=e.beingTold||e.me;if(t)return t.getAttribute(l)},evaluate:function(e){return t.unifiedEval(this,e)}}}}),E.addLeafExpression("styleRef",function(e,t,n){var r=n.matchTokenType("STYLE_REF");if(r){var o=r.value.substr(1);return o.startsWith("computed-")?{type:"computedStyleRef",name:o=o.substr("computed-".length),op:function(e){var n=e.beingTold||e.me;if(n)return t.resolveComputedStyle(n,o)},evaluate:function(e){return t.unifiedEval(this,e)}}:{type:"styleRef",name:o,op:function(e){var n=e.beingTold||e.me;if(n)return t.resolveStyle(n,o)},evaluate:function(e){return t.unifiedEval(this,e)}}}}),E.addGrammarElement("objectKey",function(e,t,n){var r;if(r=n.matchTokenType("STRING"))return{type:"objectKey",key:r.value,evaluate:function(){return r.value}};if(n.matchOpToken("[")){var o=e.parseElement("expression",n);return n.requireOpToken("]"),{type:"objectKey",expr:o,args:[o],op:function(e,t){return t},evaluate:function(e){return t.unifiedEval(this,e)}}}var a="";do{(r=n.matchTokenType("IDENTIFIER")||n.matchOpToken("-"))&&(a+=r.value)}while(r);return{type:"objectKey",key:a,evaluate:function(){return a}}}),E.addLeafExpression("objectLiteral",function(e,t,n){if(n.matchOpToken("{")){var r=[],o=[];if(!n.matchOpToken("}")){do{var a=e.requireElement("objectKey",n);n.requireOpToken(":");var i=e.requireElement("expression",n);o.push(i),r.push(a)}while(n.matchOpToken(","));n.requireOpToken("}")}return{type:"objectLiteral",args:[r,o],op:function(e,t,n){for(var r={},o=0;o<t.length;o++)r[t[o]]=n[o];return r},evaluate:function(e){return t.unifiedEval(this,e)}}}}),E.addGrammarElement("nakedNamedArgumentList",function(e,t,n){var r=[],o=[];if("IDENTIFIER"===n.currentToken().type)do{var a=n.requireTokenType("IDENTIFIER");n.requireOpToken(":");var i=e.requireElement("expression",n);o.push(i),r.push({name:a,value:i})}while(n.matchOpToken(","));return{type:"namedArgumentList",fields:r,args:[o],op:function(e,t){for(var n={_namedArgList_:!0},o=0;o<t.length;o++)n[r[o].name.value]=t[o];return n},evaluate:function(e){return t.unifiedEval(this,e)}}}),E.addGrammarElement("namedArgumentList",function(e,t,n){if(n.matchOpToken("(")){var r=e.requireElement("nakedNamedArgumentList",n);return n.requireOpToken(")"),r}}),E.addGrammarElement("symbol",function(e,t,n){var r="default";n.matchToken("global")?r="global":n.matchToken("element")||n.matchToken("module")?(r="element",n.matchOpToken("'")&&n.requireToken("s")):n.matchToken("local")&&(r="local");var o=n.matchOpToken(":"),a=n.matchTokenType("IDENTIFIER");if(a){var i=a.value;return o&&(i=":"+i),"default"===r&&(0===i.indexOf("$")&&(r="global"),0===i.indexOf(":")&&(r="element")),{type:"symbol",token:a,scope:r,name:i,evaluate:function(e){return t.resolveSymbol(i,e,r)}}}}),E.addGrammarElement("implicitMeTarget",function(e,t,n){return{type:"implicitMeTarget",evaluate:function(e){return e.beingTold||e.me}}}),E.addLeafExpression("boolean",function(e,t,n){var r=n.matchToken("true")||n.matchToken("false");if(r){var o="true"===r.value;return{type:"boolean",evaluate:function(e){return o}}}}),E.addLeafExpression("null",function(e,t,n){if(n.matchToken("null"))return{type:"null",evaluate:function(e){return null}}}),E.addLeafExpression("arrayLiteral",function(e,t,n){if(n.matchOpToken("[")){var r=[];if(!n.matchOpToken("]")){do{var o=e.requireElement("expression",n);r.push(o)}while(n.matchOpToken(","));n.requireOpToken("]")}return{type:"arrayLiteral",values:r,args:[r],op:function(e,t){return t},evaluate:function(e){return t.unifiedEval(this,e)}}}}),E.addLeafExpression("blockLiteral",function(e,t,n){if(n.matchOpToken("\\")){var r=[],o=n.matchTokenType("IDENTIFIER");if(o)for(r.push(o);n.matchOpToken(",");)r.push(n.requireTokenType("IDENTIFIER"));n.requireOpToken("-"),n.requireOpToken(">");var a=e.requireElement("expression",n);return{type:"blockLiteral",args:r,expr:a,evaluate:function(e){return function(){for(var t=0;t<r.length;t++)e[r[t].value]=arguments[t];return a.evaluate(e)}}}}}),E.addIndirectExpression("propertyAccess",function(e,t,n,r){if(n.matchOpToken(".")){var o=n.requireTokenType("IDENTIFIER");return e.parseElement("indirectExpression",n,{type:"propertyAccess",root:r,prop:o,args:[r],op:function(e,n){return t.resolveProperty(n,o.value)},evaluate:function(e){return t.unifiedEval(this,e)}})}}),E.addIndirectExpression("of",function(e,t,n,r){if(n.matchToken("of")){for(var o=e.requireElement("unaryExpression",n),a=null,i=r;i.root;)a=i,i=i.root;"symbol"!==i.type&&"attributeRef"!==i.type&&"styleRef"!==i.type&&"computedStyleRef"!==i.type&&e.raiseParseError(n,"Cannot take a property of a non-symbol: "+i.type);var u="attributeRef"===i.type,l="styleRef"===i.type||"computedStyleRef"===i.type;if(u||l)var s=i;var c=i.name,f={type:"ofExpression",prop:i.token,root:o,attribute:s,expression:r,args:[o],op:function(e,n){return u?t.resolveAttribute(n,c):l?"computedStyleRef"===i.type?t.resolveComputedStyle(n,c):t.resolveStyle(n,c):t.resolveProperty(n,c)},evaluate:function(e){return t.unifiedEval(this,e)}};return"attributeRef"===i.type&&(f.attribute=i),a?(a.root=f,a.args=[f]):r=f,e.parseElement("indirectExpression",n,r)}}),E.addIndirectExpression("possessive",function(e,t,n,r){if(!e.possessivesDisabled){var o=n.matchOpToken("'");if(o||"symbol"===r.type&&("my"===r.name||"its"===r.name||"your"===r.name)&&("IDENTIFIER"===n.currentToken().type||"ATTRIBUTE_REF"===n.currentToken().type||"STYLE_REF"===n.currentToken().type)){o&&n.requireToken("s");var a=e.parseElement("attributeRef",n);if(null==a){var i=e.parseElement("styleRef",n);if(null==i)var u=n.requireTokenType("IDENTIFIER")}return e.parseElement("indirectExpression",n,{type:"possessive",root:r,attribute:a||i,prop:u,args:[r],op:function(e,n){if(a)var r=t.resolveAttribute(n,a.name);else r=i?"computedStyleRef"===i.type?t.resolveComputedStyle(n,i.name):t.resolveStyle(n,i.name):t.resolveProperty(n,u.value);return r},evaluate:function(e){return t.unifiedEval(this,e)}})}}}),E.addIndirectExpression("inExpression",function(e,t,n,r){if(n.matchToken("in")){var o={type:"inExpression",root:r,args:[r,e.requireElement("unaryExpression",n)],op:function(e,n,r){var o=[];if(n.css)t.implicitLoop(r,function(e){for(var t=e.querySelectorAll(n.css),r=0;r<t.length;r++)o.push(t[r])});else if(n instanceof Element){var a=!1;if(t.implicitLoop(r,function(e){e.contains(n)&&(a=!0)}),a)return n}else t.implicitLoop(n,function(e){t.implicitLoop(r,function(t){e===t&&o.push(e)})});return o},evaluate:function(e){return t.unifiedEval(this,e)}};return e.parseElement("indirectExpression",n,o)}}),E.addIndirectExpression("asExpression",function(e,t,n,r){if(n.matchToken("as")){n.matchToken("a")||n.matchToken("an");var o=e.requireElement("dotOrColonPath",n).evaluate();return e.parseElement("indirectExpression",n,{type:"asExpression",root:r,args:[r],op:function(e,n){return t.convertValue(n,o)},evaluate:function(e){return t.unifiedEval(this,e)}})}}),E.addIndirectExpression("functionCall",function(e,t,n,r){if(n.matchOpToken("(")){var o=[];if(!n.matchOpToken(")")){do{o.push(e.requireElement("expression",n))}while(n.matchOpToken(","));n.requireOpToken(")")}if(r.root)var a={type:"functionCall",root:r,argExressions:o,args:[r.root,o],op:function(e,n,o){t.nullCheck(n,r.root);var a=n[r.prop.value];return t.nullCheck(a,r),a.hyperfunc&&o.push(e),a.apply(n,o)},evaluate:function(e){return t.unifiedEval(this,e)}};else a={type:"functionCall",root:r,argExressions:o,args:[r,o],op:function(e,n,o){return t.nullCheck(n,r),n.hyperfunc&&o.push(e),n.apply(null,o)},evaluate:function(e){return t.unifiedEval(this,e)}};return e.parseElement("indirectExpression",n,a)}}),E.addIndirectExpression("attributeRefAccess",function(e,t,n,r){var o=e.parseElement("attributeRef",n);if(o)return{type:"attributeRefAccess",root:r,attribute:o,args:[r],op:function(e,n){return t.resolveAttribute(n,o.name)},evaluate:function(e){return T.unifiedEval(this,e)}}}),E.addIndirectExpression("arrayIndex",function(e,t,n,r){if(n.matchOpToken("[")){var o=!1,a=!1,i=null,u=null;n.matchOpToken("..")?(o=!0,i=e.requireElement("expression",n)):(i=e.requireElement("expression",n),n.matchOpToken("..")&&(a=!0,"R_BRACKET"!==n.currentToken().type&&(u=e.parseElement("expression",n)))),n.requireOpToken("]");var l={type:"arrayIndex",root:r,prop:i,firstIndex:i,secondIndex:u,args:[r,i,u],op:function(e,t,n,r){return null==t?null:o?(n<0&&(n=t.length+n),t.slice(0,n+1)):a?null!=r?(r<0&&(r=t.length+r),t.slice(n,r+1)):t.slice(n):t[n]},evaluate:function(e){return T.unifiedEval(this,e)}};return E.parseElement("indirectExpression",n,l)}});var w=["em","ex","cap","ch","ic","rem","lh","rlh","vw","vh","vi","vb","vmin","vmax","cm","mm","Q","pc","pt","px"];E.addGrammarElement("postfixExpression",function(e,t,n){var r=e.parseElement("primaryExpression",n),o=n.matchAnyToken.apply(n,w)||n.matchOpToken("%");if(o)return{type:"stringPostfix",postfix:o.value,args:[r],op:function(e,t){return""+t+o.value},evaluate:function(e){return t.unifiedEval(this,e)}};var a=null;if(n.matchToken("s")||n.matchToken("seconds")?a=1e3:(n.matchToken("ms")||n.matchToken("milliseconds"))&&(a=1),a)return{type:"timeExpression",time:r,factor:a,args:[r],op:function(e,t){return t*a},evaluate:function(e){return t.unifiedEval(this,e)}};if(n.matchOpToken(":")){var i=n.requireTokenType("IDENTIFIER"),u=!n.matchOpToken("!");return{type:"typeCheck",typeName:i,nullOk:u,args:[r],op:function(e,n){if(t.typeCheck(n,i.value,u))return n;throw new Error("Typecheck failed! Expected: "+i.value)},evaluate:function(e){return t.unifiedEval(this,e)}}}return r}),E.addGrammarElement("logicalNot",function(e,t,n){if(n.matchToken("not")){var r=e.requireElement("unaryExpression",n);return{type:"logicalNot",root:r,args:[r],op:function(e,t){return!t},evaluate:function(e){return t.unifiedEval(this,e)}}}}),E.addGrammarElement("noExpression",function(e,t,n){if(n.matchToken("no")){var r=e.requireElement("unaryExpression",n);return{type:"noExpression",root:r,args:[r],op:function(e,n){return t.isEmpty(n)},evaluate:function(e){return t.unifiedEval(this,e)}}}}),E.addLeafExpression("some",function(e,t,n){if(n.matchToken("some")){var r=e.requireElement("expression",n);return{type:"noExpression",root:r,args:[r],op:function(e,n){return!t.isEmpty(n)},evaluate:function(e){return t.unifiedEval(this,e)}}}}),E.addGrammarElement("negativeNumber",function(e,t,n){if(n.matchOpToken("-")){var r=e.requireElement("unaryExpression",n);return{type:"negativeNumber",root:r,args:[r],op:function(e,t){return-1*t},evaluate:function(e){return t.unifiedEval(this,e)}}}}),E.addGrammarElement("unaryExpression",function(e,t,n){return n.matchToken("the"),e.parseAnyOf(["beepExpression","logicalNot","relativePositionalExpression","positionalExpression","noExpression","negativeNumber","postfixExpression"],n)}),E.addGrammarElement("beepExpression",function(e,t,n){if(n.matchToken("beep!")){var r=e.parseElement("unaryExpression",n);if(r){r.booped=!0;var o=r.evaluate;return r.evaluate=function(e){var n=o.apply(r,arguments),a=e.me;if(t.triggerEvent(a,"hyperscript:beep",{element:a,expression:r,value:n})){var i,u=n;"String"===(i=n?n instanceof v?"ElementCollection":n.constructor?n.constructor.name:"unknown":"object (null)")?u='"'+u+'"':n instanceof v&&(u=Array.from(n)),console.log("///_ BEEP! The expression ("+r.sourceFor().substr(6)+") evaluates to:",u,"of type "+i)}return n},r}}});var S=function(e,t,n,r){var o=[];T.forEach(t,function(t){(t.matches(n)||t===e)&&o.push(t)});for(var a=0;a<o.length-1;a++)if(o[a]===e)return o[a+1];if(r){var i=o[0];if(i&&i.matches(n))return i}};E.addGrammarElement("relativePositionalExpression",function(e,t,n){var r=n.matchAnyToken("next","previous");if(r){if("next"===r.value)var o=!0;var a=e.parseElement("expression",n);if(n.matchToken("from")){n.pushFollow("in");try{var i=e.requireElement("unaryExpression",n)}finally{n.popFollow()}}else i=e.requireElement("implicitMeTarget",n);var u,l=!1;if(n.matchToken("in")){l=!0;var s=e.requireElement("unaryExpression",n)}else u=n.matchToken("within")?e.requireElement("unaryExpression",n):document.body;var c=!1;return n.matchToken("with")&&(n.requireToken("wrapping"),c=!0),{type:"relativePositionalExpression",from:i,forwardSearch:o,inSearch:l,wrapping:c,inElt:s,withinElt:u,operator:r.value,args:[a,i,s,u],op:function(e,t,n,r,a){var i,u,s=t.css;if(null==s)throw"Expected a CSS value";if(l){if(r)return o?S(n,r,s,c):(i=s,u=c,S(n,Array.from(r).reverse(),i,u))}else if(a)return o?function(e,t,n,r){for(var o=t.querySelectorAll(n),a=0;a<o.length;a++){var i=o[a];if(i.compareDocumentPosition(e)===Node.DOCUMENT_POSITION_PRECEDING)return i}if(r)return o[0]}(n,a,s,c):function(e,t,n,r){for(var o=t.querySelectorAll(n),a=o.length-1;a>=0;a--){var i=o[a];if(i.compareDocumentPosition(e)===Node.DOCUMENT_POSITION_FOLLOWING)return i}if(r)return o[o.length-1]}(n,a,s,c)},evaluate:function(e){return t.unifiedEval(this,e)}}}}),E.addGrammarElement("positionalExpression",function(e,t,n){var r=n.matchAnyToken("first","last","random");if(r){n.matchAnyToken("in","from","of");var o=e.requireElement("unaryExpression",n),a=r.value;return{type:"positionalExpression",rhs:o,operator:r.value,args:[o],op:function(e,t){if(t&&!Array.isArray(t)&&(t=t.children?t.children:Array.from(t)),t){if("first"===a)return t[0];if("last"===a)return t[t.length-1];if("random"===a)return t[Math.floor(Math.random()*t.length)]}},evaluate:function(e){return t.unifiedEval(this,e)}}}}),E.addGrammarElement("mathOperator",function(e,t,n){var r,o=e.parseElement("unaryExpression",n),a=null;for(r=n.matchAnyOpToken("+","-","*","/","%");r;){var i=r.value;(a=a||r).value!==i&&e.raiseParseError(n,"You must parenthesize math operations with different operators");var u=e.parseElement("unaryExpression",n);o={type:"mathOperator",lhs:o,rhs:u,operator:i,args:[o,u],op:function(e,t,n){return"+"===i?t+n:"-"===i?t-n:"*"===i?t*n:"/"===i?t/n:"%"===i?t%n:void 0},evaluate:function(e){return t.unifiedEval(this,e)}},r=n.matchAnyOpToken("+","-","*","/","%")}return o}),E.addGrammarElement("mathExpression",function(e,t,n){return e.parseAnyOf(["mathOperator","unaryExpression"],n)}),E.addGrammarElement("comparisonOperator",function(e,t,n){var r=e.parseElement("mathExpression",n),o=n.matchAnyOpToken("<",">","<=",">=","==","===","!=","!=="),a=o?o.value:null,i=!0,u=!1;if(null==a&&(n.matchToken("is")||n.matchToken("am")?n.matchToken("not")?n.matchToken("in")?a="not in":n.matchToken("a")?(a="not a",u=!0):n.matchToken("empty")?(a="not empty",i=!1):a="!=":n.matchToken("in")?a="in":n.matchToken("a")?(a="a",u=!0):n.matchToken("empty")?(a="empty",i=!1):n.matchToken("less")?(n.requireToken("than"),n.matchToken("or")?(n.requireToken("equal"),n.requireToken("to"),a="<="):a="<"):n.matchToken("greater")?(n.requireToken("than"),n.matchToken("or")?(n.requireToken("equal"),n.requireToken("to"),a=">="):a=">"):a="==":n.matchToken("exist")||n.matchToken("exists")?(a="exist",i=!1):n.matchToken("matches")||n.matchToken("match")?a="match":n.matchToken("contains")||n.matchToken("contain")?a="contain":n.matchToken("includes")||n.matchToken("include")?a="include":(n.matchToken("do")||n.matchToken("does"))&&(n.requireToken("not"),n.matchToken("matches")||n.matchToken("match")?a="not match":n.matchToken("contains")||n.matchToken("contain")?a="not contain":n.matchToken("exist")||n.matchToken("exist")?(a="not exist",i=!1):n.matchToken("include")?a="not include":e.raiseParseError(n,"Expected matches or contains"))),a){if(u)var l=n.requireTokenType("IDENTIFIER"),s=!n.matchOpToken("!");else if(i){var c=e.requireElement("mathExpression",n);"match"!==a&&"not match"!==a||(c=c.css?c.css:c)}var f=r;r={type:"comparisonOperator",operator:a,typeName:l,nullOk:s,lhs:r,rhs:c,args:[r,c],op:function(e,n,r){if("=="===a)return n==r;if("!="===a)return n!=r;if("match"===a)return null!=n&&g(f,n,r);if("not match"===a)return null==n||!g(f,n,r);if("in"===a)return null!=r&&k(c,r,n);if("not in"===a)return null==r||!k(c,r,n);if("contain"===a)return null!=n&&k(f,n,r);if("not contain"===a)return null==n||!k(f,n,r);if("include"===a)return null!=n&&k(f,n,r);if("not include"===a)return null==n||!k(f,n,r);if("==="===a)return n===r;if("!=="===a)return n!==r;if("<"===a)return n<r;if(">"===a)return n>r;if("<="===a)return n<=r;if(">="===a)return n>=r;if("empty"===a)return t.isEmpty(n);if("not empty"===a)return!t.isEmpty(n);if("exist"===a)return t.doesExist(n);if("not exist"===a)return!t.doesExist(n);if("a"===a)return t.typeCheck(n,l.value,s);if("not a"===a)return!t.typeCheck(n,l.value,s);throw"Unknown comparison : "+a},evaluate:function(e){return t.unifiedEval(this,e)}}}return r}),E.addGrammarElement("comparisonExpression",function(e,t,n){return e.parseAnyOf(["comparisonOperator","mathExpression"],n)}),E.addGrammarElement("logicalOperator",function(e,t,n){var r,o=e.parseElement("comparisonExpression",n),a=null;r=n.matchToken("and")||n.matchToken("or");for(var i=function(){(a=a||r).value!==r.value&&e.raiseParseError(n,"You must parenthesize logical operations with different operators"),u=e.requireElement("comparisonExpression",n);var i=r.value;o={type:"logicalOperator",operator:i,lhs:o,rhs:u,args:[o,u],op:function(e,t,n){return"and"===i?t&&n:t||n},evaluate:function(e){return t.unifiedEval(this,e)}},r=n.matchToken("and")||n.matchToken("or")};r;){var u;i()}return o}),E.addGrammarElement("logicalExpression",function(e,t,n){return e.parseAnyOf(["logicalOperator","mathExpression"],n)}),E.addGrammarElement("asyncExpression",function(e,t,n){return n.matchToken("async")?{type:"asyncExpression",value:e.requireElement("logicalExpression",n),evaluate:function(e){return{asyncWrapper:!0,value:this.value.evaluate(e)}}}:e.parseElement("logicalExpression",n)}),E.addGrammarElement("expression",function(e,t,n){return n.matchToken("the"),e.parseElement("asyncExpression",n)}),E.addGrammarElement("assignableExpression",function(e,t,n){n.matchToken("the");var r=e.parseElement("primaryExpression",n);return!r||"symbol"!==r.type&&"ofExpression"!==r.type&&"propertyAccess"!==r.type&&"attributeRefAccess"!==r.type&&"attributeRef"!==r.type&&"styleRef"!==r.type&&"arrayIndex"!==r.type&&"possessive"!==r.type?(E.raiseParseError(n,"A target expression must be writable. The expression type '"+(r&&r.type)+"' is not."),r):r}),E.addGrammarElement("hyperscript",function(e,t,n){var r=[];if(n.hasMore())for(;e.featureStart(n.currentToken())||"("===n.currentToken().value;){var o=e.requireElement("feature",n);r.push(o),n.matchToken("end")}return{type:"hyperscript",features:r,apply:function(e,t,n){for(var o,a=s(r);!(o=a()).done;)o.value.install(e,t,n)}}});var N=function(e){var t=[];if("("===e.token(0).value&&(")"===e.token(1).value||","===e.token(2).value||")"===e.token(2).value)){e.matchOpToken("(");do{t.push(e.requireTokenType("IDENTIFIER"))}while(e.matchOpToken(","));e.requireOpToken(")")}return t};E.addFeature("on",function(e,t,n){if(n.matchToken("on")){var r=!1;n.matchToken("every")&&(r=!0);var o=[],a=null;do{var i=e.requireElement("eventName",n,"Expected event name").evaluate();a=a?a+" or "+i:"on "+i;var u=N(n),l=null;if(n.matchOpToken("[")&&(l=e.requireElement("expression",n),n.requireOpToken("]")),"NUMBER"===n.currentToken().type){var f=n.consumeToken(),m=parseInt(f.value);if(n.matchToken("to"))var p=n.consumeToken(),d=parseInt(p.value);else if(n.matchToken("and")){var v=!0;n.requireToken("on")}}if("intersection"===i){var h={};if(n.matchToken("with")&&(h.with=e.requireElement("expression",n).evaluate()),n.matchToken("having"))do{n.matchToken("margin")?h.rootMargin=e.requireElement("stringLike",n).evaluate():n.matchToken("threshold")?h.threshold=e.requireElement("expression",n).evaluate():e.raiseParseError(n,"Unknown intersection config specification")}while(n.matchToken("and"))}else if("mutation"===i){var E={};if(n.matchToken("of"))do{if(n.matchToken("anything"))E.attributes=!0,E.subtree=!0,E.characterData=!0,E.childList=!0;else if(n.matchToken("childList"))E.childList=!0;else if(n.matchToken("attributes"))E.attributes=!0,E.attributeOldValue=!0;else if(n.matchToken("subtree"))E.subtree=!0;else if(n.matchToken("characterData"))E.characterData=!0,E.characterDataOldValue=!0;else if("ATTRIBUTE_REF"===n.currentToken().type){var y=n.consumeToken();null==E.attributeFilter&&(E.attributeFilter=[]),0==y.value.indexOf("@")?E.attributeFilter.push(y.value.substring(1)):e.raiseParseError(n,"Only shorthand attribute references are allowed here")}else e.raiseParseError(n,"Unknown mutation config specification")}while(n.matchToken("or"));else E.attributes=!0,E.characterData=!0,E.childList=!0}var k=null,g=!1;if(n.matchToken("from")&&(n.matchToken("elsewhere")?g=!0:(k=e.parseElement("expression",n))||e.raiseParseError(n,'Expected either target value or "elsewhere".')),null===k&&!1===g&&n.matchToken("elsewhere")&&(g=!0),n.matchToken("in"))var x=e.parseElement("unaryExpression",n);if(n.matchToken("debounced")){n.requireToken("at");var b=e.requireElement("expression",n).evaluate({})}else if(n.matchToken("throttled")){n.requireToken("at");var q=e.requireElement("expression",n).evaluate({})}o.push({execCount:0,every:r,on:i,args:u,filter:l,from:k,inExpr:x,elsewhere:g,startCount:m,endCount:d,unbounded:v,debounceTime:b,throttleTime:q,mutationSpec:E,intersectionSpec:h,debounced:void 0,lastExec:void 0})}while(n.matchToken("or"));var w=!0;if(!r&&n.matchToken("queue"))if(n.matchToken("all"))w=!1;else if(n.matchToken("first"))var S=!0;else if(n.matchToken("none"))var I=!0;else n.requireToken("last");var R=e.requireElement("commandList",n);if(e.ensureTerminated(R),n.matchToken("catch")){var O=n.requireTokenType("IDENTIFIER").value,L=e.requireElement("commandList",n);e.ensureTerminated(L)}if(n.matchToken("finally")){var C=e.requireElement("commandList",n);e.ensureTerminated(C)}var A={displayName:a,events:o,start:R,every:r,execCount:0,errorHandler:L,errorSymbol:O,execute:function(e){var n=t.getEventQueueFor(e.me,A);if(n.executing&&!1===r){if(I||S&&n.queue.length>0)return;return w&&(n.queue.length=0),void n.queue.push(e)}A.execCount++,n.executing=!0,e.meta.onHalt=function(){n.executing=!1;var e=n.queue.shift();e&&setTimeout(function(){A.execute(e)},1)},e.meta.reject=function(n){console.error(n.message?n.message:n);var r=t.getHyperTrace(e,n);r&&r.print(),t.triggerEvent(e.me,"exception",{error:n})},R.execute(e)},install:function(e,n){for(var r,o=function(){var n=r.value;i=n.elsewhere?[document]:n.from?n.from.evaluate(t.makeContext(e,A,e,null)):[e],t.implicitLoop(i,function(r){var o=n.on;if(n.mutationSpec&&(o="hyperscript:mutation",new MutationObserver(function(e,t){A.executing||T.triggerEvent(r,o,{mutationList:e,observer:t})}).observe(r,n.mutationSpec)),n.intersectionSpec){o="hyperscript:insersection";var a=new IntersectionObserver(function(e){for(var t,n=s(e);!(t=n()).done;){var i=t.value,u={observer:a};(u=c(u,i)).intersecting=i.isIntersecting,T.triggerEvent(r,o,u)}},n.intersectionSpec);a.observe(r)}(r.addEventListener||r.on).call(r,o,function a(i){if("undefined"!=typeof Node&&e instanceof Node&&r!==e&&!e.isConnected)r.removeEventListener(o,a);else{var u=t.makeContext(e,A,e,i);if(!n.elsewhere||!e.contains(i.target)){n.from&&(u.result=r);for(var l,c=s(n.args);!(l=c()).done;){var f=l.value,m=u.event[f.value];void 0!==m?u[f.value]=m:"detail"in u.event&&(u[f.value]=u.event.detail[f.value])}if(u.meta.errorHandler=L,u.meta.errorSymbol=O,u.meta.finallyHandler=C,n.filter){var p=u.meta.context;u.meta.context=u.event;try{if(!n.filter.evaluate(u))return}finally{u.meta.context=p}}if(n.inExpr)for(var d=i.target;;){if(d.matches&&d.matches(n.inExpr.css)){u.result=d;break}if(null==(d=d.parentElement))return}if(n.execCount++,n.startCount)if(n.endCount){if(n.execCount<n.startCount||n.execCount>n.endCount)return}else if(n.unbounded){if(n.execCount<n.startCount)return}else if(n.execCount!==n.startCount)return;if(n.debounceTime)return n.debounced&&clearTimeout(n.debounced),void(n.debounced=setTimeout(function(){A.execute(u)},n.debounceTime));if(n.throttleTime){if(n.lastExec&&Date.now()<n.lastExec+n.throttleTime)return;n.lastExec=Date.now()}A.execute(u)}}})})},a=s(A.events);!(r=a()).done;){var i;o()}}};return e.setParent(R,A),A}}),E.addFeature("def",function(e,t,n){if(n.matchToken("def")){var r=e.requireElement("dotOrColonPath",n).evaluate(),o=r.split("."),a=o.pop(),i=[];if(n.matchOpToken("("))if(n.matchOpToken(")"));else{do{i.push(n.requireTokenType("IDENTIFIER"))}while(n.matchOpToken(","));n.requireOpToken(")")}var u=e.requireElement("commandList",n);if(n.matchToken("catch"))var l=n.requireTokenType("IDENTIFIER").value,s=e.parseElement("commandList",n);if(n.matchToken("finally")){var c=e.requireElement("commandList",n);e.ensureTerminated(c)}var f={displayName:a+"("+i.map(function(e){return e.value}).join(", ")+")",name:a,args:i,start:u,errorHandler:s,errorSymbol:l,finallyHandler:c,install:function(e,n){var m=function(){var r=t.makeContext(n,f,e,null);r.meta.errorHandler=s,r.meta.errorSymbol=l,r.meta.finallyHandler=c;for(var o=0;o<i.length;o++){var a=i[o],m=arguments[o];a&&(r[a.value]=m)}r.meta.caller=arguments[i.length],r.meta.caller&&(r.meta.callingCommand=r.meta.caller.meta.command);var p,d=null,v=new Promise(function(e,t){p=e,d=t});return u.execute(r),r.meta.returned?r.meta.returnValue:(r.meta.resolve=p,r.meta.reject=d,v)};m.hyperfunc=!0,m.hypername=r,t.assignToNamespace(e,o,a,m)}};return e.ensureTerminated(u),s&&e.ensureTerminated(s),e.setParent(u,f),f}}),E.addFeature("set",function(e,t,n){var r=e.parseElement("setCommand",n);if(r){"element"!==r.target.scope&&e.raiseParseError(n,"variables declared at the feature level must be element scoped.");var o={start:r,install:function(e,n){r&&r.execute(t.makeContext(e,o,e,null))}};return e.ensureTerminated(r),o}}),E.addFeature("init",function(e,t,n){if(n.matchToken("init")){var r=e.requireElement("commandList",n),o={start:r,install:function(e,n){setTimeout(function(){r&&r.execute(t.makeContext(e,o,e,null))},0)}};return e.ensureTerminated(r),e.setParent(r,o),o}}),E.addFeature("worker",function(e,t,n){n.matchToken("worker")&&e.raiseParseError(n,"In order to use the 'worker' feature, include the _hyperscript worker plugin. See https://hyperscript.org/features/worker/ for more info.")}),E.addFeature("behavior",function(e,t,n){if(n.matchToken("behavior")){var r=e.requireElement("dotOrColonPath",n).evaluate(),o=r.split("."),a=o.pop(),i=[];if(n.matchOpToken("(")&&!n.matchOpToken(")")){do{i.push(n.requireTokenType("IDENTIFIER").value)}while(n.matchOpToken(","));n.requireOpToken(")")}for(var u=e.requireElement("hyperscript",n),l=0;l<u.features.length;l++)u.features[l].behavior=r;return{install:function(e,n){t.assignToNamespace(d.document&&d.document.body,o,a,function(e,n,o){for(var a=f(t.getInternalData(e),r+"Scope"),l=0;l<i.length;l++)a[i[l]]=o[i[l]];u.apply(e,n)})}}}}),E.addFeature("install",function(e,t,n){if(n.matchToken("install")){var r,o=e.requireElement("dotOrColonPath",n).evaluate(),a=o.split("."),i=e.parseElement("namedArgumentList",n);return r={install:function(e,n){t.unifiedEval({args:[i],op:function(t,r){for(var i=d,u=0;u<a.length;u++)if("object"!=typeof(i=i[a[u]])&&"function"!=typeof i)throw new Error("No such behavior defined as "+o);if(!(i instanceof Function))throw new Error(o+" is not a behavior");i(e,n,r)}},t.makeContext(e,r,e))}}}}),E.addGrammarElement("jsBody",function(e,t,n){for(var r=n.currentToken().start,o=n.currentToken(),a=[],i="",u=!1;n.hasMore();){o=n.consumeToken();var l=n.token(0,!0);if("IDENTIFIER"===l.type&&"end"===l.value)break;u?"IDENTIFIER"===o.type||"NUMBER"===o.type?i+=o.value:(""!==i&&a.push(i),i="",u=!1):"IDENTIFIER"===o.type&&"function"===o.value&&(u=!0)}return{type:"jsBody",exposedFunctionNames:a,jsSource:n.source.substring(r,o.end+1)}}),E.addFeature("js",function(e,t,n){if(n.matchToken("js")){var r=e.requireElement("jsBody",n),o=r.jsSource+"\nreturn { "+r.exposedFunctionNames.map(function(e){return e+":"+e}).join(",")+" } ",a=new Function(o);return{jsSource:o,function:a,exposedFunctionNames:r.exposedFunctionNames,install:function(){c(d,a())}}}}),E.addCommand("js",function(e,t,n){if(n.matchToken("js")){var r=[];if(n.matchOpToken("("))if(n.matchOpToken(")"));else{do{var o=n.requireTokenType("IDENTIFIER");r.push(o.value)}while(n.matchOpToken(","));n.requireOpToken(")")}var a=e.requireElement("jsBody",n);n.matchToken("end");var i=m(Function,r.concat([a.jsSource]));return{jsSource:a.jsSource,function:i,inputs:r,op:function(e){var n=[];r.forEach(function(r){n.push(t.resolveSymbol(r,e,"default"))});var o=i.apply(d,n);return o&&"function"==typeof o.then?new Promise(function(n){o.then(function(r){e.result=r,n(t.findNext(this,e))})}):(e.result=o,t.findNext(this,e))}}}}),E.addCommand("async",function(e,t,n){if(n.matchToken("async")){if(n.matchToken("do")){for(var r=o=e.requireElement("commandList",n);r.next;)r=r.next;r.next=t.HALT,n.requireToken("end")}else var o=e.requireElement("command",n);var a={body:o,op:function(e){return setTimeout(function(){o.execute(e)}),t.findNext(this,e)}};return e.setParent(o,a),a}}),E.addCommand("tell",function(e,t,n){var r=n.currentToken();if(n.matchToken("tell")){var o=e.requireElement("expression",n),a=e.requireElement("commandList",n);n.hasMore()&&!e.featureStart(n.currentToken())&&n.requireToken("end");var i="tell_"+r.start,u={value:o,body:a,args:[o],resolveNext:function(e){var n=e.meta.iterators[i];return n.index<n.value.length?(e.beingTold=n.value[n.index++],a):(e.beingTold=n.originalBeingTold,this.next?this.next:t.findNext(this.parent,e))},op:function(e,t){return null==t?t=[]:Array.isArray(t)||t instanceof NodeList||(t=[t]),e.meta.iterators[i]={originalBeingTold:e.beingTold,index:0,value:t},this.resolveNext(e)}};return e.setParent(a,u),u}}),E.addCommand("wait",function(e,t,n){if(n.matchToken("wait")){var r,o;if(n.matchToken("for")){n.matchToken("a");var a=[];do{var i=n.token(0);a.push("NUMBER"===i.type||"L_PAREN"===i.type?{time:e.requireElement("expression",n).evaluate()}:{name:E.requireElement("dotOrColonPath",n,"Expected event name").evaluate(),args:N(n)})}while(n.matchToken("or"));if(n.matchToken("from"))var u=e.requireElement("expression",n);return r={event:a,on:u,args:[u],op:function(e,n){var r=this,o=n||e.me;if(!(o instanceof EventTarget))throw new Error("Not a valid event target: "+this.on.sourceFor());return new Promise(function(n){for(var i,u=!1,l=function(){var a=i.value;f=function(o){if(e.result=o,a.args)for(var i,l=s(a.args);!(i=l()).done;){var c=i.value;e[c.value]=o[c.value]||(o.detail?o.detail[c.value]:null)}u||(u=!0,n(t.findNext(r,e)))},a.name?o.addEventListener(a.name,f,{once:!0}):null!=a.time&&setTimeout(f,a.time,a.time)},c=s(a);!(i=c()).done;){var f;l()}})}},r}return n.matchToken("a")?(n.requireToken("tick"),o=0):o=E.requireElement("expression",n),{type:"waitCmd",time:o,args:[o],op:function(e,n){var r=this;return new Promise(function(o){setTimeout(function(){o(t.findNext(r,e))},n)})},execute:function(e){return t.unifiedExec(this,e)}}}}),E.addGrammarElement("dotOrColonPath",function(e,t,n){var r=n.matchTokenType("IDENTIFIER");if(r){var o=[r.value],a=n.matchOpToken(".")||n.matchOpToken(":");if(a)do{o.push(n.requireTokenType("IDENTIFIER","NUMBER").value)}while(n.matchOpToken(a.value));return{type:"dotOrColonPath",path:o,evaluate:function(){return o.join(a?a.value:"")}}}}),E.addGrammarElement("eventName",function(e,t,n){var r;return(r=n.matchTokenType("STRING"))?{evaluate:function(){return r.value}}:e.parseElement("dotOrColonPath",n)}),E.addCommand("trigger",function(e,t,n){if(n.matchToken("trigger"))return x("trigger",e,t,n)}),E.addCommand("send",function(e,t,n){if(n.matchToken("send"))return x("send",e,t,n)});var I=function(e,t,n,r){if(r)if(e.commandBoundary(n.currentToken()))e.raiseParseError(n,"'return' commands must return a value. If you do not wish to return a value, use 'exit' instead.");else var o=e.requireElement("expression",n);var a={value:o,args:[o],op:function(e,n){var r=e.meta.resolve;return e.meta.returned=!0,e.meta.returnValue=n,r&&(n?r(n):r()),t.HALT}};return a};E.addCommand("return",function(e,t,n){if(n.matchToken("return"))return I(e,t,n,!0)}),E.addCommand("exit",function(e,t,n){if(n.matchToken("exit"))return I(e,t,n,!1)}),E.addCommand("halt",function(e,t,n){if(n.matchToken("halt")){if(n.matchToken("the")){n.requireToken("event"),n.matchOpToken("'")&&n.requireToken("s");var r=!0}if(n.matchToken("bubbling"))var o=!0;else if(n.matchToken("default"))var a=!0;var i=I(e,t,n,!1);return{keepExecuting:!0,bubbling:o,haltDefault:a,exit:i,op:function(e){if(e.event)return o?e.event.stopPropagation():(a||e.event.stopPropagation(),e.event.preventDefault()),r?t.findNext(this,e):i}}}}),E.addCommand("log",function(e,t,n){if(n.matchToken("log")){for(var r=[e.parseElement("expression",n)];n.matchOpToken(",");)r.push(e.requireElement("expression",n));if(n.matchToken("with"))var o=e.requireElement("expression",n);var a={exprs:r,withExpr:o,args:[o,r],op:function(e,n,r){return n?n.apply(null,r):console.log.apply(null,r),t.findNext(this,e)}};return a}}),E.addCommand("throw",function(e,t,n){if(n.matchToken("throw")){var r=e.requireElement("expression",n),o={expr:r,args:[r],op:function(e,n){throw t.registerHyperTrace(e,n),n}};return o}});var R=function(e,t,n){var r=e.requireElement("expression",n),o={expr:r,args:[r],op:function(e,n){return e.result=n,t.findNext(o,e)}};return o};E.addCommand("call",function(e,t,n){if(n.matchToken("call")){var r=R(e,t,n);return r.expr&&"functionCall"!==r.expr.type&&e.raiseParseError(n,"Must be a function invocation"),r}}),E.addCommand("get",function(e,t,n){if(n.matchToken("get"))return R(e,t,n)}),E.addCommand("make",function(e,t,n){if(n.matchToken("make")){n.matchToken("a")||n.matchToken("an");var r,o=e.requireElement("expression",n),a=[];if("queryRef"!==o.type&&n.matchToken("from"))do{a.push(e.requireElement("expression",n))}while(n.matchOpToken(","));if(n.matchToken("called"))var i=e.requireElement("symbol",n);return"queryRef"===o.type?r={op:function(e){for(var n,r,a="div",u=[],l=/(?:(^|#|\.)([^#\. ]+))/g;n=l.exec(o.css);)""===n[1]?a=n[2].trim():"#"===n[1]?r=n[2].trim():u.push(n[2].trim());var s=document.createElement(a);void 0!==r&&(s.id=r);for(var c=0;c<u.length;c++)s.classList.add(u[c]);return e.result=s,i&&t.setSymbol(i.name,e,i.scope,s),t.findNext(this,e)}}:(r={args:[o,a],op:function(e,n,r){return e.result=m(n,r),i&&t.setSymbol(i.name,e,i.scope,e.result),t.findNext(this,e)}},r)}}),E.addGrammarElement("pseudoCommand",function(e,t,n){var r=n.token(1);if(!r||!r.op||"."!==r.value&&"("!==r.value)return null;for(var o=e.requireElement("primaryExpression",n),a=o.root,i=o;null!=a.root;)i=i.root,a=a.root;if("functionCall"!==o.type&&e.raiseParseError(n,"Pseudo-commands must be function calls"),"functionCall"===i.type&&null==i.root.root)if(n.matchAnyToken("the","to","on","with","into","from","at"))var u=e.requireElement("expression",n);else n.matchToken("me")&&(u=e.requireElement("implicitMeTarget",n));if(u)var l={type:"pseudoCommand",root:u,argExressions:i.argExressions,args:[u,i.argExressions],op:function(e,n,r){t.nullCheck(n,u);var o=n[i.root.name];return t.nullCheck(o,i),o.hyperfunc&&r.push(e),e.result=o.apply(n,r),t.findNext(l,e)},execute:function(e){return t.unifiedExec(this,e)}};else l={type:"pseudoCommand",expr:o,args:[o],op:function(e,n){return e.result=n,t.findNext(l,e)},execute:function(e){return t.unifiedExec(this,e)}};return l});var O=function(e,t,n,r,o){var a="symbol"===r.type,i="attributeRef"===r.type,u="styleRef"===r.type,l="arrayIndex"===r.type;i||u||a||null!=r.root||e.raiseParseError(n,"Can only put directly into symbols, not references");var s=null,c=null;if(a);else if(i||u){s=e.requireElement("implicitMeTarget",n);var f=r}else l?(c=r.firstIndex,s=r.root):(c=r.prop?r.prop.value:null,f=r.attribute,s=r.root);var m={target:r,symbolWrite:a,value:o,args:[s,c,o],op:function(e,n,o,i){return a?t.setSymbol(r.name,e,r.scope,i):(t.nullCheck(n,s),l?n[o]=i:t.implicitLoop(n,function(e){f?"attributeRef"===f.type?null==i?e.removeAttribute(f.name):e.setAttribute(f.name,i):e.style[f.name]=i:e[o]=i})),t.findNext(this,e)}};return m};E.addCommand("default",function(e,t,n){if(n.matchToken("default")){var r=e.requireElement("assignableExpression",n);n.requireToken("to");var o=e.requireElement("expression",n),a=O(e,t,n,r,o),i={target:r,value:o,setter:a,args:[r],op:function(e,n){return n?t.findNext(this,e):a}};return a.parent=i,i}}),E.addCommand("set",function(e,t,n){if(n.matchToken("set")){if("L_BRACE"===n.currentToken().type){var r=e.requireElement("objectLiteral",n);n.requireToken("on");var o={objectLiteral:r,target:a=e.requireElement("expression",n),args:[r,a],op:function(e,n,r){return c(r,n),t.findNext(this,e)}};return o}try{n.pushFollow("to");var a=e.requireElement("assignableExpression",n)}finally{n.popFollow()}n.requireToken("to");var i=e.requireElement("expression",n);return O(e,t,n,a,i)}}),E.addCommand("if",function(e,t,n){if(n.matchToken("if")){var r=e.requireElement("expression",n);n.matchToken("then");var o=e.parseElement("commandList",n);if(n.matchToken("else")||n.matchToken("otherwise"))var a=e.parseElement("commandList",n);n.hasMore()&&n.requireToken("end");var i={expr:r,trueBranch:o,falseBranch:a,args:[r],op:function(e,n){return n?o:a||t.findNext(this,e)}};return e.setParent(o,i),e.setParent(a,i),i}});var L=function(e,t,n,r){var o,a=t.currentToken();if(t.matchToken("for")||r){var i=t.requireTokenType("IDENTIFIER");o=i.value,t.requireToken("in");var u=e.requireElement("expression",t)}else if(t.matchToken("in"))o="it",u=e.requireElement("expression",t);else if(t.matchToken("while"))var l=e.requireElement("expression",t);else if(t.matchToken("until")){var s=!0;if(t.matchToken("event")){var c=E.requireElement("dotOrColonPath",t,"Expected event name");if(t.matchToken("from"))var f=e.requireElement("expression",t)}else l=e.requireElement("expression",t)}else if(e.commandBoundary(t.currentToken())||"forever"===t.currentToken().value){t.matchToken("forever");var m=!0}else{var p=e.requireElement("expression",t);t.requireToken("times")}if(t.matchToken("index"))var d=(i=t.requireTokenType("IDENTIFIER")).value;var v=e.parseElement("commandList",t);if(v&&c){for(var h=v;h.next;)h=h.next;var y={type:"waitATick",op:function(){return new Promise(function(e){setTimeout(function(){e(n.findNext(y))},0)})}};h.next=y}if(t.hasMore()&&t.requireToken("end"),null==o)var T=o="_implicit_repeat_"+a.start;else T=o+"_"+a.start;var k={identifier:o,indexIdentifier:d,slot:T,expression:u,forever:m,times:p,until:s,event:c,on:f,whileExpr:l,resolveNext:function(){return this},loop:v,args:[l,p],op:function(e,t,r){var a=e.meta.iterators[T],i=!1,u=null;if(this.forever)i=!0;else if(this.until)i=c?!1===e.meta.iterators[T].eventFired:!0!==t;else if(l)i=t;else if(r)i=a.index<r;else{var s=a.iterator.next();i=!s.done,u=s.value}return i?(e.result=a.value?e[o]=u:a.index,d&&(e[d]=a.index),a.index++,v):(e.meta.iterators[T]=null,n.findNext(this.parent,e))}};e.setParent(v,k);var g={name:"repeatInit",args:[u,c,f],op:function(e,t,n,r){var o={index:0,value:t,eventFired:!1};return e.meta.iterators[T]=o,t&&t[Symbol.iterator]&&(o.iterator=t[Symbol.iterator]()),c&&(r||e.me).addEventListener(n,function(t){e.meta.iterators[T].eventFired=!0},{once:!0}),k},execute:function(e){return n.unifiedExec(this,e)}};return e.setParent(k,g),g};if(E.addCommand("repeat",function(e,t,n){if(n.matchToken("repeat"))return L(e,n,t,!1)}),E.addCommand("for",function(e,t,n){if(n.matchToken("for"))return L(e,n,t,!0)}),E.addCommand("continue",function(e,t,n){if(n.matchToken("continue"))return{op:function(t){for(var r=this.parent;;r=r.parent)if(null==r&&e.raiseParseError(n,"Command `continue` cannot be used outside of a `repeat` loop."),null!=r.loop)return r.resolveNext(t)}}}),E.addCommand("break",function(e,t,n){if(n.matchToken("break"))return{op:function(r){for(var o=this.parent;;o=o.parent)if(null==o&&e.raiseParseError(n,"Command `continue` cannot be used outside of a `repeat` loop."),null!=o.loop)return t.findNext(o.parent,r)}}}),E.addGrammarElement("stringLike",function(e,t,n){return E.parseAnyOf(["string","nakedString"],n)}),E.addCommand("append",function(e,t,n){if(n.matchToken("append")){var r,o=e.requireElement("expression",n),a={type:"symbol",evaluate:function(e){return t.resolveSymbol("result",e)}};r=n.matchToken("to")?e.requireElement("expression",n):a;var i=null;"symbol"!==r.type&&"attributeRef"!==r.type&&null==r.root||(i=O(e,t,n,r,a));var u={value:o,target:r,args:[r,o],op:function(e,n,r){if(Array.isArray(n))return n.push(r),t.findNext(this,e);if(n instanceof Element)return n.innerHTML+=r,t.findNext(this,e);if(i)return e.result=(n||"")+r,i;throw Error("Unable to append a value!")},execute:function(e){return t.unifiedExec(this,e)}};return null!=i&&(i.parent=u),u}}),E.addCommand("increment",function(e,t,n){if(n.matchToken("increment")){var r,o=e.parseElement("assignableExpression",n);n.matchToken("by")&&(r=e.requireElement("expression",n));var a={type:"implicitIncrementOp",target:o,args:[o,r],op:function(e,t,n){var r=(t=t?parseFloat(t):0)+(n=n?parseFloat(n):1);return e.result=r,r},evaluate:function(e){return t.unifiedEval(this,e)}};return O(e,t,n,o,a)}}),E.addCommand("decrement",function(e,t,n){if(n.matchToken("decrement")){var r,o=e.parseElement("assignableExpression",n);n.matchToken("by")&&(r=e.requireElement("expression",n));var a={type:"implicitDecrementOp",target:o,args:[o,r],op:function(e,t,n){var r=(t=t?parseFloat(t):0)-(n=n?parseFloat(n):1);return e.result=r,r},evaluate:function(e){return t.unifiedEval(this,e)}};return O(e,t,n,o,a)}}),E.addCommand("fetch",function(e,t,n){if(n.matchToken("fetch")){var r=e.requireElement("stringLike",n);if(n.matchToken("as"))var o=b(n,e);if(n.matchToken("with")&&"{"!==n.currentToken().value)var a=e.parseElement("nakedNamedArgumentList",n);else a=e.parseElement("objectLiteral",n);null==o&&n.matchToken("as")&&(o=b(n,e));var i=o?o.type:"text",u=o?o.conversion:null,l={url:r,argExpressions:a,args:[r,a],op:function(e,n,r){var o=r||{};o.sender=e.me,o.headers=o.headers||{};var a=new AbortController,s=e.me.addEventListener("fetch:abort",function(){a.abort()},{once:!0});o.signal=a.signal,t.triggerEvent(e.me,"hyperscript:beforeFetch",o),t.triggerEvent(e.me,"fetch:beforeRequest",o);var c=!1;return(r=o).timeout&&setTimeout(function(){c||a.abort()},r.timeout),fetch(n,r).then(function(n){var r={response:n};return t.triggerEvent(e.me,"fetch:afterResponse",r),n=r.response,"response"===i?(e.result=n,t.triggerEvent(e.me,"fetch:afterRequest",{result:n}),c=!0,t.findNext(l,e)):"json"===i?n.json().then(function(n){return e.result=n,t.triggerEvent(e.me,"fetch:afterRequest",{result:n}),c=!0,t.findNext(l,e)}):n.text().then(function(n){return u&&(n=t.convertValue(n,u)),"html"===i&&(n=t.convertValue(n,"Fragment")),e.result=n,t.triggerEvent(e.me,"fetch:afterRequest",{result:n}),c=!0,t.findNext(l,e)})}).catch(function(n){throw t.triggerEvent(e.me,"fetch:error",{reason:n}),n}).finally(function(){e.me.removeEventListener("fetch:abort",s)})}};return l}}),"document"in d){var C=Array.from(document.querySelectorAll("script[type='text/hyperscript'][src]"));Promise.all(C.map(function(e){return fetch(e.src).then(function(e){return e.text()})})).then(function(e){return e.forEach(T.evaluate)}).then(function(){var e;e=function(){var e,t;(t=(e=document.querySelector('meta[name="htmx-config"]'))?function(e){try{return JSON.parse(e)}catch(e){return t=e,console.error?console.error(t):console.log&&console.log("ERROR: ",t),null}var t}(e.content):null)&&(p.config=c(p.config,t)),T.processNode(document.documentElement),document.addEventListener("htmx:load",function(e){T.processNode(e.detail.elt)})},"loading"!==document.readyState?setTimeout(e):document.addEventListener("DOMContentLoaded",e)})}var A=p=c(function(e,t){return T.evaluate(e,t)},{internals:{lexer:h,parser:E,runtime:T},ElementCollection:v,addFeature:function(e,t){E.addFeature(e,t)},addCommand:function(e,t){E.addCommand(e,t)},addLeafExpression:function(e,t){E.addLeafExpression(e,t)},addIndirectExpression:function(e,t){E.addIndirectExpression(e,t)},evaluate:T.evaluate.bind(T),parse:T.parse.bind(T),processNode:T.processNode.bind(T),config:{attributes:"_, script, data-script",defaultTransition:"all 500ms ease-in",disableSelector:"[disable-scripting], [data-disable-scripting]",conversions:y}});return function(e){e.addCommand("settle",function(e,t,n){if(n.matchToken("settle")){if(e.commandBoundary(n.currentToken()))r=e.requireElement("implicitMeTarget",n);else var r=e.requireElement("expression",n);var o={type:"settleCmd",args:[r],op:function(e,n){t.nullCheck(n,r);var a=null,i=!1,u=new Promise(function(e){a=e});return n.addEventListener("transitionstart",function(){i=!0},{once:!0}),setTimeout(function(){i||a(t.findNext(o,e))},500),n.addEventListener("transitionend",function(){a(t.findNext(o,e))},{once:!0}),u},execute:function(e){return t.unifiedExec(this,e)}};return o}}),e.addCommand("add",function(e,t,n){if(n.matchToken("add")){var r=e.parseElement("classRef",n),o=null,a=null;if(null==r)null==(o=e.parseElement("attributeRef",n))&&null==(a=e.parseElement("styleLiteral",n))&&e.raiseParseError(n,"Expected either a class reference or attribute expression");else for(var i=[r];r=e.parseElement("classRef",n);)i.push(r);if(n.matchToken("to"))var u=e.requireElement("expression",n);else u=e.requireElement("implicitMeTarget",n);if(n.matchToken("when")){a&&e.raiseParseError(n,"Only class and properties are supported with a when clause");var l=e.requireElement("expression",n)}return i?{classRefs:i,to:u,args:[u,i],op:function(e,n,r){return t.nullCheck(n,u),t.forEach(r,function(r){t.implicitLoop(n,function(n){l?(e.result=n,t.evaluateNoPromise(l,e)?n instanceof Element&&n.classList.add(r.className):n instanceof Element&&n.classList.remove(r.className),e.result=null):n instanceof Element&&n.classList.add(r.className)})}),t.findNext(this,e)}}:o?{type:"addCmd",attributeRef:o,to:u,args:[u],op:function(e,n,r){return t.nullCheck(n,u),t.implicitLoop(n,function(n){l?(e.result=n,t.evaluateNoPromise(l,e)?n.setAttribute(o.name,o.value):n.removeAttribute(o.name),e.result=null):n.setAttribute(o.name,o.value)}),t.findNext(this,e)},execute:function(e){return t.unifiedExec(this,e)}}:{type:"addCmd",cssDeclaration:a,to:u,args:[u,a],op:function(e,n,r){return t.nullCheck(n,u),t.implicitLoop(n,function(e){e.style.cssText+=r}),t.findNext(this,e)},execute:function(e){return t.unifiedExec(this,e)}}}}),e.internals.parser.addGrammarElement("styleLiteral",function(e,t,n){if(n.matchOpToken("{")){for(var r=[""],o=[];n.hasMore();){if(n.matchOpToken("\\"))n.consumeToken();else{if(n.matchOpToken("}"))break;if(n.matchToken("$")){var a=n.matchOpToken("{"),i=e.parseElement("expression",n);a&&n.requireOpToken("}"),o.push(i),r.push("")}else{var u=n.consumeToken();r[r.length-1]+=n.source.substring(u.start,u.end)}}r[r.length-1]+=n.lastWhitespace()}return{type:"styleLiteral",args:[o],op:function(e,t){var n="";return r.forEach(function(e,r){n+=e,r in t&&(n+=t[r])}),n},evaluate:function(e){return t.unifiedEval(this,e)}}}}),e.addCommand("remove",function(e,t,n){if(n.matchToken("remove")){var r=e.parseElement("classRef",n),o=null,a=null;if(null==r)null==(o=e.parseElement("attributeRef",n))&&null==(a=e.parseElement("expression",n))&&e.raiseParseError(n,"Expected either a class reference, attribute expression or value expression");else for(var i=[r];r=e.parseElement("classRef",n);)i.push(r);if(n.matchToken("from"))var u=e.requireElement("expression",n);else u=e.requireElement("implicitMeTarget",n);return a?{elementExpr:a,from:u,args:[a],op:function(e,n){return t.nullCheck(n,a),t.implicitLoop(n,function(e){e.parentElement&&e.parentElement.removeChild(e)}),t.findNext(this,e)}}:{classRefs:i,attributeRef:o,elementExpr:a,from:u,args:[i,u],op:function(e,n,r){return t.nullCheck(r,u),n?t.forEach(n,function(e){t.implicitLoop(r,function(t){t.classList.remove(e.className)})}):t.implicitLoop(r,function(e){e.removeAttribute(o.name)}),t.findNext(this,e)}}}}),e.addCommand("toggle",function(e,t,n){if(n.matchToken("toggle")){if(n.matchAnyToken("the","my"),"STYLE_REF"===n.currentToken().type){var o=n.consumeToken().value.substr(1),a=!0,i=r(e,n,o);if(n.matchToken("of")){n.pushFollow("with");try{var u=e.requireElement("expression",n)}finally{n.popFollow()}}else u=e.requireElement("implicitMeTarget",n)}else if(n.matchToken("between")){var l=!0,s=e.parseElement("classRef",n);n.requireToken("and");var c=e.requireElement("classRef",n)}else{s=e.parseElement("classRef",n);var f=null;if(null==s)null==(f=e.parseElement("attributeRef",n))&&e.raiseParseError(n,"Expected either a class reference or attribute expression");else for(var m=[s];s=e.parseElement("classRef",n);)m.push(s)}if(!0!==a&&(u=n.matchToken("on")?e.requireElement("expression",n):e.requireElement("implicitMeTarget",n)),n.matchToken("for"))var p=e.requireElement("expression",n);else if(n.matchToken("until")){var d=e.requireElement("dotOrColonPath",n,"Expected event name");if(n.matchToken("from"))var v=e.requireElement("expression",n)}var h={classRef:s,classRef2:c,classRefs:m,attributeRef:f,on:u,time:p,evt:d,from:v,toggle:function(e,n,r,o){t.nullCheck(e,u),a?t.implicitLoop(e,function(e){i("toggle",e)}):l?t.implicitLoop(e,function(e){e.classList.contains(n.className)?(e.classList.remove(n.className),e.classList.add(r.className)):(e.classList.add(n.className),e.classList.remove(r.className))}):o?t.forEach(o,function(n){t.implicitLoop(e,function(e){e.classList.toggle(n.className)})}):t.forEach(e,function(e){e.hasAttribute(f.name)?e.removeAttribute(f.name):e.setAttribute(f.name,f.value)})},args:[u,p,d,v,s,c,m],op:function(e,n,r,o,a,i,u,l){return r?new Promise(function(o){h.toggle(n,i,u,l),setTimeout(function(){h.toggle(n,i,u,l),o(t.findNext(h,e))},r)}):o?new Promise(function(r){(a||e.me).addEventListener(o,function(){h.toggle(n,i,u,l),r(t.findNext(h,e))},{once:!0}),h.toggle(n,i,u,l)}):(this.toggle(n,i,u,l),t.findNext(h,e))}};return h}});var t={display:function(n,r,o){if(o)r.style.display=o;else if("toggle"===n)"none"===getComputedStyle(r).display?t.display("show",r,o):t.display("hide",r,o);else if("hide"===n){var a=e.internals.runtime.getInternalData(r);null==a.originalDisplay&&(a.originalDisplay=r.style.display),r.style.display="none"}else{var i=e.internals.runtime.getInternalData(r);i.originalDisplay&&"none"!==i.originalDisplay?r.style.display=i.originalDisplay:r.style.removeProperty("display")}},visibility:function(e,n,r){r?n.style.visibility=r:"toggle"===e?"hidden"===getComputedStyle(n).visibility?t.visibility("show",n,r):t.visibility("hide",n,r):n.style.visibility="hide"===e?"hidden":"visible"},opacity:function(e,n,r){r?n.style.opacity=r:"toggle"===e?"0"===getComputedStyle(n).opacity?t.opacity("show",n,r):t.opacity("hide",n,r):n.style.opacity="hide"===e?"0":"1"}},n=function(e,t,n){var r=n.currentToken();return"when"===r.value||"with"===r.value||e.commandBoundary(r)?e.parseElement("implicitMeTarget",n):e.parseElement("expression",n)},r=function(n,r,o){var a=e.config.defaultHideShowStrategy,i=t;e.config.hideShowStrategies&&(i=c(i,e.config.hideShowStrategies));var u=i[o=o||a||"display"];return null==u&&n.raiseParseError(r,"Unknown show/hide strategy : "+o),u};function o(t,n,r,o){if(null!=r)var a=t.resolveSymbol(r,n);else a=n;if(a instanceof Element||a instanceof HTMLDocument){for(;a.firstChild;)a.removeChild(a.firstChild);a.append(e.internals.runtime.convertValue(o,"Fragment"))}else{if(null==r)throw"Don't know how to put a value into "+typeof n;t.setSymbol(r,n,null,o)}}function a(e,t,n){var r;if(n.matchToken("the")||n.matchToken("element")||n.matchToken("elements")||"CLASS_REF"===n.currentToken().type||"ID_REF"===n.currentToken().type||n.currentToken().op&&"<"===n.currentToken().value){e.possessivesDisabled=!0;try{r=e.parseElement("expression",n)}finally{delete e.possessivesDisabled}n.matchOpToken("'")&&n.requireToken("s")}else if("IDENTIFIER"===n.currentToken().type&&"its"===n.currentToken().value){var o=n.matchToken("its");r={type:"pseudopossessiveIts",token:o,name:o.value,evaluate:function(e){return t.resolveSymbol("it",e)}}}else n.matchToken("my")||n.matchToken("me"),r=e.parseElement("implicitMeTarget",n);return r}e.addCommand("hide",function(e,t,o){if(o.matchToken("hide")){var a=n(e,0,o),i=null;o.matchToken("with")&&0===(i=o.requireTokenType("IDENTIFIER","STYLE_REF").value).indexOf("*")&&(i=i.substr(1));var u=r(e,o,i);return{target:a,args:[a],op:function(e,n){return t.nullCheck(n,a),t.implicitLoop(n,function(e){u("hide",e)}),t.findNext(this,e)}}}}),e.addCommand("show",function(e,t,o){if(o.matchToken("show")){var a=n(e,0,o),i=null;o.matchToken("with")&&0===(i=o.requireTokenType("IDENTIFIER","STYLE_REF").value).indexOf("*")&&(i=i.substr(1));var u=null;if(o.matchOpToken(":")){var l=o.consumeUntilWhitespace();o.matchTokenType("WHITESPACE"),u=l.map(function(e){return e.value}).join("")}if(o.matchToken("when"))var s=e.requireElement("expression",o);var c=r(e,o,i);return{target:a,when:s,args:[a],op:function(e,n){return t.nullCheck(n,a),t.implicitLoop(n,function(n){s?(e.result=n,t.evaluateNoPromise(s,e)?c("show",n,u):c("hide",n),e.result=null):c("show",n,u)}),t.findNext(this,e)}}}}),e.addCommand("take",function(e,t,n){if(n.matchToken("take")){var r=e.requireElement("classRef",n);if(n.matchToken("from"))var o=e.requireElement("expression",n);else o=r;if(n.matchToken("for"))var a=e.requireElement("expression",n);else a=e.requireElement("implicitMeTarget",n);return{classRef:r,from:o,forElt:a,args:[r,o,a],op:function(e,n,r,i){t.nullCheck(r,o),t.nullCheck(i,a);var u=n.className;return t.implicitLoop(r,function(e){e.classList.remove(u)}),t.implicitLoop(i,function(e){e.classList.add(u)}),t.findNext(this,e)}}}}),e.addCommand("put",function(e,t,n){if(n.matchToken("put")){var r=e.requireElement("expression",n),a=n.matchAnyToken("into","before","after");null==a&&n.matchToken("at")&&(n.matchToken("the"),a=n.matchAnyToken("start","end"),n.requireToken("of")),null==a&&e.raiseParseError(n,"Expected one of 'into', 'before', 'at start of', 'at end of', 'after'");var i=e.requireElement("expression",n),u=a.value,l=!1,s=!1,c=null,f=null;if("arrayIndex"===i.type&&"into"===u)l=!0,f=i.prop,c=i.root;else if(i.prop&&i.root&&"into"===u)f=i.prop.value,c=i.root;else if("symbol"===i.type&&"into"===u)s=!0,f=i.name;else if("attributeRef"===i.type&&"into"===u){var m=!0;f=i.name,c=e.requireElement("implicitMeTarget",n)}else if("styleRef"===i.type&&"into"===u){var p=!0;f=i.name,c=e.requireElement("implicitMeTarget",n)}else i.attribute&&"into"===u?(m="attributeRef"===i.attribute.type,p="styleRef"===i.attribute.type,f=i.attribute.name,c=i.root):c=i;var d={target:i,operation:u,symbolWrite:s,value:r,args:[c,f,r],op:function(e,n,r,a){if(s)o(t,e,r,a);else if(t.nullCheck(n,c),"into"===u)m?t.implicitLoop(n,function(e){e.setAttribute(r,a)}):p?t.implicitLoop(n,function(e){e.style[r]=a}):l?n[r]=a:t.implicitLoop(n,function(e){o(t,e,r,a)});else{var i="before"===u?Element.prototype.before:"after"===u?Element.prototype.after:"start"===u?Element.prototype.prepend:Element.prototype.append;t.implicitLoop(n,function(e){i.call(e,a instanceof Node?a:t.convertValue(a,"Fragment"))})}return t.findNext(this,e)}};return d}}),e.addCommand("transition",function(t,n,r){if(r.matchToken("transition")){for(var o=a(t,n,r),i=[],u=[],l=[],s=r.currentToken();!t.commandBoundary(s)&&"over"!==s.value&&"using"!==s.value;)"STYLE_REF"===r.currentToken().type?function(){var e=r.consumeToken().value.substr(1);i.push({type:"styleRefValue",evaluate:function(){return e}})}():i.push(t.requireElement("stringLike",r)),r.matchToken("from")?u.push(t.requireElement("expression",r)):u.push(null),r.requireToken("to"),r.matchToken("initial")?l.push({type:"initial_literal",evaluate:function(){return"initial"}}):l.push(t.requireElement("expression",r)),s=r.currentToken();if(r.matchToken("over"))var c=t.requireElement("expression",r);else if(r.matchToken("using"))var f=t.requireElement("expression",r);var m={to:l,args:[o,i,u,l,f,c],op:function(t,r,a,i,u,l,s){n.nullCheck(r,o);var c=[];return n.implicitLoop(r,function(t){var r=new Promise(function(r,o){var c=t.style.transition;t.style.transition=s?"all "+s+"ms ease-in":l||e.config.defaultTransition;for(var f=n.getInternalData(t),m=getComputedStyle(t),p={},d=0;d<m.length;d++){var v=m[d];p[v]=m[v]}for(f.initalStyles||(f.initalStyles=p),d=0;d<a.length;d++){var h=a[d],E=i[d];t.style[h]="computed"===E||null==E?p[h]:E}var y=!1,T=!1;t.addEventListener("transitionend",function(){T||(t.style.transition=c,T=!0,r())},{once:!0}),t.addEventListener("transitionstart",function(){y=!0},{once:!0}),setTimeout(function(){T||y||(t.style.transition=c,T=!0,r())},100),setTimeout(function(){for(var e=0;e<a.length;e++){var n=a[e],r=u[e];t.style[n]="initial"===r?f.initalStyles[n]:r}},0)});c.push(r)}),Promise.all(c).then(function(){return n.findNext(m,t)})}};return m}}),e.addCommand("measure",function(e,t,n){if(n.matchToken("measure")){var r=a(e,t,n),o=[];if(!e.commandBoundary(n.currentToken()))do{o.push(n.matchTokenType("IDENTIFIER").value)}while(n.matchOpToken(","));return{properties:o,args:[r],op:function(e,n){t.nullCheck(n,r),0 in n&&(n=n[0]);var a=n.getBoundingClientRect(),i={top:n.scrollTop,left:n.scrollLeft,topMax:n.scrollTopMax,leftMax:n.scrollLeftMax,height:n.scrollHeight,width:n.scrollWidth};return e.result={x:a.x,y:a.y,left:a.left,top:a.top,right:a.right,bottom:a.bottom,width:a.width,height:a.height,bounds:a,scrollLeft:i.left,scrollTop:i.top,scrollLeftMax:i.leftMax,scrollTopMax:i.topMax,scrollWidth:i.width,scrollHeight:i.height,scroll:i},t.forEach(o,function(t){if(!(t in e.result))throw"No such measurement as "+t;e[t]=e.result[t]}),t.findNext(this,e)}}}}),e.addLeafExpression("closestExpr",function(e,t,n){if(n.matchToken("closest")){if(n.matchToken("parent"))var r=!0;var o=null;if("ATTRIBUTE_REF"===n.currentToken().type){var a=e.requireElement("attributeRefAccess",n,null);o="["+a.attribute.name+"]"}if(null==o){var i=e.requireElement("expression",n);null==i.css?e.raiseParseError(n,"Expected a CSS expression"):o=i.css}if(n.matchToken("to"))var u=e.parseElement("expression",n);else u=e.parseElement("implicitMeTarget",n);var l={type:"closestExpr",parentSearch:r,expr:i,css:o,to:u,args:[u],op:function(e,n){if(null==n)return null;var a=[];return t.implicitLoop(n,function(e){a.push(r?e.parentElement?e.parentElement.closest(o):null:e.closest(o))}),t.shouldAutoIterate(n)?a:a[0]},evaluate:function(e){return t.unifiedEval(this,e)}};return a?(a.root=l,a.args=[l],a):l}}),e.addCommand("go",function(e,t,n){if(n.matchToken("go")){if(n.matchToken("back"))var r=!0;else if(n.matchToken("to"),n.matchToken("url")){var o=e.requireElement("stringLike",n),a=!0;if(n.matchToken("in")){n.requireToken("new"),n.requireToken("window");var i=!0}}else{n.matchToken("the");var u=n.matchAnyToken("top","middle","bottom"),l=n.matchAnyToken("left","center","right");(u||l)&&n.requireToken("of"),o=e.requireElement("unaryExpression",n);var s=n.matchAnyOpToken("+","-");if(s){n.pushFollow("px");try{var c=e.requireElement("expression",n)}finally{n.popFollow()}}n.matchToken("px");var f=n.matchAnyToken("smoothly","instantly"),m={};u&&("top"===u.value?m.block="start":"bottom"===u.value?m.block="end":"middle"===u.value&&(m.block="center")),l&&("left"===l.value?m.inline="start":"center"===l.value?m.inline="center":"right"===l.value&&(m.inline="end")),f&&("smoothly"===f.value?m.behavior="smooth":"instantly"===f.value&&(m.behavior="instant"))}var p={target:o,args:[o,c],op:function(e,n,o){return r?window.history.back():a?n&&(i?window.open(n):window.location.href=n):t.implicitLoop(n,function(e){if(e===window&&(e=document.body),s){var t=e.getBoundingClientRect(),n=document.createElement("div");if("-"===s.value)var r=-o;else r=o;n.style.position="absolute",n.style.top=t.x+r+"px",n.style.left=t.y+r+"px",n.style.height=t.height+2*r+"px",n.style.width=t.width+2*r+"px",n.style.zIndex=""+Number.MIN_SAFE_INTEGER,n.style.opacity="0",document.body.appendChild(n),setTimeout(function(){document.body.removeChild(n)},100),e=n}e.scrollIntoView(m)}),t.findNext(p,e)}};return p}}),e.config.conversions.dynamicResolvers.push(function(t,n){if("Values"===t||0===t.indexOf("Values:")){var r=t.split(":")[1],o={};if((0,e.internals.runtime.implicitLoop)(n,function(e){var t=i(e);void 0===t?null!=e.querySelectorAll&&e.querySelectorAll("input,select,textarea").forEach(a):o[t.name]=t.value}),r){if("JSON"===r)return JSON.stringify(o);if("Form"===r)return new URLSearchParams(o).toString();throw"Unknown conversion: "+r}return o}function a(e){var t=i(e);null!=t&&(null!=o[t.name]?Array.isArray(o[t.name])&&Array.isArray(t.value)&&(o[t.name]=[].concat(o[t.name],t.value)):o[t.name]=t.value)}function i(e){try{var t={name:e.name,value:e.value};if(null==t.name||null==t.value)return;if("radio"==e.type&&0==e.checked)return;if("checkbox"==e.type&&(0==e.checked?t.value=void 0:"string"==typeof t.value&&(t.value=[t.value])),"select-multiple"==e.type){var n=e.querySelectorAll("option[selected]");t.value=[];for(var r=0;r<n.length;r++)t.value.push(n[r].value)}return t}catch(e){return}}}),e.config.conversions.HTML=function(e){return function e(t){if(t instanceof Array)return t.map(function(t){return e(t)}).join("");if(t instanceof HTMLElement)return t.outerHTML;if(t instanceof NodeList){for(var n="",r=0;r<t.length;r++){var o=t[r];o instanceof HTMLElement&&(n+=o.outerHTML)}return n}return t.toString?t.toString():""}(e)},e.config.conversions.Fragment=function(t){var n=document.createDocumentFragment();return e.internals.runtime.implicitLoop(t,function(e){if(e instanceof Node)n.append(e);else{var t=document.createElement("template");t.innerHTML=e,n.append(t.content)}}),n}}(A),A}); +//# sourceMappingURL=_hyperscript_web.min.js.map diff --git a/common/static/lib/js/multiple-select.min.js b/common/static/lib/js/multiple-select.min.js deleted file mode 100644 index 7bffe99d..00000000 --- a/common/static/lib/js/multiple-select.min.js +++ /dev/null @@ -1,10 +0,0 @@ -/** - * multiple-select - Multiple select is a jQuery plugin to select multiple elements with checkboxes :). - * - * @version v1.5.2 - * @homepage http://multiple-select.wenzhixin.net.cn - * @author wenzhixin <wenzhixin2010@gmail.com> (http://wenzhixin.net.cn/) - * @license MIT - */ - -!function(t,e){"object"==typeof exports&&"undefined"!=typeof module?e(require("jquery")):"function"==typeof define&&define.amd?define(["jquery"],e):e((t=t||self).jQuery)}(this,(function(t){"use strict";function e(t){return(e="function"==typeof Symbol&&"symbol"==typeof Symbol.iterator?function(t){return typeof t}:function(t){return t&&"function"==typeof Symbol&&t.constructor===Symbol&&t!==Symbol.prototype?"symbol":typeof t})(t)}function n(t,e){if(!(t instanceof e))throw new TypeError("Cannot call a class as a function")}function i(t,e){for(var n=0;n<e.length;n++){var i=e[n];i.enumerable=i.enumerable||!1,i.configurable=!0,"value"in i&&(i.writable=!0),Object.defineProperty(t,i.key,i)}}function r(t,e,n){return e&&i(t.prototype,e),n&&i(t,n),t}function u(t,e){return function(t){if(Array.isArray(t))return t}(t)||function(t,e){if(!(Symbol.iterator in Object(t)||"[object Arguments]"===Object.prototype.toString.call(t)))return;var n=[],i=!0,r=!1,u=void 0;try{for(var o,s=t[Symbol.iterator]();!(i=(o=s.next()).done)&&(n.push(o.value),!e||n.length!==e);i=!0);}catch(t){r=!0,u=t}finally{try{i||null==s.return||s.return()}finally{if(r)throw u}}return n}(t,e)||function(){throw new TypeError("Invalid attempt to destructure non-iterable instance")}()}function o(t){return function(t){if(Array.isArray(t)){for(var e=0,n=new Array(t.length);e<t.length;e++)n[e]=t[e];return n}}(t)||function(t){if(Symbol.iterator in Object(t)||"[object Arguments]"===Object.prototype.toString.call(t))return Array.from(t)}(t)||function(){throw new TypeError("Invalid attempt to spread non-iterable instance")}()}t=t&&t.hasOwnProperty("default")?t.default:t;var s="undefined"!=typeof globalThis?globalThis:"undefined"!=typeof window?window:"undefined"!=typeof global?global:"undefined"!=typeof self?self:{};function l(t,e){return t(e={exports:{}},e.exports),e.exports}var a,c,h,f="object",p=function(t){return t&&t.Math==Math&&t},d=p(typeof globalThis==f&&globalThis)||p(typeof window==f&&window)||p(typeof self==f&&self)||p(typeof s==f&&s)||Function("return this")(),v=function(t){try{return!!t()}catch(t){return!0}},g=!v((function(){return 7!=Object.defineProperty({},"a",{get:function(){return 7}}).a})),y={}.propertyIsEnumerable,E=Object.getOwnPropertyDescriptor,b={f:E&&!y.call({1:2},1)?function(t){var e=E(this,t);return!!e&&e.enumerable}:y},m=function(t,e){return{enumerable:!(1&t),configurable:!(2&t),writable:!(4&t),value:e}},A={}.toString,F=function(t){return A.call(t).slice(8,-1)},S="".split,C=v((function(){return!Object("z").propertyIsEnumerable(0)}))?function(t){return"String"==F(t)?S.call(t,""):Object(t)}:Object,k=function(t){if(null==t)throw TypeError("Can't call method on "+t);return t},D=function(t){return C(k(t))},O=function(t){return"object"==typeof t?null!==t:"function"==typeof t},w=function(t,e){if(!O(t))return t;var n,i;if(e&&"function"==typeof(n=t.toString)&&!O(i=n.call(t)))return i;if("function"==typeof(n=t.valueOf)&&!O(i=n.call(t)))return i;if(!e&&"function"==typeof(n=t.toString)&&!O(i=n.call(t)))return i;throw TypeError("Can't convert object to primitive value")},x={}.hasOwnProperty,$=function(t,e){return x.call(t,e)},B=d.document,j=O(B)&&O(B.createElement),T=function(t){return j?B.createElement(t):{}},L=!g&&!v((function(){return 7!=Object.defineProperty(T("div"),"a",{get:function(){return 7}}).a})),_=Object.getOwnPropertyDescriptor,I={f:g?_:function(t,e){if(t=D(t),e=w(e,!0),L)try{return _(t,e)}catch(t){}if($(t,e))return m(!b.f.call(t,e),t[e])}},R=function(t){if(!O(t))throw TypeError(String(t)+" is not an object");return t},M=Object.defineProperty,P={f:g?M:function(t,e,n){if(R(t),e=w(e,!0),R(n),L)try{return M(t,e,n)}catch(t){}if("get"in n||"set"in n)throw TypeError("Accessors not supported");return"value"in n&&(t[e]=n.value),t}},N=g?function(t,e,n){return P.f(t,e,m(1,n))}:function(t,e,n){return t[e]=n,t},H=function(t,e){try{N(d,t,e)}catch(n){d[t]=e}return e},G=l((function(t){var e=d["__core-js_shared__"]||H("__core-js_shared__",{});(t.exports=function(t,n){return e[t]||(e[t]=void 0!==n?n:{})})("versions",[]).push({version:"3.2.1",mode:"global",copyright:"© 2019 Denis Pushkarev (zloirock.ru)"})})),U=G("native-function-to-string",Function.toString),W=d.WeakMap,V="function"==typeof W&&/native code/.test(U.call(W)),K=0,z=Math.random(),q=function(t){return"Symbol("+String(void 0===t?"":t)+")_"+(++K+z).toString(36)},Y=G("keys"),J=function(t){return Y[t]||(Y[t]=q(t))},Q={},Z=d.WeakMap;if(V){var X=new Z,tt=X.get,et=X.has,nt=X.set;a=function(t,e){return nt.call(X,t,e),e},c=function(t){return tt.call(X,t)||{}},h=function(t){return et.call(X,t)}}else{var it=J("state");Q[it]=!0,a=function(t,e){return N(t,it,e),e},c=function(t){return $(t,it)?t[it]:{}},h=function(t){return $(t,it)}}var rt={set:a,get:c,has:h,enforce:function(t){return h(t)?c(t):a(t,{})},getterFor:function(t){return function(e){var n;if(!O(e)||(n=c(e)).type!==t)throw TypeError("Incompatible receiver, "+t+" required");return n}}},ut=l((function(t){var e=rt.get,n=rt.enforce,i=String(U).split("toString");G("inspectSource",(function(t){return U.call(t)})),(t.exports=function(t,e,r,u){var o=!!u&&!!u.unsafe,s=!!u&&!!u.enumerable,l=!!u&&!!u.noTargetGet;"function"==typeof r&&("string"!=typeof e||$(r,"name")||N(r,"name",e),n(r).source=i.join("string"==typeof e?e:"")),t!==d?(o?!l&&t[e]&&(s=!0):delete t[e],s?t[e]=r:N(t,e,r)):s?t[e]=r:H(e,r)})(Function.prototype,"toString",(function(){return"function"==typeof this&&e(this).source||U.call(this)}))})),ot=d,st=function(t){return"function"==typeof t?t:void 0},lt=function(t,e){return arguments.length<2?st(ot[t])||st(d[t]):ot[t]&&ot[t][e]||d[t]&&d[t][e]},at=Math.ceil,ct=Math.floor,ht=function(t){return isNaN(t=+t)?0:(t>0?ct:at)(t)},ft=Math.min,pt=function(t){return t>0?ft(ht(t),9007199254740991):0},dt=Math.max,vt=Math.min,gt=function(t,e){var n=ht(t);return n<0?dt(n+e,0):vt(n,e)},yt=function(t){return function(e,n,i){var r,u=D(e),o=pt(u.length),s=gt(i,o);if(t&&n!=n){for(;o>s;)if((r=u[s++])!=r)return!0}else for(;o>s;s++)if((t||s in u)&&u[s]===n)return t||s||0;return!t&&-1}},Et={includes:yt(!0),indexOf:yt(!1)},bt=Et.indexOf,mt=function(t,e){var n,i=D(t),r=0,u=[];for(n in i)!$(Q,n)&&$(i,n)&&u.push(n);for(;e.length>r;)$(i,n=e[r++])&&(~bt(u,n)||u.push(n));return u},At=["constructor","hasOwnProperty","isPrototypeOf","propertyIsEnumerable","toLocaleString","toString","valueOf"],Ft=At.concat("length","prototype"),St={f:Object.getOwnPropertyNames||function(t){return mt(t,Ft)}},Ct={f:Object.getOwnPropertySymbols},kt=lt("Reflect","ownKeys")||function(t){var e=St.f(R(t)),n=Ct.f;return n?e.concat(n(t)):e},Dt=function(t,e){for(var n=kt(e),i=P.f,r=I.f,u=0;u<n.length;u++){var o=n[u];$(t,o)||i(t,o,r(e,o))}},Ot=/#|\.prototype\./,wt=function(t,e){var n=$t[xt(t)];return n==jt||n!=Bt&&("function"==typeof e?v(e):!!e)},xt=wt.normalize=function(t){return String(t).replace(Ot,".").toLowerCase()},$t=wt.data={},Bt=wt.NATIVE="N",jt=wt.POLYFILL="P",Tt=wt,Lt=I.f,_t=function(t,e){var n,i,r,u,o,s=t.target,l=t.global,a=t.stat;if(n=l?d:a?d[s]||H(s,{}):(d[s]||{}).prototype)for(i in e){if(u=e[i],r=t.noTargetGet?(o=Lt(n,i))&&o.value:n[i],!Tt(l?i:s+(a?".":"#")+i,t.forced)&&void 0!==r){if(typeof u==typeof r)continue;Dt(u,r)}(t.sham||r&&r.sham)&&N(u,"sham",!0),ut(n,i,u,t)}},It=!!Object.getOwnPropertySymbols&&!v((function(){return!String(Symbol())})),Rt=d.Symbol,Mt=G("wks"),Pt=function(t){return Mt[t]||(Mt[t]=It&&Rt[t]||(It?Rt:q)("Symbol."+t))},Nt=Object.keys||function(t){return mt(t,At)},Ht=g?Object.defineProperties:function(t,e){R(t);for(var n,i=Nt(e),r=i.length,u=0;r>u;)P.f(t,n=i[u++],e[n]);return t},Gt=lt("document","documentElement"),Ut=J("IE_PROTO"),Wt=function(){},Vt=function(){var t,e=T("iframe"),n=At.length;for(e.style.display="none",Gt.appendChild(e),e.src=String("javascript:"),(t=e.contentWindow.document).open(),t.write("<script>document.F=Object<\/script>"),t.close(),Vt=t.F;n--;)delete Vt.prototype[At[n]];return Vt()},Kt=Object.create||function(t,e){var n;return null!==t?(Wt.prototype=R(t),n=new Wt,Wt.prototype=null,n[Ut]=t):n=Vt(),void 0===e?n:Ht(n,e)};Q[Ut]=!0;var zt=Pt("unscopables"),qt=Array.prototype;null==qt[zt]&&N(qt,zt,Kt(null));var Yt=function(t){qt[zt][t]=!0},Jt=Et.includes;_t({target:"Array",proto:!0},{includes:function(t){return Jt(this,t,arguments.length>1?arguments[1]:void 0)}}),Yt("includes");var Qt=function(t){return Object(k(t))},Zt=Object.assign,Xt=!Zt||v((function(){var t={},e={},n=Symbol();return t[n]=7,"abcdefghijklmnopqrst".split("").forEach((function(t){e[t]=t})),7!=Zt({},t)[n]||"abcdefghijklmnopqrst"!=Nt(Zt({},e)).join("")}))?function(t,e){for(var n=Qt(t),i=arguments.length,r=1,u=Ct.f,o=b.f;i>r;)for(var s,l=C(arguments[r++]),a=u?Nt(l).concat(u(l)):Nt(l),c=a.length,h=0;c>h;)s=a[h++],g&&!o.call(l,s)||(n[s]=l[s]);return n}:Zt;_t({target:"Object",stat:!0,forced:Object.assign!==Xt},{assign:Xt});var te=Pt("match"),ee=function(t){var e;return O(t)&&(void 0!==(e=t[te])?!!e:"RegExp"==F(t))},ne=function(t){if(ee(t))throw TypeError("The method doesn't accept regular expressions");return t},ie=Pt("match");_t({target:"String",proto:!0,forced:!function(t){var e=/./;try{"/./"[t](e)}catch(n){try{return e[ie]=!1,"/./"[t](e)}catch(t){}}return!1}("includes")},{includes:function(t){return!!~String(k(this)).indexOf(ne(t),arguments.length>1?arguments[1]:void 0)}});var re="\t\n\v\f\r \u2028\u2029\ufeff",ue="["+re+"]",oe=RegExp("^"+ue+ue+"*"),se=RegExp(ue+ue+"*$"),le=function(t){return function(e){var n=String(k(e));return 1&t&&(n=n.replace(oe,"")),2&t&&(n=n.replace(se,"")),n}},ae={start:le(1),end:le(2),trim:le(3)},ce=ae.trim;_t({target:"String",proto:!0,forced:function(t){return v((function(){return!!re[t]()||" "!=" "[t]()||re[t].name!==t}))}("trim")},{trim:function(){return ce(this)}});var he={name:"",placeholder:"",data:void 0,locale:void 0,selectAll:!0,single:void 0,singleRadio:!1,multiple:!1,hideOptgroupCheckboxes:!1,multipleWidth:80,width:void 0,dropWidth:void 0,maxHeight:250,maxHeightUnit:"px",position:"bottom",displayValues:!1,displayTitle:!1,displayDelimiter:", ",minimumCountSelected:3,ellipsis:!1,isOpen:!1,keepOpen:!1,openOnHover:!1,container:null,filter:!1,filterGroup:!1,filterPlaceholder:"",filterAcceptOnEnter:!1,filterByDataLength:void 0,customFilter:function(t,e){return t.includes(e)},showClear:!1,animate:void 0,styler:function(){return!1},textTemplate:function(t){return t[0].innerHTML.trim()},labelTemplate:function(t){return t[0].getAttribute("label")},onOpen:function(){return!1},onClose:function(){return!1},onCheckAll:function(){return!1},onUncheckAll:function(){return!1},onFocus:function(){return!1},onBlur:function(){return!1},onOptgroupClick:function(){return!1},onClick:function(){return!1},onFilter:function(){return!1},onClear:function(){return!1},onAfterCreate:function(){return!1}},fe={formatSelectAll:function(){return"[Select all]"},formatAllSelected:function(){return"All selected"},formatCountSelected:function(t,e){return t+" of "+e+" selected"},formatNoMatchesFound:function(){return"No matches found"}};Object.assign(he,fe);var pe={VERSION:"1.5.2",BLOCK_ROWS:50,CLUSTER_BLOCKS:4,DEFAULTS:he,METHODS:["getOptions","refreshOptions","getSelects","setSelects","enable","disable","open","close","check","uncheck","checkAll","uncheckAll","checkInvert","focus","blur","refresh","destroy"],LOCALES:{en:fe,"en-US":fe}},de=Array.isArray||function(t){return"Array"==F(t)},ve=St.f,ge={}.toString,ye="object"==typeof window&&window&&Object.getOwnPropertyNames?Object.getOwnPropertyNames(window):[],Ee={f:function(t){return ye&&"[object Window]"==ge.call(t)?function(t){try{return ve(t)}catch(t){return ye.slice()}}(t):ve(D(t))}},be={f:Pt},me=P.f,Ae=function(t){var e=ot.Symbol||(ot.Symbol={});$(e,t)||me(e,t,{value:be.f(t)})},Fe=P.f,Se=Pt("toStringTag"),Ce=function(t,e,n){t&&!$(t=n?t:t.prototype,Se)&&Fe(t,Se,{configurable:!0,value:e})},ke=function(t){if("function"!=typeof t)throw TypeError(String(t)+" is not a function");return t},De=Pt("species"),Oe=function(t,e){var n;return de(t)&&("function"!=typeof(n=t.constructor)||n!==Array&&!de(n.prototype)?O(n)&&null===(n=n[De])&&(n=void 0):n=void 0),new(void 0===n?Array:n)(0===e?0:e)},we=[].push,xe=function(t){var e=1==t,n=2==t,i=3==t,r=4==t,u=6==t,o=5==t||u;return function(s,l,a,c){for(var h,f,p=Qt(s),d=C(p),v=function(t,e,n){if(ke(t),void 0===e)return t;switch(n){case 0:return function(){return t.call(e)};case 1:return function(n){return t.call(e,n)};case 2:return function(n,i){return t.call(e,n,i)};case 3:return function(n,i,r){return t.call(e,n,i,r)}}return function(){return t.apply(e,arguments)}}(l,a,3),g=pt(d.length),y=0,E=c||Oe,b=e?E(s,g):n?E(s,0):void 0;g>y;y++)if((o||y in d)&&(f=v(h=d[y],y,p),t))if(e)b[y]=f;else if(f)switch(t){case 3:return!0;case 5:return h;case 6:return y;case 2:we.call(b,h)}else if(r)return!1;return u?-1:i||r?r:b}},$e={forEach:xe(0),map:xe(1),filter:xe(2),some:xe(3),every:xe(4),find:xe(5),findIndex:xe(6)},Be=$e.forEach,je=J("hidden"),Te=Pt("toPrimitive"),Le=rt.set,_e=rt.getterFor("Symbol"),Ie=Object.prototype,Re=d.Symbol,Me=d.JSON,Pe=Me&&Me.stringify,Ne=I.f,He=P.f,Ge=Ee.f,Ue=b.f,We=G("symbols"),Ve=G("op-symbols"),Ke=G("string-to-symbol-registry"),ze=G("symbol-to-string-registry"),qe=G("wks"),Ye=d.QObject,Je=!Ye||!Ye.prototype||!Ye.prototype.findChild,Qe=g&&v((function(){return 7!=Kt(He({},"a",{get:function(){return He(this,"a",{value:7}).a}})).a}))?function(t,e,n){var i=Ne(Ie,e);i&&delete Ie[e],He(t,e,n),i&&t!==Ie&&He(Ie,e,i)}:He,Ze=function(t,e){var n=We[t]=Kt(Re.prototype);return Le(n,{type:"Symbol",tag:t,description:e}),g||(n.description=e),n},Xe=It&&"symbol"==typeof Re.iterator?function(t){return"symbol"==typeof t}:function(t){return Object(t)instanceof Re},tn=function(t,e,n){t===Ie&&tn(Ve,e,n),R(t);var i=w(e,!0);return R(n),$(We,i)?(n.enumerable?($(t,je)&&t[je][i]&&(t[je][i]=!1),n=Kt(n,{enumerable:m(0,!1)})):($(t,je)||He(t,je,m(1,{})),t[je][i]=!0),Qe(t,i,n)):He(t,i,n)},en=function(t,e){R(t);var n=D(e),i=Nt(n).concat(on(n));return Be(i,(function(e){g&&!nn.call(n,e)||tn(t,e,n[e])})),t},nn=function(t){var e=w(t,!0),n=Ue.call(this,e);return!(this===Ie&&$(We,e)&&!$(Ve,e))&&(!(n||!$(this,e)||!$(We,e)||$(this,je)&&this[je][e])||n)},rn=function(t,e){var n=D(t),i=w(e,!0);if(n!==Ie||!$(We,i)||$(Ve,i)){var r=Ne(n,i);return!r||!$(We,i)||$(n,je)&&n[je][i]||(r.enumerable=!0),r}},un=function(t){var e=Ge(D(t)),n=[];return Be(e,(function(t){$(We,t)||$(Q,t)||n.push(t)})),n},on=function(t){var e=t===Ie,n=Ge(e?Ve:D(t)),i=[];return Be(n,(function(t){!$(We,t)||e&&!$(Ie,t)||i.push(We[t])})),i};It||(ut((Re=function(){if(this instanceof Re)throw TypeError("Symbol is not a constructor");var t=arguments.length&&void 0!==arguments[0]?String(arguments[0]):void 0,e=q(t),n=function(t){this===Ie&&n.call(Ve,t),$(this,je)&&$(this[je],e)&&(this[je][e]=!1),Qe(this,e,m(1,t))};return g&&Je&&Qe(Ie,e,{configurable:!0,set:n}),Ze(e,t)}).prototype,"toString",(function(){return _e(this).tag})),b.f=nn,P.f=tn,I.f=rn,St.f=Ee.f=un,Ct.f=on,g&&(He(Re.prototype,"description",{configurable:!0,get:function(){return _e(this).description}}),ut(Ie,"propertyIsEnumerable",nn,{unsafe:!0})),be.f=function(t){return Ze(Pt(t),t)}),_t({global:!0,wrap:!0,forced:!It,sham:!It},{Symbol:Re}),Be(Nt(qe),(function(t){Ae(t)})),_t({target:"Symbol",stat:!0,forced:!It},{for:function(t){var e=String(t);if($(Ke,e))return Ke[e];var n=Re(e);return Ke[e]=n,ze[n]=e,n},keyFor:function(t){if(!Xe(t))throw TypeError(t+" is not a symbol");if($(ze,t))return ze[t]},useSetter:function(){Je=!0},useSimple:function(){Je=!1}}),_t({target:"Object",stat:!0,forced:!It,sham:!g},{create:function(t,e){return void 0===e?Kt(t):en(Kt(t),e)},defineProperty:tn,defineProperties:en,getOwnPropertyDescriptor:rn}),_t({target:"Object",stat:!0,forced:!It},{getOwnPropertyNames:un,getOwnPropertySymbols:on}),_t({target:"Object",stat:!0,forced:v((function(){Ct.f(1)}))},{getOwnPropertySymbols:function(t){return Ct.f(Qt(t))}}),Me&&_t({target:"JSON",stat:!0,forced:!It||v((function(){var t=Re();return"[null]"!=Pe([t])||"{}"!=Pe({a:t})||"{}"!=Pe(Object(t))}))},{stringify:function(t){for(var e,n,i=[t],r=1;arguments.length>r;)i.push(arguments[r++]);if(n=e=i[1],(O(e)||void 0!==t)&&!Xe(t))return de(e)||(e=function(t,e){if("function"==typeof n&&(e=n.call(this,t,e)),!Xe(e))return e}),i[1]=e,Pe.apply(Me,i)}}),Re.prototype[Te]||N(Re.prototype,Te,Re.prototype.valueOf),Ce(Re,"Symbol"),Q[je]=!0;var sn=P.f,ln=d.Symbol;if(g&&"function"==typeof ln&&(!("description"in ln.prototype)||void 0!==ln().description)){var an={},cn=function(){var t=arguments.length<1||void 0===arguments[0]?void 0:String(arguments[0]),e=this instanceof cn?new ln(t):void 0===t?ln():ln(t);return""===t&&(an[e]=!0),e};Dt(cn,ln);var hn=cn.prototype=ln.prototype;hn.constructor=cn;var fn=hn.toString,pn="Symbol(test)"==String(ln("test")),dn=/^Symbol\((.*)\)[^)]+$/;sn(hn,"description",{configurable:!0,get:function(){var t=O(this)?this.valueOf():this,e=fn.call(t);if($(an,t))return"";var n=pn?e.slice(7,-1):e.replace(dn,"$1");return""===n?void 0:n}}),_t({global:!0,forced:!0},{Symbol:cn})}Ae("iterator");var vn=function(t,e,n){var i=w(e);i in t?P.f(t,i,m(0,n)):t[i]=n},gn=Pt("species"),yn=function(t){return!v((function(){var e=[];return(e.constructor={})[gn]=function(){return{foo:1}},1!==e[t](Boolean).foo}))},En=Pt("isConcatSpreadable"),bn=!v((function(){var t=[];return t[En]=!1,t.concat()[0]!==t})),mn=yn("concat"),An=function(t){if(!O(t))return!1;var e=t[En];return void 0!==e?!!e:de(t)};_t({target:"Array",proto:!0,forced:!bn||!mn},{concat:function(t){var e,n,i,r,u,o=Qt(this),s=Oe(o,0),l=0;for(e=-1,i=arguments.length;e<i;e++)if(u=-1===e?o:arguments[e],An(u)){if(l+(r=pt(u.length))>9007199254740991)throw TypeError("Maximum allowed index exceeded");for(n=0;n<r;n++,l++)n in u&&vn(s,l,u[n])}else{if(l>=9007199254740991)throw TypeError("Maximum allowed index exceeded");vn(s,l++,u)}return s.length=l,s}});var Fn=$e.filter;_t({target:"Array",proto:!0,forced:!yn("filter")},{filter:function(t){return Fn(this,t,arguments.length>1?arguments[1]:void 0)}});var Sn=$e.find,Cn=!0;"find"in[]&&Array(1).find((function(){Cn=!1})),_t({target:"Array",proto:!0,forced:Cn},{find:function(t){return Sn(this,t,arguments.length>1?arguments[1]:void 0)}}),Yt("find");var kn,Dn,On,wn=!v((function(){function t(){}return t.prototype.constructor=null,Object.getPrototypeOf(new t)!==t.prototype})),xn=J("IE_PROTO"),$n=Object.prototype,Bn=wn?Object.getPrototypeOf:function(t){return t=Qt(t),$(t,xn)?t[xn]:"function"==typeof t.constructor&&t instanceof t.constructor?t.constructor.prototype:t instanceof Object?$n:null},jn=Pt("iterator"),Tn=!1;[].keys&&("next"in(On=[].keys())?(Dn=Bn(Bn(On)))!==Object.prototype&&(kn=Dn):Tn=!0),null==kn&&(kn={}),$(kn,jn)||N(kn,jn,(function(){return this}));var Ln={IteratorPrototype:kn,BUGGY_SAFARI_ITERATORS:Tn},_n=Ln.IteratorPrototype,In=Object.setPrototypeOf||("__proto__"in{}?function(){var t,e=!1,n={};try{(t=Object.getOwnPropertyDescriptor(Object.prototype,"__proto__").set).call(n,[]),e=n instanceof Array}catch(t){}return function(n,i){return R(n),function(t){if(!O(t)&&null!==t)throw TypeError("Can't set "+String(t)+" as a prototype")}(i),e?t.call(n,i):n.__proto__=i,n}}():void 0),Rn=Ln.IteratorPrototype,Mn=Ln.BUGGY_SAFARI_ITERATORS,Pn=Pt("iterator"),Nn=function(){return this},Hn=function(t,e,n,i,r,u,o){!function(t,e,n){var i=e+" Iterator";t.prototype=Kt(_n,{next:m(1,n)}),Ce(t,i,!1)}(n,e,i);var s,l,a,c=function(t){if(t===r&&v)return v;if(!Mn&&t in p)return p[t];switch(t){case"keys":case"values":case"entries":return function(){return new n(this,t)}}return function(){return new n(this)}},h=e+" Iterator",f=!1,p=t.prototype,d=p[Pn]||p["@@iterator"]||r&&p[r],v=!Mn&&d||c(r),g="Array"==e&&p.entries||d;if(g&&(s=Bn(g.call(new t)),Rn!==Object.prototype&&s.next&&(Bn(s)!==Rn&&(In?In(s,Rn):"function"!=typeof s[Pn]&&N(s,Pn,Nn)),Ce(s,h,!0))),"values"==r&&d&&"values"!==d.name&&(f=!0,v=function(){return d.call(this)}),p[Pn]!==v&&N(p,Pn,v),r)if(l={values:c("values"),keys:u?v:c("keys"),entries:c("entries")},o)for(a in l)!Mn&&!f&&a in p||ut(p,a,l[a]);else _t({target:e,proto:!0,forced:Mn||f},l);return l},Gn=rt.set,Un=rt.getterFor("Array Iterator"),Wn=Hn(Array,"Array",(function(t,e){Gn(this,{type:"Array Iterator",target:D(t),index:0,kind:e})}),(function(){var t=Un(this),e=t.target,n=t.kind,i=t.index++;return!e||i>=e.length?(t.target=void 0,{value:void 0,done:!0}):"keys"==n?{value:i,done:!1}:"values"==n?{value:e[i],done:!1}:{value:[i,e[i]],done:!1}}),"values");Yt("keys"),Yt("values"),Yt("entries");var Vn=function(t,e){var n=[][t];return!n||!v((function(){n.call(null,e||function(){throw 1},1)}))},Kn=[].join,zn=C!=Object,qn=Vn("join",",");_t({target:"Array",proto:!0,forced:zn||qn},{join:function(t){return Kn.call(D(this),void 0===t?",":t)}});var Yn=$e.map;_t({target:"Array",proto:!0,forced:!yn("map")},{map:function(t){return Yn(this,t,arguments.length>1?arguments[1]:void 0)}});var Jn=Pt("species"),Qn=[].slice,Zn=Math.max;_t({target:"Array",proto:!0,forced:!yn("slice")},{slice:function(t,e){var n,i,r,u=D(this),o=pt(u.length),s=gt(t,o),l=gt(void 0===e?o:e,o);if(de(u)&&("function"!=typeof(n=u.constructor)||n!==Array&&!de(n.prototype)?O(n)&&null===(n=n[Jn])&&(n=void 0):n=void 0,n===Array||void 0===n))return Qn.call(u,s,l);for(i=new(void 0===n?Array:n)(Zn(l-s,0)),r=0;s<l;s++,r++)s in u&&vn(i,r,u[s]);return i.length=r,i}});var Xn=P.f,ti=Function.prototype,ei=ti.toString,ni=/^\s*function ([^ (]*)/;!g||"name"in ti||Xn(ti,"name",{configurable:!0,get:function(){try{return ei.call(this).match(ni)[1]}catch(t){return""}}});var ii=b.f,ri=function(t){return function(e){for(var n,i=D(e),r=Nt(i),u=r.length,o=0,s=[];u>o;)n=r[o++],g&&!ii.call(i,n)||s.push(t?[n,i[n]]:i[n]);return s}},ui={entries:ri(!0),values:ri(!1)}.entries;_t({target:"Object",stat:!0},{entries:function(t){return ui(t)}});var oi=v((function(){Nt(1)}));_t({target:"Object",stat:!0,forced:oi},{keys:function(t){return Nt(Qt(t))}});var si=Pt("toStringTag"),li="Arguments"==F(function(){return arguments}()),ai={};ai[Pt("toStringTag")]="z";var ci="[object z]"!==String(ai)?function(){return"[object "+function(t){var e,n,i;return void 0===t?"Undefined":null===t?"Null":"string"==typeof(n=function(t,e){try{return t[e]}catch(t){}}(e=Object(t),si))?n:li?F(e):"Object"==(i=F(e))&&"function"==typeof e.callee?"Arguments":i}(this)+"]"}:ai.toString,hi=Object.prototype;ci!==hi.toString&&ut(hi,"toString",ci,{unsafe:!0});var fi=function(t){return function(e,n){var i,r,u=String(k(e)),o=ht(n),s=u.length;return o<0||o>=s?t?"":void 0:(i=u.charCodeAt(o))<55296||i>56319||o+1===s||(r=u.charCodeAt(o+1))<56320||r>57343?t?u.charAt(o):i:t?u.slice(o,o+2):r-56320+(i-55296<<10)+65536}},pi={codeAt:fi(!1),charAt:fi(!0)},di=pi.charAt,vi=rt.set,gi=rt.getterFor("String Iterator");Hn(String,"String",(function(t){vi(this,{type:"String Iterator",string:String(t),index:0})}),(function(){var t,e=gi(this),n=e.string,i=e.index;return i>=n.length?{value:void 0,done:!0}:(t=di(n,i),e.index+=t.length,{value:t,done:!1})}));var yi,Ei,bi=function(){var t=R(this),e="";return t.global&&(e+="g"),t.ignoreCase&&(e+="i"),t.multiline&&(e+="m"),t.dotAll&&(e+="s"),t.unicode&&(e+="u"),t.sticky&&(e+="y"),e},mi=RegExp.prototype.exec,Ai=String.prototype.replace,Fi=mi,Si=(yi=/a/,Ei=/b*/g,mi.call(yi,"a"),mi.call(Ei,"a"),0!==yi.lastIndex||0!==Ei.lastIndex),Ci=void 0!==/()??/.exec("")[1];(Si||Ci)&&(Fi=function(t){var e,n,i,r,u=this;return Ci&&(n=new RegExp("^"+u.source+"$(?!\\s)",bi.call(u))),Si&&(e=u.lastIndex),i=mi.call(u,t),Si&&i&&(u.lastIndex=u.global?i.index+i[0].length:e),Ci&&i&&i.length>1&&Ai.call(i[0],n,(function(){for(r=1;r<arguments.length-2;r++)void 0===arguments[r]&&(i[r]=void 0)})),i});var ki=Fi,Di=Pt("species"),Oi=!v((function(){var t=/./;return t.exec=function(){var t=[];return t.groups={a:"7"},t},"7"!=="".replace(t,"$<a>")})),wi=!v((function(){var t=/(?:)/,e=t.exec;t.exec=function(){return e.apply(this,arguments)};var n="ab".split(t);return 2!==n.length||"a"!==n[0]||"b"!==n[1]})),xi=function(t,e,n,i){var r=Pt(t),u=!v((function(){var e={};return e[r]=function(){return 7},7!=""[t](e)})),o=u&&!v((function(){var e=!1,n=/a/;return n.exec=function(){return e=!0,null},"split"===t&&(n.constructor={},n.constructor[Di]=function(){return n}),n[r](""),!e}));if(!u||!o||"replace"===t&&!Oi||"split"===t&&!wi){var s=/./[r],l=n(r,""[t],(function(t,e,n,i,r){return e.exec===ki?u&&!r?{done:!0,value:s.call(e,n,i)}:{done:!0,value:t.call(n,e,i)}:{done:!1}})),a=l[0],c=l[1];ut(String.prototype,t,a),ut(RegExp.prototype,r,2==e?function(t,e){return c.call(t,this,e)}:function(t){return c.call(t,this)}),i&&N(RegExp.prototype[r],"sham",!0)}},$i=Pt("species"),Bi=pi.charAt,ji=function(t,e,n){return e+(n?Bi(t,e).length:1)},Ti=function(t,e){var n=t.exec;if("function"==typeof n){var i=n.call(t,e);if("object"!=typeof i)throw TypeError("RegExp exec method returned something other than an Object or null");return i}if("RegExp"!==F(t))throw TypeError("RegExp#exec called on incompatible receiver");return ki.call(t,e)},Li=[].push,_i=Math.min,Ii=!v((function(){return!RegExp(4294967295,"y")}));xi("split",2,(function(t,e,n){var i;return i="c"=="abbc".split(/(b)*/)[1]||4!="test".split(/(?:)/,-1).length||2!="ab".split(/(?:ab)*/).length||4!=".".split(/(.?)(.?)/).length||".".split(/()()/).length>1||"".split(/.?/).length?function(t,n){var i=String(k(this)),r=void 0===n?4294967295:n>>>0;if(0===r)return[];if(void 0===t)return[i];if(!ee(t))return e.call(i,t,r);for(var u,o,s,l=[],a=(t.ignoreCase?"i":"")+(t.multiline?"m":"")+(t.unicode?"u":"")+(t.sticky?"y":""),c=0,h=new RegExp(t.source,a+"g");(u=ki.call(h,i))&&!((o=h.lastIndex)>c&&(l.push(i.slice(c,u.index)),u.length>1&&u.index<i.length&&Li.apply(l,u.slice(1)),s=u[0].length,c=o,l.length>=r));)h.lastIndex===u.index&&h.lastIndex++;return c===i.length?!s&&h.test("")||l.push(""):l.push(i.slice(c)),l.length>r?l.slice(0,r):l}:"0".split(void 0,0).length?function(t,n){return void 0===t&&0===n?[]:e.call(this,t,n)}:e,[function(e,n){var r=k(this),u=null==e?void 0:e[t];return void 0!==u?u.call(e,r,n):i.call(String(r),e,n)},function(t,r){var u=n(i,t,this,r,i!==e);if(u.done)return u.value;var o=R(t),s=String(this),l=function(t,e){var n,i=R(t).constructor;return void 0===i||null==(n=R(i)[$i])?e:ke(n)}(o,RegExp),a=o.unicode,c=(o.ignoreCase?"i":"")+(o.multiline?"m":"")+(o.unicode?"u":"")+(Ii?"y":"g"),h=new l(Ii?o:"^(?:"+o.source+")",c),f=void 0===r?4294967295:r>>>0;if(0===f)return[];if(0===s.length)return null===Ti(h,s)?[s]:[];for(var p=0,d=0,v=[];d<s.length;){h.lastIndex=Ii?d:0;var g,y=Ti(h,Ii?s:s.slice(d));if(null===y||(g=_i(pt(h.lastIndex+(Ii?0:d)),s.length))===p)d=ji(s,d,a);else{if(v.push(s.slice(p,d)),v.length===f)return v;for(var E=1;E<=y.length-1;E++)if(v.push(y[E]),v.length===f)return v;d=p=g}}return v.push(s.slice(p)),v}]}),!Ii);var Ri={CSSRuleList:0,CSSStyleDeclaration:0,CSSValueList:0,ClientRectList:0,DOMRectList:0,DOMStringList:0,DOMTokenList:1,DataTransferItemList:0,FileList:0,HTMLAllCollection:0,HTMLCollection:0,HTMLFormElement:0,HTMLSelectElement:0,MediaList:0,MimeTypeArray:0,NamedNodeMap:0,NodeList:1,PaintRequestList:0,Plugin:0,PluginArray:0,SVGLengthList:0,SVGNumberList:0,SVGPathSegList:0,SVGPointList:0,SVGStringList:0,SVGTransformList:0,SourceBufferList:0,StyleSheetList:0,TextTrackCueList:0,TextTrackList:0,TouchList:0},Mi=$e.forEach,Pi=Vn("forEach")?function(t){return Mi(this,t,arguments.length>1?arguments[1]:void 0)}:[].forEach;for(var Ni in Ri){var Hi=d[Ni],Gi=Hi&&Hi.prototype;if(Gi&&Gi.forEach!==Pi)try{N(Gi,"forEach",Pi)}catch(t){Gi.forEach=Pi}}var Ui=Pt("iterator"),Wi=Pt("toStringTag"),Vi=Wn.values;for(var Ki in Ri){var zi=d[Ki],qi=zi&&zi.prototype;if(qi){if(qi[Ui]!==Vi)try{N(qi,Ui,Vi)}catch(t){qi[Ui]=Vi}if(qi[Wi]||N(qi,Wi,Ki),Ri[Ki])for(var Yi in Wn)if(qi[Yi]!==Wn[Yi])try{N(qi,Yi,Wn[Yi])}catch(t){qi[Yi]=Wn[Yi]}}}var Ji=function(){function t(e){var i=this;n(this,t),this.rows=e.rows,this.scrollEl=e.scrollEl,this.contentEl=e.contentEl,this.callback=e.callback,this.cache={},this.scrollTop=this.scrollEl.scrollTop,this.initDOM(this.rows),this.scrollEl.scrollTop=this.scrollTop,this.lastCluster=0;var r=function(){i.lastCluster!==(i.lastCluster=i.getNum())&&(i.initDOM(i.rows),i.callback())};this.scrollEl.addEventListener("scroll",r,!1),this.destroy=function(){i.contentEl.innerHtml="",i.scrollEl.removeEventListener("scroll",r,!1)}}return r(t,[{key:"initDOM",value:function(t){void 0===this.clusterHeight&&(this.cache.scrollTop=this.scrollEl.scrollTop,this.cache.data=this.contentEl.innerHTML=t[0]+t[0]+t[0],this.getRowsHeight(t));var e=this.initData(t,this.getNum()),n=e.rows.join(""),i=this.checkChanges("data",n),r=this.checkChanges("top",e.topOffset),u=this.checkChanges("bottom",e.bottomOffset),o=[];i&&r?(e.topOffset&&o.push(this.getExtra("top",e.topOffset)),o.push(n),e.bottomOffset&&o.push(this.getExtra("bottom",e.bottomOffset)),this.contentEl.innerHTML=o.join("")):u&&(this.contentEl.lastChild.style.height="".concat(e.bottomOffset,"px"))}},{key:"getRowsHeight",value:function(){if(void 0===this.itemHeight){var t=this.contentEl.children,e=t[Math.floor(t.length/2)];this.itemHeight=e.offsetHeight}this.blockHeight=this.itemHeight*pe.BLOCK_ROWS,this.clusterRows=pe.BLOCK_ROWS*pe.CLUSTER_BLOCKS,this.clusterHeight=this.blockHeight*pe.CLUSTER_BLOCKS}},{key:"getNum",value:function(){return this.scrollTop=this.scrollEl.scrollTop,Math.floor(this.scrollTop/(this.clusterHeight-this.blockHeight))||0}},{key:"initData",value:function(t,e){if(t.length<pe.BLOCK_ROWS)return{topOffset:0,bottomOffset:0,rowsAbove:0,rows:t};var n=Math.max((this.clusterRows-pe.BLOCK_ROWS)*e,0),i=n+this.clusterRows,r=Math.max(n*this.itemHeight,0),u=Math.max((t.length-i)*this.itemHeight,0),o=[],s=n;r<1&&s++;for(var l=n;l<i;l++)t[l]&&o.push(t[l]);return this.dataStart=n,this.dataEnd=i,{topOffset:r,bottomOffset:u,rowsAbove:s,rows:o}}},{key:"checkChanges",value:function(t,e){var n=e!==this.cache[t];return this.cache[t]=e,n}},{key:"getExtra",value:function(t,e){var n=document.createElement("li");return n.className="virtual-scroll-".concat(t),e&&(n.style.height="".concat(e,"px")),n.outerHTML}}]),t}(),Qi=Math.max,Zi=Math.min,Xi=Math.floor,tr=/\$([$&'`]|\d\d?|<[^>]*>)/g,er=/\$([$&'`]|\d\d?)/g;xi("replace",2,(function(t,e,n){return[function(n,i){var r=k(this),u=null==n?void 0:n[t];return void 0!==u?u.call(n,r,i):e.call(String(r),n,i)},function(t,r){var u=n(e,t,this,r);if(u.done)return u.value;var o=R(t),s=String(this),l="function"==typeof r;l||(r=String(r));var a=o.global;if(a){var c=o.unicode;o.lastIndex=0}for(var h=[];;){var f=Ti(o,s);if(null===f)break;if(h.push(f),!a)break;""===String(f[0])&&(o.lastIndex=ji(s,pt(o.lastIndex),c))}for(var p,d="",v=0,g=0;g<h.length;g++){f=h[g];for(var y=String(f[0]),E=Qi(Zi(ht(f.index),s.length),0),b=[],m=1;m<f.length;m++)b.push(void 0===(p=f[m])?p:String(p));var A=f.groups;if(l){var F=[y].concat(b,E,s);void 0!==A&&F.push(A);var S=String(r.apply(void 0,F))}else S=i(y,s,E,b,A,r);E>=v&&(d+=s.slice(v,E)+S,v=E+y.length)}return d+s.slice(v)}];function i(t,n,i,r,u,o){var s=i+t.length,l=r.length,a=er;return void 0!==u&&(u=Qt(u),a=tr),e.call(o,a,(function(e,o){var a;switch(o.charAt(0)){case"$":return"$";case"&":return t;case"`":return n.slice(0,i);case"'":return n.slice(s);case"<":a=u[o.slice(1,-1)];break;default:var c=+o;if(0===c)return e;if(c>l){var h=Xi(c/10);return 0===h?e:h<=l?void 0===r[h-1]?o.charAt(1):r[h-1]+o.charAt(1):e}a=r[c-1]}return void 0===a?"":a}))}}));var nr=function(t){if(t.normalize)return t.normalize("NFD").replace(/[\u0300-\u036F]/g,"");return[{base:"A",letters:/[\u0041\u24B6\uFF21\u00C0\u00C1\u00C2\u1EA6\u1EA4\u1EAA\u1EA8\u00C3\u0100\u0102\u1EB0\u1EAE\u1EB4\u1EB2\u0226\u01E0\u00C4\u01DE\u1EA2\u00C5\u01FA\u01CD\u0200\u0202\u1EA0\u1EAC\u1EB6\u1E00\u0104\u023A\u2C6F]/g},{base:"AA",letters:/[\uA732]/g},{base:"AE",letters:/[\u00C6\u01FC\u01E2]/g},{base:"AO",letters:/[\uA734]/g},{base:"AU",letters:/[\uA736]/g},{base:"AV",letters:/[\uA738\uA73A]/g},{base:"AY",letters:/[\uA73C]/g},{base:"B",letters:/[\u0042\u24B7\uFF22\u1E02\u1E04\u1E06\u0243\u0182\u0181]/g},{base:"C",letters:/[\u0043\u24B8\uFF23\u0106\u0108\u010A\u010C\u00C7\u1E08\u0187\u023B\uA73E]/g},{base:"D",letters:/[\u0044\u24B9\uFF24\u1E0A\u010E\u1E0C\u1E10\u1E12\u1E0E\u0110\u018B\u018A\u0189\uA779]/g},{base:"DZ",letters:/[\u01F1\u01C4]/g},{base:"Dz",letters:/[\u01F2\u01C5]/g},{base:"E",letters:/[\u0045\u24BA\uFF25\u00C8\u00C9\u00CA\u1EC0\u1EBE\u1EC4\u1EC2\u1EBC\u0112\u1E14\u1E16\u0114\u0116\u00CB\u1EBA\u011A\u0204\u0206\u1EB8\u1EC6\u0228\u1E1C\u0118\u1E18\u1E1A\u0190\u018E]/g},{base:"F",letters:/[\u0046\u24BB\uFF26\u1E1E\u0191\uA77B]/g},{base:"G",letters:/[\u0047\u24BC\uFF27\u01F4\u011C\u1E20\u011E\u0120\u01E6\u0122\u01E4\u0193\uA7A0\uA77D\uA77E]/g},{base:"H",letters:/[\u0048\u24BD\uFF28\u0124\u1E22\u1E26\u021E\u1E24\u1E28\u1E2A\u0126\u2C67\u2C75\uA78D]/g},{base:"I",letters:/[\u0049\u24BE\uFF29\u00CC\u00CD\u00CE\u0128\u012A\u012C\u0130\u00CF\u1E2E\u1EC8\u01CF\u0208\u020A\u1ECA\u012E\u1E2C\u0197]/g},{base:"J",letters:/[\u004A\u24BF\uFF2A\u0134\u0248]/g},{base:"K",letters:/[\u004B\u24C0\uFF2B\u1E30\u01E8\u1E32\u0136\u1E34\u0198\u2C69\uA740\uA742\uA744\uA7A2]/g},{base:"L",letters:/[\u004C\u24C1\uFF2C\u013F\u0139\u013D\u1E36\u1E38\u013B\u1E3C\u1E3A\u0141\u023D\u2C62\u2C60\uA748\uA746\uA780]/g},{base:"LJ",letters:/[\u01C7]/g},{base:"Lj",letters:/[\u01C8]/g},{base:"M",letters:/[\u004D\u24C2\uFF2D\u1E3E\u1E40\u1E42\u2C6E\u019C]/g},{base:"N",letters:/[\u004E\u24C3\uFF2E\u01F8\u0143\u00D1\u1E44\u0147\u1E46\u0145\u1E4A\u1E48\u0220\u019D\uA790\uA7A4]/g},{base:"NJ",letters:/[\u01CA]/g},{base:"Nj",letters:/[\u01CB]/g},{base:"O",letters:/[\u004F\u24C4\uFF2F\u00D2\u00D3\u00D4\u1ED2\u1ED0\u1ED6\u1ED4\u00D5\u1E4C\u022C\u1E4E\u014C\u1E50\u1E52\u014E\u022E\u0230\u00D6\u022A\u1ECE\u0150\u01D1\u020C\u020E\u01A0\u1EDC\u1EDA\u1EE0\u1EDE\u1EE2\u1ECC\u1ED8\u01EA\u01EC\u00D8\u01FE\u0186\u019F\uA74A\uA74C]/g},{base:"OI",letters:/[\u01A2]/g},{base:"OO",letters:/[\uA74E]/g},{base:"OU",letters:/[\u0222]/g},{base:"P",letters:/[\u0050\u24C5\uFF30\u1E54\u1E56\u01A4\u2C63\uA750\uA752\uA754]/g},{base:"Q",letters:/[\u0051\u24C6\uFF31\uA756\uA758\u024A]/g},{base:"R",letters:/[\u0052\u24C7\uFF32\u0154\u1E58\u0158\u0210\u0212\u1E5A\u1E5C\u0156\u1E5E\u024C\u2C64\uA75A\uA7A6\uA782]/g},{base:"S",letters:/[\u0053\u24C8\uFF33\u1E9E\u015A\u1E64\u015C\u1E60\u0160\u1E66\u1E62\u1E68\u0218\u015E\u2C7E\uA7A8\uA784]/g},{base:"T",letters:/[\u0054\u24C9\uFF34\u1E6A\u0164\u1E6C\u021A\u0162\u1E70\u1E6E\u0166\u01AC\u01AE\u023E\uA786]/g},{base:"TZ",letters:/[\uA728]/g},{base:"U",letters:/[\u0055\u24CA\uFF35\u00D9\u00DA\u00DB\u0168\u1E78\u016A\u1E7A\u016C\u00DC\u01DB\u01D7\u01D5\u01D9\u1EE6\u016E\u0170\u01D3\u0214\u0216\u01AF\u1EEA\u1EE8\u1EEE\u1EEC\u1EF0\u1EE4\u1E72\u0172\u1E76\u1E74\u0244]/g},{base:"V",letters:/[\u0056\u24CB\uFF36\u1E7C\u1E7E\u01B2\uA75E\u0245]/g},{base:"VY",letters:/[\uA760]/g},{base:"W",letters:/[\u0057\u24CC\uFF37\u1E80\u1E82\u0174\u1E86\u1E84\u1E88\u2C72]/g},{base:"X",letters:/[\u0058\u24CD\uFF38\u1E8A\u1E8C]/g},{base:"Y",letters:/[\u0059\u24CE\uFF39\u1EF2\u00DD\u0176\u1EF8\u0232\u1E8E\u0178\u1EF6\u1EF4\u01B3\u024E\u1EFE]/g},{base:"Z",letters:/[\u005A\u24CF\uFF3A\u0179\u1E90\u017B\u017D\u1E92\u1E94\u01B5\u0224\u2C7F\u2C6B\uA762]/g},{base:"a",letters:/[\u0061\u24D0\uFF41\u1E9A\u00E0\u00E1\u00E2\u1EA7\u1EA5\u1EAB\u1EA9\u00E3\u0101\u0103\u1EB1\u1EAF\u1EB5\u1EB3\u0227\u01E1\u00E4\u01DF\u1EA3\u00E5\u01FB\u01CE\u0201\u0203\u1EA1\u1EAD\u1EB7\u1E01\u0105\u2C65\u0250]/g},{base:"aa",letters:/[\uA733]/g},{base:"ae",letters:/[\u00E6\u01FD\u01E3]/g},{base:"ao",letters:/[\uA735]/g},{base:"au",letters:/[\uA737]/g},{base:"av",letters:/[\uA739\uA73B]/g},{base:"ay",letters:/[\uA73D]/g},{base:"b",letters:/[\u0062\u24D1\uFF42\u1E03\u1E05\u1E07\u0180\u0183\u0253]/g},{base:"c",letters:/[\u0063\u24D2\uFF43\u0107\u0109\u010B\u010D\u00E7\u1E09\u0188\u023C\uA73F\u2184]/g},{base:"d",letters:/[\u0064\u24D3\uFF44\u1E0B\u010F\u1E0D\u1E11\u1E13\u1E0F\u0111\u018C\u0256\u0257\uA77A]/g},{base:"dz",letters:/[\u01F3\u01C6]/g},{base:"e",letters:/[\u0065\u24D4\uFF45\u00E8\u00E9\u00EA\u1EC1\u1EBF\u1EC5\u1EC3\u1EBD\u0113\u1E15\u1E17\u0115\u0117\u00EB\u1EBB\u011B\u0205\u0207\u1EB9\u1EC7\u0229\u1E1D\u0119\u1E19\u1E1B\u0247\u025B\u01DD]/g},{base:"f",letters:/[\u0066\u24D5\uFF46\u1E1F\u0192\uA77C]/g},{base:"g",letters:/[\u0067\u24D6\uFF47\u01F5\u011D\u1E21\u011F\u0121\u01E7\u0123\u01E5\u0260\uA7A1\u1D79\uA77F]/g},{base:"h",letters:/[\u0068\u24D7\uFF48\u0125\u1E23\u1E27\u021F\u1E25\u1E29\u1E2B\u1E96\u0127\u2C68\u2C76\u0265]/g},{base:"hv",letters:/[\u0195]/g},{base:"i",letters:/[\u0069\u24D8\uFF49\u00EC\u00ED\u00EE\u0129\u012B\u012D\u00EF\u1E2F\u1EC9\u01D0\u0209\u020B\u1ECB\u012F\u1E2D\u0268\u0131]/g},{base:"j",letters:/[\u006A\u24D9\uFF4A\u0135\u01F0\u0249]/g},{base:"k",letters:/[\u006B\u24DA\uFF4B\u1E31\u01E9\u1E33\u0137\u1E35\u0199\u2C6A\uA741\uA743\uA745\uA7A3]/g},{base:"l",letters:/[\u006C\u24DB\uFF4C\u0140\u013A\u013E\u1E37\u1E39\u013C\u1E3D\u1E3B\u017F\u0142\u019A\u026B\u2C61\uA749\uA781\uA747]/g},{base:"lj",letters:/[\u01C9]/g},{base:"m",letters:/[\u006D\u24DC\uFF4D\u1E3F\u1E41\u1E43\u0271\u026F]/g},{base:"n",letters:/[\u006E\u24DD\uFF4E\u01F9\u0144\u00F1\u1E45\u0148\u1E47\u0146\u1E4B\u1E49\u019E\u0272\u0149\uA791\uA7A5]/g},{base:"nj",letters:/[\u01CC]/g},{base:"o",letters:/[\u006F\u24DE\uFF4F\u00F2\u00F3\u00F4\u1ED3\u1ED1\u1ED7\u1ED5\u00F5\u1E4D\u022D\u1E4F\u014D\u1E51\u1E53\u014F\u022F\u0231\u00F6\u022B\u1ECF\u0151\u01D2\u020D\u020F\u01A1\u1EDD\u1EDB\u1EE1\u1EDF\u1EE3\u1ECD\u1ED9\u01EB\u01ED\u00F8\u01FF\u0254\uA74B\uA74D\u0275]/g},{base:"oi",letters:/[\u01A3]/g},{base:"ou",letters:/[\u0223]/g},{base:"oo",letters:/[\uA74F]/g},{base:"p",letters:/[\u0070\u24DF\uFF50\u1E55\u1E57\u01A5\u1D7D\uA751\uA753\uA755]/g},{base:"q",letters:/[\u0071\u24E0\uFF51\u024B\uA757\uA759]/g},{base:"r",letters:/[\u0072\u24E1\uFF52\u0155\u1E59\u0159\u0211\u0213\u1E5B\u1E5D\u0157\u1E5F\u024D\u027D\uA75B\uA7A7\uA783]/g},{base:"s",letters:/[\u0073\u24E2\uFF53\u00DF\u015B\u1E65\u015D\u1E61\u0161\u1E67\u1E63\u1E69\u0219\u015F\u023F\uA7A9\uA785\u1E9B]/g},{base:"t",letters:/[\u0074\u24E3\uFF54\u1E6B\u1E97\u0165\u1E6D\u021B\u0163\u1E71\u1E6F\u0167\u01AD\u0288\u2C66\uA787]/g},{base:"tz",letters:/[\uA729]/g},{base:"u",letters:/[\u0075\u24E4\uFF55\u00F9\u00FA\u00FB\u0169\u1E79\u016B\u1E7B\u016D\u00FC\u01DC\u01D8\u01D6\u01DA\u1EE7\u016F\u0171\u01D4\u0215\u0217\u01B0\u1EEB\u1EE9\u1EEF\u1EED\u1EF1\u1EE5\u1E73\u0173\u1E77\u1E75\u0289]/g},{base:"v",letters:/[\u0076\u24E5\uFF56\u1E7D\u1E7F\u028B\uA75F\u028C]/g},{base:"vy",letters:/[\uA761]/g},{base:"w",letters:/[\u0077\u24E6\uFF57\u1E81\u1E83\u0175\u1E87\u1E85\u1E98\u1E89\u2C73]/g},{base:"x",letters:/[\u0078\u24E7\uFF58\u1E8B\u1E8D]/g},{base:"y",letters:/[\u0079\u24E8\uFF59\u1EF3\u00FD\u0177\u1EF9\u0233\u1E8F\u00FF\u1EF7\u1E99\u1EF5\u01B4\u024F\u1EFF]/g},{base:"z",letters:/[\u007A\u24E9\uFF5A\u017A\u1E91\u017C\u017E\u1E93\u1E95\u01B6\u0225\u0240\u2C6C\uA763]/g}].reduce((function(t,e){var n=e.letters,i=e.base;return t.replace(n,i)}),t)},ir=function(t,e,n){var i=!0,r=!1,u=void 0;try{for(var o,s=t[Symbol.iterator]();!(i=(o=s.next()).done);i=!0){var l=o.value;if(l[e]===n||l[e]===+l[e]+""&&+l[e]===n)return l;if("optgroup"===l.type){var a=!0,c=!1,h=void 0;try{for(var f,p=l.children[Symbol.iterator]();!(a=(f=p.next()).done);a=!0){var d=f.value;if(d[e]===n||d[e]===+d[e]+""&&+d[e]===n)return d}}catch(t){c=!0,h=t}finally{try{a||null==p.return||p.return()}finally{if(c)throw h}}}}}catch(t){r=!0,u=t}finally{try{i||null==s.return||s.return()}finally{if(r)throw u}}},rr=function(t){return Object.keys(t).forEach((function(e){return void 0===t[e]?delete t[e]:""})),t},ur=function(){function i(e,r){n(this,i),this.$el=e,this.options=t.extend({},pe.DEFAULTS,r)}return r(i,[{key:"init",value:function(){this.initLocale(),this.initContainer(),this.initData(),this.initSelected(!0),this.initFilter(),this.initDrop(),this.initView(),this.options.onAfterCreate()}},{key:"initLocale",value:function(){if(this.options.locale){var e=t.fn.multipleSelect.locales,n=this.options.locale.split(/-|_/);n[0]=n[0].toLowerCase(),n[1]&&(n[1]=n[1].toUpperCase()),e[this.options.locale]?t.extend(this.options,e[this.options.locale]):e[n.join("-")]?t.extend(this.options,e[n.join("-")]):e[n[0]]&&t.extend(this.options,e[n[0]])}}},{key:"initContainer",value:function(){var e=this,n=this.$el[0],i=n.getAttribute("name")||this.options.name||"";this.$el.hide(),this.$label=this.$el.closest("label"),!this.$label.length&&this.$el.attr("id")&&(this.$label=t('label[for="'.concat(this.$el.attr("id"),'"]'))),this.$label.find(">input").length&&(this.$label=null),void 0===this.options.single&&(this.options.single=null===n.getAttribute("multiple")),this.$parent=t('\n <div class="ms-parent '.concat(n.getAttribute("class")||"",'"\n title="').concat(n.getAttribute("title")||"",'" />\n ')),this.options.placeholder=this.options.placeholder||n.getAttribute("placeholder")||"",this.tabIndex=n.getAttribute("tabindex");var r="";if(null!==this.tabIndex&&(this.$el.attr("tabindex",-1),r=this.tabIndex&&'tabindex="'.concat(this.tabIndex,'"')),this.$choice=t('\n <button type="button" class="ms-choice"'.concat(r,'>\n <span class="placeholder">').concat(this.options.placeholder,"</span>\n ").concat(this.options.showClear?'<div class="icon-close"></div>':"",'\n <div class="icon-caret"></div>\n </button>\n ')),this.$drop=t('<div class="ms-drop '.concat(this.options.position,'" />')),this.$close=this.$choice.find(".icon-close"),this.options.dropWidth&&this.$drop.css("width",this.options.dropWidth),this.$el.after(this.$parent),this.$parent.append(this.$choice),this.$parent.append(this.$drop),n.disabled&&this.$choice.addClass("disabled"),this.selectAllName='data-name="selectAll'.concat(i,'"'),this.selectGroupName='data-name="selectGroup'.concat(i,'"'),this.selectItemName='data-name="selectItem'.concat(i,'"'),!this.options.keepOpen){var u=function(){var t=arguments.length>0&&void 0!==arguments[0]?arguments[0]:"";return t=t||"".concat(+new Date).concat(~~(1e6*Math.random())),"click.multiple-select-".concat(t)}(this.$el.attr("id"));t(document).off(u).on(u,(function(i){t(i.target)[0]!==e.$choice[0]&&t(i.target).parents(".ms-choice")[0]!==e.$choice[0]&&(t(i.target)[0]===e.$drop[0]||t(i.target).parents(".ms-drop")[0]!==e.$drop[0]&&i.target!==n)&&e.options.isOpen&&e.close()}))}}},{key:"initData",value:function(){var n=this,i=[];if(this.options.data){if(Array.isArray(this.options.data))this.data=this.options.data.map((function(t){return"string"==typeof t||"number"==typeof t?{text:t,value:t}:t}));else if("object"===e(this.options.data)){for(var r=0,o=Object.entries(this.options.data);r<o.length;r++){var s=u(o[r],2),l=s[0],a=s[1];i.push({value:l,text:a})}this.data=i}}else t.each(this.$el.children(),(function(t,e){n.initRow(t,e)&&i.push(n.initRow(t,e))})),this.options.data=i,this.data=i,this.fromHtml=!0;this.dataTotal=function(t){var e=0;return t.forEach((function(t,n){"optgroup"===t.type?(t._key="group_".concat(n),t.visible=void 0===t.visible||t.visible,t.children.forEach((function(t,e){t._key="option_".concat(n,"_").concat(e),t.visible=void 0===t.visible||t.visible})),e+=t.children.length):(t._key="option_".concat(n),t.visible=void 0===t.visible||t.visible,e+=1)})),e}(this.data)}},{key:"initRow",value:function(e,n,i){var r=this,u={},o=t(n);return o.is("option")?(u.type="option",u.text=this.options.textTemplate(o),u.value=n.value,u.visible=!0,u.selected=!!n.selected,u.disabled=i||n.disabled,u.classes=n.getAttribute("class")||"",u.title=n.getAttribute("title")||"",o.data("value")&&(u._value=o.data("value")),Object.keys(o.data()).length&&(u._data=o.data()),u):o.is("optgroup")?(u.type="optgroup",u.label=this.options.labelTemplate(o),u.visible=!0,u.selected=!!n.selected,u.disabled=n.disabled,u.children=[],Object.keys(o.data()).length&&(u._data=o.data()),t.each(o.children(),(function(t,e){u.children.push(r.initRow(t,e,u.disabled))})),u):null}},{key:"initSelected",value:function(t){var e=0,n=!0,i=!1,r=void 0;try{for(var u,o=this.data[Symbol.iterator]();!(n=(u=o.next()).done);n=!0){var s=u.value;if("optgroup"===s.type){var l=s.children.filter((function(t){return t.selected&&!t.disabled&&t.visible})).length;s.selected=l&&l===s.children.filter((function(t){return!t.disabled&&t.visible})).length,e+=l}else e+=s.selected&&!s.disabled&&s.visible?1:0}}catch(t){i=!0,r=t}finally{try{n||null==o.return||o.return()}finally{if(i)throw r}}this.allSelected=this.data.filter((function(t){return t.selected&&!t.disabled&&t.visible})).length===this.data.filter((function(t){return!t.disabled&&t.visible})).length,t||(this.allSelected?this.options.onCheckAll():0===e&&this.options.onUncheckAll())}},{key:"initFilter",value:function(){if(this.filterText="",!this.options.filter&&this.options.filterByDataLength){var t=0,e=!0,n=!1,i=void 0;try{for(var r,u=this.data[Symbol.iterator]();!(e=(r=u.next()).done);e=!0){var o=r.value;"optgroup"===o.type?t+=o.children.length:t+=1}}catch(t){n=!0,i=t}finally{try{e||null==u.return||u.return()}finally{if(n)throw i}}this.options.filter=t>this.options.filterByDataLength}}},{key:"initDrop",value:function(){var t=this;this.initList(),this.update(!0),this.options.isOpen&&setTimeout((function(){t.open()}),50),this.options.openOnHover&&this.$parent.hover((function(){t.open()}),(function(){t.close()}))}},{key:"initList",value:function(){var t=[];this.options.filter&&t.push('\n <div class="ms-search">\n <input type="text" autocomplete="off" autocorrect="off"\n autocapitalize="off" spellcheck="false"\n placeholder="'.concat(this.options.filterPlaceholder,'">\n </div>\n ')),t.push("<ul></ul>"),this.$drop.html(t.join("")),this.$ul=this.$drop.find(">ul"),this.initListItems()}},{key:"initListItems",value:function(){var t=this,e=this.getListRows(),n=0;if(this.options.selectAll&&!this.options.single&&(n=-1),e.length>pe.BLOCK_ROWS*pe.CLUSTER_BLOCKS){this.virtualScroll&&this.virtualScroll.destroy();var i=this.$drop.is(":visible");i||this.$drop.css("left",-1e4).show();var r=function(){t.updateDataStart=t.virtualScroll.dataStart+n,t.updateDataEnd=t.virtualScroll.dataEnd+n,t.updateDataStart<0&&(t.updateDataStart=0),t.updateDataEnd>t.data.length&&(t.updateDataEnd=t.data.length)};this.virtualScroll=new Ji({rows:e,scrollEl:this.$ul[0],contentEl:this.$ul[0],callback:function(){r(),t.events()}}),r(),i||this.$drop.css("left",0).hide()}else this.$ul.html(e.join("")),this.updateDataStart=0,this.updateDataEnd=this.updateData.length,this.virtualScroll=null;this.events()}},{key:"getListRows",value:function(){var t=this,e=[];return this.options.selectAll&&!this.options.single&&e.push('\n <li class="ms-select-all">\n <label>\n <input type="checkbox" '.concat(this.selectAllName).concat(this.allSelected?' checked="checked"':""," />\n <span>").concat(this.options.formatSelectAll(),"</span>\n </label>\n </li>\n ")),this.updateData=[],this.data.forEach((function(n){e.push.apply(e,o(t.initListItem(n)))})),e.push('<li class="ms-no-results">'.concat(this.options.formatNoMatchesFound(),"</li>")),e}},{key:"initListItem",value:function(t){var e=this,n=arguments.length>1&&void 0!==arguments[1]?arguments[1]:0,i=t.title?'title="'.concat(t.title,'"'):"",r=this.options.multiple?"multiple":"",u=this.options.single?"radio":"checkbox",s="";if(!t.visible)return[];if(this.updateData.push(t),this.options.single&&!this.options.singleRadio&&(s="hide-radio "),t.selected&&(s+="selected "),"optgroup"===t.type){var l=this.options.styler(t),a=l?'style="'.concat(l,'"'):"",c=[],h=this.options.hideOptgroupCheckboxes||this.options.single?"<span ".concat(this.selectGroupName,' data-key="').concat(t._key,'"></span>'):'<input type="checkbox"\n '.concat(this.selectGroupName,'\n data-key="').concat(t._key,'"\n ').concat(t.selected?' checked="checked"':"","\n ").concat(t.disabled?' disabled="disabled"':"","\n >");return s.includes("hide-radio")||!this.options.hideOptgroupCheckboxes&&!this.options.single||(s+="hide-radio "),c.push('\n <li class="group '.concat(s,'" ').concat(a,'>\n <label class="optgroup').concat(this.options.single||t.disabled?" disabled":"",'">\n ').concat(h).concat(t.label,"\n </label>\n </li>\n ")),t.children.forEach((function(t){c.push.apply(c,o(e.initListItem(t,1)))})),c}var f=this.options.styler(t),p=f?'style="'.concat(f,'"'):"";return s+=t.classes||"",n&&this.options.single&&(s+="option-level-".concat(n," ")),['\n <li class="'.concat(r," ").concat(s,'" ').concat(i," ").concat(p,'>\n <label class="').concat(t.disabled?"disabled":"",'">\n <input type="').concat(u,'"\n value="').concat(t.value,'"\n data-key="').concat(t._key,'"\n ').concat(this.selectItemName,"\n ").concat(t.selected?' checked="checked"':"","\n ").concat(t.disabled?' disabled="disabled"':"","\n >\n <span>").concat(t.text,"</span>\n </label>\n </li>\n ")]}},{key:"events",value:function(){var e=this;this.$searchInput=this.$drop.find(".ms-search input"),this.$selectAll=this.$drop.find("input[".concat(this.selectAllName,"]")),this.$selectGroups=this.$drop.find("input[".concat(this.selectGroupName,"],span[").concat(this.selectGroupName,"]")),this.$selectItems=this.$drop.find("input[".concat(this.selectItemName,"]:enabled")),this.$disableItems=this.$drop.find("input[".concat(this.selectItemName,"]:disabled")),this.$noResults=this.$drop.find(".ms-no-results");var n=function(n){n.preventDefault(),t(n.target).hasClass("icon-close")||e[e.options.isOpen?"close":"open"]()};this.$label&&this.$label.length&&this.$label.off("click").on("click",(function(t){"label"===t.target.nodeName.toLowerCase()&&(n(t),e.options.filter&&e.options.isOpen||e.focus(),t.stopPropagation())})),this.$choice.off("click").on("click",n).off("focus").on("focus",this.options.onFocus).off("blur").on("blur",this.options.onBlur),this.$parent.off("keydown").on("keydown",(function(t){27!==t.which||e.options.keepOpen||(e.close(),e.$choice.focus())})),this.$close.off("click").on("click",(function(t){t.preventDefault(),e._checkAll(!1,!0),e.initSelected(!1),e.updateSelected(),e.update(),e.options.onClear()})),this.$searchInput.off("keydown").on("keydown",(function(t){9===t.keyCode&&t.shiftKey&&e.close()})).off("keyup").on("keyup",(function(t){if(e.options.filterAcceptOnEnter&&[13,32].includes(t.which)&&e.$searchInput.val()){if(e.options.single){var n=e.$selectItems.closest("li").filter(":visible");n.length&&e.setSelects([n.first().find("input[".concat(e.selectItemName,"]")).val()])}else e.$selectAll.click();return e.close(),void e.focus()}e.filter()})),this.$selectAll.off("click").on("click",(function(n){e._checkAll(t(n.currentTarget).prop("checked"))})),this.$selectGroups.off("click").on("click",(function(n){var i=t(n.currentTarget),r=i.prop("checked"),u=ir(e.data,"_key",i.data("key"));e._checkGroup(u,r),e.options.onOptgroupClick(rr({label:u.label,selected:u.selected,data:u._data,children:u.children.map((function(t){return rr({text:t.text,value:t.value,selected:t.selected,disabled:t.disabled,data:t._data})}))}))})),this.$selectItems.off("click").on("click",(function(n){var i=t(n.currentTarget),r=i.prop("checked"),u=ir(e.data,"_key",i.data("key"));e._check(u,r),e.options.onClick(rr({text:u.text,value:u.value,selected:u.selected,data:u._data})),e.options.single&&e.options.isOpen&&!e.options.keepOpen&&e.close()}))}},{key:"initView",value:function(){var t;window.getComputedStyle?"auto"===(t=window.getComputedStyle(this.$el[0]).width)&&(t=this.$drop.outerWidth()+20):t=this.$el.outerWidth()+20,this.$parent.css("width",this.options.width||t),this.$el.show().addClass("ms-offscreen")}},{key:"open",value:function(){if(!this.$choice.hasClass("disabled")){if(this.options.isOpen=!0,this.$choice.find(">div").addClass("open"),this.$drop[this.animateMethod("show")](),this.$selectAll.parent().show(),this.$noResults.hide(),this.data.length||(this.$selectAll.parent().hide(),this.$noResults.show()),this.options.container){var e=this.$drop.offset();this.$drop.appendTo(t(this.options.container)),this.$drop.offset({top:e.top,left:e.left}).css("min-width","auto").outerWidth(this.$parent.outerWidth())}var n=this.options.maxHeight;"row"===this.options.maxHeightUnit&&(n=this.$drop.find(">ul>li").first().outerHeight()*this.options.maxHeight),this.$drop.find(">ul").css("max-height","".concat(n,"px")),this.$drop.find(".multiple").css("width","".concat(this.options.multipleWidth,"px")),this.data.length&&this.options.filter&&(this.$searchInput.val(""),this.$searchInput.focus(),this.filter(!0)),this.options.onOpen()}}},{key:"close",value:function(){this.options.isOpen=!1,this.$choice.find(">div").removeClass("open"),this.$drop[this.animateMethod("hide")](),this.options.container&&(this.$parent.append(this.$drop),this.$drop.css({top:"auto",left:"auto"})),this.options.onClose()}},{key:"animateMethod",value:function(t){return{show:{fade:"fadeIn",slide:"slideDown"},hide:{fade:"fadeOut",slide:"slideUp"}}[t][this.options.animate]||t}},{key:"update",value:function(t){var e=this.getSelects(),n=this.getSelects("text");this.options.displayValues&&(n=e);var i=this.$choice.find(">span"),r=e.length,u="";0===r?i.addClass("placeholder").html(this.options.placeholder):u=r<this.options.minimumCountSelected?n.join(this.options.displayDelimiter):this.options.formatAllSelected()&&r===this.dataTotal?this.options.formatAllSelected():this.options.ellipsis&&r>this.options.minimumCountSelected?"".concat(n.slice(0,this.options.minimumCountSelected).join(this.options.displayDelimiter),"..."):this.options.formatCountSelected()&&r>this.options.minimumCountSelected?this.options.formatCountSelected(r,this.dataTotal):n.join(this.options.displayDelimiter),u&&i.removeClass("placeholder").html(u),this.options.displayTitle&&i.prop("title",this.getSelects("text")),this.$el.val(this.getSelects()),t||this.$el.trigger("change")}},{key:"updateSelected",value:function(){for(var t=this.updateDataStart;t<this.updateDataEnd;t++){var e=this.updateData[t];this.$drop.find("input[data-key=".concat(e._key,"]")).prop("checked",e.selected).closest("li").toggleClass("selected",e.selected)}var n=0===this.data.filter((function(t){return t.visible})).length;this.$selectAll.length&&this.$selectAll.prop("checked",this.allSelected).closest("li").toggle(!n),this.$noResults.toggle(n),this.virtualScroll&&(this.virtualScroll.rows=this.getListRows())}},{key:"getOptions",value:function(){var e=t.extend({},this.options);return delete e.data,t.extend(!0,{},e)}},{key:"refreshOptions",value:function(e){(function(t,e,n){var i=Object.keys(t),r=Object.keys(e);if(n&&i.length!==r.length)return!1;for(var u=0,o=i;u<o.length;u++){var s=o[u];if(r.includes(s)&&t[s]!==e[s])return!1}return!0})(this.options,e,!0)||(this.options=t.extend(this.options,e),this.destroy(),this.init())}},{key:"getSelects",value:function(){var t=arguments.length>0&&void 0!==arguments[0]?arguments[0]:"value",e=[],n=!0,i=!1,r=void 0;try{for(var u,s=this.data[Symbol.iterator]();!(n=(u=s.next()).done);n=!0){var l=u.value;if("optgroup"===l.type){var a=l.children.filter((function(t){return t.selected}));if(!a.length)continue;if("value"===t||this.options.single)e.push.apply(e,o(a.map((function(e){return"value"===t&&e._value||e[t]}))));else{var c=[];c.push("["),c.push(l.label),c.push(": ".concat(a.map((function(e){return e[t]})).join(", "))),c.push("]"),e.push(c.join(""))}}else l.selected&&e.push("value"===t&&l._value||l[t])}}catch(t){i=!0,r=t}finally{try{n||null==s.return||s.return()}finally{if(i)throw r}}return e}},{key:"setSelects",value:function(t,e){var n=!1,i=function(e){var i=!0,r=!1,u=void 0;try{for(var o,s=e[Symbol.iterator]();!(i=(o=s.next()).done);i=!0){var l=o.value,a=t.includes(l._value||l.value);a||l.value!==+l.value+""||(a=t.includes(+l.value)),l.selected!==a&&(n=!0),l.selected=a}}catch(t){r=!0,u=t}finally{try{i||null==s.return||s.return()}finally{if(r)throw u}}},r=!0,u=!1,o=void 0;try{for(var s,l=this.data[Symbol.iterator]();!(r=(s=l.next()).done);r=!0){var a=s.value;"optgroup"===a.type?i(a.children):i([a])}}catch(t){u=!0,o=t}finally{try{r||null==l.return||l.return()}finally{if(u)throw o}}n&&(this.initSelected(e),this.updateSelected(),this.update(e))}},{key:"enable",value:function(){this.$choice.removeClass("disabled")}},{key:"disable",value:function(){this.$choice.addClass("disabled")}},{key:"check",value:function(t){var e=ir(this.data,"value",t);e&&this._check(e,!0)}},{key:"uncheck",value:function(t){var e=ir(this.data,"value",t);e&&this._check(e,!1)}},{key:"_check",value:function(t,e){this.options.single&&this._checkAll(!1,!0),t.selected=e,this.initSelected(),this.updateSelected(),this.update()}},{key:"checkAll",value:function(){this._checkAll(!0)}},{key:"uncheckAll",value:function(){this._checkAll(!1)}},{key:"_checkAll",value:function(t,e){var n=!0,i=!1,r=void 0;try{for(var u,o=this.data[Symbol.iterator]();!(n=(u=o.next()).done);n=!0){var s=u.value;"optgroup"===s.type?this._checkGroup(s,t,!0):s.disabled||!e&&!s.visible||(s.selected=t)}}catch(t){i=!0,r=t}finally{try{n||null==o.return||o.return()}finally{if(i)throw r}}e||(this.initSelected(),this.updateSelected(),this.update())}},{key:"_checkGroup",value:function(t,e,n){t.selected=e,t.children.forEach((function(t){t.disabled||!n&&!t.visible||(t.selected=e)})),n||(this.initSelected(),this.updateSelected(),this.update())}},{key:"checkInvert",value:function(){if(!this.options.single){var t=!0,e=!1,n=void 0;try{for(var i,r=this.data[Symbol.iterator]();!(t=(i=r.next()).done);t=!0){var u=i.value;if("optgroup"===u.type){var o=!0,s=!1,l=void 0;try{for(var a,c=u.children[Symbol.iterator]();!(o=(a=c.next()).done);o=!0){var h=a.value;h.selected=!h.selected}}catch(t){s=!0,l=t}finally{try{o||null==c.return||c.return()}finally{if(s)throw l}}}else u.selected=!u.selected}}catch(t){e=!0,n=t}finally{try{t||null==r.return||r.return()}finally{if(e)throw n}}this.initSelected(),this.updateSelected(),this.update()}}},{key:"focus",value:function(){this.$choice.focus(),this.options.onFocus()}},{key:"blur",value:function(){this.$choice.blur(),this.options.onBlur()}},{key:"refresh",value:function(){this.destroy(),this.init()}},{key:"filter",value:function(e){var n=t.trim(this.$searchInput.val()),i=n.toLowerCase();if(this.filterText!==i){this.filterText=i;var r=!0,u=!1,o=void 0;try{for(var s,l=this.data[Symbol.iterator]();!(r=(s=l.next()).done);r=!0){var a=s.value;if("optgroup"===a.type)if(this.options.filterGroup){var c=this.options.customFilter(nr(a.label.toLowerCase()),nr(i),a.label,n);a.visible=c;var h=!0,f=!1,p=void 0;try{for(var d,v=a.children[Symbol.iterator]();!(h=(d=v.next()).done);h=!0){d.value.visible=c}}catch(t){f=!0,p=t}finally{try{h||null==v.return||v.return()}finally{if(f)throw p}}}else{var g=!0,y=!1,E=void 0;try{for(var b,m=a.children[Symbol.iterator]();!(g=(b=m.next()).done);g=!0){var A=b.value;A.visible=this.options.customFilter(nr(A.text.toLowerCase()),nr(i),A.text,n)}}catch(t){y=!0,E=t}finally{try{g||null==m.return||m.return()}finally{if(y)throw E}}a.visible=a.children.filter((function(t){return t.visible})).length>0}else a.visible=this.options.customFilter(nr(a.text.toLowerCase()),nr(i),a.text,n)}}catch(t){u=!0,o=t}finally{try{r||null==l.return||l.return()}finally{if(u)throw o}}this.initListItems(),this.initSelected(e),this.updateSelected(),e||this.options.onFilter(i)}}},{key:"destroy",value:function(){this.$parent&&(this.$el.before(this.$parent).removeClass("ms-offscreen"),null!==this.tabIndex&&this.$el.attr("tabindex",this.tabIndex),this.$parent.remove(),this.fromHtml&&(delete this.options.data,this.fromHtml=!1))}}]),i}();t.fn.multipleSelect=function(n){for(var i=arguments.length,r=new Array(i>1?i-1:0),u=1;u<i;u++)r[u-1]=arguments[u];var o;return this.each((function(i,u){var s=t(u),l=s.data("multipleSelect"),a=t.extend({},s.data(),"object"===e(n)&&n);if(l||(l=new ur(s,a),s.data("multipleSelect",l)),"string"==typeof n){var c;if(t.inArray(n,pe.METHODS)<0)throw new Error("Unknown method: ".concat(n));o=(c=l)[n].apply(c,r),"destroy"===n&&s.removeData("multipleSelect")}else l.init()})),void 0!==o?o:this},t.fn.multipleSelect.defaults=pe.DEFAULTS,t.fn.multipleSelect.locales=pe.LOCALES,t.fn.multipleSelect.methods=pe.METHODS})); diff --git a/common/static/opensearch.xml b/common/static/opensearch.xml new file mode 100644 index 00000000..03ea8902 --- /dev/null +++ b/common/static/opensearch.xml @@ -0,0 +1,8 @@ +<?xml version="1.0" encoding="UTF-8"?> +<OpenSearchDescription xmlns="http://a9.com/-/spec/opensearch/1.1/"> +<ShortName>NeoDB</ShortName> +<Description>输入关键字或站外条目链接,搜索NeoDB书影音游戏</Description> +<InputEncoding>UTF-8</InputEncoding> +<Image type="image/jpeg" width="64" height="64">https://neodb.social/static/img/logo-square.jpg</Image> +<Url type="text/html" template="https://neodb.social/search/?q={searchTerms}"/> +</OpenSearchDescription> diff --git a/common/static/sass/_AsideSection.sass b/common/static/sass/_AsideSection.sass index 1ae9ee88..35ff5b0f 100644 --- a/common/static/sass/_AsideSection.sass +++ b/common/static/sass/_AsideSection.sass @@ -236,7 +236,7 @@ $panel-padding : 0 background-color: $color-quaternary border-radius: 0 height: 10px - width: 65% + width: 54% progress::-webkit-progress-bar background-color: $color-quaternary diff --git a/common/static/sass/_Label.sass b/common/static/sass/_Label.sass index cdea7e25..ea0a28b1 100644 --- a/common/static/sass/_Label.sass +++ b/common/static/sass/_Label.sass @@ -7,10 +7,18 @@ $spotify-color-primary: #1ed760 $spotify-color-secondary: black $imdb-color-primary: #F5C518 $imdb-color-secondary: #121212 +$igdb-color-primary: #323A44 +$igdb-color-secondary: #DFE1E2 $steam-color-primary: #1387b8 $steam-color-secondary: #111d2e $bangumi-color-primary: #F09199 $bangumi-color-secondary: #FCFCFC +$goodreads-color-primary: #372213 +$goodreads-color-secondary: #F4F1EA +$tmdb-color-primary: #91CCA3 +$tmdb-color-secondary: #1FB4E2 +$bandcamp-color-primary: #28A0C1 +$bandcamp-color-secondary: white .source-label display: inline @@ -50,6 +58,11 @@ $bangumi-color-secondary: #FCFCFC color: $imdb-color-secondary border: none font-weight: bold + &.source-label__igdb + background-color: $igdb-color-primary + color: $igdb-color-secondary + border: none + font-weight: bold &.source-label__steam background: linear-gradient(30deg, $steam-color-primary, $steam-color-secondary) color: white @@ -60,4 +73,27 @@ $bangumi-color-secondary: #FCFCFC background: $bangumi-color-secondary color: $bangumi-color-primary font-style: italic - font-weight: 600 \ No newline at end of file + font-weight: 600 + &.source-label__goodreads + background: $goodreads-color-secondary + color: $goodreads-color-primary + font-weight: lighter + &.source-label__tmdb + background: linear-gradient(90deg, $tmdb-color-primary, $tmdb-color-secondary) + color: white + border: none + font-weight: lighter + padding-top: 2px + &.source-label__googlebooks + color: white + background-color: #4285F4 + border-color: #4285F4 + &.source-label__bandcamp + color: $bandcamp-color-secondary + background-color: $bandcamp-color-primary + // transform: skewX(-30deg) + display: inline-block + &.source-label__bandcamp span + // transform: skewX(30deg) + display: inline-block + margin: 0 4px diff --git a/common/static/sass/_Modal.sass b/common/static/sass/_Modal.sass index df146ca3..dda313d5 100644 --- a/common/static/sass/_Modal.sass +++ b/common/static/sass/_Modal.sass @@ -115,10 +115,12 @@ &__content word-break: break-all +.add-to-list-modal + @include modal // Small devices (landscape phones, 576px and up) @media (max-width: $small-devices) - .mark-modal, .confirm-modal, .announcement-modal + .mark-modal, .confirm-modal, .announcement-modal .add-to-list-modal width: 100% // Medium devices (tablets, 768px and up) @media (max-width: $medium-devices) diff --git a/common/static/sass/_Vendor.sass b/common/static/sass/_Vendor.sass index b68099e6..bffa2a2f 100644 --- a/common/static/sass/_Vendor.sass +++ b/common/static/sass/_Vendor.sass @@ -50,4 +50,7 @@ .tippy-content .tag-input input - flex-grow: 1 \ No newline at end of file + flex-grow: 1 + +.tools-section-wrapper input, .tools-section-wrapper select + width: unset diff --git a/common/templates/common/error.html b/common/templates/common/error.html index e2c1b70c..f4290972 100644 --- a/common/templates/common/error.html +++ b/common/templates/common/error.html @@ -5,9 +5,8 @@ <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> - <meta http-equiv="refresh" content="3;url={% url 'common:home' %}"> - <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/skeleton-css@2.0.4/css/normalize.css"> - <link rel="stylesheet" href="{% static 'lib/css/milligram.css' %}"> + <meta http-equiv="refresh" content="5;url={% if url %}{{url}}{% else %}{% url 'common:home' %}{% endif %}"> + <link rel="stylesheet" href="https://static.neodb.social/cdnjs.cloudflare.com/ajax/libs/milligram/1.4.1/milligram.min.css"> <link rel="stylesheet" href="{% static 'css/boofilsic_edit.css' %}"> <link rel="stylesheet" href="{% static 'css/boofilsic_box.css' %}"> <title>{% trans '错误' %}</title> diff --git a/common/templates/common/external_search_result.html b/common/templates/common/external_search_result.html new file mode 100644 index 00000000..9d2132bb --- /dev/null +++ b/common/templates/common/external_search_result.html @@ -0,0 +1,48 @@ +{% load static %} +{% load i18n %} +{% load l10n %} +{% load humanize %} +{% load admin_url %} +{% load mastodon %} +{% load oauth_token %} +{% load truncate %} +{% load highlight %} +{% load thumb %} + +{% for item in external_items %} +<li class="entity-list__entity"> + <div class="entity-list__entity-img-wrapper"> + <a href="{{ item.link }}"> + <img src="{{ item.cover_url }}" alt="" class="entity-list__entity-img"> + </a> + </div> + <div class="entity-list__entity-text"> + <div class="entity-list__entity-title" style="font-style:italic;"> + <a href="{{ item.link }}" class="entity-list__entity-link"> + {% if request.GET.q %} + {{ item.title | highlight:request.GET.q }} + {% else %} + {{ item.title }} + {% endif %} + </a> + + {% if not request.GET.c or not request.GET.c in categories %} + <span class="entity-list__entity-category">[{{item.verbose_category_name}}]</span> + {% endif %} + <a href="{{ item.source_url }}"> + <span class="source-label source-label__{{ item.source_site }}">{{ item.source_site.label }}</span> + </a> + </div> + + <span class="entity-list__entity-info entity-list__entity-info--full-length"> + {{item.subtitle}} + </span> + <p class="entity-list__entity-brief"> + {{ item.brief }} + </p> + <div class="tag-collection"> + </div> + </div> + +</li> +{% endfor %} \ No newline at end of file diff --git a/common/templates/common/search_result.html b/common/templates/common/search_result.html index cc420eed..0144093d 100644 --- a/common/templates/common/search_result.html +++ b/common/templates/common/search_result.html @@ -14,12 +14,14 @@ <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> - <title>{% trans 'NiceDB - 搜索结果' %}</title> - <script src="https://cdnjs.cloudflare.com/ajax/libs/jquery/3.5.1/jquery.min.js"></script> + <title>{{ site_name }} - {% trans '搜索结果' %}</title> + <script src="https://static.neodb.social/cdnjs.cloudflare.com/ajax/libs/jquery/3.6.0/jquery.min.js"></script> + <script src="https://static.neodb.social/cdnjs.cloudflare.com/ajax/libs/htmx/1.8.0/htmx.min.js"></script> <script src="{% static 'lib/js/rating-star.js' %}"></script> <script src="{% static 'js/rating-star-readonly.js' %}"></script> <link rel="stylesheet" href="{% static 'css/boofilsic.min.css' %}"> <link rel="stylesheet" href="{% static 'lib/css/rating-star.css' %}"> + <link rel="stylesheet" href="{% static 'lib/css/neo.css' %}"> </head> <body> @@ -43,399 +45,39 @@ <ul class="entity-list__entities"> {% for item in items %} - - {% if item.category_name|lower == 'book' %} - - {% with book=item %} - <li class="entity-list__entity"> - <div class="entity-list__entity-img-wrapper"> - <a href="{% url 'books:retrieve' book.id %}"> - <img src="{{ book.cover|thumb:'normal' }}" alt="" class="entity-list__entity-img"> - </a> - </div> - <div class="entity-list__entity-text"> - - <div class="entity-list__entity-title"> - - <a href="{% url 'books:retrieve' book.id %}" class="entity-list__entity-link"> - {% if request.GET.q %} - {{ book.title | highlight:request.GET.q }} - {% else %} - {{ book.title }} - {% endif %} - - </a> - {% if not request.GET.c or not request.GET.c in categories %} - <span class="entity-list__entity-category">[{{item.verbose_category_name}}]</span> - {% endif %} - <a href="{{ book.source_url }}"> - <span class="source-label source-label__{{ book.source_site }}">{{ book.get_source_site_display }}</span> - </a> - </div> - - {% if book.rating %} - <div class="rating-star entity-list__rating-star" data-rating-score="{{ book.rating | floatformat:"0" }}"></div> - <span class="entity-list__rating-score rating-score">{{ book.rating }}</span> - {% else %} - <div class="entity-list__rating entity-list__rating--empty"> {% trans '暂无评分' %}</div> - {% endif %} - - <span class="entity-list__entity-info"> - {% if book.pub_year %} - {{ book.pub_year }}{% trans '年' %} - {% if book.pub_month %} - {{book.pub_month }}{% trans '月' %} / - {% endif %} - {% endif %} - - {% if book.author %} - {% trans '作者' %} - {% for author in book.author %} - {{ author }}{% if not forloop.last %},{% endif %} - {% endfor %}/ - {% endif %} - - {% if book.translator %} - {% trans '译者' %} - {% for translator in book.translator %} - {{ translator }}{% if not forloop.last %},{% endif %} - {% endfor %}/ - {% endif %} - - {% if book.orig_title %} - {% trans '原名' %} - {{ book.orig_title }} - {% endif %} - </span> - <p class="entity-list__entity-brief"> - {{ book.brief }} - </p> - - <div class="tag-collection"> - {% for tag_dict in book.tag_list %} - {% for k, v in tag_dict.items %} - {% if k == 'content' %} - <span class="tag-collection__tag"> - <a href="{% url 'common:search' %}?tag={{ v }}">{{ v }}</a> - </span> - {% endif %} - {% endfor %} - {% endfor %} - </div> - </div> - </li> - {% endwith %} - - {% elif item.category_name|lower == 'movie' %} - - {% with movie=item %} - <li class="entity-list__entity"> - <div class="entity-list__entity-img-wrapper"> - <a href="{% url 'movies:retrieve' movie.id %}"> - <img src="{{ movie.cover|thumb:'normal' }}" alt="" class="entity-list__entity-img"> - </a> - </div> - <div class="entity-list__entity-text"> - <div class="entity-list__entity-title"> - <a href="{% url 'movies:retrieve' movie.id %}" class="entity-list__entity-link"> - {% if movie.season %} - - {% if request.GET.q %} - {{ movie.title | highlight:request.GET.q }} {% trans '第' %}{{ movie.season|apnumber }}{% trans '季' %} - {{ movie.orig_title | highlight:request.GET.q }} Season {{ movie.season }} - {% if movie.year %}({{ movie.year }}){% endif %} - {% else %} - {{ movie.title }} {% trans '第' %}{{ movie.season|apnumber }}{% trans '季' %} - {{ movie.orig_title }} Season {{ movie.season }} - {% if movie.year %}({{ movie.year }}){% endif %} - {% endif %} - - {% else %} - {% if request.GET.q %} - {{ movie.title | highlight:request.GET.q }} {{ movie.orig_title | highlight:request.GET.q }} - {% if movie.year %}({{ movie.year }}){% endif %} - {% else %} - {{ movie.title }} {{ movie.orig_title }} - {% if movie.year %}({{ movie.year }}){% endif %} - {% endif %} - {% endif %} - </a> - - {% if not request.GET.c or not request.GET.c in categories %} - <span class="entity-list__entity-category">[{{item.verbose_category_name}}]</span> - {% endif %} - <a href="{{ movie.source_url }}"> - <span class="source-label source-label__{{ movie.source_site }}">{{ movie.get_source_site_display }}</span> - </a> - </div> - - {% if movie.rating %} - <div class="rating-star entity-list__rating-star" data-rating-score="{{ movie.rating | floatformat:"0" }}"></div> - <span class="entity-list__rating-score rating-score">{{ movie.rating }}</span> - {% else %} - <div class="entity-list__rating entity-list__rating--empty"> {% trans '暂无评分' %}</div> - {% endif %} - - <span class="entity-list__entity-info "> - - - {% if movie.director %}{% trans '导演' %} - {% for director in movie.director %} - {{ director }}{% if not forloop.last %} {% endif %} - {% endfor %}/ - {% endif %} - - {% if movie.genre %}{% trans '类型' %} - {% for genre in movie.get_genre_display %} - {{ genre }}{% if not forloop.last %} {% endif %} - {% endfor %}/ - {% endif %} - - </span> - <span class="entity-list__entity-info entity-list__entity-info--full-length"> - {% if movie.actor %}{% trans '主演' %} - {% for actor in movie.actor %} - <span {% if forloop.counter > 5 %}style="display: none;" {% endif %}>{{ actor }}</span> - {% if forloop.counter <= 5 %} - {% if not forloop.counter == 5 and not forloop.last %} {% endif %} - {% endif %} - {% endfor %} - {% endif %} - </span> - <p class="entity-list__entity-brief"> - {{ movie.brief }} - </p> - <div class="tag-collection"> - {% for tag_dict in movie.tag_list %} - {% for k, v in tag_dict.items %} - {% if k == 'content' %} - <span class="tag-collection__tag"> - <a href="{% url 'common:search' %}?tag={{ v }}">{{ v }}</a> - </span> - {% endif %} - {% endfor %} - {% endfor %} - </div> - </div> - - </li> - {% endwith %} - - {% elif item.category_name|lower == 'game' %} - - {% with game=item %} - <li class="entity-list__entity"> - <div class="entity-list__entity-img-wrapper"> - <a href="{% url 'games:retrieve' game.id %}"> - <img src="{{ game.cover|thumb:'normal' }}" alt="" class="entity-list__entity-img"> - </a> - </div> - <div class="entity-list__entity-text"> - <div class="entity-list__entity-title"> - <a href="{% url 'games:retrieve' game.id %}" class="entity-list__entity-link"> - {% if request.GET.q %} - {{ game.title | highlight:request.GET.q }} - {% else %} - {{ game.title }} - {% endif %} - </a> - - {% if not request.GET.c or not request.GET.c in categories %} - <span class="entity-list__entity-category">[{{item.verbose_category_name}}]</span> - {% endif %} - <a href="{{ game.source_url }}"> - <span class="source-label source-label__{{ game.source_site }}">{{ game.get_source_site_display }}</span> - </a> - </div> - - {% if game.rating %} - <div class="rating-star entity-list__rating-star" data-rating-score="{{ game.rating | floatformat:"0" }}"></div> - <span class="entity-list__rating-score rating-score">{{ game.rating }}</span> - {% else %} - <div class="entity-list__rating entity-list__rating--empty"> {% trans '暂无评分' %}</div> - {% endif %} - - <span class="entity-list__entity-info entity-list__entity-info--full-length"> - - {% if game.other_title %}{% trans '别名' %} - {% for other_title in game.other_title %} - {{ other_title }}{% if not forloop.last %} {% endif %} - {% endfor %}/ - {% endif %} - - {% if game.developer %}{% trans '开发商' %} - {% for developer in game.developer %} - {{ developer }}{% if not forloop.last %} {% endif %} - {% endfor %}/ - {% endif %} - - {% if game.genre %}{% trans '类型' %} - {% for genre in game.genre %} - {{ genre }}{% if not forloop.last %} {% endif %} - {% endfor %}/ - {% endif %} - - {% if game.platform %}{% trans '平台' %} - {% for platform in game.platform %} - {{ platform }}{% if not forloop.last %} {% endif %} - {% endfor %}/ - {% endif %} - - </span> - <p class="entity-list__entity-brief"> - {{ game.brief }} - </p> - <div class="tag-collection"> - {% for tag_dict in game.tag_list %} - {% for k, v in tag_dict.items %} - {% if k == 'content' %} - <span class="tag-collection__tag"> - <a href="{% url 'common:search' %}?tag={{ v }}">{{ v }}</a> - </span> - {% endif %} - {% endfor %} - {% endfor %} - </div> - </div> - - </li> - {% endwith %} - - {% elif item.category_name|lower == 'album' or item.category_name|lower == 'song' %} - - {% with music=item %} - <li class="entity-list__entity"> - <div class="entity-list__entity-img-wrapper"> - - {% if item.category_name|lower == 'album' %} - <a href="{% url 'music:retrieve_album' music.id %}"> - <img src="{{ music.cover|thumb:'normal' }}" alt="" class="entity-list__entity-img"> - </a> - {% elif item.category_name|lower == 'song' %} - <a href="{% url 'music:retrieve_song' music.id %}"> - <img src="{{ music.cover|thumb:'normal' }}" alt="" class="entity-list__entity-img"> - </a> - {% endif %} - - </div> - <div class="entity-list__entity-text"> - <div class="entity-list__entity-title"> - - {% if item.category_name|lower == 'album' %} - <a href="{% url 'music:retrieve_album' music.id %}" class="entity-list__entity-link"> - {% if request.GET.q %} - {{ music.title | highlight:request.GET.q }} - {% else %} - {{ music.title }} - {% endif %} - </a> - {% elif item.category_name|lower == 'song' %} - <a href="{% url 'music:retrieve_song' music.id %}" class="entity-list__entity-link"> - {% if request.GET.q %} - {{ music.title | highlight:request.GET.q }} - {% else %} - {{ music.title }} - {% endif %} - </a> - {% endif %} - - - {% if not request.GET.c or not request.GET.c in categories %} - <span class="entity-list__entity-category">[{{item.verbose_category_name}}]</span> - {% endif %} - <a href="{{ music.source_url }}"> - <span class="source-label source-label__{{ music.source_site }}">{{ music.get_source_site_display }}</span> - </a> - </div> - - {% if music.rating %} - <div class="rating-star entity-list__rating-star" data-rating-score="{{ music.rating | floatformat:"0" }}"></div> - <span class="entity-list__rating-score rating-score">{{ music.rating }}</span> - {% else %} - <div class="entity-list__rating entity-list__rating--empty"> {% trans '暂无评分' %}</div> - {% endif %} - - <span class="entity-list__entity-info "> - {% if music.artist %}{% trans '艺术家' %} - {% for artist in music.artist %} - <span>{{ artist }}</span> - {% if not forloop.last %} {% endif %} - {% endfor %} - {% endif %} - - {% if music.genre %}/ {% trans '流派' %} - {{ music.genre }} - {% endif %} - - {% if music.release_date %}/ {% trans '发行日期' %} - {{ music.release_date }} - {% endif %} - </span> - <span class="entity-list__entity-info entity-list__entity-info--full-length"> - - </span> - - {% if music.brief %} - <p class="entity-list__entity-brief"> - {{ music.brief }} - </p> - {% elif music.category_name|lower == 'album' %} - <p class="entity-list__entity-brief"> - {% trans '曲目:' %}{{ music.track_list }} - </p> - {% else %} - <!-- song --> - <p class="entity-list__entity-brief"> - {% trans '所属专辑:' %}{{ music.album }} - </p> - {% endif %} - - <div class="tag-collection"> - {% for tag_dict in music.tag_list %} - {% for k, v in tag_dict.items %} - {% if k == 'content' %} - <span class="tag-collection__tag"> - <a href="{% url 'common:search' %}?tag={{ v }}">{{ v }}</a> - </span> - {% endif %} - {% endfor %} - {% endfor %} - </div> - </div> - - </li> - {% endwith %} - - {% endif %} - - - + {% include "partial/list_item.html" %} {% empty %} - {% trans '无结果' %} + <li class="entity-list__entity"> + {% trans '无站内条目匹配' %} + </li> {% endfor %} - + {% if request.GET.q and user.is_authenticated %} + <li class="entity-list__entity" hx-get="{% url 'common:external_search' %}?q={{ request.GET.q }}&c={{ request.GET.c }}&page={% if pagination.current_page %}{{ pagination.current_page }}{% else %}1{% endif %}" hx-trigger="load" hx-swap="outerHTML"> + {% trans '正在实时搜索站外条目' %} + </li> + {% endif %} </ul> </div> <div class="pagination" > - {% if items.pagination.has_prev %} - <a href="?page=1&{% if request.GET.q %}q={{ request.GET.q }}{% elif request.GET.tag %}tag={{ request.GET.tag }}{% endif %}" class="pagination__nav-link pagination__nav-link">«</a> - <a href="?page={{ items.previous_page_number }}&{% if request.GET.q %}q={{ request.GET.q }}{% elif request.GET.tag %}tag={{ request.GET.tag }}{% endif %}" class="pagination__nav-link pagination__nav-link--right-margin pagination__nav-link">‹</a> + {% if pagination.has_prev %} + <a href="?page=1&{% if request.GET.q %}q={{ request.GET.q }}{% elif request.GET.tag %}tag={{ request.GET.tag }}{% endif %}{% if request.GET.c %}&c={{ request.GET.c }}{% endif %}" class="pagination__nav-link pagination__nav-link">«</a> + <a href="?page={{ pagination.previous_page }}&{% if request.GET.q %}q={{ request.GET.q }}{% elif request.GET.tag %}tag={{ request.GET.tag }}{% endif %}{% if request.GET.c %}&c={{ request.GET.c }}{% endif %}" class="pagination__nav-link pagination__nav-link--right-margin pagination__nav-link">‹</a> {% endif %} - {% for page in items.pagination.page_range %} + {% for page in pagination.page_range %} - {% if page == items.pagination.current_page %} - <a href="?page={{ page }}&{% if request.GET.q %}q={{ request.GET.q }}{% elif request.GET.tag %}tag={{ request.GET.tag }}{% endif %}" class="pagination__page-link pagination__page-link--current">{{ page }}</a> + {% if page == pagination.current_page %} + <a href="?page={{ page }}&{% if request.GET.q %}q={{ request.GET.q }}{% elif request.GET.tag %}tag={{ request.GET.tag }}{% endif %}{% if request.GET.c %}&c={{ request.GET.c }}{% endif %}" class="pagination__page-link pagination__page-link--current">{{ page }}</a> {% else %} - <a href="?page={{ page }}&{% if request.GET.q %}q={{ request.GET.q }}{% elif request.GET.tag %}tag={{ request.GET.tag }}{% endif %}" class="pagination__page-link">{{ page }}</a> + <a href="?page={{ page }}&{% if request.GET.q %}q={{ request.GET.q }}{% elif request.GET.tag %}tag={{ request.GET.tag }}{% endif %}{% if request.GET.c %}&c={{ request.GET.c }}{% endif %}" class="pagination__page-link">{{ page }}</a> {% endif %} {% endfor %} - {% if items.pagination.has_next %} - <a href="?page={{ items.next_page_number }}&{% if request.GET.q %}q={{ request.GET.q }}{% elif request.GET.tag %}tag={{ request.GET.tag }}{% endif %}" class="pagination__nav-link pagination__nav-link--left-margin">›</a> - <a href="?page={{ items.pagination.last_page }}&{% if request.GET.q %}q={{ request.GET.q }}{% elif request.GET.tag %}tag={{ request.GET.tag }}{% endif %}" class="pagination__nav-link">»</a> + {% if pagination.has_next %} + <a href="?page={{ pagination.next_page }}&{% if request.GET.q %}q={{ request.GET.q }}{% elif request.GET.tag %}tag={{ request.GET.tag }}{% endif %}{% if request.GET.c %}&c={{ request.GET.c }}{% endif %}" class="pagination__nav-link pagination__nav-link--left-margin">›</a> + <a href="?page={{ pagination.last_page }}&{% if request.GET.q %}q={{ request.GET.q }}{% elif request.GET.tag %}tag={{ request.GET.tag }}{% endif %}{% if request.GET.c %}&c={{ request.GET.c }}{% endif %}" class="pagination__nav-link">»</a> {% endif %} </div> @@ -500,7 +142,7 @@ </a> {% endif %} </div> - <div class="add-entity-entries__entry"> + <!-- div class="add-entity-entries__entry"> {% if request.GET.c and request.GET.c in categories %} {% if request.GET.c|lower == 'book' %} @@ -560,7 +202,7 @@ </a> {% endif %} - </div> + </div --> </div> @@ -573,15 +215,11 @@ </div> - {% comment %} - <div id="oauth2Token" hidden="true">{% oauth_token %}</div> - <div id="mastodonURI" hidden="true">{% mastodon request.user.mastodon_site %}</div> - <!--current user mastodon id--> - <div id="userMastodonID" hidden="true">{{ user.mastodon_id }}</div> - {% endcomment %} <script> - + document.body.addEventListener('htmx:configRequest', (event) => { + event.detail.headers['X-CSRFToken'] = '{{ csrf_token }}'; + }) </script> </body> diff --git a/common/templates/partial/_announcement.html b/common/templates/partial/_announcement.html new file mode 100644 index 00000000..166d2856 --- /dev/null +++ b/common/templates/partial/_announcement.html @@ -0,0 +1,61 @@ +{% load static %} +{% load i18n %} +{% load admin_url %} +{% load mastodon %} +{% load oauth_token %} +{% load truncate %} +{% load thumb %} +<div id="modals"> + <style> + .bottom-link { + margin-top: 30px; text-align: center; margin-bottom: 5px; + } + .bottom-link a { + color: #ccc; + } + </style> + <div class="announcement-modal modal"> + <div class="announcement-modal__head"> + <h4 class="announcement-modal__title">{% trans '公告' %}</h4> + + <span class="announcement-modal__close-button modal-close"> + <span class="icon-cross"> + <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20"> + <polygon + points="20 2.61 17.39 0 10 7.39 2.61 0 0 2.61 7.39 10 0 17.39 2.61 20 10 12.61 17.39 20 20 17.39 12.61 10 20 2.61"> + </polygon> + </svg> + </span> + </span> + </div> + <div class="announcement-modal__body"> + <ul> + {% for ann in unread_announcements %} + <li class="announcement"> + <a href="{% url 'management:retrieve' ann.pk %}"> + <h5 class="announcement__title">{{ ann.title }}</h5> + </a> + <span class="announcement__datetime">{{ ann.created_time }}</span> + <p class="announcement__content">{{ ann.get_plain_content | truncate:200 }}</p> + </li> + {% if not forloop.last %} + <div class="dividing-line" style="border-top-style: dashed;"></div> + {% endif %} + {% endfor %} + </ul> + <div class="bottom-link"> + <a href="{% url 'management:list' %}">{% trans '查看全部公告' %}</a> + </div> + </div> + </div> +</div> +<div class="bg-mask"></div> +<script> + // because the modal and mask elements only exist when there are new announcements + $(".announcement-modal").show(); + $(".bg-mask").show(); + $(".modal-close").on('click', function () { + $(this).parents(".modal").hide(); + $(".bg-mask").hide(); + }); +</script> diff --git a/common/templates/partial/_common_libs.html b/common/templates/partial/_common_libs.html new file mode 100644 index 00000000..9460e2da --- /dev/null +++ b/common/templates/partial/_common_libs.html @@ -0,0 +1,23 @@ +{% load static %} +{% if sentry_dsn %} +<script src="https://static.neodb.social/browser.sentry-cdn.com/7.7.0/bundle.min.js"></script> +<script> + if (window.Sentry) Sentry.init({ + dsn: "{{ sentry_dsn }}", + release: "NeoDB@{{ version_hash }}", + environment: "{{ settings_module }}", + tracesSampleRate: 1.0, + }); +</script> +{% endif %} +{% if jquery %} +<script src="https://static.neodb.social/cdnjs.cloudflare.com/ajax/libs/jquery/3.6.0/jquery.min.js"></script> +{% else %} +<script src="https://static.neodb.social/cdnjs.cloudflare.com/ajax/libs/cash/8.1.1/cash.min.js"></script> +{% endif %} +<script src="https://static.neodb.social/cdnjs.cloudflare.com/ajax/libs/htmx/1.8.0/htmx.min.js"></script> +<script src="https://static.neodb.social/unpkg.com/hyperscript.org@0.9.7.js"></script> +<link rel="stylesheet" href="{% static 'css/boofilsic.css' %}"> +<link rel="stylesheet" href="{% static 'lib/css/rating-star.css' %}"> +<link rel="stylesheet" href="{% static 'lib/css/neo.css' %}"> +<link rel="search"type="application/opensearchdescription+xml" title="{{ site_name }}" href="{% static 'opensearch.xml' %}"> diff --git a/common/templates/partial/_footer.html b/common/templates/partial/_footer.html index e5710473..412f00ad 100644 --- a/common/templates/partial/_footer.html +++ b/common/templates/partial/_footer.html @@ -1,13 +1,12 @@ <footer class="footer"> <div class="grid"> <div class="footer__border"> - <a class="footer__link" target="_blank" href="https://donotban.com/@whitiewhite">作者长毛象</a> - <a class="footer__link" target="_blank" href="https://github.com/doubaniux/boofilsic/issues">报告错误</a> + <a class="footer__link" target="_blank" href="https://donotban.com/@whitiewhite">原作者</a> + <a class="footer__link" target="_blank" href="{{ support_link }}">报告错误</a> <a class="footer__link" target="_blank" href="https://github.com/doubaniux/boofilsic" id="githubLink">Github</a> - <a class="footer__link" target="_blank" href="https://patreon.com/tertius" id="sponsor">捐助项目</a> + <a class="footer__link" target="_blank" href="https://patreon.com/tertius" id="sponsor">捐助上游项目</a> <a class="footer__link" target="_blank" href="/announcement/supported-sites/" id="supported-sites">支持的网站</a> <a class="footer__link" target="_blank" href="/announcement/" id="supported-sites">公告栏</a> - <a class="footer__link" href="javascript:void();" id="version">V0.4.4</a> </div> </div> </footer> \ No newline at end of file diff --git a/common/templates/partial/_navbar.html b/common/templates/partial/_navbar.html index 171eda8d..90d7a459 100644 --- a/common/templates/partial/_navbar.html +++ b/common/templates/partial/_navbar.html @@ -1,24 +1,24 @@ {% load static %} {% load i18n %} {% load admin_url %} +<form method="get" action="{% url 'common:search' %}"> <section id="navbar"> <nav class="navbar"> <div class="grid"> <div class="navbar__wrapper"> - <a href="{% url 'common:home' %}" class="navbar__logo"> <img src="{% static 'img/logo.svg' %}" alt="" class="navbar__logo-img"> </a> <div class="navbar__search-box"> <!-- <input type="search" class="" name="q" id="searchInput" required="true" value="{% for v in request.GET.values %}{{ v }}{% endfor %}" --> <input type="search" class="" name="q" id="searchInput" required="true" value="{% if request.GET.q %}{{ request.GET.q }}{% endif %}" - placeholder="搜索书影音"> - <select class="navbar__search-dropdown" id="searchCategory"> - <option value="all" {% if request.GET.c and request.GET.c != 'movie' and request.GET.c != 'book' or not request.GET.c %}selected{% endif %}>{% trans '任意' %}</option> - <option value="book" {% if request.GET.c and request.GET.c == 'book' %}selected{% endif %}>{% trans '书籍' %}</option> - <option value="movie" {% if request.GET.c and request.GET.c == 'movie' %}selected{% endif %}>{% trans '电影' %}</option> - <option value="music" {% if request.GET.c and request.GET.c == 'music' %}selected{% endif %}>{% trans '音乐' %}</option> - <option value="game" {% if request.GET.c and request.GET.c == 'game' %}selected{% endif %}>{% trans '游戏' %}</option> + placeholder="搜索书影音游戏,或输入站外条目链接如 https://movie.douban.com/subject/1297880/ 支持站点列表见页底公告栏"> + <select class="navbar__search-dropdown" id="searchCategory" name="c"> + <option value="all" {% if request.GET.c and request.GET.c == 'all' or not request.GET.c %}selected{% endif %}>{% trans '任意' %}</option> + <option value="book" {% if request.GET.c and request.GET.c == 'book' or '/books/' in request.path %}selected{% endif %}>{% trans '书籍' %}</option> + <option value="movie" {% if request.GET.c and request.GET.c == 'movie' or '/movies/' in request.path %}selected{% endif %}>{% trans '电影' %}</option> + <option value="music" {% if request.GET.c and request.GET.c == 'music' or '/music/' in request.path %}selected{% endif %}>{% trans '音乐' %}</option> + <option value="game" {% if request.GET.c and request.GET.c == 'game' or '/games/' in request.path %}selected{% endif %}>{% trans '游戏' %}</option> </select> </div> <button class="navbar__dropdown-btn">• • •</button> @@ -26,8 +26,11 @@ {% if request.user.is_authenticated %} + <a class="navbar__link" href="{% url 'users:home' request.user.mastodon_username %}">{% trans '主页' %}</a> + <a class="navbar__link" href="{% url 'timeline:timeline' %}">{% trans '动态' %}</a> + <a class="navbar__link" id="logoutLink" href="{% url 'users:data' %}">{% trans '数据' %}</a> + <a class="navbar__link" id="logoutLink" href="{% url 'users:preferences' %}">{% trans '设置' %}</a> <a class="navbar__link" id="logoutLink" href="{% url 'users:logout' %}">{% trans '登出' %}</a> - <a class="navbar__link" href="{% url 'common:home' %}">{% trans '主页' %}</a> {% if request.user.is_staff %} <a class="navbar__link" href="{% admin_url %}">{% trans '后台' %}</a> {% endif %} @@ -36,23 +39,9 @@ <a class="navbar__link" href="{% url 'users:login' %}?next={{ request.path }}">{% trans '登录' %}</a> {% endif %} </ul> - </div> </div> </nav> - <script> - $("#searchInput").on('keyup', function (e) { - // e.preventDefault(); - if (e.keyCode === 13) { - let q = $(this).val(); - let c = $("#searchCategory").val(); - if (q) { - let new_location = "{% url 'common:search' %}" + "?c=" + c + "&q=" + q; - setTimeout(function () { document.location.href = new_location; }, 150); - } - } - }); - - </script> -</section> \ No newline at end of file +</section> +</form> diff --git a/common/templates/partial/_sidebar.html b/common/templates/partial/_sidebar.html new file mode 100644 index 00000000..90bc2c0d --- /dev/null +++ b/common/templates/partial/_sidebar.html @@ -0,0 +1,186 @@ +{% load static %} +{% load i18n %} +{% load admin_url %} +{% load mastodon %} +{% load oauth_token %} +{% load truncate %} +{% load thumb %} +{% load neo %} +<div class="grid__aside grid__aside--reverse-order grid__aside--tablet-column"> + <div class="aside-section-wrapper aside-section-wrapper--no-margin"> + <div class="user-profile" id="userInfoCard"> + <div class="user-profile__header"> + <!-- <img src="" class="user-profile__avatar mast-avatar" alt="{{ user.username }}"> --> + <img src="{{ user.mastodon_account.avatar }}" class="user-profile__avatar mast-avatar"> + <a href="{% url 'users:home' user.mastodon_username %}"> + <h5 class="user-profile__username mast-displayname">{{ user.mastodon_account.display_name }}</h5> + </a> + </div> + <p><a class="user-profile__link mast-acct" target="_blank" href="{{ user.mastodon_account.url }}">@{{ user.username }}@{{ user.mastodon_site }}</a> + {% current_user_relationship user as relationship %} + {% if relationship %} + <a class="user-profile__report-link"> + {{ relationship }} + </a> + {% endif %} + </p> + <p class="user-profile__bio mast-brief">{{ user.mastodon_account.note }}</p> + + {% if request.user != user %} + <a href="{% url 'users:report' %}?user_id={{ user.id }}" + class="user-profile__report-link">{% trans '投诉用户' %}</a> + {% endif %} + + </div> + </div> + + <div class="relation-dropdown"> + <div class="relation-dropdown__button"> + <span class="icon-arrow"> + <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 10 10"> + <path d="M8.12,3.29,5,6.42,1.86,3.29H.45L5,7.84,9.55,3.29Z" /> + </svg> + </span> + </div> + {% if user == request.user %} + <div class="relation-dropdown__body"> + <div + class="aside-section-wrapper aside-section-wrapper--transparent aside-section-wrapper--collapse"> + + <div class="user-relation" id="followings"> + <h5 class="user-relation__label"> + {% trans '关注的人' %} + </h5> + <a href="{% url 'users:following' user.mastodon_username %}" + class="user-relation__more-link mast-following-more">{% trans '更多' %}</a> + <ul class="user-relation__related-user-list mast-following"> + <li class="user-relation__related-user"> + <a> + <img src="" alt="" class="user-relation__related-user-avatar"> + <div class="user-relation__related-user-name mast-displayname"> + </div> + </a> + </li> + </ul> + </div> + + <div class="user-relation" id="followers"> + <h5 class="user-relation__label"> + {% trans '被他们关注' %} + </h5> + <a href="{% url 'users:followers' user.mastodon_username %}" + class="user-relation__more-link mast-followers-more">{% trans '更多' %}</a> + <ul class="user-relation__related-user-list mast-followers"> + <li class="user-relation__related-user"> + <a> + <img src="" alt="" class="user-relation__related-user-avatar"> + <div class="user-relation__related-user-name mast-displayname"> + </div> + </a> + </li> + </ul> + </div> + + <div class="user-relation"> + <h5 class="user-relation__label"> + {% trans '常用标签' %} + </h5> + <a href="{% url 'users:tag_list' user.mastodon_username %}">{% trans '更多' %}</a> + <div class="tag-collection" style="margin-left: 0;"> + {% if book_tags %} + <div>{% trans '书籍' %}</div> + {% for v in book_tags %} + <span class="tag-collection__tag"> + <a href="{% url 'users:book_list' user.mastodon_username 'tagged' %}?t={{ v.content }}">{{ v.content }}</a> + </span> + {% endfor %} + <div class="clearfix"></div> + {% endif %} + + {% if movie_tags %} + <div>{% trans '电影和剧集' %}</div> + {% for v in movie_tags %} + <span class="tag-collection__tag"> + <a href="{% url 'users:movie_list' user.mastodon_username 'tagged' %}?t={{ v.content }}">{{ v.content }}</a> + </span> + {% endfor %} + <div class="clearfix"></div> + {% endif %} + + {% if music_tags %} + <div>{% trans '音乐' %}</div> + {% for v in music_tags %} + <span class="tag-collection__tag"> + <a href="{% url 'users:music_list' user.mastodon_username 'tagged' %}?t={{ v.content }}">{{ v.content }}</a> + </span> + {% endfor %} + <div class="clearfix"></div> + {% endif %} + + {% if game_tags %} + <div>{% trans '游戏' %}</div> + {% for v in game_tags %} + <span class="tag-collection__tag"> + <a href="{% url 'users:game_list' user.mastodon_username 'tagged' %}?t={{ v.content }}">{{ v.content }}</a> + </span> + {% endfor %} + <div class="clearfix"></div> + {% endif %} + </div> + </div> + + </div> + + <div + class="aside-section-wrapper aside-section-wrapper--transparent aside-section-wrapper--collapse"> + {% if request.user.is_staff and request.user == user%} + <div class="report-panel"> + <h5 class="report-panel__label">{% trans '投诉信息' %}</h5> + <a class="report-panel__all-link" + href="{% url 'users:manage_report' %}">全部投诉</a> + <div class="report-panel__body"> + <ul class="report-panel__report-list"> + {% for report in reports %} + <li class="report-panel__report"> + <a href="{% url 'users:home' report.submit_user.mastodon_username %}" + class="report-panel__user-link">{{ report.submit_user }}</a>{% trans '已投诉' %}<a + href="{% url 'users:home' report.reported_user.mastodon_username %}" + class="report-panel__user-link">{{ report.reported_user }}</a> + </li> + {% empty %} + <div>{% trans '暂无新投诉' %}</div> + {% endfor %} + + </ul> + </div> + </div> + {% endif %} + </div> + </div> + {% endif %} + </div> +</div> + +{% if user == request.user %} +<div id="oauth2Token" hidden="true">{{ request.user.mastodon_token }}</div> +<div id="mastodonURI" hidden="true">{{ request.user.mastodon_site }}</div> +<div id="userMastodonID" hidden="true">{{ user.mastodon_id }}</div> +<div id="userPageURL" hidden="true">{% url 'users:home' 0 %}</div> + +<div id="spinner" hidden> + <div class="spinner"> + <div></div> + <div></div> + <div></div> + <div></div> + <div></div> + <div></div> + <div></div> + <div></div> + <div></div> + <div></div> + <div></div> + <div></div> + </div> +</div> +{% endif %} diff --git a/common/templates/partial/list_item.html b/common/templates/partial/list_item.html new file mode 100644 index 00000000..9b4eb9ad --- /dev/null +++ b/common/templates/partial/list_item.html @@ -0,0 +1,9 @@ +{% if item.category_name|lower == 'book' %} +{% include "partial/list_item_book.html" with book=item %} +{% elif item.category_name|lower == 'movie' %} +{% include "partial/list_item_movie.html" with movie=item %} +{% elif item.category_name|lower == 'game' %} +{% include "partial/list_item_game.html" with game=item %} +{% elif item.category_name|lower == 'album' or item.category_name|lower == 'song' %} +{% include "partial/list_item_music.html" with music=item %} +{% endif %} \ No newline at end of file diff --git a/common/templates/partial/list_item_book.html b/common/templates/partial/list_item_book.html new file mode 100644 index 00000000..3c5dc9eb --- /dev/null +++ b/common/templates/partial/list_item_book.html @@ -0,0 +1,159 @@ +{% load thumb %} +{% load highlight %} +{% load i18n %} +{% load l10n %} +{% load neo %} +{% current_user_marked_item book as marked %} +<li class="entity-list__entity"> + <div class="entity-list__entity-img-wrapper"> + <a href="{% url 'books:retrieve' book.id %}"> + <img src="{{ book.cover|thumb:'normal' }}" alt="" class="entity-list__entity-img"> + </a> + {% if not marked %} + <a class="entity-list__entity-action-icon" hx-post="{% url 'books:wish' book.id %}" title="加入想读">➕</a> + {% endif %} + </div> + + <div class="entity-list__entity-text"> + {% if editable %} + <div class="collection-item-position-edit"> + {% if not forloop.first %} + <a hx-target=".entity-list" hx-post="{% url 'collection:move_up_item' form.instance.id collectionitem.id %}">▲</a> + {% endif %} + {% if not forloop.last %} + <a hx-target=".entity-list" hx-post="{% url 'collection:move_down_item' form.instance.id collectionitem.id %}">▼</a> + {% endif %} + <a hx-target=".entity-list" hx-post="{% url 'collection:delete_item' form.instance.id collectionitem.id %}">✖</a> + </div> + {% endif %} + + <div class="entity-list__entity-title"> + + <a href="{% url 'books:retrieve' book.id %}" class="entity-list__entity-link"> + {% if request.GET.q %} + {{ book.title | highlight:request.GET.q }} + {% else %} + {{ book.title }} + {% endif %} + + </a> + {% if not request.GET.c and not hide_category %} + <span class="entity-list__entity-category">[{{book.verbose_category_name}}]</span> + {% endif %} + <a href="{{ book.source_url }}"> + <span class="source-label source-label__{{ book.source_site }}">{{ book.get_source_site_display }}</span> + </a> + </div> + + {% if book.rating %} + <div class="rating-star entity-list__rating-star" data-rating-score="{{ book.rating | floatformat:"0" }}"></div> + <span class="entity-list__rating-score rating-score">{{ book.rating }}</span> + {% else %} + <div class="entity-list__rating entity-list__rating--empty"> {% trans '暂无评分' %}</div> + {% endif %} + + <span class="entity-list__entity-info"> + {% if book.pub_year %} / + {{ book.pub_year }}{% trans '年' %}{% if book.pub_month %}{{book.pub_month }}{% trans '月' %}{% endif %} + {% endif %} + + {% if book.author %} / + {% for author in book.author %} + {% if request.GET.q %} + {{ author | highlight:request.GET.q }} + {% else %} + {{ author }} + {% endif %} + {% if not forloop.last %},{% endif %} + {% endfor %} + {% endif %} + + {% if book.translator %} / + {% trans '翻译' %}: + {% for translator in book.translator %} + {% if request.GET.q %} + {{ translator | highlight:request.GET.q }} + {% else %} + {{ translator }} + {% endif %} + {% if not forloop.last %},{% endif %} + {% endfor %} + {% endif %} + + {% if book.subtitle %} / + {% trans '副标题' %}: + {% if request.GET.q %} + {{ book.subtitle | highlight:request.GET.q }} + {% else %} + {{ book.subtitle }} + {% endif %} + {% endif %} + + {% if book.orig_title %} / + {% trans '原名' %}: + {% if request.GET.q %} + {{ book.orig_title | highlight:request.GET.q }} + {% else %} + {{ book.orig_title }} + {% endif %} + {% endif %} + </span> + <p class="entity-list__entity-brief"> + {{ book.brief }} + </p> + + <div class="tag-collection"> + {% for tag_dict in book.top_tags %} + <span class="tag-collection__tag"> + <a href="{% url 'common:search' %}?tag={{ tag_dict.content }}">{{ tag_dict.content }}</a> + </span> + {% endfor %} + </div> + + {% if mark %} + <div class="clearfix"></div> + <div class="dividing-line dividing-line--dashed"></div> + <div class="entity-marks" style="margin-bottom: 0;"> + <ul class="entity-marks__mark-list"> + <li class="entity-marks__mark"> + {% if mark.rating %} + <span class="entity-marks__rating-star rating-star" + data-rating-score="{{ mark.rating | floatformat:"0" }}" style="left: -4px;"></span> + {% endif %} + {% if mark.visibility > 0 %} + <span class="icon-lock"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20"> + <path + d="M17,8.48h-.73V6.27a6.27,6.27,0,1,0-12.53,0V8.48H3a.67.67,0,0,0-.67.67V19.33A.67.67,0,0,0,3,20H17a.67.67,0,0,0,.67-.67V9.15A.67.67,0,0,0,17,8.48ZM6.42,6.27h0a3.57,3.57,0,0,1,7.14,0h0V8.48H6.42Z" /> + </svg></span> + {% endif %} + <span class="entity-marks__mark-time"> + {% trans '于' %} {{ mark.created_time }} + {% if status == 'reviewed' %} + {% trans '评论' %}: <a href="{% url 'books:retrieve_review' mark.id %}">{{ mark.title }}</a> + {% else %} + {% trans '标记' %} + {% endif %} + </span> + {% if mark.text %} + <p class="entity-marks__mark-content">{{ mark.text }}</p> + {% endif %} + </li> + </ul> + </div> + {% endif %} + + {% if collectionitem %} + <div class="clearfix"></div> + <div class="dividing-line dividing-line--dashed"></div> + <div class="entity-marks" style="margin-bottom: 0;"> + <ul class="entity-marks__mark-list"> + <li class="entity-marks__mark"> + <p class="entity-marks__mark-content" hx-target="this" hx-swap="innerHTML"> + {% include "show_item_comment.html" %} + </p> + </li> + </ul> + </div> + {% endif %} + </div> +</li> \ No newline at end of file diff --git a/common/templates/partial/list_item_game.html b/common/templates/partial/list_item_game.html new file mode 100644 index 00000000..42346910 --- /dev/null +++ b/common/templates/partial/list_item_game.html @@ -0,0 +1,139 @@ +{% load thumb %} +{% load highlight %} +{% load i18n %} +{% load l10n %} +{% load neo %} +{% current_user_marked_item game as marked %} +<li class="entity-list__entity"> + <div class="entity-list__entity-img-wrapper"> + <a href="{% url 'games:retrieve' game.id %}"> + <img src="{{ game.cover|thumb:'normal' }}" alt="" class="entity-list__entity-img"> + </a> + {% if not marked %} + <a class="entity-list__entity-action-icon" hx-post="{% url 'games:wish' game.id %}" title="加入想玩">➕</a> + {% endif %} + </div> + <div class="entity-list__entity-text"> + {% if editable %} + <div class="collection-item-position-edit"> + {% if not forloop.first %} + <a hx-target=".entity-list" hx-post="{% url 'collection:move_up_item' form.instance.id collectionitem.id %}">▲</a> + {% endif %} + {% if not forloop.last %} + <a hx-target=".entity-list" hx-post="{% url 'collection:move_down_item' form.instance.id collectionitem.id %}">▼</a> + {% endif %} + <a hx-target=".entity-list" hx-post="{% url 'collection:delete_item' form.instance.id collectionitem.id %}">✖</a> + </div> + {% endif %} + + <div class="entity-list__entity-title"> + <a href="{% url 'games:retrieve' game.id %}" class="entity-list__entity-link"> + {% if request.GET.q %} + {{ game.title | highlight:request.GET.q }} + {% else %} + {{ game.title }} + {% endif %} + </a> + + {% if not request.GET.c and not hide_category %} + <span class="entity-list__entity-category">[{{item.verbose_category_name}}]</span> + {% endif %} + <a href="{{ game.source_url }}"> + <span class="source-label source-label__{{ game.source_site }}">{{ game.get_source_site_display }}</span> + </a> + </div> + + {% if game.rating %} + <div class="rating-star entity-list__rating-star" data-rating-score="{{ game.rating | floatformat:"0" }}"></div> + <span class="entity-list__rating-score rating-score">{{ game.rating }}</span> + {% else %} + <div class="entity-list__rating entity-list__rating--empty"> {% trans '暂无评分' %}</div> + {% endif %} + + <span class="entity-list__entity-info entity-list__entity-info--full-length"> + + {% if game.other_title %}{% trans '别名' %}: + {% for other_title in game.other_title %} + {{ other_title }}{% if not forloop.last %} {% endif %} + {% endfor %}/ + {% endif %} + + {% if game.developer %}{% trans '开发商' %}: + {% for developer in game.developer %} + {{ developer }}{% if not forloop.last %} {% endif %} + {% endfor %}/ + {% endif %} + + {% if game.genre %}{% trans '类型' %}: + {% for genre in game.genre %} + {{ genre }}{% if not forloop.last %} {% endif %} + {% endfor %}/ + {% endif %} + + {% if game.platform %}{% trans '平台' %}: + {% for platform in game.platform %} + {{ platform }}{% if not forloop.last %} {% endif %} + {% endfor %} + {% endif %} + + </span> + <p class="entity-list__entity-brief"> + {{ game.brief }} + </p> + + <div class="tag-collection"> + {% for tag_dict in game.top_tags %} + <span class="tag-collection__tag"> + <a href="{% url 'common:search' %}?tag={{ tag_dict.content }}">{{ tag_dict.content }}</a> + </span> + {% endfor %} + </div> + + {% if mark %} + <div class="clearfix"></div> + <div class="dividing-line dividing-line--dashed"></div> + <div class="entity-marks" style="margin-bottom: 0;"> + <ul class="entity-marks__mark-list"> + <li class="entity-marks__mark"> + {% if mark.rating %} + <span class="entity-marks__rating-star rating-star" + data-rating-score="{{ mark.rating | floatformat:"0" }}" style="left: -4px;"></span> + {% endif %} + {% if mark.visibility > 0 %} + <span class="icon-lock"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20"> + <path + d="M17,8.48h-.73V6.27a6.27,6.27,0,1,0-12.53,0V8.48H3a.67.67,0,0,0-.67.67V19.33A.67.67,0,0,0,3,20H17a.67.67,0,0,0,.67-.67V9.15A.67.67,0,0,0,17,8.48ZM6.42,6.27h0a3.57,3.57,0,0,1,7.14,0h0V8.48H6.42Z" /> + </svg></span> + {% endif %} + <span class="entity-marks__mark-time"> + {% trans '于' %} {{ mark.created_time }} + {% if status == 'reviewed' %} + {% trans '评论' %}: <a href="{% url 'games:retrieve_review' mark.id %}">{{ mark.title }}</a> + {% else %} + {% trans '标记' %} + {% endif %} + </span> + {% if mark.text %} + <p class="entity-marks__mark-content">{{ mark.text }}</p> + {% endif %} + </li> + </ul> + </div> + {% endif %} + + {% if collectionitem %} + <div class="clearfix"></div> + <div class="dividing-line dividing-line--dashed"></div> + <div class="entity-marks" style="margin-bottom: 0;"> + <ul class="entity-marks__mark-list"> + <li class="entity-marks__mark"> + <p class="entity-marks__mark-content" hx-target="this" hx-swap="innerHTML"> + {% include "show_item_comment.html" %} + </p> + </li> + </ul> + </div> + {% endif %} + </div> + +</li> \ No newline at end of file diff --git a/common/templates/partial/list_item_movie.html b/common/templates/partial/list_item_movie.html new file mode 100644 index 00000000..f20f40f5 --- /dev/null +++ b/common/templates/partial/list_item_movie.html @@ -0,0 +1,164 @@ +{% load thumb %} +{% load highlight %} +{% load i18n %} +{% load l10n %} +{% load humanize %} +{% load neo %} +{% current_user_marked_item movie as marked %} +<li class="entity-list__entity"> + <div class="entity-list__entity-img-wrapper"> + <a href="{% url 'movies:retrieve' movie.id %}"> + <img src="{{ movie.cover|thumb:'normal' }}" alt="" class="entity-list__entity-img"> + </a> + {% if not marked %} + <a class="entity-list__entity-action-icon" hx-post="{% url 'movies:wish' movie.id %}" title="加入想看">➕</a> + {% endif %} + </div> + <div class="entity-list__entity-text"> + {% if editable %} + <div class="collection-item-position-edit"> + {% if not forloop.first %} + <a hx-target=".entity-list" hx-post="{% url 'collection:move_up_item' form.instance.id collectionitem.id %}">▲</a> + {% endif %} + {% if not forloop.last %} + <a hx-target=".entity-list" hx-post="{% url 'collection:move_down_item' form.instance.id collectionitem.id %}">▼</a> + {% endif %} + <a hx-target=".entity-list" hx-post="{% url 'collection:delete_item' form.instance.id collectionitem.id %}">✖</a> + </div> + {% endif %} + + <div class="entity-list__entity-title"> + <a href="{% url 'movies:retrieve' movie.id %}" class="entity-list__entity-link"> + {% if movie.season %} + + {% if request.GET.q %} + {{ movie.title | highlight:request.GET.q }} {% trans '第' %}{{ movie.season|apnumber }}{% trans '季' %} + {{ movie.orig_title | highlight:request.GET.q }} Season {{ movie.season }} + {% if movie.year %}({{ movie.year }}){% endif %} + {% else %} + {{ movie.title }} {% trans '第' %}{{ movie.season|apnumber }}{% trans '季' %} + {{ movie.orig_title }} Season {{ movie.season }} + {% if movie.year %}({{ movie.year }}){% endif %} + {% endif %} + + {% else %} + {% if request.GET.q %} + {{ movie.title | highlight:request.GET.q }} {{ movie.orig_title | highlight:request.GET.q }} + {% if movie.year %}({{ movie.year }}){% endif %} + {% else %} + {{ movie.title }} {{ movie.orig_title }} + {% if movie.year %}({{ movie.year }}){% endif %} + {% endif %} + {% endif %} + </a> + + {% if not request.GET.c and not hide_category %} + <span class="entity-list__entity-category">[{{movie.verbose_category_name}}]</span> + {% endif %} + <a href="{{ movie.source_url }}"> + <span class="source-label source-label__{{ movie.source_site }}">{{ movie.get_source_site_display }}</span> + </a> + </div> + + {% if movie.rating %} + <div class="rating-star entity-list__rating-star" data-rating-score="{{ movie.rating | floatformat:"0" }}"></div> + <span class="entity-list__rating-score rating-score">{{ movie.rating }}</span> + {% else %} + <div class="entity-list__rating entity-list__rating--empty"> {% trans '暂无评分' %}</div> + {% endif %} + + <span class="entity-list__entity-info "> + + {% if movie.director %}{% trans '导演' %}: + {% for director in movie.director %} + {% if request.GET.q %} + {{ director | highlight:request.GET.q }} + {% else %} + {{ director }} + {% endif %} + {% if not forloop.last %},{% endif %} + {% endfor %}/ + {% endif %} + + {% if movie.genre %}{% trans '类型' %}: + {% for genre in movie.get_genre_display %} + {{ genre }}{% if not forloop.last %} {% endif %} + {% endfor %}/ + {% endif %} + + </span> + <span class="entity-list__entity-info entity-list__entity-info--full-length"> + {% if movie.actor %}{% trans '主演' %}: + {% for actor in movie.actor %} + <span {% if forloop.counter > 5 %}style="display: none;" {% endif %}> + {% if request.GET.q %} + {{ actor | highlight:request.GET.q }} + {% else %} + {{ actor }} + {% endif %} + </span> + {% if forloop.counter <= 5 %} + {% if not forloop.counter == 5 and not forloop.last %} {% endif %} + {% endif %} + {% endfor %} + {% endif %} + </span> + <p class="entity-list__entity-brief"> + {{ movie.brief }} + </p> + <div class="tag-collection"> + {% for tag_dict in movie.top_tags %} + <span class="tag-collection__tag"> + <a href="{% url 'common:search' %}?tag={{ tag_dict.content }}">{{ tag_dict.content }}</a> + </span> + {% endfor %} + </div> + + {% if mark %} + <div class="clearfix"></div> + <div class="dividing-line dividing-line--dashed"></div> + <div class="entity-marks" style="margin-bottom: 0;"> + <ul class="entity-marks__mark-list"> + <li class="entity-marks__mark"> + {% if mark.rating %} + <span class="entity-marks__rating-star rating-star" + data-rating-score="{{ mark.rating | floatformat:"0" }}" style="left: -4px;"></span> + {% endif %} + {% if mark.visibility > 0 %} + <span class="icon-lock"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20"> + <path + d="M17,8.48h-.73V6.27a6.27,6.27,0,1,0-12.53,0V8.48H3a.67.67,0,0,0-.67.67V19.33A.67.67,0,0,0,3,20H17a.67.67,0,0,0,.67-.67V9.15A.67.67,0,0,0,17,8.48ZM6.42,6.27h0a3.57,3.57,0,0,1,7.14,0h0V8.48H6.42Z" /> + </svg></span> + {% endif %} + <span class="entity-marks__mark-time"> + {% trans '于' %} {{ mark.created_time }} + {% if status == 'reviewed' %} + {% trans '评论' %}: <a href="{% url 'movies:retrieve_review' mark.id %}">{{ mark.title }}</a> + {% else %} + {% trans '标记' %} + {% endif %} + </span> + {% if mark.text %} + <p class="entity-marks__mark-content">{{ mark.text }}</p> + {% endif %} + </li> + </ul> + </div> + {% endif %} + + {% if collectionitem %} + <div class="clearfix"></div> + <div class="dividing-line dividing-line--dashed"></div> + <div class="entity-marks" style="margin-bottom: 0;"> + <ul class="entity-marks__mark-list"> + <li class="entity-marks__mark"> + <p class="entity-marks__mark-content" hx-target="this" hx-swap="innerHTML"> + {% include "show_item_comment.html" %} + </p> + </li> + </ul> + </div> + {% endif %} + </div> + +</li> \ No newline at end of file diff --git a/common/templates/partial/list_item_music.html b/common/templates/partial/list_item_music.html new file mode 100644 index 00000000..bcf0d29a --- /dev/null +++ b/common/templates/partial/list_item_music.html @@ -0,0 +1,171 @@ +{% load thumb %} +{% load highlight %} +{% load i18n %} +{% load l10n %} +{% load neo %} +{% current_user_marked_item music as marked %} +<li class="entity-list__entity"> + <div class="entity-list__entity-img-wrapper"> + + {% if music.category_name|lower == 'album' %} + <a href="{% url 'music:retrieve_album' music.id %}"> + <img src="{{ music.cover|thumb:'normal' }}" alt="" class="entity-list__entity-img"> + </a> + {% if not marked %} + <a class="entity-list__entity-action-icon" hx-post="{% url 'music:wish_album' music.id %}" title="加入想听">➕</a> + {% endif %} + {% elif music.category_name|lower == 'song' %} + <a href="{% url 'music:retrieve_song' music.id %}"> + <img src="{{ music.cover|thumb:'normal' }}" alt="" class="entity-list__entity-img"> + </a> + {% if not marked %} + <a class="entity-list__entity-action-icon" hx-post="{% url 'music:wish_song' music.id %}" title="加入想听">➕</a> + {% endif %} + {% endif %} + </div> + <div class="entity-list__entity-text"> + {% if editable %} + <div class="collection-item-position-edit"> + {% if not forloop.first %} + <a hx-target=".entity-list" hx-post="{% url 'collection:move_up_item' form.instance.id collectionitem.id %}">▲</a> + {% endif %} + {% if not forloop.last %} + <a hx-target=".entity-list" hx-post="{% url 'collection:move_down_item' form.instance.id collectionitem.id %}">▼</a> + {% endif %} + <a hx-target=".entity-list" hx-post="{% url 'collection:delete_item' form.instance.id collectionitem.id %}">✖</a> + </div> + {% endif %} + + <div class="entity-list__entity-title"> + + {% if music.category_name|lower == 'album' %} + <a href="{% url 'music:retrieve_album' music.id %}" class="entity-list__entity-link"> + {% if request.GET.q %} + {{ music.title | highlight:request.GET.q }} + {% else %} + {{ music.title }} + {% endif %} + </a> + {% elif music.category_name|lower == 'song' %} + <a href="{% url 'music:retrieve_song' music.id %}" class="entity-list__entity-link"> + {% if request.GET.q %} + {{ music.title | highlight:request.GET.q }} + {% else %} + {{ music.title }} + {% endif %} + </a> + {% endif %} + + + {% if not request.GET.c and not hide_category %} + <span class="entity-list__entity-category">[{{music.verbose_category_name}}]</span> + {% endif %} + <a href="{{ music.source_url }}"> + <span class="source-label source-label__{{ music.source_site }}">{{ music.get_source_site_display }}</span> + </a> + </div> + + {% if music.rating %} + <div class="rating-star entity-list__rating-star" data-rating-score="{{ music.rating | floatformat:"0" }}"></div> + <span class="entity-list__rating-score rating-score">{{ music.rating }}</span> + {% else %} + <div class="entity-list__rating entity-list__rating--empty"> {% trans '暂无评分' %}</div> + {% endif %} + + <span class="entity-list__entity-info "> + {% if music.artist %}{% trans '艺术家' %}: + {% for artist in music.artist %} + <span>{{ artist }}</span> + {% if not forloop.last %} {% endif %} + {% endfor %} + {% endif %} + + {% if music.genre %}/ {% trans '流派' %}: + {{ music.genre }} + {% endif %} + + {% if music.release_date %}/ {% trans '发行日期' %}: + {{ music.release_date }} + {% endif %} + </span> + <span class="entity-list__entity-info entity-list__entity-info--full-length"> + + </span> + + {% if music.brief %} + <p class="entity-list__entity-brief"> + {{ music.brief }} + </p> + {% elif music.category_name|lower == 'album' %} + <p class="entity-list__entity-brief"> + {% trans '曲目:' %}{{ music.track_list }} + </p> + {% else %} + <!-- song --> + <p class="entity-list__entity-brief"> + {% trans '所属专辑:' %}{{ music.album }} + </p> + {% endif %} + + <div class="tag-collection"> + {% for tag_dict in music.top_tags %} + <span class="tag-collection__tag"> + <a href="{% url 'common:search' %}?tag={{ tag_dict.content }}">{{ tag_dict.content }}</a> + </span> + {% endfor %} + </div> + + {% if mark %} + <div class="clearfix"></div> + <div class="dividing-line dividing-line--dashed"></div> + <div class="entity-marks" style="margin-bottom: 0;"> + <ul class="entity-marks__mark-list"> + <li class="entity-marks__mark"> + {% if mark.rating %} + <span class="entity-marks__rating-star rating-star" + data-rating-score="{{ mark.rating | floatformat:"0" }}" style="left: -4px;"></span> + {% endif %} + {% if mark.visibility > 0 %} + <span class="icon-lock"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20"> + <path + d="M17,8.48h-.73V6.27a6.27,6.27,0,1,0-12.53,0V8.48H3a.67.67,0,0,0-.67.67V19.33A.67.67,0,0,0,3,20H17a.67.67,0,0,0,.67-.67V9.15A.67.67,0,0,0,17,8.48ZM6.42,6.27h0a3.57,3.57,0,0,1,7.14,0h0V8.48H6.42Z" /> + </svg></span> + {% endif %} + <span class="entity-marks__mark-time"> + {% trans '于' %} {{ mark.created_time }} + {% if status == 'reviewed' %} + {% trans '评论' %}: + {% if music.category_name|lower == 'album' %} + <a href="{% url 'music:retrieve_album_review' mark.id %}">{{ mark.title }}</a> + {% else %} + <a href="{% url 'music:retrieve_song_review' mark.id %}">{{ mark.title }}</a> + {% endif %} + {% else %} + {% trans '标记' %} + {% endif %} + </span> + {% if mark.text %} + <p class="entity-marks__mark-content">{{ mark.text }}</p> + {% endif %} + </li> + </ul> + </div> + {% endif %} + + {% if collectionitem %} + <div class="clearfix"></div> + <div class="dividing-line dividing-line--dashed"></div> + <div class="entity-marks" style="margin-bottom: 0;"> + <ul class="entity-marks__mark-list"> + <li class="entity-marks__mark"> + <p class="entity-marks__mark-content" hx-target="this" hx-swap="innerHTML"> + {% include "show_item_comment.html" %} + </p> + </li> + </ul> + </div> + {% endif %} + + </div> + +</li> \ No newline at end of file diff --git a/common/templates/partial/mark_list.html b/common/templates/partial/mark_list.html new file mode 100644 index 00000000..71c4102a --- /dev/null +++ b/common/templates/partial/mark_list.html @@ -0,0 +1,37 @@ +{% load i18n %} + +<ul class="entity-marks__mark-list"> +{% for others_mark in mark_list %} +<li class="entity-marks__mark"> + <a href="{% url 'users:home' others_mark.owner.mastodon_username %}" class="entity-marks__owner-link">{{ others_mark.owner.username }}</a> + + <span>{{ others_mark.get_status_display }}</span> + + {% if others_mark.rating %} + <span class="entity-marks__rating-star rating-star" data-rating-score="{{ others_mark.rating | floatformat:"0" }}"></span> + {% endif %} + + {% if others_mark.visibility > 0 %} + <span class="icon-lock"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20"><path d="M17,8.48h-.73V6.27a6.27,6.27,0,1,0-12.53,0V8.48H3a.67.67,0,0,0-.67.67V19.33A.67.67,0,0,0,3,20H17a.67.67,0,0,0,.67-.67V9.15A.67.67,0,0,0,17,8.48ZM6.42,6.27h0a3.57,3.57,0,0,1,7.14,0h0V8.48H6.42Z"/></svg></span> + {% endif %} + + {% if others_mark.shared_link %} + <a href="{{ others_mark.shared_link }}" target="_blank"><span class="entity-marks__mark-time">{{ others_mark.created_time }}</span></a> + {% else %} + <span class="entity-marks__mark-time">{{ others_mark.created_time }}</span> + {% endif %} + + {% if current_item and others_mark.item != current_item %} + <span class="entity-marks__mark-time source-label"><a class="entity-marks__mark-time" href="{% url 'books:retrieve' others_mark.item.id %}">{{ others_mark.item.get_source_site_display }}</a></span> + {% endif %} + + {% if others_mark.text %} + <p class="entity-marks__mark-content">{{ others_mark.text }}</p> + {% endif %} +</li> +{% empty %} + +<div> {% trans '暂无标记' %} </div> + +{% endfor %} +</ul> \ No newline at end of file diff --git a/common/templatetags/highlight.py b/common/templatetags/highlight.py index a2276800..02d79bbb 100644 --- a/common/templatetags/highlight.py +++ b/common/templatetags/highlight.py @@ -1,17 +1,19 @@ from django import template from django.utils.safestring import mark_safe from django.template.defaultfilters import stringfilter -from django.utils.html import format_html +from opencc import OpenCC -import re +cc = OpenCC('t2s') register = template.Library() + @register.filter @stringfilter def highlight(text, search): - to_be_replaced_words = set(re.findall(search, text, flags=re.IGNORECASE)) - - for word in to_be_replaced_words: - text = text.replace(word, f'<span class="highlight">{word}</span>') + for s in cc.convert(search.strip().lower()).split(' '): + if s: + p = cc.convert(text.lower()).find(s) + if p != -1: + text = f'{text[0:p]}<span class="highlight">{text[p:p+len(s)]}</span>{text[p+len(s):]}' return mark_safe(text) diff --git a/common/templatetags/neo.py b/common/templatetags/neo.py new file mode 100644 index 00000000..b915b7b9 --- /dev/null +++ b/common/templatetags/neo.py @@ -0,0 +1,48 @@ +from django import template +import datetime +from django.utils import timezone +from collection.models import Collection + + +register = template.Library() + + +@register.simple_tag(takes_context=True) +def current_user_marked_item(context, item): + user = context['request'].user + if user and user.is_authenticated: + if isinstance(item, Collection) and item.owner == user: + return item + else: + return context['request'].user.get_mark_for_item(item) + return None + + +@register.simple_tag(takes_context=True) +def current_user_relationship(context, user): + current_user = context['request'].user + if current_user and current_user.is_authenticated: + if current_user.is_following(user): + if current_user.is_followed_by(user): + return '互相关注' + else: + return '已关注' + elif current_user.is_followed_by(user): + return '被ta关注' + return None + + +@register.filter +def prettydate(d): + diff = timezone.now() - d + s = diff.seconds + if diff.days > 14 or diff.days < 0: + return d.strftime('%Y年%m月%d日') + elif diff.days >= 1: + return '{} 天前'.format(diff.days) + elif s < 120: + return '刚刚' + elif s < 3600: + return '{} 分钟前'.format(s // 60) + else: + return '{} 小时前'.format(s // 3600) diff --git a/common/templatetags/oauth_token.py b/common/templatetags/oauth_token.py index 7aac83a1..b2f24677 100644 --- a/common/templatetags/oauth_token.py +++ b/common/templatetags/oauth_token.py @@ -7,7 +7,7 @@ register = template.Library() class OAuthTokenNode(template.Node): def render(self, context): request = context.get('request') - oauth_token = request.session.get('oauth_token', default='') + oauth_token = request.user.mastodon_token return format_html(oauth_token) diff --git a/common/templatetags/thumb.py b/common/templatetags/thumb.py index 2e21c6ca..aa698abb 100644 --- a/common/templatetags/thumb.py +++ b/common/templatetags/thumb.py @@ -12,4 +12,7 @@ def thumb(source, alias): if source.url.endswith('.svg'): return source.url else: - return thumbnail_url(source, alias) \ No newline at end of file + try: + return thumbnail_url(source, alias) + except Exception as e: + return '' diff --git a/common/urls.py b/common/urls.py index 6de4f66a..b803a47d 100644 --- a/common/urls.py +++ b/common/urls.py @@ -6,4 +6,6 @@ urlpatterns = [ path('', home), path('home/', home, name='home'), path('search/', search, name='search'), + path('search.json/', search, name='search.json'), + path('external_search/', external_search, name='external_search'), ] diff --git a/common/utils.py b/common/utils.py index 5d26a3d9..4d51ca16 100644 --- a/common/utils.py +++ b/common/utils.py @@ -10,6 +10,8 @@ class PageLinksGenerator: def __init__(self, length, current_page, total_pages): current_page = int(current_page) self.current_page = current_page + self.previous_page = current_page - 1 if current_page > 1 else None + self.next_page = current_page + 1 if current_page < total_pages else None self.start_page = None self.end_page = None self.page_range = None diff --git a/common/views.py b/common/views.py index c028184a..e527e09a 100644 --- a/common/views.py +++ b/common/views.py @@ -17,21 +17,93 @@ from music.models import Album, Song, AlbumMark, SongMark from users.models import Report, User, Preference from mastodon.decorators import mastodon_request_included from users.views import home as user_home +from timeline.views import timeline as user_timeline from common.models import MarkStatusEnum from common.utils import PageLinksGenerator -from common.scraper import scraper_registry +from common.scraper import get_scraper_by_url, get_normalized_url from common.config import * +from common.searcher import ExternalSources from management.models import Announcement +from django.conf import settings +from common.index import Indexer +from django.http import JsonResponse +from django.db.utils import IntegrityError + logger = logging.getLogger(__name__) + @login_required def home(request): - return user_home(request, request.user.id) + if request.user.get_preference().classic_homepage: + return redirect(reverse("users:home", args=[request.user.mastodon_username])) + else: + return redirect(reverse("timeline:timeline")) @login_required +def external_search(request): + category = request.GET.get("c", default='all').strip().lower() + if category == 'all': + category = None + keywords = request.GET.get("q", default='').strip() + page_number = int(request.GET.get('page', default=1)) + return render( + request, + "common/external_search_result.html", + { + "external_items": ExternalSources.search(category, keywords, page_number) if keywords else [], + } + ) + + def search(request): + if settings.SEARCH_BACKEND is None: + return search2(request) + category = request.GET.get("c", default='all').strip().lower() + if category == 'all': + category = None + keywords = request.GET.get("q", default='').strip() + tag = request.GET.get("tag", default='').strip() + p = request.GET.get('page', default='1') + page_number = int(p) if p.isdigit() else 1 + if not (keywords or tag): + return render( + request, + "common/search_result.html", + { + "items": None, + } + ) + if request.user.is_authenticated: + url_validator = URLValidator() + try: + url_validator(keywords) + # validation success + return jump_or_scrape(request, keywords) + except ValidationError as e: + pass + + result = Indexer.search(keywords, page=page_number, category=category, tag=tag) + for item in result.items: + item.tag_list = item.all_tag_list[:TAG_NUMBER_ON_LIST] + if request.path.endswith('.json/'): + return JsonResponse({ + 'num_pages': result.num_pages, + 'items':list(map(lambda i:i.get_json(), result.items)) + }) + return render( + request, + "common/search_result.html", + { + "items": result.items, + "pagination": PageLinksGenerator(PAGE_LINK_NUMBER, page_number, result.num_pages), + "categories": ['book', 'movie', 'music', 'game'], + } + ) + + +def search2(request): if request.method == 'GET': # test if input serach string is empty or not excluding param ?c= @@ -109,7 +181,7 @@ def search(request): else: ordered_queryset = list(queryset) return ordered_queryset - + def movie_param_handler(**kwargs): # keywords keywords = kwargs.get('keywords') @@ -240,7 +312,7 @@ def search(request): elif music.__class__ == Song: similarity += 1/2 * SequenceMatcher(None, keyword, music.title).quick_ratio() \ + 1/6 * SequenceMatcher(None, keyword, artist_dump).quick_ratio() \ - + 1/6 * SequenceMatcher(None, keyword, music.album.title).quick_ratio() + + 1/6 * (SequenceMatcher(None, keyword, music.album.title).quick_ratio() if music.album is not None else 0) n += 1 music.similarity = similarity / n elif tag: @@ -322,32 +394,50 @@ def jump_or_scrape(request, url): if this_site in url: return redirect(url) - # match url to registerd sites - matched_host = None - for host in scraper_registry: - if host in url: - matched_host = host - break - - if matched_host is None: + url = get_normalized_url(url) + scraper = get_scraper_by_url(url) + if scraper is None: # invalid url - return render(request, 'common/error.html', {'msg': _("链接非法,查询失败")}) + return render(request, 'common/error.html', {'msg': _("链接无效,查询失败")}) else: - scraper = scraper_registry[matched_host] + try: + effective_url = scraper.get_effective_url(url) + except ValueError: + return render(request, 'common/error.html', {'msg': _("链接无效,查询失败")}) try: # raise ObjectDoesNotExist - effective_url = scraper.get_effective_url(url) entity = scraper.data_class.objects.get(source_url=effective_url) # if exists then jump to detail page + if request.path.endswith('.json/'): + return JsonResponse({ + 'num_pages': 1, + 'items': [entity.get_json()] + }) return redirect(entity) except ObjectDoesNotExist: # scrape if not exists try: scraper.scrape(url) form = scraper.save(request_user=request.user) + except IntegrityError as ie: # duplicate key on source_url may be caused by user's double submission + try: + entity = scraper.data_class.objects.get(source_url=effective_url) + return redirect(entity) + except Exception as e: + logger.error(f"Scrape Failed URL: {url}\n{e}") + if settings.DEBUG: + logger.error("Expections during saving scraped data:", exc_info=e) + return render(request, 'common/error.html', {'msg': _("爬取数据失败😫")}) except Exception as e: - logger.error(f"Scrape Failed URL: {url}") - logger.error("Expections during saving scraped data:", exc_info=e) + logger.error(f"Scrape Failed URL: {url}\n{e}") + if settings.DEBUG: + logger.error("Expections during saving scraped data:", exc_info=e) return render(request, 'common/error.html', {'msg': _("爬取数据失败😫")}) return redirect(form.instance) + +def go_relogin(request): + return render(request, 'common/error.html', { + 'url': reverse("users:connect") + '?domain=' + request.user.mastodon_site, + 'msg': _("信息已保存,但是未能分享到联邦网络"), + 'secondary_msg': _("可能是你在联邦网络(Mastodon/Pleroma/...)的登录状态过期了,正在跳转到联邦网络重新登录😼")}) diff --git a/doc/GUIDE.md b/doc/GUIDE.md new file mode 100644 index 00000000..055ca2bb --- /dev/null +++ b/doc/GUIDE.md @@ -0,0 +1,106 @@ +NiceDB / NeoDB - Getting Start +============================== +This is a very basic guide with limited detail, contributions welcomed + +Install +------- +Install PostgreSQL, Redis and Python if not yet + +Setup database +``` +CREATE DATABASE neodb ENCODING 'UTF8' LC_COLLATE='en_US.UTF-8' LC_CTYPE='en_US.UTF-8' TEMPLATE template0; +\c neodb; +CREATE EXTENSION hstore WITH SCHEMA public; +CREATE ROLE neodb with LOGIN ENCRYPTED PASSWORD 'abadface'; +GRANT ALL ON DATABASE neodb TO neodb; +``` + +Create and edit your own configuration file (optional but very much recommended) +``` +mkdir mysite && cp boofilsic/settings.py mysite/ +export DJANGO_SETTINGS_MODULE=mysite.settings +``` + +Create and use `venv` as you normally would, then install packages +``` +python3 -m pip install -r requirements.txt +``` + +Quick check +``` +python3 manage.py check +``` + +Initialize database +``` +python3 manage.py makemigrations users books movies games music sync mastodon management collection +python3 manage.py migrate users +python3 manage.py migrate +``` + +Build static assets +``` +python3 manage.py collectstatic +``` + + +Start services +-------------- +Make sure PostgreSQL and Redis are running + +Start job queue server +``` +export OBJC_DISABLE_INITIALIZE_FORK_SAFETY=YES # required and only for macOS, otherwise it may crash +python3 manage.py rqworker --with-scheduler doufen export mastodon +``` + +Run web server in dev mode +``` +python3 manage.py runserver 0.0.0.0:80 +``` + +It should be ready to serve from here, to run web server for production, consider `gunicorn -w 8 boofilsic.wsgi` in systemd or sth similar + + +Migrate from an earlier version +------------------------------- +Update database +``` +python3 manage.py makemigrations +python3 manage.py migrate +``` + +Rebuild static assets +``` +python3 manage.py sass common/static/sass/boofilsic.sass common/static/css/boofilsic.min.css -t compressed +python3 manage.py sass common/static/sass/boofilsic.sass common/static/css/boofilsic.css +python3 manage.py collectstatic +``` + +Add Cron Jobs +------------- +add `python manage.py refresh_mastodon` to crontab to run hourly, it will refresh cached users' follow/mute/block from mastodon + +Index and Search +---------------- +Install TypeSense or Meilisearch, change `SEARCH_BACKEND` and coniguration for search server in `settings.py` + +Build initial index, it may take a few minutes or hours +``` +python3 manage.py init_index +python3 manage.py reindex +``` + +Other maintenance tasks +----------------------- +Requeue failed jobs +``` +rq requeue --all --queue doufen +``` + +Run in Docker +``` +docker-compose build +docker-compose up db && docker exec -it app_db_1 psql -U postgres postgres -c 'CREATE EXTENSION hstore WITH SCHEMA public;' # first time only +docker-compose up +``` diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 00000000..86f8c09f --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,32 @@ +version: '3' + +services: + redis: + image: redis:alpine + + db: + image: postgres:14-alpine + volumes: + - /tmp/data/db:/var/lib/postgresql/data + environment: + - POSTGRES_DB=postgres + - POSTGRES_USER=postgres + - POSTGRES_PASSWORD=postgres + + web: + build: . + command: python manage.py runserver 0.0.0.0:8000 + volumes: + - .:/code + ports: + - "8000:8000" + environment: + - DB_HOST=db + - DB_NAME=postgres + - DB_USER=postgres + - DB_PASSWORD=postgres + - REDIS_HOST=redis + - DJANGO_SETTINGS_MODULE=neodb.dev + depends_on: + - db + - redis diff --git a/docker/entrypoint.sh b/docker/entrypoint.sh new file mode 100755 index 00000000..5feb0354 --- /dev/null +++ b/docker/entrypoint.sh @@ -0,0 +1,13 @@ +#!/bin/bash + +set -o errexit +set -o pipefail +set -o nounset + +python manage.py collectstatic --noinput +python manage.py makemigrations users books movies games music sync mastodon management collection +python manage.py makemigrations +python manage.py migrate users +python manage.py migrate + +exec "$@" diff --git a/docker/start.sh b/docker/start.sh new file mode 100755 index 00000000..a54a76c7 --- /dev/null +++ b/docker/start.sh @@ -0,0 +1,36 @@ +#!/bin/bash + +cd /app + +if [ $# -eq 0 ]; then + echo "Usage: start.sh <server|rq>" + exit 1 +fi + +PROCESS_TYPE=$1 + +if [ "$PROCESS_TYPE" = "server" ]; then + if [ "$DJANGO_DEBUG" = "true" ]; then + gunicorn \ + --reload \ + --bind 0.0.0.0:8000 \ + --workers 2 \ + --worker-class eventlet \ + --log-level DEBUG \ + --access-logfile "-" \ + --error-logfile "-" \ + boofilsic.wsgi + else + gunicorn \ + --bind 0.0.0.0:8000 \ + --workers 2 \ + --worker-class eventlet \ + --log-level DEBUG \ + --access-logfile "-" \ + --error-logfile "-" \ + boofilsic.wsgi + fi +elif [ "$PROCESS_TYPE" = "rq" ]; then + rqworker --with-scheduler doufen export mastodon +fi + diff --git a/games/admin.py b/games/admin.py index fe72bd9a..36cc44fa 100644 --- a/games/admin.py +++ b/games/admin.py @@ -1,7 +1,8 @@ from django.contrib import admin from .models import * +from simple_history.admin import SimpleHistoryAdmin -admin.site.register(Game) +admin.site.register(Game, SimpleHistoryAdmin) admin.site.register(GameMark) admin.site.register(GameReview) admin.site.register(GameTag) diff --git a/games/apps.py b/games/apps.py index b74f62c9..a204c094 100644 --- a/games/apps.py +++ b/games/apps.py @@ -3,3 +3,8 @@ from django.apps import AppConfig class GamesConfig(AppConfig): name = 'games' + + def ready(self): + from common.index import Indexer + from .models import Game + Indexer.update_model_indexable(Game) diff --git a/games/forms.py b/games/forms.py index 5aa53330..e758f9fd 100644 --- a/games/forms.py +++ b/games/forms.py @@ -1,28 +1,24 @@ from django import forms from django.contrib.postgres.forms import SimpleArrayField from django.utils.translation import gettext_lazy as _ -from .models import Game, GameMark, GameReview +from .models import Game, GameMark, GameReview, GameMarkStatusTranslation from common.models import MarkStatusEnum from common.forms import * def GameMarkStatusTranslator(status): - trans_dict = { - MarkStatusEnum.DO.value: _("在玩"), - MarkStatusEnum.WISH.value: _("想玩"), - MarkStatusEnum.COLLECT.value: _("玩过") - } - return trans_dict[status] + return GameMarkStatusTranslation[status] class GameForm(forms.ModelForm): - # id = forms.IntegerField(required=False, widget=forms.HiddenInput()) + id = forms.IntegerField(required=False, widget=forms.HiddenInput()) other_info = JSONField(required=False, label=_("其他信息")) class Meta: model = Game fields = [ + 'id', 'title', 'source_site', 'source_url', @@ -66,11 +62,8 @@ class GameMarkForm(MarkForm): 'status', 'rating', 'text', - 'is_private', + 'visibility', ] - labels = { - 'rating': _("评分"), - } widgets = { 'game': forms.TextInput(attrs={"hidden": ""}), } @@ -85,14 +78,8 @@ class GameReviewForm(ReviewForm): 'game', 'title', 'content', - 'is_private' + 'visibility' ] - labels = { - 'book': "", - 'title': _("标题"), - 'content': _("正文"), - 'share_to_mastodon': _("分享到长毛象") - } widgets = { 'game': forms.TextInput(attrs={"hidden": ""}), } diff --git a/games/models.py b/games/models.py index a2614f0a..fba9e639 100644 --- a/games/models.py +++ b/games/models.py @@ -1,17 +1,25 @@ import uuid import django.contrib.postgres.fields as postgres -from django.utils.translation import ugettext_lazy as _ +from django.utils.translation import gettext_lazy as _ from django.db import models from django.core.serializers.json import DjangoJSONEncoder from django.shortcuts import reverse -from common.models import Entity, Mark, Review, Tag +from common.models import Entity, Mark, Review, Tag, MarkStatusEnum from common.utils import ChoicesDictGenerator, GenerateDateUUIDMediaFilePath -from boofilsic.settings import GAME_MEDIA_PATH_ROOT, DEFAULT_GAME_IMAGE from django.utils import timezone +from django.conf import settings +from simple_history.models import HistoricalRecords + + +GameMarkStatusTranslation = { + MarkStatusEnum.DO.value: _("在玩"), + MarkStatusEnum.WISH.value: _("想玩"), + MarkStatusEnum.COLLECT.value: _("玩过") +} def game_cover_path(instance, filename): - return GenerateDateUUIDMediaFilePath(instance, filename, GAME_MEDIA_PATH_ROOT) + return GenerateDateUUIDMediaFilePath(instance, filename, settings.GAME_MEDIA_PATH_ROOT) class Game(Entity): @@ -53,7 +61,7 @@ class Game(Entity): ) genre = postgres.ArrayField( - models.CharField(blank=True, default='', max_length=50), + models.CharField(blank=True, default='', max_length=200), null=True, blank=True, default=list, @@ -61,23 +69,39 @@ class Game(Entity): ) platform = postgres.ArrayField( - models.CharField(blank=True, default='', max_length=50), + models.CharField(blank=True, default='', max_length=200), null=True, blank=True, default=list, verbose_name=_("平台") ) - cover = models.ImageField(_("封面"), upload_to=game_cover_path, default=DEFAULT_GAME_IMAGE, blank=True) - + cover = models.ImageField(_("封面"), upload_to=game_cover_path, default=settings.DEFAULT_GAME_IMAGE, blank=True) + history = HistoricalRecords() def __str__(self): return self.title + def get_json(self): + r = { + 'developer': self.developer, + 'other_title': self.other_title, + 'publisher': self.publisher, + 'release_date': self.release_date, + 'platform': self.platform, + 'genre': self.genre, + } + r.update(super().get_json()) + return r + def get_absolute_url(self): return reverse("games:retrieve", args=[self.id]) + @property + def wish_url(self): + return reverse("games:wish", args=[self.id]) + def get_tags_manager(self): return self.game_tags @@ -85,6 +109,14 @@ class Game(Entity): def verbose_category_name(self): return _("游戏") + @property + def mark_class(self): + return GameMark + + @property + def tag_class(self): + return GameTag + class GameMark(Mark): game = models.ForeignKey( @@ -96,6 +128,10 @@ class GameMark(Mark): fields=['owner', 'game'], name='unique_game_mark') ] + @property + def translated_status(self): + return GameMarkStatusTranslation[self.status] + class GameReview(Review): game = models.ForeignKey( @@ -107,6 +143,14 @@ class GameReview(Review): fields=['owner', 'game'], name='unique_game_review') ] + @property + def url(self): + return settings.APP_WEBSITE + reverse("games:retrieve_review", args=[self.id]) + + @property + def item(self): + return self.game + class GameTag(Tag): game = models.ForeignKey( @@ -119,3 +163,7 @@ class GameTag(Tag): models.UniqueConstraint( fields=['content', 'mark'], name="unique_gamemark_tag") ] + + @property + def item(self): + return self.game diff --git a/games/templates/games/create_update.html b/games/templates/games/create_update.html index 40a768e2..178089c4 100644 --- a/games/templates/games/create_update.html +++ b/games/templates/games/create_update.html @@ -10,8 +10,8 @@ <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> - <title>{% trans 'NiceDB - ' %}{{ title }}</title> - <script src="https://cdnjs.cloudflare.com/ajax/libs/jquery/3.5.1/jquery.min.js"></script> + <title>{{ site_name }} - {{ title }}</title> + <script src="https://static.neodb.social/cdnjs.cloudflare.com/ajax/libs/jquery/3.6.0/jquery.min.js"></script> <link rel="stylesheet" href="{% static 'css/boofilsic.min.css' %}"> </head> @@ -22,9 +22,24 @@ <section id="content" class="container"> <div class="grid"> + {% if is_update and form.source_site.value != 'in-site' %} + <div style="float:right;padding-left:16px"> + <div class="aside-section-wrapper"> + <div class="action-panel"> + <div class="action-panel__label">{% trans '源网站' %}: <a href="{{ form.source_url.value }}">{{ form.source_site.value }}</a></div> + <div class="action-panel__button-group"> + <form method="post" action="{% url 'games:rescrape' form.id.value %}"> + {% csrf_token %} + <input class="button" type="submit" value="{% trans '从源网站重新抓取' %}"> + </form> + </div> + </div> + </div> + </div> + {% endif %} + <div class="single-section-wrapper" id="main"> - <a href="{% url 'games:scrape' %}" - class="single-section-wrapper__link single-section-wrapper__link--secondary">{% trans '>>> 试试一键剽取~ <<<' %}</a> + {% comment %} <a href="{% url 'games:scrape' %}" class="single-section-wrapper__link single-section-wrapper__link--secondary">{% trans '>>> 试试一键剽取~ <<<' %}</a> {% endcomment %} <form class="entity-form" action="{{ submit_url }}" method="post" enctype="multipart/form-data"> {% csrf_token %} {{ form.media }} @@ -55,12 +70,6 @@ </div> - {% comment %} - <div id="oauth2Token" hidden="true">{% oauth_token %}</div> - <div id="mastodonURI" hidden="true">{% mastodon request.user.mastodon_site %}</div> - <!--current user mastodon id--> - <div id="userMastodonID" hidden="true">{{ user.mastodon_id }}</div> - {% endcomment %} <script> // mark required diff --git a/games/templates/games/create_update_review.html b/games/templates/games/create_update_review.html index 54391b6d..cc9f8a2c 100644 --- a/games/templates/games/create_update_review.html +++ b/games/templates/games/create_update_review.html @@ -13,8 +13,8 @@ <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> - <title>{% trans 'NiceDB - ' %}{{ title }}</title> - <script src="https://cdnjs.cloudflare.com/ajax/libs/jquery/3.5.1/jquery.min.js"></script> + <title>{{ site_name }} - {{ title }}</title> + <script src="https://static.neodb.social/cdnjs.cloudflare.com/ajax/libs/jquery/3.6.0/jquery.min.js"></script> <script src="{% static 'js/create_update_review.js' %}"></script> <script src="{% static 'lib/js/rating-star.js' %}"></script> <link rel="stylesheet" href="{% static 'lib/css/rating-star.css' %}"> @@ -94,7 +94,7 @@ <div class="review-form__option"> <div class="review-form__visibility-radio"> - {{ form.is_private.label }}{{ form.is_private }} + {{ form.visibility.label }}{{ form.visibility }} </div> <div class="review-form__share-checkbox"> {{ form.share_to_mastodon }}{{ form.share_to_mastodon.label }} @@ -114,12 +114,6 @@ {% include "partial/_footer.html" %} </div> - {% comment %} - <div id="oauth2Token" hidden="true">{% oauth_token %}</div> - <div id="mastodonURI" hidden="true">{% mastodon request.user.mastodon_site %}</div> - <!--current user mastodon id--> - <div id="userMastodonID" hidden="true">{{ user.mastodon_id }}</div> - {% endcomment %} <script> diff --git a/games/templates/games/delete.html b/games/templates/games/delete.html index 008a561b..a7a2adde 100644 --- a/games/templates/games/delete.html +++ b/games/templates/games/delete.html @@ -13,8 +13,8 @@ <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> - <title>{% trans 'NiceDB - 删除电影/剧集' %}</title> - <script src="https://cdnjs.cloudflare.com/ajax/libs/jquery/3.5.1/jquery.min.js"></script> + <title>{{ site_name }} - {% trans '删除电影/剧集' %}</title> + <script src="https://static.neodb.social/cdnjs.cloudflare.com/ajax/libs/jquery/3.6.0/jquery.min.js"></script> <script src="{% static 'lib/js/rating-star.js' %}"></script> <script src="{% static 'js/rating-star-readonly.js' %}"></script> <link rel="stylesheet" href="{% static 'lib/css/rating-star.css' %}"> @@ -55,7 +55,7 @@ {% if game.last_editor %} <div> {% trans '最近编辑者:' %} - <a href="{% url 'users:home' game.last_editor.id %}"> + <a href="{% url 'users:home' game.last_editor.mastodon_username %}"> <span>{{ game.last_editor | default:"" }}</span> </a> </div> @@ -89,12 +89,6 @@ </div> - {% comment %} - <div id="oauth2Token" hidden="true">{% oauth_token %}</div> - <div id="mastodonURI" hidden="true">{% mastodon request.user.mastodon_site %}</div> - <!--current user mastodon id--> - <div id="userMastodonID" hidden="true">{{ user.mastodon_id }}</div> - {% endcomment %} <script> diff --git a/games/templates/games/delete_review.html b/games/templates/games/delete_review.html index b24fc5d2..f39cdf62 100644 --- a/games/templates/games/delete_review.html +++ b/games/templates/games/delete_review.html @@ -10,8 +10,8 @@ <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> - <title>{% trans 'NiceDB - 删除评论' %}</title> - <script src="https://cdnjs.cloudflare.com/ajax/libs/jquery/3.5.1/jquery.min.js"></script> + <title>{{ site_name }} - {% trans '删除评论' %}</title> + <script src="https://static.neodb.social/cdnjs.cloudflare.com/ajax/libs/jquery/3.6.0/jquery.min.js"></script> <link rel="stylesheet" href="{% static 'lib/css/rating-star.css' %}"> <link rel="stylesheet" href="{% static 'css/boofilsic.min.css' %}"> @@ -35,7 +35,7 @@ <h5 class="review-head__title"> {{ review.title }} </h5> - {% if review.is_private %} + {% if review.visibility > 0 %} <span class="icon-lock"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20"> <path @@ -46,7 +46,7 @@ <div class="review-head__body"> <div class="review-head__info"> - <a href="{% url 'users:home' review.owner.id %}" + <a href="{% url 'users:home' review.owner.mastodon_username %}" class="review-head__owner-link">{{ review.owner.username }}</a> {% if mark %} @@ -89,12 +89,6 @@ </div> - {% comment %} - <div id="oauth2Token" hidden="true">{% oauth_token %}</div> - <div id="mastodonURI" hidden="true">{% mastodon request.user.mastodon_site %}</div> - <!--current user mastodon id--> - <div id="userMastodonID" hidden="true">{{ user.mastodon_id }}</div> - {% endcomment %} <script> diff --git a/games/templates/games/detail.html b/games/templates/games/detail.html index e32ebf66..fed9eb7a 100644 --- a/games/templates/games/detail.html +++ b/games/templates/games/detail.html @@ -14,21 +14,20 @@ <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> - <meta property="og:title" content="NiceDB游戏 - {{ game.title }}"> + <meta property="og:title" content="{{ site_name }}游戏 - {{ game.title }}"> <meta property="og:type" content="game"> <meta property="og:url" content="{{ request.build_absolute_uri }}"> <meta property="og:image" content="{{ request.scheme }}://{{ request.get_host }}{{ game.cover.url }}"> - <meta property="og:site_name" content="NiceDB"> - <meta property="og:description"content="{{ game.brief }}"> + <meta property="og:site_name" content="{{ site_name }}"> + <meta property="og:description" content="{{ game.brief }}"> - <title>{% trans 'NiceDB - 游戏详情' %} | {{ game.title }}</title> + <title>{{ site_name }} - {% trans '游戏详情' %} | {{ game.title }}</title> - <script src="https://cdnjs.cloudflare.com/ajax/libs/jquery/3.5.1/jquery.min.js"></script> + {% include "partial/_common_libs.html" with jquery=1 %} + <script src="{% static 'lib/js/rating-star.js' %}"></script> <script src="{% static 'js/detail.js' %}"></script> - <link rel="stylesheet" href="{% static 'css/boofilsic.css' %}"> <link rel="stylesheet" href="{% static 'lib/css/rating-star.css' %}"> - </head> <body> @@ -54,11 +53,12 @@ <div class="entity-detail__fields"> <div class="entity-detail__rating"> - {% if game.rating %} + {% if game.rating and game.rating_number >= 5 %} <span class="entity-detail__rating-star rating-star" data-rating-score="{{ game.rating | floatformat:"0" }}"></span> <span class="entity-detail__rating-score"> {{ game.rating }} </span> + <small>({{ game.rating_number }}人评分)</small> {% else %} - <span> {% trans '评分:暂无评分' %}</span> + <span> {% trans '评分:评分人数不足' %}</span> {% endif %} </div> @@ -72,7 +72,7 @@ {% if game.other_title|length > 5 %} <a href="javascript:void(0);" id="otherTitleMore">{% trans '更多' %}</a> <script> - $("#otherTitleMore").click(function (e) { + $("#otherTitleMore").on('click', function (e) { $("span.other_title:not(:visible)").each(function (e) { $(this).parent().removeAttr('style'); }); @@ -111,7 +111,15 @@ {% trans '发行日期:' %}{{ game.release_date }} {% endif %} </div> - + + {% if game.other_info %} + {% for k, v in game.other_info.items %} + <div> + {{ k }}:{{ v | urlize }} + </div> + {% endfor %} + {% endif %} + </div> <div class="entity-detail__fields"> @@ -123,17 +131,10 @@ {% endif %} </div> - {% if game.other_info %} - {% for k, v in game.other_info.items %} - <div> - {{ k }}:{{ v | urlize }} - </div> - {% endfor %} - {% endif %} - + {% if game.last_editor %} - <div>{% trans '最近编辑者:' %}<a href="{% url 'users:home' game.last_editor.id %}">{{ game.last_editor | default:"" }}</a></div> + <div>{% trans '最近编辑者:' %}<a href="{% url 'users:home' game.last_editor.mastodon_username %}">{{ game.last_editor | default:"" }}</a></div> {% endif %} <div> @@ -178,43 +179,23 @@ <h5 class="entity-marks__title">{% trans '这个游戏的标记' %}</h5> {% if mark_list_more %} - <a href="{% url 'games:retrieve_mark_list' game.id %}" class="entity-marks__more-link">{% trans '更多' %}</a> - {% endif %} - {% if mark_list %} - <ul class="entity-marks__mark-list"> - {% for others_mark in mark_list %} - <li class="entity-marks__mark"> - <a href="{% url 'users:home' others_mark.owner.id %}" class="entity-marks__owner-link">{{ others_mark.owner.username }}</a> - <span>{{ others_mark.get_status_display }}</span> - {% if others_mark.rating %} - <span class="entity-marks__rating-star rating-star" data-rating-score="{{ others_mark.rating | floatformat:"0" }}"></span> - {% endif %} - {% if others_mark.is_private %} - <span class="icon-lock"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20"><path d="M17,8.48h-.73V6.27a6.27,6.27,0,1,0-12.53,0V8.48H3a.67.67,0,0,0-.67.67V19.33A.67.67,0,0,0,3,20H17a.67.67,0,0,0,.67-.67V9.15A.67.67,0,0,0,17,8.48ZM6.42,6.27h0a3.57,3.57,0,0,1,7.14,0h0V8.48H6.42Z"/></svg></span> - {% endif %} - <span class="entity-marks__mark-time">{{ others_mark.edited_time }}</span> - {% if others_mark.text %} - <p class="entity-marks__mark-content">{{ others_mark.text }}</p> - {% endif %} - </li> - {% endfor %} - </ul> - {% else %} - <div>{% trans '暂无标记' %}</div> + <a href="{% url 'games:retrieve_mark_list' game.id %}" class="entity-marks__more-link">{% trans '全部标记' %}</a> {% endif %} + <a href="{% url 'games:retrieve_mark_list' game.id 1 %}" class="entity-marks__more-link">关注的人的标记</a> + {% include "partial/mark_list.html" with mark_list=mark_list current_item=game %} </div> <div class="entity-reviews"> <h5 class="entity-reviews__title">{% trans '这个游戏的评论' %}</h5> {% if review_list_more %} - <a href="{% url 'games:retrieve_review_list' game.id %}" class="entity-reviews__more-link">{% trans '更多' %}</a> + <a href="{% url 'games:retrieve_review_list' game.id %}" class="entity-reviews__more-link">{% trans '全部评论' %}</a> {% endif %} {% if review_list %} <ul class="entity-reviews__review-list"> {% for others_review in review_list %} <li class="entity-reviews__review"> - <a href="{% url 'users:home' others_review.owner.id %}" class="entity-reviews__owner-link">{{ others_review.owner.username }}</a> - {% if others_review.is_private %} + <a href="{% url 'users:home' others_review.owner.mastodon_username %}" class="entity-reviews__owner-link">{{ others_review.owner.username }}</a> + {% if others_review.visibility > 0 %} <span class="icon-lock"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20"><path d="M17,8.48h-.73V6.27a6.27,6.27,0,1,0-12.53,0V8.48H3a.67.67,0,0,0-.67.67V19.33A.67.67,0,0,0,3,20H17a.67.67,0,0,0,.67-.67V9.15A.67.67,0,0,0,17,8.48ZM6.42,6.27h0a3.57,3.57,0,0,1,7.14,0h0V8.48H6.42Z"/></svg></span> {% endif %} <span class="entity-reviews__review-time">{{ others_review.edited_time }}</span> @@ -242,7 +223,7 @@ <span class="mark-panel__rating-star rating-star" data-rating-score="{{ mark.rating | floatformat:"0" }}"></span> {% endif %} {% endif %} - {% if mark.is_private %} + {% if mark.visibility > 0 %} <span class="icon-lock"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20"><path d="M17,8.48h-.73V6.27a6.27,6.27,0,1,0-12.53,0V8.48H3a.67.67,0,0,0-.67.67V19.33A.67.67,0,0,0,3,20H17a.67.67,0,0,0,.67-.67V9.15A.67.67,0,0,0,17,8.48ZM6.42,6.27h0a3.57,3.57,0,0,1,7.14,0h0V8.48H6.42Z"/></svg></span> {% endif %} <span class="mark-panel__actions"> @@ -254,7 +235,7 @@ </span> <div class="mark-panel__clear"></div> - <div class="mark-panel__time">{{ mark.edited_time }}</div> + <div class="mark-panel__time">{{ mark.created_time }}</div> {% if mark.text %} <p class="mark-panel__text">{{ mark.text }}</p> @@ -286,7 +267,7 @@ <div class="review-panel"> <span class="review-panel__label">{% trans '我的评论' %}</span> - {% if review.is_private %} + {% if review.visibility > 0 %} <span class="icon-lock"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20"><path d="M17,8.48h-.73V6.27a6.27,6.27,0,1,0-12.53,0V8.48H3a.67.67,0,0,0-.67.67V19.33A.67.67,0,0,0,3,20H17a.67.67,0,0,0,.67-.67V9.15A.67.67,0,0,0,17,8.48ZM6.42,6.27h0a3.57,3.57,0,0,1,7.14,0h0V8.48H6.42Z"/></svg></span> {% endif %} @@ -315,6 +296,24 @@ {% endif %} </div> + + {% if collection_list %} + <div class="aside-section-wrapper"> + <div class="action-panel"> + <div class="action-panel__label">{% trans '相关收藏单' %}</div> + <div > + {% for c in collection_list %} + <p> + <a href="{% url 'collection:retrieve' c.id %}">{{ c.title }}</a> + </p> + {% endfor %} + <div class="action-panel__button-group action-panel__button-group--center"> + <button class="action-panel__button add-to-list" hx-get="{% url 'collection:add_to_list' 'game' game.id %}" hx-target="body" hx-swap="beforeend">{% trans '添加到收藏单' %}</button> + </div> + </div> + </div> + </div> + {% endif %} </div> </div> @@ -377,8 +376,8 @@ <div class="mark-modal__option"> <div class="mark-modal__visibility-radio"> - <span>{{ mark_form.is_private.label }}:</span> - {{ mark_form.is_private }} + <span>{{ mark_form.visibility.label }}:</span> + {{ mark_form.visibility }} </div> <div class="mark-modal__share-checkbox"> {{ mark_form.share_to_mastodon }}{{ mark_form.share_to_mastodon.label }} diff --git a/games/templates/games/mark_list.html b/games/templates/games/mark_list.html index 45071f89..6f0acffc 100644 --- a/games/templates/games/mark_list.html +++ b/games/templates/games/mark_list.html @@ -14,8 +14,8 @@ <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> - <title>{% trans 'NiceDB - ' %}{{ game.title }}{% trans '的标记' %}</title> - <script src="https://cdnjs.cloudflare.com/ajax/libs/jquery/3.5.1/jquery.min.js"></script> + <title>{{ site_name }} - {{ game.title }}{% trans '的标记' %}</title> + <script src="https://static.neodb.social/cdnjs.cloudflare.com/ajax/libs/jquery/3.6.0/jquery.min.js"></script> <script src="{% static 'lib/js/rating-star.js' %}"></script> <script src="{% static 'js/rating-star-readonly.js' %}"></script> <link rel="stylesheet" href="{% static 'css/boofilsic.min.css' %}"> @@ -35,37 +35,7 @@ <h5 class="entity-marks__title entity-marks__title--stand-alone"> <a href="{% url 'games:retrieve' game.id %}">{{ game.title }}</a>{% trans ' 的标记' %} </h5> - <ul class="entity-marks__mark-list"> - - {% for mark in marks %} - - <li class="entity-marks__mark entity-marks__mark--wider"> - <a href="{% url 'users:home' mark.owner.id %}" - class="entity-marks__owner-link">{{ mark.owner.username }}</a> - <span>{{ mark.get_status_display }}</span> - {% if mark.rating %} - <span class="entity-marks__rating-star rating-star" - data-rating-score="{{ mark.rating | floatformat:"0" }}"></span> - {% endif %} - {% if mark.is_private %} - <span class="icon-lock"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20"> - <path - d="M17,8.48h-.73V6.27a6.27,6.27,0,1,0-12.53,0V8.48H3a.67.67,0,0,0-.67.67V19.33A.67.67,0,0,0,3,20H17a.67.67,0,0,0,.67-.67V9.15A.67.67,0,0,0,17,8.48ZM6.42,6.27h0a3.57,3.57,0,0,1,7.14,0h0V8.48H6.42Z" /> - </svg></span> - {% endif %} - <span class="entity-marks__mark-time">{{ mark.edited_time }}</span> - {% if mark.text %} - <p class="entity-marks__mark-content">{{ mark.text }}</p> - {% endif %} - </li> - - {% empty %} - <div> - {% trans '无结果' %} - </div> - {% endfor %} - - </ul> + {% include "partial/mark_list.html" with mark_list=marks current_item=game %} </div> <div class="pagination"> @@ -149,12 +119,6 @@ </div> - {% comment %} - <div id="oauth2Token" hidden="true">{% oauth_token %}</div> - <div id="mastodonURI" hidden="true">{% mastodon request.user.mastodon_site %}</div> - <!--current user mastodon id--> - <div id="userMastodonID" hidden="true">{{ user.mastodon_id }}</div> - {% endcomment %} <script> diff --git a/games/templates/games/review_detail.html b/games/templates/games/review_detail.html index 1ffab003..6b0c357f 100644 --- a/games/templates/games/review_detail.html +++ b/games/templates/games/review_detail.html @@ -13,17 +13,18 @@ <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> - <meta property="og:title" content="NiceDB影评 - {{ review.title }}"> + <meta property="og:title" content="{{ site_name }}游戏评论 - {{ review.title }}"> <meta property="og:type" content="article"> <meta property="og:article:author" content="{{ review.owner.username }}"> <meta property="og:url" content="{{ request.build_absolute_uri }}"> - <meta property="og:image" content="{{ request.scheme }}://{{ request.get_host }}{% static 'img/logo_square.svg' %}"> - <title>{% trans 'NiceDB - 评论详情' %}</title> - <script src="https://cdnjs.cloudflare.com/ajax/libs/jquery/3.5.1/jquery.min.js"></script> + <meta property="og:image" content="{{ game.cover|thumb:'normal' }}"> + <title>{{ site_name }}游戏评论 - {{ review.title }}</title> + <script src="https://static.neodb.social/cdnjs.cloudflare.com/ajax/libs/jquery/3.6.0/jquery.min.js"></script> <script src="{% static 'lib/js/rating-star.js' %}"></script> <script src="{% static 'js/rating-star-readonly.js' %}"></script> <link rel="stylesheet" href="{% static 'lib/css/rating-star.css' %}"> <link rel="stylesheet" href="{% static 'css/boofilsic.min.css' %}"> + <link rel="stylesheet" href="{% static 'lib/css/neo.css' %}"> </head> <body> @@ -39,7 +40,7 @@ <h5 class="review-head__title"> {{ review.title }} </h5> - {% if review.is_private %} + {% if review.visibility > 0 %} <span class="icon-lock"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20"> <path d="M17,8.48h-.73V6.27a6.27,6.27,0,1,0-12.53,0V8.48H3a.67.67,0,0,0-.67.67V19.33A.67.67,0,0,0,3,20H17a.67.67,0,0,0,.67-.67V9.15A.67.67,0,0,0,17,8.48ZM6.42,6.27h0a3.57,3.57,0,0,1,7.14,0h0V8.48H6.42Z" /> @@ -48,7 +49,7 @@ <div class="review-head__body"> <div class="review-head__info"> - <a href="{% url 'users:home' review.owner.id %}" class="review-head__owner-link">{{ review.owner.username }}</a> + <a href="{% url 'users:home' review.owner.mastodon_username %}" class="review-head__owner-link">{{ review.owner.username }}</a> {% if mark %} @@ -73,6 +74,7 @@ {{ form.content }} </div> {{ form.media }} + {% csrf_token %} </div> </div> @@ -134,16 +136,8 @@ </div> - {% comment %} - <div id="oauth2Token" hidden="true">{% oauth_token %}</div> - <div id="mastodonURI" hidden="true">{% mastodon request.user.mastodon_site %}</div> - <!--current user mastodon id--> - <div id="userMastodonID" hidden="true">{{ user.mastodon_id }}</div> - {% endcomment %} <script> - - $(".markdownx textarea").hide(); </script> </body> diff --git a/games/templates/games/review_list.html b/games/templates/games/review_list.html index 10b01d43..8b05318d 100644 --- a/games/templates/games/review_list.html +++ b/games/templates/games/review_list.html @@ -14,8 +14,8 @@ <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> - <title>{% trans 'NiceDB - ' %}{{ game.title }}{% trans '的评论' %}</title> - <script src="https://cdnjs.cloudflare.com/ajax/libs/jquery/3.5.1/jquery.min.js"></script> + <title>{{ site_name }} - {{ game.title }}{% trans '的评论' %}</title> + <script src="https://static.neodb.social/cdnjs.cloudflare.com/ajax/libs/jquery/3.6.0/jquery.min.js"></script> <script src="{% static 'lib/js/rating-star.js' %}"></script> <script src="{% static 'js/rating-star-readonly.js' %}"></script> <link rel="stylesheet" href="{% static 'lib/css/rating-star.css' %}"> @@ -41,8 +41,8 @@ <li class="entity-reviews__review entity-reviews__review--wider"> - <a href="{% url 'users:home' review.owner.id %}" class="entity-reviews__owner-link">{{ review.owner.username }}</a> - {% if review.is_private %} + <a href="{% url 'users:home' review.owner.mastodon_username %}" class="entity-reviews__owner-link">{{ review.owner.username }}</a> + {% if review.visibility > 0 %} <span class="icon-lock"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20"><path d="M17,8.48h-.73V6.27a6.27,6.27,0,1,0-12.53,0V8.48H3a.67.67,0,0,0-.67.67V19.33A.67.67,0,0,0,3,20H17a.67.67,0,0,0,.67-.67V9.15A.67.67,0,0,0,17,8.48ZM6.42,6.27h0a3.57,3.57,0,0,1,7.14,0h0V8.48H6.42Z"/></svg></span> {% endif %} <span class="entity-reviews__review-time">{{ review.edited_time }}</span> @@ -137,12 +137,6 @@ </div> - {% comment %} - <div id="oauth2Token" hidden="true">{% oauth_token %}</div> - <div id="mastodonURI" hidden="true">{% mastodon request.user.mastodon_site %}</div> - <!--current user mastodon id--> - <div id="userMastodonID" hidden="true">{{ user.mastodon_id }}</div> - {% endcomment %} <script> diff --git a/games/templates/games/scrape.html b/games/templates/games/scrape.html index 87f2efa8..aaaeb99b 100644 --- a/games/templates/games/scrape.html +++ b/games/templates/games/scrape.html @@ -10,8 +10,8 @@ <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> - <title>{% trans 'NiceDB - 从豆瓣获取数据' %}</title> - <script src="https://cdnjs.cloudflare.com/ajax/libs/jquery/3.5.1/jquery.min.js"></script> + <title>{{ site_name }} - {% trans '从豆瓣获取数据' %}</title> + <script src="https://static.neodb.social/cdnjs.cloudflare.com/ajax/libs/jquery/3.6.0/jquery.min.js"></script> <script src="{% static 'js/scrape.js' %}"></script> <link rel="stylesheet" href="{% static 'css/boofilsic.min.css' %}"> </head> diff --git a/games/urls.py b/games/urls.py index ff984632..88ce1629 100644 --- a/games/urls.py +++ b/games/urls.py @@ -1,4 +1,4 @@ -from django.urls import path +from django.urls import path, re_path from .views import * @@ -8,9 +8,10 @@ urlpatterns = [ path('<int:id>/', retrieve, name='retrieve'), path('update/<int:id>/', update, name='update'), path('delete/<int:id>/', delete, name='delete'), + path('rescrape/<int:id>/', rescrape, name='rescrape'), path('mark/', create_update_mark, name='create_update_mark'), - path('<int:game_id>/mark/list/', - retrieve_mark_list, name='retrieve_mark_list'), + path('wish/<int:id>/', wish, name='wish'), + re_path('(?P<game_id>[0-9]+)/mark/list/(?:(?P<following_only>\\d+))?', retrieve_mark_list, name='retrieve_mark_list'), path('mark/delete/<int:id>/', delete_mark, name='delete_mark'), path('<int:game_id>/review/create/', create_review, name='create_review'), path('review/update/<int:id>/', update_review, name='update_review'), diff --git a/games/views.py b/games/views.py index f69e4918..0f0fc166 100644 --- a/games/views.py +++ b/games/views.py @@ -2,21 +2,23 @@ import logging from django.shortcuts import render, get_object_or_404, redirect, reverse from django.contrib.auth.decorators import login_required, permission_required from django.utils.translation import gettext_lazy as _ -from django.http import HttpResponseBadRequest, HttpResponseServerError +from django.http import HttpResponseBadRequest, HttpResponseServerError, HttpResponse from django.core.exceptions import ObjectDoesNotExist, PermissionDenied from django.db import IntegrityError, transaction from django.db.models import Count from django.utils import timezone from django.core.paginator import Paginator from mastodon import mastodon_request_included -from mastodon.api import check_visibility, post_toot, TootVisibilityEnum -from mastodon.utils import rating_to_emoji +from mastodon.models import MastodonApplication +from mastodon.api import share_mark, share_review from common.utils import PageLinksGenerator -from common.views import PAGE_LINK_NUMBER, jump_or_scrape +from common.views import PAGE_LINK_NUMBER, jump_or_scrape, go_relogin from common.models import SourceSiteEnum from .models import * from .forms import * -from boofilsic.settings import MASTODON_TAGS +from django.conf import settings +from collection.models import CollectionItem +from common.scraper import get_scraper_by_url, get_normalized_url logger = logging.getLogger(__name__) @@ -87,6 +89,18 @@ def create(request): return HttpResponseBadRequest() +@login_required +def rescrape(request, id): + if request.method != 'POST': + return HttpResponseBadRequest() + item = get_object_or_404(Game, pk=id) + url = get_normalized_url(item.source_url) + scraper = get_scraper_by_url(url) + scraper.scrape(url) + form = scraper.save(request_user=request.user, instance=item) + return redirect(reverse("games:retrieve", args=[form.instance.id])) + + @login_required def update(request, id): if request.method == 'GET': @@ -98,6 +112,7 @@ def update(request, id): 'games/create_update.html', { 'form': form, + 'is_update': True, 'title': page_title, 'submit_url': reverse("games:update", args=[game.id]), # provided for frontend js @@ -127,6 +142,7 @@ def update(request, id): 'games/create_update.html', { 'form': form, + 'is_update': True, 'title': page_title, 'submit_url': reverse("games:update", args=[game.id]), # provided for frontend js @@ -167,6 +183,7 @@ def retrieve(request, id): else: mark_form = GameMarkForm(initial={ 'game': game, + 'visibility': request.user.get_preference().default_visibility if request.user.is_authenticated else 0, 'tags': mark_tags }) @@ -186,10 +203,8 @@ def retrieve(request, id): mark_list_more = None review_list_more = None else: - mark_list = GameMark.get_available( - game, request.user, request.session['oauth_token']) - review_list = GameReview.get_available( - game, request.user, request.session['oauth_token']) + mark_list = GameMark.get_available(game, request.user) + review_list = GameReview.get_available(game, request.user) mark_list_more = True if len(mark_list) > MARK_NUMBER else False mark_list = mark_list[:MARK_NUMBER] for m in mark_list: @@ -197,6 +212,7 @@ def retrieve(request, id): review_list_more = True if len( review_list) > REVIEW_NUMBER else False review_list = review_list[:REVIEW_NUMBER] + collection_list = filter(lambda c: c.is_visible_to(request.user), map(lambda i: i.collection, CollectionItem.objects.filter(game=game))) # def strip_html_tags(text): # import re @@ -221,6 +237,7 @@ def retrieve(request, id): 'review_list_more': review_list_more, 'game_tag_list': game_tag_list, 'mark_tags': mark_tags, + 'collection_list': collection_list, } ) else: @@ -265,12 +282,19 @@ def create_update_mark(request): pk = request.POST.get('id') old_rating = None old_tags = None + if not pk: + game_id = request.POST.get('game') + mark = GameMark.objects.filter(game_id=game_id, owner=request.user).first() + if mark: + pk = mark.id if pk: mark = get_object_or_404(GameMark, pk=pk) if request.user != mark.owner: return HttpResponseBadRequest() old_rating = mark.rating old_tags = mark.gamemark_tags.all() + if mark.status != request.POST.get('status'): + mark.created_time = timezone.now() # update form = GameMarkForm(request.POST, instance=mark) else: @@ -278,7 +302,7 @@ def create_update_mark(request): form = GameMarkForm(request.POST) if form.is_valid(): - if form.instance.status == MarkStatusEnum.WISH.value: + if form.instance.status == MarkStatusEnum.WISH.value or form.instance.rating == 0: form.instance.rating = None form.cleaned_data['rating'] = None form.instance.owner = request.user @@ -306,28 +330,10 @@ def create_update_mark(request): return HttpResponseServerError("integrity error") if form.cleaned_data['share_to_mastodon']: - if form.cleaned_data['is_private']: - visibility = TootVisibilityEnum.PRIVATE - else: - visibility = TootVisibilityEnum.UNLISTED - url = "https://" + request.get_host() + reverse("games:retrieve", - args=[game.id]) - words = GameMarkStatusTranslator(form.cleaned_data['status']) +\ - f"《{game.title}》" + \ - rating_to_emoji(form.cleaned_data['rating']) - - # tags = MASTODON_TAGS % {'category': '书', 'type': '标记'} - tags = '' - content = words + '\n' + url + '\n' + \ - form.cleaned_data['text'] + '\n' + tags - response = post_toot(request.user.mastodon_site, content, visibility, - request.session['oauth_token']) - if response.status_code != 200: - mastodon_logger.error( - f"CODE:{response.status_code} {response.text}") - return HttpResponseServerError("publishing mastodon status failed") + if not share_mark(form.instance): + return go_relogin(request) else: - return HttpResponseBadRequest("invalid form data") + return HttpResponseBadRequest(f"invalid form data {form.errors}") return redirect(reverse("games:retrieve", args=[form.instance.game.id])) else: @@ -336,11 +342,30 @@ def create_update_mark(request): @mastodon_request_included @login_required -def retrieve_mark_list(request, game_id): +def wish(request, id): + if request.method == 'POST': + game = get_object_or_404(Game, pk=id) + params = { + 'owner': request.user, + 'status': MarkStatusEnum.WISH, + 'visibility': 0, + 'game': game, + } + try: + GameMark.objects.create(**params) + except Exception: + pass + return HttpResponse("✔️") + else: + return HttpResponseBadRequest("invalid method") + + +@mastodon_request_included +@login_required +def retrieve_mark_list(request, game_id, following_only=False): if request.method == 'GET': game = get_object_or_404(Game, pk=game_id) - queryset = GameMark.get_available( - game, request.user, request.session['oauth_token']) + queryset = GameMark.get_available(game, request.user, following_only=following_only) paginator = Paginator(queryset, MARK_PER_PAGE) page_number = request.GET.get('page', default=1) marks = paginator.get_page(page_number) @@ -401,23 +426,8 @@ def create_review(request, game_id): form.instance.owner = request.user form.save() if form.cleaned_data['share_to_mastodon']: - if form.cleaned_data['is_private']: - visibility = TootVisibilityEnum.PRIVATE - else: - visibility = TootVisibilityEnum.UNLISTED - url = "https://" + request.get_host() + reverse("games:retrieve_review", - args=[form.instance.id]) - words = "发布了关于" + f"《{form.instance.game.title}》" + "的评论" - # tags = MASTODON_TAGS % {'category': '书', 'type': '评论'} - tags = '' - content = words + '\n' + url + \ - '\n' + form.cleaned_data['title'] + '\n' + tags - response = post_toot(request.user.mastodon_site, content, visibility, - request.session['oauth_token']) - if response.status_code != 200: - mastodon_logger.error( - f"CODE:{response.status_code} {response.text}") - return HttpResponseServerError("publishing mastodon status failed") + if not share_review(form.instance): + return go_relogin(request) return redirect(reverse("games:retrieve_review", args=[form.instance.id])) else: return HttpResponseBadRequest() @@ -453,23 +463,8 @@ def update_review(request, id): form.instance.edited_time = timezone.now() form.save() if form.cleaned_data['share_to_mastodon']: - if form.cleaned_data['is_private']: - visibility = TootVisibilityEnum.PRIVATE - else: - visibility = TootVisibilityEnum.UNLISTED - url = "https://" + request.get_host() + reverse("games:retrieve_review", - args=[form.instance.id]) - words = "发布了关于" + f"《{form.instance.game.title}》" + "的评论" - # tags = MASTODON_TAGS % {'category': '书', 'type': '评论'} - tags = '' - content = words + '\n' + url + \ - '\n' + form.cleaned_data['title'] + '\n' + tags - response = post_toot(request.user.mastodon_site, content, visibility, - request.session['oauth_token']) - if response.status_code != 200: - mastodon_logger.error( - f"CODE:{response.status_code} {response.text}") - return HttpResponseServerError("publishing mastodon status failed") + if not share_review(form.instance): + return go_relogin(request) return redirect(reverse("games:retrieve_review", args=[form.instance.id])) else: return HttpResponseBadRequest() @@ -504,11 +499,10 @@ def delete_review(request, id): @mastodon_request_included -@login_required def retrieve_review(request, id): if request.method == 'GET': review = get_object_or_404(GameReview, pk=id) - if not check_visibility(review, request.session['oauth_token'], request.user): + if not review.is_visible_to(request.user): msg = _("你没有访问这个页面的权限😥") return render( request, @@ -543,8 +537,7 @@ def retrieve_review(request, id): def retrieve_review_list(request, game_id): if request.method == 'GET': game = get_object_or_404(Game, pk=game_id) - queryset = GameReview.get_available( - game, request.user, request.session['oauth_token']) + queryset = GameReview.get_available(game, request.user) paginator = Paginator(queryset, REVIEW_PER_PAGE) page_number = request.GET.get('page', default=1) reviews = paginator.get_page(page_number) diff --git a/management/models.py b/management/models.py index 9816781e..c5bf2f64 100644 --- a/management/models.py +++ b/management/models.py @@ -1,7 +1,7 @@ import re from django.db import models from django.shortcuts import reverse -from django.utils.translation import ugettext_lazy as _ +from django.utils.translation import gettext_lazy as _ from markdownx.models import MarkdownxField from markdown import markdown diff --git a/management/templates/management/create_update.html b/management/templates/management/create_update.html index 9ffd568c..692dc126 100644 --- a/management/templates/management/create_update.html +++ b/management/templates/management/create_update.html @@ -4,8 +4,8 @@ <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> - <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/skeleton-css@2.0.4/css/normalize.css"> - <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/skeleton-css@2.0.4/css/skeleton.css"> + <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/normalize/8.0.1/normalize.min.css"> + <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/skeleton/2.0.4/skeleton.min.css"> <title>Create/Update Announcement</title> </head> <body> diff --git a/management/templates/management/delete.html b/management/templates/management/delete.html index 11f08209..d48b740d 100644 --- a/management/templates/management/delete.html +++ b/management/templates/management/delete.html @@ -4,8 +4,8 @@ <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> - <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/skeleton-css@2.0.4/css/normalize.css"> - <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/skeleton-css@2.0.4/css/skeleton.css"> + <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/normalize/8.0.1/normalize.min.css"> + <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/skeleton/2.0.4/skeleton.min.css"> <title>Delete Announcement</title> </head> <body> diff --git a/management/templates/management/detail.html b/management/templates/management/detail.html index a7cf64e5..76150b1d 100644 --- a/management/templates/management/detail.html +++ b/management/templates/management/detail.html @@ -6,9 +6,9 @@ <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> - <script src="https://cdnjs.cloudflare.com/ajax/libs/jquery/3.5.1/jquery.min.js"></script> + <script src="https://static.neodb.social/cdnjs.cloudflare.com/ajax/libs/jquery/3.6.0/jquery.min.js"></script> <link rel="stylesheet" href="{% static 'css/boofilsic.min.css' %}"> - <title>NiceDB - {{ object.title }}</title> + <title>{{ site_name }} - {{ object.title }}</title> </head> <body> diff --git a/management/templates/management/list.html b/management/templates/management/list.html index c8199e1f..ab5a2b24 100644 --- a/management/templates/management/list.html +++ b/management/templates/management/list.html @@ -7,10 +7,10 @@ <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> - <script src="https://cdnjs.cloudflare.com/ajax/libs/jquery/3.5.1/jquery.min.js"></script> + <script src="https://static.neodb.social/cdnjs.cloudflare.com/ajax/libs/jquery/3.6.0/jquery.min.js"></script> <link rel="stylesheet" href="{% static 'css/boofilsic.min.css' %}"> - <title>{% trans 'NiceDB - 公告栏' %}</title> + <title>{{ site_name }} - {% trans '公告栏' %}</title> </head> <body> diff --git a/mastodon/admin.py b/mastodon/admin.py index b29c2a3c..e81bc74b 100644 --- a/mastodon/admin.py +++ b/mastodon/admin.py @@ -38,7 +38,7 @@ class MastodonApplicationModelAdmin(admin.ModelAdmin): try: response = create_app(request.POST.get('domain_name')) except (Timeout, ConnectionError): - request.POST['domain_name'] = _("长毛象请求超时。") + request.POST['domain_name'] = _("联邦网络请求超时。") except Exception as e: request.POST['domain_name'] = str(e) else: diff --git a/mastodon/api.py b/mastodon/api.py index 5e48c944..1919f099 100644 --- a/mastodon/api.py +++ b/mastodon/api.py @@ -2,10 +2,16 @@ import requests import string import random import functools +import logging from django.core.exceptions import ObjectDoesNotExist -from boofilsic.settings import MASTODON_TIMEOUT -from boofilsic.settings import CLIENT_NAME, APP_WEBSITE, REDIRECT_URIS -from .models import CrossSiteUserInfo +from django.conf import settings +from django.shortcuts import reverse +from urllib.parse import quote +from .models import CrossSiteUserInfo, MastodonApplication +from mastodon.utils import rating_to_emoji + + +logger = logging.getLogger(__name__) # See https://docs.joinmastodon.org/methods/accounts/ @@ -46,39 +52,76 @@ API_CREATE_APP = '/api/v1/apps' # GET API_SEARCH = '/api/v2/search' +TWITTER_DOMAIN = 'twitter.com' -get = functools.partial(requests.get, timeout=MASTODON_TIMEOUT) -post = functools.partial(requests.post, timeout=MASTODON_TIMEOUT) +TWITTER_API_ME = 'https://api.twitter.com/2/users/me' + +TWITTER_API_POST = 'https://api.twitter.com/2/tweets' + +TWITTER_API_TOKEN = 'https://api.twitter.com/2/oauth2/token' + +USER_AGENT = f"{settings.CLIENT_NAME}/1.0" + +get = functools.partial(requests.get, timeout=settings.MASTODON_TIMEOUT) +post = functools.partial(requests.post, timeout=settings.MASTODON_TIMEOUT) # low level api below -def get_relationships(site, id_list, token): +def get_relationships(site, id_list, token): # no longer in use url = 'https://' + site + API_GET_RELATIONSHIPS payload = {'id[]': id_list} headers = { + 'User-Agent': USER_AGENT, 'Authorization': f'Bearer {token}' } - response = get(url, headers=headers, data=payload) + response = get(url, headers=headers, params=payload) return response.json() def post_toot(site, content, visibility, token, local_only=False): - url = 'https://' + site + API_PUBLISH_TOOT headers = { + 'User-Agent': USER_AGENT, 'Authorization': f'Bearer {token}', 'Idempotency-Key': random_string_generator(16) } - payload = { - 'status': content, - 'visibility': visibility, - 'local_only': True, - } - if not local_only: - del payload['local_only'] - response = post(url, headers=headers, data=payload) + if site == TWITTER_DOMAIN: + url = TWITTER_API_POST + payload = { + 'text': content if len(content) <= 150 else content[0:150] + '...' + } + response = post(url, headers=headers, json=payload) + else: + url = 'https://' + site + API_PUBLISH_TOOT + payload = { + 'status': content, + 'visibility': visibility, + } + if local_only: + payload['local_only'] = True + try: + response = post(url, headers=headers, data=payload) + if response.status_code == 201: + response.status_code = 200 + if response.status_code != 200: + logger.error(f"Error {url} {response.status_code}") + except Exception: + response = None return response +def get_instance_info(domain_name): + if domain_name.lower().strip() == TWITTER_DOMAIN: + return TWITTER_DOMAIN, '' + try: + url = f'https://{domain_name}/api/v1/instance' + response = get(url, headers={'User-Agent': USER_AGENT}) + j = response.json() + return j['uri'].lower().split('//')[-1].split('/')[0], j['version'] + except Exception: + logger.error(f"Error {url}") + return domain_name, '' + + def create_app(domain_name): # naive protocal strip is_http = False @@ -96,18 +139,13 @@ def create_app(domain_name): url = 'http://' + domain_name + API_CREATE_APP payload = { - 'client_name': CLIENT_NAME, - 'scopes': 'read write follow', - 'redirect_uris': REDIRECT_URIS, - 'website': APP_WEBSITE + 'client_name': settings.CLIENT_NAME, + 'scopes': settings.MASTODON_CLIENT_SCOPE, + 'redirect_uris': settings.REDIRECT_URIS, + 'website': settings.APP_WEBSITE } - from boofilsic.settings import DEBUG - if DEBUG: - payload['redirect_uris'] = 'http://localhost/users/OAuth2_login/\nurn:ietf:wg:oauth:2.0:oob' - payload['client_name'] = 'test_do_not_authorise' - - response = post(url, data=payload) + response = post(url, data=payload, headers={'User-Agent': USER_AGENT}) return response @@ -116,26 +154,35 @@ def get_site_id(username, user_site, target_site, token): payload = { 'limit': 1, 'type': 'accounts', + 'resolve': True, 'q': f"{username}@{user_site}" } headers = { + 'User-Agent': USER_AGENT, 'Authorization': f'Bearer {token}' } - response = get(url, data=payload, headers=headers) - data = response.json() - if not data['accounts']: + response = get(url, params=payload, headers=headers) + try: + data = response.json() + except Exception: + logger.error(f"Error parsing JSON from {url}") + return None + if 'accounts' not in data: + return None + elif len(data['accounts']) == 0: # target site may return empty if no cache of this user + return None + elif data['accounts'][0]['acct'] != f"{username}@{user_site}": # or return another user with a similar id which needs to be skipped return None else: return data['accounts'][0]['id'] # high level api below -def get_relationship(request_user, target_user, token): - if request_user.mastodon_site == target_user.mastodon_site: - return get_relationships(request_user.mastodon_site, target_user.mastodon_id, token) - else: - cross_site_id = get_cross_site_id(target_user, request_user.mastodon_site, token) - return get_relationships(request_user.mastodon_site, [cross_site_id,], token) +def get_relationship(request_user, target_user, useless_token=None): + return [{ + 'blocked_by': target_user.is_blocking(request_user), + 'following': request_user.is_following(target_user), + }] def get_cross_site_id(target_user, target_site, token): @@ -147,6 +194,8 @@ def get_cross_site_id(target_user, target_site, token): """ if target_site == target_user.mastodon_site: return target_user.mastodon_id + if target_site == TWITTER_DOMAIN: + return None try: cross_site_info = CrossSiteUserInfo.objects.get( @@ -157,6 +206,7 @@ def get_cross_site_id(target_user, target_site, token): cross_site_id = get_site_id( target_user.username, target_user.mastodon_site, target_site, token) if not cross_site_id: + logger.error(f'unable to find cross_site_id for {target_user} on {target_site}') return None cross_site_info = CrossSiteUserInfo.objects.create( uid=f"{target_user.username}@{target_user.mastodon_site}", @@ -167,30 +217,251 @@ def get_cross_site_id(target_user, target_site, token): return cross_site_info.site_id -def check_visibility(user_owned_entity, token, visitor): - """ - check if given user can see the user owned entity - """ - if not visitor == user_owned_entity.owner: - # mastodon request - relationship = get_relationship(visitor, user_owned_entity.owner, token)[0] - if relationship['blocked_by']: - return False - if not relationship['following'] and user_owned_entity.is_private: - return False - return True - else: - return True - - # utils below def random_string_generator(n): s = string.ascii_letters + string.punctuation + string.digits return ''.join(random.choice(s) for i in range(n)) +def verify_account(site, token): + if site == TWITTER_DOMAIN: + url = TWITTER_API_ME + '?user.fields=id,username,name,description,profile_image_url,created_at,protected' + try: + response = get(url, headers={'User-Agent': USER_AGENT, 'Authorization': f'Bearer {token}'}) + if response.status_code != 200: + logger.error(f"Error {url} {response.status_code}") + return response.status_code, None + r = response.json()['data'] + r['display_name'] = r['name'] + r['note'] = r['description'] + r['avatar'] = r['profile_image_url'] + r['avatar_static'] = r['profile_image_url'] + r['locked'] = r['protected'] + r['url'] = f'https://{TWITTER_DOMAIN}/{r["username"]}' + return 200, r + except Exception: + return -1, None + url = 'https://' + site + API_VERIFY_ACCOUNT + try: + response = get(url, headers={'User-Agent': USER_AGENT, 'Authorization': f'Bearer {token}'}) + return response.status_code, (response.json() if response.status_code == 200 else None) + except Exception: + return -1, None + + +def get_related_acct_list(site, token, api): + if site == TWITTER_DOMAIN: + return [] + url = 'https://' + site + api + results = [] + while url: + response = get(url, headers={'User-Agent': USER_AGENT, 'Authorization': f'Bearer {token}'}) + url = None + if response.status_code == 200: + results.extend(map(lambda u: (u['acct'] if u['acct'].find('@') != -1 else u['acct'] + '@' + site) if 'acct' in u else u, response.json())) + if 'Link' in response.headers: + for ls in response.headers['Link'].split(','): + li = ls.strip().split(';') + if li[1].strip() == 'rel="next"': + url = li[0].strip().replace('>', '').replace('<', '') + return results + + class TootVisibilityEnum: PUBLIC = 'public' PRIVATE = 'private' DIRECT = 'direct' UNLISTED = 'unlisted' + + +def get_mastodon_application(domain): + app = MastodonApplication.objects.filter(domain_name=domain).first() + if app is not None: + return app, '' + if domain == TWITTER_DOMAIN: + return None, 'Twitter未配置' + error_msg = None + try: + response = create_app(domain) + except (requests.exceptions.Timeout, ConnectionError): + error_msg = "联邦网络请求超时。" + logger.error(f'Error creating app for {domain}: Timeout') + except Exception as e: + error_msg = "联邦网络请求失败 " + str(e) + logger.error(f'Error creating app for {domain}: {e}') + else: + # fill the form with returned data + if response.status_code != 200: + error_msg = "实例连接错误,代码: " + str(response.status_code) + logger.error(f'Error creating app for {domain}: {response.status_code}') + else: + try: + data = response.json() + except Exception: + error_msg = "实例返回内容无法识别" + logger.error(f'Error creating app for {domain}: unable to parse response') + else: + if settings.MASTODON_ALLOW_ANY_SITE: + app = MastodonApplication.objects.create(domain_name=domain, app_id=data['id'], client_id=data['client_id'], + client_secret=data['client_secret'], vapid_key=data['vapid_key'] if 'vapid_key' in data else '') + else: + error_msg = "不支持其它实例登录" + logger.error(f'Disallowed to create app for {domain}') + return app, error_msg + + +def get_mastodon_login_url(app, login_domain, version, request): + url = request.scheme + "://" + request.get_host() + reverse('users:OAuth2_login') + if login_domain == TWITTER_DOMAIN: + return f"https://twitter.com/i/oauth2/authorize?response_type=code&client_id={app.client_id}&redirect_uri={quote(url)}&scope={quote(settings.TWITTER_CLIENT_SCOPE)}&state=state&code_challenge=challenge&code_challenge_method=plain" + scope = settings.MASTODON_LEGACY_CLIENT_SCOPE if 'Pixelfed' in version else settings.MASTODON_CLIENT_SCOPE + return "https://" + login_domain + "/oauth/authorize?client_id=" + app.client_id + "&scope=" + quote(scope) + "&redirect_uri=" + url + "&response_type=code" + + +def obtain_token(site, request, code): + """ Returns token if success else None. """ + mast_app = MastodonApplication.objects.get(domain_name=site) + redirect_uri = request.scheme + "://" + request.get_host() + reverse('users:OAuth2_login') + payload = { + 'client_id': mast_app.client_id, + 'client_secret': mast_app.client_secret, + 'redirect_uri': redirect_uri, + 'grant_type': 'authorization_code', + 'code': code + } + headers = {'User-Agent': USER_AGENT} + auth = None + if mast_app.is_proxy: + url = 'https://' + mast_app.proxy_to + API_OBTAIN_TOKEN + elif site == TWITTER_DOMAIN: + url = TWITTER_API_TOKEN + auth = (mast_app.client_id, mast_app.client_secret) + del payload['client_secret'] + payload['code_verifier'] = 'challenge' + else: + url = 'https://' + mast_app.domain_name + API_OBTAIN_TOKEN + try: + response = post(url, data=payload, headers=headers, auth=auth) + # {"token_type":"bearer","expires_in":7200,"access_token":"VGpkOEZGR3FQRDJ5NkZ0dmYyYWIwS0dqeHpvTnk4eXp0NV9nWDJ2TEpmM1ZTOjE2NDg3ODMxNTU4Mzc6MToxOmF0OjE","scope":"block.read follows.read offline.access tweet.write users.read mute.read","refresh_token":"b1pXbGEzeUF1WE5yZHJOWmxTeWpvMTBrQmZPd0czLU0tQndZQTUyU3FwRDVIOjE2NDg3ODMxNTU4Mzg6MToxOnJ0OjE"} + if response.status_code != 200: + logger.error(f"Error {url} {response.status_code}") + return None, None + except Exception as e: + logger.error(f"Error {url} {e}") + return None, None + data = response.json() + return data.get('access_token'), data.get('refresh_token', '') + + +def refresh_access_token(site, refresh_token): + if site != TWITTER_DOMAIN: + return None + mast_app = MastodonApplication.objects.get(domain_name=site) + url = TWITTER_API_TOKEN + payload = { + 'client_id': mast_app.client_id, + 'refresh_token': refresh_token, + 'grant_type': 'refresh_token', + } + headers = {'User-Agent': USER_AGENT} + auth = (mast_app.client_id, mast_app.client_secret) + response = post(url, data=payload, headers=headers, auth=auth) + if response.status_code != 200: + logger.error(f"Error {url} {response.status_code}") + return None + data = response.json() + return data.get('access_token') + + +def revoke_token(site, token): + mast_app = MastodonApplication.objects.get(domain_name=site) + + payload = { + 'client_id': mast_app.client_id, + 'client_secret': mast_app.client_secret, + 'token': token + } + + if mast_app.is_proxy: + url = 'https://' + mast_app.proxy_to + API_REVOKE_TOKEN + else: + url = 'https://' + site + API_REVOKE_TOKEN + post(url, data=payload, headers={'User-Agent': USER_AGENT}) + + +def share_mark(mark): + user = mark.owner + if mark.visibility == 2: + visibility = TootVisibilityEnum.DIRECT + elif mark.visibility == 1: + visibility = TootVisibilityEnum.PRIVATE + elif user.get_preference().mastodon_publish_public: + visibility = TootVisibilityEnum.PUBLIC + else: + visibility = TootVisibilityEnum.UNLISTED + tags = '\n' + user.get_preference().mastodon_append_tag.replace('[category]', str(mark.item.verbose_category_name)) if user.get_preference().mastodon_append_tag else '' + stars = rating_to_emoji(mark.rating, MastodonApplication.objects.get(domain_name=user.mastodon_site).star_mode) + content = f"{mark.translated_status}《{mark.item.title}》{stars}\n{mark.item.url}\n{mark.text}{tags}" + response = post_toot(user.mastodon_site, content, visibility, user.mastodon_token) + if response and response.status_code in [200, 201]: + j = response.json() + if 'url' in j: + mark.shared_link = j['url'] + elif 'data' in j: + mark.shared_link = f"https://twitter.com/{user.username}/status/{j['data']['id']}" + if mark.shared_link: + mark.save(update_fields=['shared_link']) + return True + else: + return False + + +def share_review(review): + user = review.owner + if review.visibility == 2: + visibility = TootVisibilityEnum.DIRECT + elif review.visibility == 1: + visibility = TootVisibilityEnum.PRIVATE + elif user.get_preference().mastodon_publish_public: + visibility = TootVisibilityEnum.PUBLIC + else: + visibility = TootVisibilityEnum.UNLISTED + tags = '\n' + user.get_preference().mastodon_append_tag.replace('[category]', str(review.item.verbose_category_name)) if user.get_preference().mastodon_append_tag else '' + content = f"发布了关于《{review.item.title}》的评论\n{review.url}\n{review.title}{tags}" + response = post_toot(user.mastodon_site, content, visibility, user.mastodon_token) + if response and response.status_code in [200, 201]: + j = response.json() + if 'url' in j: + review.shared_link = j['url'] + elif 'data' in j: + review.shared_link = f"https://twitter.com/{user.username}/status/{j['data']['id']}" + if review.shared_link: + review.save(update_fields=['shared_link']) + return True + else: + return False + + +def share_collection(collection, comment, user, visibility_no): + if visibility_no == 2: + visibility = TootVisibilityEnum.DIRECT + elif visibility_no == 1: + visibility = TootVisibilityEnum.PRIVATE + elif user.get_preference().mastodon_publish_public: + visibility = TootVisibilityEnum.PUBLIC + else: + visibility = TootVisibilityEnum.UNLISTED + tags = '\n' + user.get_preference().mastodon_append_tag.replace('[category]', '收藏单') if user.get_preference().mastodon_append_tag else '' + content = f"分享收藏单《{collection.title}》\n{collection.url}\n{comment}{tags}" + response = post_toot(user.mastodon_site, content, visibility, user.mastodon_token) + if response and response.status_code in [200, 201]: + j = response.json() + if 'url' in j: + shared_link = j['url'] + elif 'data' in j: + shared_link = f"https://twitter.com/{user.username}/status/{j['data']['id']}" + if shared_link: + pass + return True + else: + return False diff --git a/mastodon/auth.py b/mastodon/auth.py index 29a691e8..02d7703f 100644 --- a/mastodon/auth.py +++ b/mastodon/auth.py @@ -1,74 +1,5 @@ from django.contrib.auth.backends import ModelBackend, UserModel -from django.shortcuts import reverse -from .api import * -from .models import MastodonApplication - - -def obtain_token(site, request, code): - """ Returns token if success else None. """ - mast_app = MastodonApplication.objects.get(domain_name=site) - payload = { - 'client_id': mast_app.client_id, - 'client_secret': mast_app.client_secret, - 'redirect_uri': f"https://{request.get_host()}{reverse('users:OAuth2_login')}", - 'grant_type': 'authorization_code', - 'code': code, - 'scope': 'read write' - } - from boofilsic.settings import DEBUG - if DEBUG: - payload['redirect_uri']= f"http://{request.get_host()}{reverse('users:OAuth2_login')}", - if mast_app.is_proxy: - url = 'https://' + mast_app.proxy_to + API_OBTAIN_TOKEN - else: - url = 'https://' + mast_app.domain_name + API_OBTAIN_TOKEN - response = post(url, data=payload) - if response.status_code != 200: - return - data = response.json() - return data.get('access_token') - - -def get_user_data(site, token): - url = 'https://' + site + API_VERIFY_ACCOUNT - headers = { - 'Authorization': f'Bearer {token}' - } - response = get(url, headers=headers) - if response.status_code != 200: - return None - return response.json() - - -def revoke_token(site, token): - mast_app = MastodonApplication.objects.get(domain_name=site) - - payload = { - 'client_id': mast_app.client_id, - 'client_secret': mast_app.client_secret, - 'scope': token - } - - if mast_app.is_proxy: - url = 'https://' + mast_app.proxy_to + API_REVOKE_TOKEN - else: - url = 'https://' + site + API_REVOKE_TOKEN - response = post(url, data=payload) - - -def verify_token(site, token): - """ Check if the token is valid and is of local instance. """ - url = 'https://' + site + API_VERIFY_ACCOUNT - headers = { - 'Authorization': f'Bearer {token}' - } - response = get(url, headers=headers) - if response.status_code == 200: - res_data = response.json() - # check if is local instance user - if res_data['acct'] == res_data['username']: - return True - return False +from .api import verify_account class OAuth2Backend(ModelBackend): @@ -76,22 +7,23 @@ class OAuth2Backend(ModelBackend): # "authenticate() should check the credentials it gets and returns # a user object that matches those credentials." # arg request is an interface specification, not used in this implementation - def authenticate(self, request, token=None, username=None, site=None, **kwargs): + + def authenticate(self, request, token=None, username=None, site=None, **kwargs): """ when username is provided, assume that token is newly obtained and valid """ if token is None or site is None: return if username is None: - user_data = get_user_data(site, token) - if user_data: - username = user_data['username'] + code, user_data = verify_account(site, token) + if code == 200: + userid = user_data['id'] else: # aquiring user data fail means token is invalid thus auth fail return None # when username is provided, assume that token is newly obtained and valid try: - user = UserModel._default_manager.get_by_natural_key(user_data['username']) + user = UserModel._default_manager.get(mastodon_id=userid, mastodon_site=site) except UserModel.DoesNotExist: return None else: diff --git a/mastodon/decorators.py b/mastodon/decorators.py index 3f2ec5cd..ef7a31d1 100644 --- a/mastodon/decorators.py +++ b/mastodon/decorators.py @@ -16,7 +16,7 @@ def mastodon_request_included(func): args[0], 'common/error.html', { - 'msg': _("长毛象请求超时叻_(´ཀ`」 ∠)__ ") + 'msg': _("联邦网络请求超时叻_(´ཀ`」 ∠)__ ") } ) return wrapper diff --git a/mastodon/management/commands/wrong_sites.py b/mastodon/management/commands/wrong_sites.py new file mode 100644 index 00000000..f985a1e7 --- /dev/null +++ b/mastodon/management/commands/wrong_sites.py @@ -0,0 +1,21 @@ +from django.core.management.base import BaseCommand +from mastodon.models import MastodonApplication +from django.conf import settings +from mastodon.api import get_instance_info +from users.models import User + + +class Command(BaseCommand): + help = 'Find wrong sites' + + def handle(self, *args, **options): + for site in MastodonApplication.objects.all(): + d = site.domain_name + login_domain = d.strip().lower().split('//')[-1].split('/')[0].split('@')[-1] + domain, version = get_instance_info(login_domain) + if d != domain: + print(f'{d} should be {domain}') + for u in User.objects.filter(mastodon_site=d, is_active=True): + u.mastodon_site = domain + print(f'fixing {u}') + u.save() diff --git a/mastodon/models.py b/mastodon/models.py index b6ac1207..49346810 100644 --- a/mastodon/models.py +++ b/mastodon/models.py @@ -1,14 +1,16 @@ from django.db import models from django.utils import timezone -from django.utils.translation import ugettext_lazy as _ +from django.utils.translation import gettext_lazy as _ class MastodonApplication(models.Model): domain_name = models.CharField(_('site domain name'), max_length=100, unique=True) - app_id = models.PositiveIntegerField(_('in-site app id')) + app_id = models.PositiveIntegerField(_('in-site app id')) # TODO Remove? bc 1) it seems useless 2) GoToSocial returns a hash text id client_id = models.CharField(_('client id'), max_length=100) client_secret = models.CharField(_('client secret'), max_length=100) - vapid_key = models.CharField(_('vapid key'), max_length=200) + vapid_key = models.CharField(_('vapid key'), max_length=200, null=True, blank=True) + star_mode = models.PositiveIntegerField(_('0: custom emoji; 1: unicode moon; 2: text'), blank=False, default=0) + max_status_len = models.PositiveIntegerField(_('max toot len'), blank=False, default=500) is_proxy = models.BooleanField(default=False, blank=True) proxy_to = models.CharField(max_length=100, blank=True, default='') @@ -27,7 +29,7 @@ class CrossSiteUserInfo(models.Model): # target site domain name target_site = models.CharField(_("target site domain name"), max_length=100) # target site id - site_id = models.PositiveIntegerField() + site_id = models.CharField(max_length=100, blank=False) class Meta: constraints = [ diff --git a/mastodon/utils.py b/mastodon/utils.py index 7da7f94e..8bada43a 100644 --- a/mastodon/utils.py +++ b/mastodon/utils.py @@ -1,14 +1,17 @@ -from boofilsic.settings import STAR_EMPTY, STAR_HALF, STAR_SOLID +from django.conf import settings -def rating_to_emoji(score): +def rating_to_emoji(score, star_mode = 0): """ convert score to mastodon star emoji code """ if score is None or score == '' or score == 0: return '' solid_stars = score // 2 half_star = int(bool(score % 2)) empty_stars = 5 - solid_stars if not half_star else 5 - solid_stars - 1 - emoji_code = STAR_SOLID * solid_stars + STAR_HALF * half_star + STAR_EMPTY * empty_stars + if star_mode == 1: + emoji_code = "🌕" * solid_stars + "🌗" * half_star + "🌑" * empty_stars + else: + emoji_code = settings.STAR_SOLID * solid_stars + settings.STAR_HALF * half_star + settings.STAR_EMPTY * empty_stars emoji_code = emoji_code.replace("::", ": :") emoji_code = ' ' + emoji_code + ' ' return emoji_code \ No newline at end of file diff --git a/movies/admin.py b/movies/admin.py index ca4d1b0e..bb2b1a54 100644 --- a/movies/admin.py +++ b/movies/admin.py @@ -1,7 +1,8 @@ from django.contrib import admin from .models import * +from simple_history.admin import SimpleHistoryAdmin -admin.site.register(Movie) +admin.site.register(Movie, SimpleHistoryAdmin) admin.site.register(MovieMark) admin.site.register(MovieReview) admin.site.register(MovieTag) diff --git a/movies/apps.py b/movies/apps.py index bda16f08..3b025da4 100644 --- a/movies/apps.py +++ b/movies/apps.py @@ -3,3 +3,8 @@ from django.apps import AppConfig class MoviesConfig(AppConfig): name = 'movies' + + def ready(self): + from common.index import Indexer + from .models import Movie + Indexer.update_model_indexable(Movie) diff --git a/movies/forms.py b/movies/forms.py index aa320bd5..587d92e9 100644 --- a/movies/forms.py +++ b/movies/forms.py @@ -1,18 +1,13 @@ from django import forms from django.contrib.postgres.forms import SimpleArrayField from django.utils.translation import gettext_lazy as _ -from .models import Movie, MovieMark, MovieReview, MovieGenreEnum +from .models import Movie, MovieMark, MovieReview, MovieGenreEnum, MovieMarkStatusTranslation from common.models import MarkStatusEnum from common.forms import * def MovieMarkStatusTranslator(status): - trans_dict = { - MarkStatusEnum.DO.value: _("在看"), - MarkStatusEnum.WISH.value: _("想看"), - MarkStatusEnum.COLLECT.value: _("看过") - } - return trans_dict[status] + return MovieMarkStatusTranslation[status] class MovieForm(forms.ModelForm): @@ -119,11 +114,8 @@ class MovieMarkForm(MarkForm): 'status', 'rating', 'text', - 'is_private', + 'visibility', ] - labels = { - 'rating': _("评分"), - } widgets = { 'movie': forms.TextInput(attrs={"hidden": ""}), } @@ -138,14 +130,8 @@ class MovieReviewForm(ReviewForm): 'movie', 'title', 'content', - 'is_private' + 'visibility' ] - labels = { - 'book': "", - 'title': _("标题"), - 'content': _("正文"), - 'share_to_mastodon': _("分享到长毛象") - } widgets = { 'movie': forms.TextInput(attrs={"hidden": ""}), } diff --git a/movies/management/commands/fix-movie-poster.py b/movies/management/commands/fix-movie-poster.py new file mode 100644 index 00000000..b8a35f85 --- /dev/null +++ b/movies/management/commands/fix-movie-poster.py @@ -0,0 +1,203 @@ +from django.core.management.base import BaseCommand +from django.core.files.uploadedfile import SimpleUploadedFile +from common.scraper import * +from django.conf import settings +from movies.models import Movie +from movies.forms import MovieForm +import requests +import re +import filetype +from lxml import html +from PIL import Image +from io import BytesIO + + +class DoubanPatcherMixin: + @classmethod + def download_page(cls, url, headers): + url = cls.get_effective_url(url) + r = None + error = 'DoubanScrapper: error occured when downloading ' + url + content = None + + def get(url, timeout): + nonlocal r + # print('Douban GET ' + url) + try: + r = requests.get(url, timeout=timeout) + except Exception as e: + r = requests.Response() + r.status_code = f"Exception when GET {url} {e}" + url + # print('Douban CODE ' + str(r.status_code)) + return r + + def check_content(): + nonlocal r, error, content + content = None + if r.status_code == 200: + content = r.content.decode('utf-8') + if content.find('关于豆瓣') == -1: + if content.find('你的 IP 发出') == -1: + error = error + 'Content not authentic' # response is garbage + else: + error = error + 'IP banned' + content = None + elif re.search('不存在[^<]+</title>', content, re.MULTILINE): + content = None + error = error + 'Not found or hidden by Douban' + else: + error = error + str(r.status_code) + + def fix_wayback_links(): + nonlocal content + # fix links + content = re.sub(r'href="http[^"]+http', r'href="http', content) + # https://img9.doubanio.com/view/subject/{l|m|s}/public/s1234.jpg + content = re.sub(r'src="[^"]+/(s\d+\.\w+)"', + r'src="https://img9.doubanio.com/view/subject/m/public/\1"', content) + # https://img9.doubanio.com/view/photo/s_ratio_poster/public/p2681329386.jpg + # https://img9.doubanio.com/view/photo/{l|m|s}/public/p1234.webp + content = re.sub(r'src="[^"]+/(p\d+\.\w+)"', + r'src="https://img9.doubanio.com/view/photo/m/public/\1"', content) + + # Wayback Machine: get latest available + def wayback(): + nonlocal r, error, content + error = error + '\nWayback: ' + get('http://archive.org/wayback/available?url=' + url, 10) + if r.status_code == 200: + w = r.json() + if w['archived_snapshots'] and w['archived_snapshots']['closest']: + get(w['archived_snapshots']['closest']['url'], 10) + check_content() + if content is not None: + fix_wayback_links() + else: + error = error + 'No snapshot available' + else: + error = error + str(r.status_code) + + # Wayback Machine: guess via CDX API + def wayback_cdx(): + nonlocal r, error, content + error = error + '\nWayback: ' + get('http://web.archive.org/cdx/search/cdx?url=' + url, 10) + if r.status_code == 200: + dates = re.findall(r'[^\s]+\s+(\d+)\s+[^\s]+\s+[^\s]+\s+\d+\s+[^\s]+\s+\d{5,}', + r.content.decode('utf-8')) + # assume snapshots whose size >9999 contain real content, use the latest one of them + if len(dates) > 0: + get('http://web.archive.org/web/' + dates[-1] + '/' + url, 10) + check_content() + if content is not None: + fix_wayback_links() + else: + error = error + 'No snapshot available' + else: + error = error + str(r.status_code) + + def latest(): + nonlocal r, error, content + if settings.SCRAPESTACK_KEY is None: + error = error + '\nDirect: ' + get(url, 60) + else: + error = error + '\nScraperAPI: ' + get(f'http://api.scrapestack.com/scrape?access_key={settings.SCRAPESTACK_KEY}&url={url}', 60) + check_content() + + # wayback_cdx() + # if content is None: + latest() + + if content is None: + logger.error(error) + content = '<html />' + # with open('/tmp/temp.html', 'w', encoding='utf-8') as fp: + # fp.write(content) + return html.fromstring(content) + + @classmethod + def download_image(cls, url, item_url=None): + if url is None: + return None, None + raw_img = None + ext = None + + dl_url = url + if settings.SCRAPESTACK_KEY is not None: + dl_url = f'http://api.scrapestack.com/scrape?access_key={settings.SCRAPESTACK_KEY}&url={url}' + + try: + img_response = requests.get(dl_url, timeout=90) + if img_response.status_code == 200: + raw_img = img_response.content + img = Image.open(BytesIO(raw_img)) + img.load() # corrupted image will trigger exception + content_type = img_response.headers.get('Content-Type') + ext = filetype.get_type(mime=content_type.partition(';')[0].strip()).extension + else: + logger.error(f"Douban: download image failed {img_response.status_code} {dl_url} {item_url}") + # raise RuntimeError(f"Douban: download image failed {img_response.status_code} {dl_url}") + except Exception as e: + raw_img = None + ext = None + logger.error(f"Douban: download image failed {e} {dl_url} {item_url}") + if raw_img is None and settings.SCRAPESTACK_KEY is not None: + try: + img_response = requests.get(dl_url, timeout=90) + if img_response.status_code == 200: + raw_img = img_response.content + img = Image.open(BytesIO(raw_img)) + img.load() # corrupted image will trigger exception + content_type = img_response.headers.get('Content-Type') + ext = filetype.get_type(mime=content_type.partition(';')[0].strip()).extension + else: + logger.error(f"Douban: download image failed {img_response.status_code} {dl_url} {item_url}") + except Exception as e: + raw_img = None + ext = None + logger.error(f"Douban: download image failed {e} {dl_url} {item_url}") + return raw_img, ext + + +class DoubanMoviePatcher(DoubanPatcherMixin, AbstractScraper): + site_name = SourceSiteEnum.DOUBAN.value + host = 'movie.douban.com' + data_class = Movie + form_class = MovieForm + + regex = re.compile(r"https://movie\.douban\.com/subject/\d+/{0,1}") + + def scrape(self, url): + headers = DEFAULT_REQUEST_HEADERS.copy() + headers['Host'] = self.host + content = self.download_page(url, headers) + img_url_elem = content.xpath("//img[@rel='v:image']/@src") + img_url = img_url_elem[0].strip() if img_url_elem else None + raw_img, ext = self.download_image(img_url, url) + return raw_img, ext + + +class Command(BaseCommand): + help = 'fix cover image' + + def add_arguments(self, parser): + parser.add_argument('threadId', type=int, help='% 8') + + def handle(self, *args, **options): + t = int(options['threadId']) + for m in Movie.objects.filter(cover='movie/default.svg', source_site='douban'): + if m.id % 8 == t: + print(f'Re-fetching {m.source_url}') + try: + raw_img, img_ext = DoubanMoviePatcher.scrape(m.source_url) + if img_ext is not None: + m.cover = SimpleUploadedFile('temp.' + img_ext, raw_img) + m.save() + print(f'Saved {m.source_url}') + else: + print(f'Skipped {m.source_url}') + except Exception as e: + print(e) + # return diff --git a/movies/models.py b/movies/models.py index be6d4ba3..ae3a9990 100644 --- a/movies/models.py +++ b/movies/models.py @@ -1,17 +1,27 @@ import uuid import django.contrib.postgres.fields as postgres -from django.utils.translation import ugettext_lazy as _ +from django.utils.translation import gettext_lazy as _ from django.db import models from django.core.serializers.json import DjangoJSONEncoder from django.shortcuts import reverse -from common.models import Entity, Mark, Review, Tag +from common.models import Entity, Mark, Review, Tag, MarkStatusEnum from common.utils import ChoicesDictGenerator, GenerateDateUUIDMediaFilePath -from boofilsic.settings import MOVIE_MEDIA_PATH_ROOT, DEFAULT_MOVIE_IMAGE from django.utils import timezone +from django.conf import settings +from django.db.models import Q +import re +from simple_history.models import HistoricalRecords + + +MovieMarkStatusTranslation = { + MarkStatusEnum.DO.value: _("在看"), + MarkStatusEnum.WISH.value: _("想看"), + MarkStatusEnum.COLLECT.value: _("看过") +} def movie_cover_path(instance, filename): - return GenerateDateUUIDMediaFilePath(instance, filename, MOVIE_MEDIA_PATH_ROOT) + return GenerateDateUUIDMediaFilePath(instance, filename, settings.MOVIE_MEDIA_PATH_ROOT) class MovieGenreEnum(models.TextChoices): @@ -47,6 +57,10 @@ class MovieGenreEnum(models.TextChoices): REALITY_TV = 'Reality-TV', _('真人秀') FAMILY = 'Family', _('家庭') TALK_SHOW = 'Talk-Show', _('脱口秀') + NEWS = 'News', _('新闻') + SOAP = 'Soap', _('肥皂剧') + TV_MOVIE = 'TV Movie', _('电视电影') + THEATRE = 'Theatre', _('舞台艺术') OTHER = 'Other', _('其他') @@ -58,13 +72,13 @@ class Movie(Entity): Can either be movie or series. ''' # widely recognized name, usually in Chinese - title = models.CharField(_("title"), max_length=200) + title = models.CharField(_("title"), max_length=500) # original name, for books in foreign language orig_title = models.CharField( - _("original title"), blank=True, default='', max_length=200) + _("original title"), blank=True, default='', max_length=500) other_title = postgres.ArrayField( models.CharField(_("other title"), blank=True, - default='', max_length=300), + default='', max_length=500), null=True, blank=True, default=list, @@ -73,21 +87,21 @@ class Movie(Entity): blank=True, max_length=10, null=False, db_index=True, default='') director = postgres.ArrayField( models.CharField(_("director"), blank=True, - default='', max_length=100), + default='', max_length=200), null=True, blank=True, default=list, ) playwright = postgres.ArrayField( models.CharField(_("playwright"), blank=True, - default='', max_length=100), + default='', max_length=200), null=True, blank=True, default=list, ) actor = postgres.ArrayField( models.CharField(_("actor"), blank=True, - default='', max_length=100), + default='', max_length=200), null=True, blank=True, default=list, @@ -112,7 +126,7 @@ class Movie(Entity): default=list, ) site = models.URLField(_('site url'), blank=True, default='', max_length=200) - + # country or region area = postgres.ArrayField( models.CharField( @@ -140,7 +154,7 @@ class Movie(Entity): year = models.PositiveIntegerField(null=True, blank=True) duration = models.CharField(blank=True, default='', max_length=200) - cover = models.ImageField(_("poster"), upload_to=movie_cover_path, default=DEFAULT_MOVIE_IMAGE, blank=True) + cover = models.ImageField(_("poster"), upload_to=movie_cover_path, default=settings.DEFAULT_MOVIE_IMAGE, blank=True) ############################################ # exclusive fields to series @@ -157,27 +171,67 @@ class Movie(Entity): ############################################ is_series = models.BooleanField(default=False) + history = HistoricalRecords() def __str__(self): if self.year: - return self.title + f"({self.year})" + return self.title + f"({self.year})" else: return self.title + def get_json(self): + r = { + 'other_title': self.other_title, + 'original_title': self.orig_title, + 'director': self.director, + 'playwright': self.playwright, + 'actor': self.actor, + 'release_year': self.year, + 'genre': self.genre, + 'language': self.language, + 'season': self.season, + 'duration': self.duration, + 'imdb_code': self.imdb_code, + } + r.update(super().get_json()) + return r def get_absolute_url(self): return reverse("movies:retrieve", args=[self.id]) + @property + def wish_url(self): + return reverse("movies:wish", args=[self.id]) + def get_tags_manager(self): return self.movie_tags - def get_genre_display(self): translated_genre = [] for g in self.genre: translated_genre.append(MovieGenreTranslator[g]) return translated_genre + def get_related_movies(self): + imdb = 'no match' if self.imdb_code is None or self.imdb_code == '' else self.imdb_code + qs = Q(imdb_code=imdb) + if self.is_series: + prefix = re.sub(r'\d+', '', re.sub(r'\s+第.+季', '', self.title)) + if not prefix: + prefix = self.title + qs = qs | Q(title__startswith=prefix) + qs = qs & ~Q(id=self.id) + return Movie.objects.filter(qs).order_by('season') + + def get_identicals(self): + qs = Q(orig_title=self.title) + if self.imdb_code: + qs = Q(imdb_code=self.imdb_code) + # qs = qs & ~Q(id=self.id) + return Movie.objects.filter(qs) + else: + return [self] # Book.objects.filter(id=self.id) + @property def verbose_category_name(self): if self.is_series: @@ -185,23 +239,45 @@ class Movie(Entity): else: return _("电影") + @property + def mark_class(self): + return MovieMark + + @property + def tag_class(self): + return MovieTag + class MovieMark(Mark): movie = models.ForeignKey(Movie, on_delete=models.CASCADE, related_name='movie_marks', null=True) + class Meta: constraints = [ models.UniqueConstraint(fields=['owner', 'movie'], name='unique_movie_mark') ] + @property + def translated_status(self): + return MovieMarkStatusTranslation[self.status] + class MovieReview(Review): movie = models.ForeignKey(Movie, on_delete=models.CASCADE, related_name='movie_reviews', null=True) + class Meta: constraints = [ models.UniqueConstraint( fields=['owner', 'movie'], name='unique_movie_review') ] + @property + def url(self): + return settings.APP_WEBSITE + reverse("movies:retrieve_review", args=[self.id]) + + @property + def item(self): + return self.movie + class MovieTag(Tag): movie = models.ForeignKey(Movie, on_delete=models.CASCADE, related_name='movie_tags', null=True) @@ -211,3 +287,7 @@ class MovieTag(Tag): models.UniqueConstraint( fields=['content', 'mark'], name="unique_moviemark_tag") ] + + @property + def item(self): + return self.movie diff --git a/movies/templates/movies/create_update.html b/movies/templates/movies/create_update.html index f55c026b..497c9bce 100644 --- a/movies/templates/movies/create_update.html +++ b/movies/templates/movies/create_update.html @@ -10,8 +10,8 @@ <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> - <title>{% trans 'NiceDB - ' %}{{ title }}</title> - <script src="https://cdnjs.cloudflare.com/ajax/libs/jquery/3.5.1/jquery.min.js"></script> + <title>{{ site_name }} - {{ title }}</title> + <script src="https://static.neodb.social/cdnjs.cloudflare.com/ajax/libs/jquery/3.6.0/jquery.min.js"></script> <link rel="stylesheet" href="{% static 'css/boofilsic.min.css' %}"> </head> @@ -21,10 +21,25 @@ {% include "partial/_navbar.html" %} <section id="content" class="container"> - <div class="grid"> + <div class="grid" class="single-section-wrapper"> + {% if is_update and form.source_site.value != 'in-site' %} + <div style="float:right;padding-left:16px"> + <div class="aside-section-wrapper"> + <div class="action-panel"> + <div class="action-panel__label">{% trans '源网站' %}: <a href="{{ form.source_url.value }}">{{ form.source_site.value }}</a></div> + <div class="action-panel__button-group"> + <form method="post" action="{% url 'movies:rescrape' form.id.value %}"> + {% csrf_token %} + <input class="button" type="submit" value="{% trans '从源网站重新抓取' %}"> + </form> + </div> + </div> + </div> + </div> + {% endif %} + <div class="single-section-wrapper" id="main"> - <a href="{% url 'movies:scrape' %}" - class="single-section-wrapper__link single-section-wrapper__link--secondary">{% trans '>>> 试试一键剽取~ <<<' %}</a> + {% comment %} <a href="{% url 'movies:scrape' %}" class="single-section-wrapper__link single-section-wrapper__link--secondary">{% trans '>>> 试试一键剽取~ <<<' %}</a> {% endcomment %} <form class="entity-form" action="{{ submit_url }}" method="post" enctype="multipart/form-data"> {% csrf_token %} {{ form.media }} @@ -53,12 +68,6 @@ </div> - {% comment %} - <div id="oauth2Token" hidden="true">{% oauth_token %}</div> - <div id="mastodonURI" hidden="true">{% mastodon request.user.mastodon_site %}</div> - <!--current user mastodon id--> - <div id="userMastodonID" hidden="true">{{ user.mastodon_id }}</div> - {% endcomment %} <script> // mark required diff --git a/movies/templates/movies/create_update_review.html b/movies/templates/movies/create_update_review.html index 1f4e5b34..87046277 100644 --- a/movies/templates/movies/create_update_review.html +++ b/movies/templates/movies/create_update_review.html @@ -13,8 +13,8 @@ <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> - <title>{% trans 'NiceDB - ' %}{{ title }}</title> - <script src="https://cdnjs.cloudflare.com/ajax/libs/jquery/3.5.1/jquery.min.js"></script> + <title>{{ site_name }} - {{ title }}</title> + <script src="https://static.neodb.social/cdnjs.cloudflare.com/ajax/libs/jquery/3.6.0/jquery.min.js"></script> <script src="{% static 'js/create_update_review.js' %}"></script> <script src="{% static 'lib/js/rating-star.js' %}"></script> <link rel="stylesheet" href="{% static 'lib/css/rating-star.css' %}"> @@ -61,7 +61,7 @@ {% if movie.director|length > 5 %} <a href="javascript:void(0);" id="directorMore">{% trans '更多' %}</a> <script> - $("#directorMore").click(function (e) { + $("#directorMore").on('click', function (e) { $("span.director:not(:visible)").each(function (e) { $(this).parent().removeAttr('style'); }); @@ -89,7 +89,7 @@ {% if movie.actor|length > 5 %} <a href="javascript:void(0);" id="actorMore">{% trans '更多' %}</a> <script> - $("#actorMore").click(function (e) { + $("#actorMore").on('click', function (e) { $("span.actor:not(:visible)").each(function (e) { $(this).parent().removeAttr('style'); }); @@ -138,7 +138,7 @@ <div class="review-form__option"> <div class="review-form__visibility-radio"> - {{ form.is_private.label }}{{ form.is_private }} + {{ form.visibility.label }}{{ form.visibility }} </div> <div class="review-form__share-checkbox"> {{ form.share_to_mastodon }}{{ form.share_to_mastodon.label }} @@ -158,12 +158,6 @@ {% include "partial/_footer.html" %} </div> - {% comment %} - <div id="oauth2Token" hidden="true">{% oauth_token %}</div> - <div id="mastodonURI" hidden="true">{% mastodon request.user.mastodon_site %}</div> - <!--current user mastodon id--> - <div id="userMastodonID" hidden="true">{{ user.mastodon_id }}</div> - {% endcomment %} <script> diff --git a/movies/templates/movies/delete.html b/movies/templates/movies/delete.html index 5aa651ac..94dfb2ce 100644 --- a/movies/templates/movies/delete.html +++ b/movies/templates/movies/delete.html @@ -13,8 +13,8 @@ <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> - <title>{% trans 'NiceDB - 删除电影/剧集' %}</title> - <script src="https://cdnjs.cloudflare.com/ajax/libs/jquery/3.5.1/jquery.min.js"></script> + <title>{{ site_name }} - {% trans '删除电影/剧集' %}</title> + <script src="https://static.neodb.social/cdnjs.cloudflare.com/ajax/libs/jquery/3.6.0/jquery.min.js"></script> <script src="{% static 'lib/js/rating-star.js' %}"></script> <script src="{% static 'js/rating-star-readonly.js' %}"></script> <link rel="stylesheet" href="{% static 'lib/css/rating-star.css' %}"> @@ -62,7 +62,7 @@ {% if movie.last_editor %} <div> {% trans '最近编辑者:' %} - <a href="{% url 'users:home' movie.last_editor.id %}"> + <a href="{% url 'users:home' movie.last_editor.mastodon_username %}"> <span>{{ movie.last_editor | default:"" }}</span> </a> </div> @@ -96,12 +96,6 @@ </div> - {% comment %} - <div id="oauth2Token" hidden="true">{% oauth_token %}</div> - <div id="mastodonURI" hidden="true">{% mastodon request.user.mastodon_site %}</div> - <!--current user mastodon id--> - <div id="userMastodonID" hidden="true">{{ user.mastodon_id }}</div> - {% endcomment %} <script> diff --git a/movies/templates/movies/delete_review.html b/movies/templates/movies/delete_review.html index a16e6c49..db3d3fe0 100644 --- a/movies/templates/movies/delete_review.html +++ b/movies/templates/movies/delete_review.html @@ -10,8 +10,8 @@ <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> - <title>{% trans 'NiceDB - 删除评论' %}</title> - <script src="https://cdnjs.cloudflare.com/ajax/libs/jquery/3.5.1/jquery.min.js"></script> + <title>{{ site_name }} - {% trans '删除评论' %}</title> + <script src="https://static.neodb.social/cdnjs.cloudflare.com/ajax/libs/jquery/3.6.0/jquery.min.js"></script> <link rel="stylesheet" href="{% static 'lib/css/rating-star.css' %}"> <link rel="stylesheet" href="{% static 'css/boofilsic.min.css' %}"> @@ -35,7 +35,7 @@ <h5 class="review-head__title"> {{ review.title }} </h5> - {% if review.is_private %} + {% if review.visibility > 0 %} <span class="icon-lock"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20"> <path @@ -46,7 +46,7 @@ <div class="review-head__body"> <div class="review-head__info"> - <a href="{% url 'users:home' review.owner.id %}" + <a href="{% url 'users:home' review.owner.mastodon_username %}" class="review-head__owner-link">{{ review.owner.username }}</a> {% if mark %} @@ -89,12 +89,6 @@ </div> - {% comment %} - <div id="oauth2Token" hidden="true">{% oauth_token %}</div> - <div id="mastodonURI" hidden="true">{% mastodon request.user.mastodon_site %}</div> - <!--current user mastodon id--> - <div id="userMastodonID" hidden="true">{{ user.mastodon_id }}</div> - {% endcomment %} <script> diff --git a/movies/templates/movies/detail.html b/movies/templates/movies/detail.html index 8dbb4a09..72788ad0 100644 --- a/movies/templates/movies/detail.html +++ b/movies/templates/movies/detail.html @@ -14,12 +14,12 @@ <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> - <meta property="og:title" content="NiceDB电影 - {{ movie.title }}"> + <meta property="og:title" content="{{ site_name }}电影 - {{ movie.title }}"> <meta property="og:type" content="video.movie"> <meta property="og:url" content="{{ request.build_absolute_uri }}"> <meta property="og:image" content="{{ request.scheme }}://{{ request.get_host }}{{ movie.cover.url }}"> - <meta property="og:site_name" content="NiceDB"> - <meta property="og:description"content="{{ movie.brief }}"> + <meta property="og:site_name" content="{{ site_name }}"> + <meta property="og:description" content="{{ movie.brief }}"> <!-- video:actor - profile array - Actors in the movie. video:actor:role - string - The role they played. @@ -31,17 +31,15 @@ --> {% if movie.is_series %} - <title>{% trans 'NiceDB - 剧集详情' %} | {{ movie.title }}</title> + <title>{{ site_name }} - {% trans '剧集详情' %} | {{ movie.title }}</title> {% else %} - <title>{% trans 'NiceDB - 电影详情' %} | {{ movie.title }}</title> + <title>{{ site_name }} - {% trans '电影详情' %} | {{ movie.title }}</title> {% endif %} - <script src="https://cdnjs.cloudflare.com/ajax/libs/jquery/3.5.1/jquery.min.js"></script> + {% include "partial/_common_libs.html" with jquery=1 %} + <script src="{% static 'lib/js/rating-star.js' %}"></script> <script src="{% static 'js/detail.js' %}"></script> - <link rel="stylesheet" href="{% static 'css/boofilsic.css' %}"> - <link rel="stylesheet" href="{% static 'lib/css/rating-star.css' %}"> - </head> <body> @@ -78,11 +76,12 @@ <div class="entity-detail__fields"> <div class="entity-detail__rating"> - {% if movie.rating %} + {% if movie.rating and movie.rating_number >= 5 %} <span class="entity-detail__rating-star rating-star" data-rating-score="{{ movie.rating | floatformat:"0" }}"></span> <span class="entity-detail__rating-score"> {{ movie.rating }} </span> + <small>({{ movie.rating_number }}人评分)</small> {% else %} - <span> {% trans '评分:暂无评分' %}</span> + <span> {% trans '评分:评分人数不足' %}</span> {% endif %} </div> <div>{% if movie.imdb_code %} @@ -99,7 +98,7 @@ {% if movie.director|length > 5 %} <a href="javascript:void(0);" id="directorMore">{% trans '更多' %}</a> <script> - $("#directorMore").click(function (e) { + $("#directorMore").on('click', function (e) { $("span.director:not(:visible)").each(function (e) { $(this).parent().removeAttr('style'); }); @@ -118,7 +117,7 @@ {% if movie.playwright|length > 5 %} <a href="javascript:void(0);" id="playwrightMore">{% trans '更多' %}</a> <script> - $("#playwrightMore").click(function (e) { + $("#playwrightMore").on('click', function (e) { $("span.playwright:not(:visible)").each(function (e) { $(this).parent().removeAttr('style'); }); @@ -138,7 +137,7 @@ {% if movie.actor|length > 5 %} <a href="javascript:void(0);" id="actorMore">{% trans '更多' %}</a> <script> - $("#actorMore").click(function(e) { + $("#actorMore").on('click', function(e) { $("span.actor:not(:visible)").each(function(e){ $(this).parent().removeAttr('style'); }); @@ -197,7 +196,7 @@ {% if movie.last_editor %} - <div>{% trans '最近编辑者:' %}<a href="{% url 'users:home' movie.last_editor.id %}">{{ movie.last_editor | default:"" }}</a></div> + <div>{% trans '最近编辑者:' %}<a href="{% url 'users:home' movie.last_editor.mastodon_username %}">{{ movie.last_editor | default:"" }}</a></div> {% endif %} <div> @@ -249,30 +248,10 @@ <h5 class="entity-marks__title">{% trans '这部电影的标记' %}</h5> {% endif %} {% if mark_list_more %} - <a href="{% url 'movies:retrieve_mark_list' movie.id %}" class="entity-marks__more-link">{% trans '更多' %}</a> - {% endif %} - {% if mark_list %} - <ul class="entity-marks__mark-list"> - {% for others_mark in mark_list %} - <li class="entity-marks__mark"> - <a href="{% url 'users:home' others_mark.owner.id %}" class="entity-marks__owner-link">{{ others_mark.owner.username }}</a> - <span>{{ others_mark.get_status_display }}</span> - {% if others_mark.rating %} - <span class="entity-marks__rating-star rating-star" data-rating-score="{{ others_mark.rating | floatformat:"0" }}"></span> - {% endif %} - {% if others_mark.is_private %} - <span class="icon-lock"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20"><path d="M17,8.48h-.73V6.27a6.27,6.27,0,1,0-12.53,0V8.48H3a.67.67,0,0,0-.67.67V19.33A.67.67,0,0,0,3,20H17a.67.67,0,0,0,.67-.67V9.15A.67.67,0,0,0,17,8.48ZM6.42,6.27h0a3.57,3.57,0,0,1,7.14,0h0V8.48H6.42Z"/></svg></span> - {% endif %} - <span class="entity-marks__mark-time">{{ others_mark.edited_time }}</span> - {% if others_mark.text %} - <p class="entity-marks__mark-content">{{ others_mark.text }}</p> - {% endif %} - </li> - {% endfor %} - </ul> - {% else %} - <div>{% trans '暂无标记' %}</div> + <a href="{% url 'movies:retrieve_mark_list' movie.id %}" class="entity-marks__more-link">{% trans '全部标记' %}</a> {% endif %} + <a href="{% url 'movies:retrieve_mark_list' movie.id 1 %}" class="entity-marks__more-link">关注的人的标记</a> + {% include "partial/mark_list.html" with mark_list=mark_list current_item=movie %} </div> <div class="entity-reviews"> {% if movie.is_series %} @@ -282,17 +261,21 @@ {% endif %} {% if review_list_more %} - <a href="{% url 'movies:retrieve_review_list' movie.id %}" class="entity-reviews__more-link">{% trans '更多' %}</a> + <a href="{% url 'movies:retrieve_review_list' movie.id %}" class="entity-reviews__more-link">{% trans '全部评论' %}</a> {% endif %} {% if review_list %} <ul class="entity-reviews__review-list"> {% for others_review in review_list %} <li class="entity-reviews__review"> - <a href="{% url 'users:home' others_review.owner.id %}" class="entity-reviews__owner-link">{{ others_review.owner.username }}</a> - {% if others_review.is_private %} + <a href="{% url 'users:home' others_review.owner.mastodon_username %}" class="entity-reviews__owner-link">{{ others_review.owner.username }}</a> + {% if others_review.visibility > 0 %} <span class="icon-lock"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20"><path d="M17,8.48h-.73V6.27a6.27,6.27,0,1,0-12.53,0V8.48H3a.67.67,0,0,0-.67.67V19.33A.67.67,0,0,0,3,20H17a.67.67,0,0,0,.67-.67V9.15A.67.67,0,0,0,17,8.48ZM6.42,6.27h0a3.57,3.57,0,0,1,7.14,0h0V8.48H6.42Z"/></svg></span> {% endif %} <span class="entity-reviews__review-time">{{ others_review.edited_time }}</span> + {% if others_review.movie != movie %} + <span class="entity-reviews__review-time source-label"><a class="entity-reviews__review-time" href="{% url 'movies:retrieve' others_review.movie.id %}">{{ others_review.movie.get_source_site_display }}</a></span> + {% endif %} + <span class="entity-reviews__review-title"> <a href="{% url 'movies:retrieve_review' others_review.id %}">{{ others_review.title }}</a></span> <span>{{ others_review.get_plain_content | truncate:100 }}</span> </li> @@ -317,7 +300,7 @@ <span class="mark-panel__rating-star rating-star" data-rating-score="{{ mark.rating | floatformat:"0" }}"></span> {% endif %} {% endif %} - {% if mark.is_private %} + {% if mark.visibility > 0 %} <span class="icon-lock"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20"><path d="M17,8.48h-.73V6.27a6.27,6.27,0,1,0-12.53,0V8.48H3a.67.67,0,0,0-.67.67V19.33A.67.67,0,0,0,3,20H17a.67.67,0,0,0,.67-.67V9.15A.67.67,0,0,0,17,8.48ZM6.42,6.27h0a3.57,3.57,0,0,1,7.14,0h0V8.48H6.42Z"/></svg></span> {% endif %} <span class="mark-panel__actions"> @@ -329,7 +312,7 @@ </span> <div class="mark-panel__clear"></div> - <div class="mark-panel__time">{{ mark.edited_time }}</div> + <div class="mark-panel__time">{{ mark.created_time }}</div> {% if mark.text %} <p class="mark-panel__text">{{ mark.text }}</p> @@ -365,7 +348,7 @@ <div class="review-panel"> <span class="review-panel__label">{% trans '我的评论' %}</span> - {% if review.is_private %} + {% if review.visibility > 0 %} <span class="icon-lock"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20"><path d="M17,8.48h-.73V6.27a6.27,6.27,0,1,0-12.53,0V8.48H3a.67.67,0,0,0-.67.67V19.33A.67.67,0,0,0,3,20H17a.67.67,0,0,0,.67-.67V9.15A.67.67,0,0,0,17,8.48ZM6.42,6.27h0a3.57,3.57,0,0,1,7.14,0h0V8.48H6.42Z"/></svg></span> {% endif %} @@ -394,7 +377,42 @@ {% endif %} </div> - + + {% if movie.get_related_movies.count > 0 %} + <div class="aside-section-wrapper"> + <div class="action-panel"> + <div class="action-panel__label">{% trans '相关条目' %}</div> + <div > + {% for m in movie.get_related_movies %} + <p> + <a href="{% url 'movies:retrieve' m.id %}">{{ m.title }}</a> + {% if movie.source_site != m.source_site %} + <span class="source-label source-label__{{ m.source_site }}">{{ m.get_source_site_display }}</span> + {% endif %} + </p> + {% endfor %} + </div> + </div> + </div> + {% endif %} + + {% if collection_list %} + <div class="aside-section-wrapper"> + <div class="action-panel"> + <div class="action-panel__label">{% trans '相关收藏单' %}</div> + <div > + {% for c in collection_list %} + <p> + <a href="{% url 'collection:retrieve' c.id %}">{{ c.title }}</a> + </p> + {% endfor %} + <div class="action-panel__button-group action-panel__button-group--center"> + <button class="action-panel__button add-to-list" hx-get="{% url 'collection:add_to_list' 'movie' movie.id %}" hx-target="body" hx-swap="beforeend">{% trans '添加到收藏单' %}</button> + </div> + </div> + </div> + </div> + {% endif %} </div> </div> </section> @@ -464,8 +482,8 @@ <div class="mark-modal__option"> <div class="mark-modal__visibility-radio"> - <span>{{ mark_form.is_private.label }}:</span> - {{ mark_form.is_private }} + <span>{{ mark_form.visibility.label }}:</span> + {{ mark_form.visibility }} </div> <div class="mark-modal__share-checkbox"> {{ mark_form.share_to_mastodon }}{{ mark_form.share_to_mastodon.label }} diff --git a/movies/templates/movies/mark_list.html b/movies/templates/movies/mark_list.html index 23e57b28..27cc0e1e 100644 --- a/movies/templates/movies/mark_list.html +++ b/movies/templates/movies/mark_list.html @@ -14,8 +14,8 @@ <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> - <title>{% trans 'NiceDB - ' %}{{ movie.title }}{% trans '的标记' %}</title> - <script src="https://cdnjs.cloudflare.com/ajax/libs/jquery/3.5.1/jquery.min.js"></script> + <title>{{ site_name }} - {{ movie.title }}{% trans '的标记' %}</title> + <script src="https://static.neodb.social/cdnjs.cloudflare.com/ajax/libs/jquery/3.6.0/jquery.min.js"></script> <script src="{% static 'lib/js/rating-star.js' %}"></script> <script src="{% static 'js/rating-star-readonly.js' %}"></script> <link rel="stylesheet" href="{% static 'css/boofilsic.min.css' %}"> @@ -35,37 +35,7 @@ <h5 class="entity-marks__title entity-marks__title--stand-alone"> <a href="{% url 'movies:retrieve' movie.id %}">{{ movie.title }}</a>{% trans ' 的标记' %} </h5> - <ul class="entity-marks__mark-list"> - - {% for mark in marks %} - - <li class="entity-marks__mark entity-marks__mark--wider"> - <a href="{% url 'users:home' mark.owner.id %}" - class="entity-marks__owner-link">{{ mark.owner.username }}</a> - <span>{{ mark.get_status_display }}</span> - {% if mark.rating %} - <span class="entity-marks__rating-star rating-star" - data-rating-score="{{ mark.rating | floatformat:"0" }}"></span> - {% endif %} - {% if mark.is_private %} - <span class="icon-lock"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20"> - <path - d="M17,8.48h-.73V6.27a6.27,6.27,0,1,0-12.53,0V8.48H3a.67.67,0,0,0-.67.67V19.33A.67.67,0,0,0,3,20H17a.67.67,0,0,0,.67-.67V9.15A.67.67,0,0,0,17,8.48ZM6.42,6.27h0a3.57,3.57,0,0,1,7.14,0h0V8.48H6.42Z" /> - </svg></span> - {% endif %} - <span class="entity-marks__mark-time">{{ mark.edited_time }}</span> - {% if mark.text %} - <p class="entity-marks__mark-content">{{ mark.text }}</p> - {% endif %} - </li> - - {% empty %} - <div> - {% trans '无结果' %} - </div> - {% endfor %} - - </ul> + {% include "partial/mark_list.html" with mark_list=marks current_item=movie %} </div> <div class="pagination"> @@ -129,7 +99,7 @@ {% if movie.director|length > 5 %} <a href="javascript:void(0);" id="directorMore">{% trans '更多' %}</a> <script> - $("#directorMore").click(function (e) { + $("#directorMore").on('click', function (e) { $("span.director:not(:visible)").each(function (e) { $(this).parent().removeAttr('style'); }); @@ -168,12 +138,6 @@ </div> - {% comment %} - <div id="oauth2Token" hidden="true">{% oauth_token %}</div> - <div id="mastodonURI" hidden="true">{% mastodon request.user.mastodon_site %}</div> - <!--current user mastodon id--> - <div id="userMastodonID" hidden="true">{{ user.mastodon_id }}</div> - {% endcomment %} <script> diff --git a/movies/templates/movies/review_detail.html b/movies/templates/movies/review_detail.html index b52c7b54..4a83227e 100644 --- a/movies/templates/movies/review_detail.html +++ b/movies/templates/movies/review_detail.html @@ -13,17 +13,18 @@ <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> - <meta property="og:title" content="NiceDB影评 - {{ review.title }}"> + <meta property="og:title" content="{{ site_name }}影评 - {{ review.title }}"> <meta property="og:type" content="article"> <meta property="og:article:author" content="{{ review.owner.username }}"> <meta property="og:url" content="{{ request.build_absolute_uri }}"> - <meta property="og:image" content="{{ request.scheme }}://{{ request.get_host }}{% static 'img/logo_square.svg' %}"> - <title>{% trans 'NiceDB - 评论详情' %}</title> - <script src="https://cdnjs.cloudflare.com/ajax/libs/jquery/3.5.1/jquery.min.js"></script> + <meta property="og:image" content="{{ movie.cover|thumb:'normal' }}"> + <title>{{ site_name }}影评 - {{ review.title }}</title> + <script src="https://static.neodb.social/cdnjs.cloudflare.com/ajax/libs/jquery/3.6.0/jquery.min.js"></script> <script src="{% static 'lib/js/rating-star.js' %}"></script> <script src="{% static 'js/rating-star-readonly.js' %}"></script> <link rel="stylesheet" href="{% static 'lib/css/rating-star.css' %}"> <link rel="stylesheet" href="{% static 'css/boofilsic.min.css' %}"> + <link rel="stylesheet" href="{% static 'lib/css/neo.css' %}"> </head> <body> @@ -39,7 +40,7 @@ <h5 class="review-head__title"> {{ review.title }} </h5> - {% if review.is_private %} + {% if review.visibility > 0 %} <span class="icon-lock"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20"> <path d="M17,8.48h-.73V6.27a6.27,6.27,0,1,0-12.53,0V8.48H3a.67.67,0,0,0-.67.67V19.33A.67.67,0,0,0,3,20H17a.67.67,0,0,0,.67-.67V9.15A.67.67,0,0,0,17,8.48ZM6.42,6.27h0a3.57,3.57,0,0,1,7.14,0h0V8.48H6.42Z" /> @@ -48,7 +49,7 @@ <div class="review-head__body"> <div class="review-head__info"> - <a href="{% url 'users:home' review.owner.id %}" class="review-head__owner-link">{{ review.owner.username }}</a> + <a href="{% url 'users:home' review.owner.mastodon_username %}" class="review-head__owner-link">{{ review.owner.username }}</a> {% if mark %} @@ -73,6 +74,7 @@ {{ form.content }} </div> {{ form.media }} + {% csrf_token %} </div> </div> @@ -108,7 +110,7 @@ {% if movie.director|length > 5 %} <a href="javascript:void(0);" id="directorMore">{% trans '更多' %}</a> <script> - $("#directorMore").click(function (e) { + $("#directorMore").on('click', function (e) { $("span.director:not(:visible)").each(function (e) { $(this).parent().removeAttr('style'); }); @@ -148,16 +150,8 @@ </div> - {% comment %} - <div id="oauth2Token" hidden="true">{% oauth_token %}</div> - <div id="mastodonURI" hidden="true">{% mastodon request.user.mastodon_site %}</div> - <!--current user mastodon id--> - <div id="userMastodonID" hidden="true">{{ user.mastodon_id }}</div> - {% endcomment %} <script> - - $(".markdownx textarea").hide(); </script> </body> diff --git a/movies/templates/movies/review_list.html b/movies/templates/movies/review_list.html index ed32cfe0..daedf296 100644 --- a/movies/templates/movies/review_list.html +++ b/movies/templates/movies/review_list.html @@ -14,8 +14,8 @@ <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> - <title>{% trans 'NiceDB - ' %}{{ movie.title }}{% trans '的评论' %}</title> - <script src="https://cdnjs.cloudflare.com/ajax/libs/jquery/3.5.1/jquery.min.js"></script> + <title>{{ site_name }} - {{ movie.title }}{% trans '的评论' %}</title> + <script src="https://static.neodb.social/cdnjs.cloudflare.com/ajax/libs/jquery/3.6.0/jquery.min.js"></script> <script src="{% static 'lib/js/rating-star.js' %}"></script> <script src="{% static 'js/rating-star-readonly.js' %}"></script> <link rel="stylesheet" href="{% static 'lib/css/rating-star.css' %}"> @@ -41,11 +41,14 @@ <li class="entity-reviews__review entity-reviews__review--wider"> - <a href="{% url 'users:home' review.owner.id %}" class="entity-reviews__owner-link">{{ review.owner.username }}</a> - {% if review.is_private %} + <a href="{% url 'users:home' review.owner.mastodon_username %}" class="entity-reviews__owner-link">{{ review.owner.username }}</a> + {% if review.visibility > 0 %} <span class="icon-lock"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20"><path d="M17,8.48h-.73V6.27a6.27,6.27,0,1,0-12.53,0V8.48H3a.67.67,0,0,0-.67.67V19.33A.67.67,0,0,0,3,20H17a.67.67,0,0,0,.67-.67V9.15A.67.67,0,0,0,17,8.48ZM6.42,6.27h0a3.57,3.57,0,0,1,7.14,0h0V8.48H6.42Z"/></svg></span> {% endif %} <span class="entity-reviews__review-time">{{ review.edited_time }}</span> + {% if review.movie != movie %} + <span class="entity-reviews__review-time source-label"><a class="entity-reviews__review-time" href="{% url 'movies:retrieve' review.movie.id %}">{{ review.movie.get_source_site_display }}</a></span> + {% endif %} <span href="{% url 'movies:retrieve_review' review.id %}" class="entity-reviews__review-title"><a href="{% url 'movies:retrieve_review' review.id %}">{{ review.title }}</a></span> @@ -116,7 +119,7 @@ {% if movie.director|length > 5 %} <a href="javascript:void(0);" id="directorMore">{% trans '更多' %}</a> <script> - $("#directorMore").click(function (e) { + $("#directorMore").on('click', function (e) { $("span.director:not(:visible)").each(function (e) { $(this).parent().removeAttr('style'); }); @@ -156,12 +159,6 @@ </div> - {% comment %} - <div id="oauth2Token" hidden="true">{% oauth_token %}</div> - <div id="mastodonURI" hidden="true">{% mastodon request.user.mastodon_site %}</div> - <!--current user mastodon id--> - <div id="userMastodonID" hidden="true">{{ user.mastodon_id }}</div> - {% endcomment %} <script> diff --git a/movies/templates/movies/scrape.html b/movies/templates/movies/scrape.html index 91cd90c9..0387cc10 100644 --- a/movies/templates/movies/scrape.html +++ b/movies/templates/movies/scrape.html @@ -10,8 +10,8 @@ <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> - <title>{% trans 'NiceDB - 从豆瓣获取数据' %}</title> - <script src="https://cdnjs.cloudflare.com/ajax/libs/jquery/3.5.1/jquery.min.js"></script> + <title>{{ site_name }} - {% trans '从豆瓣获取数据' %}</title> + <script src="https://static.neodb.social/cdnjs.cloudflare.com/ajax/libs/jquery/3.6.0/jquery.min.js"></script> <script src="{% static 'js/scrape.js' %}"></script> <link rel="stylesheet" href="{% static 'css/boofilsic.min.css' %}"> </head> diff --git a/movies/urls.py b/movies/urls.py index 9f359e9e..0495a9f0 100644 --- a/movies/urls.py +++ b/movies/urls.py @@ -1,4 +1,4 @@ -from django.urls import path +from django.urls import path, re_path from .views import * @@ -8,8 +8,10 @@ urlpatterns = [ path('<int:id>/', retrieve, name='retrieve'), path('update/<int:id>/', update, name='update'), path('delete/<int:id>/', delete, name='delete'), + path('rescrape/<int:id>/', rescrape, name='rescrape'), path('mark/', create_update_mark, name='create_update_mark'), - path('<int:movie_id>/mark/list/', retrieve_mark_list, name='retrieve_mark_list'), + path('wish/<int:id>/', wish, name='wish'), + re_path('(?P<movie_id>[0-9]+)/mark/list/(?:(?P<following_only>\\d+))?', retrieve_mark_list, name='retrieve_mark_list'), path('mark/delete/<int:id>/', delete_mark, name='delete_mark'), path('<int:movie_id>/review/create/', create_review, name='create_review'), path('review/update/<int:id>/', update_review, name='update_review'), diff --git a/movies/views.py b/movies/views.py index f63cdeef..d4fc21f5 100644 --- a/movies/views.py +++ b/movies/views.py @@ -2,21 +2,23 @@ import logging from django.shortcuts import render, get_object_or_404, redirect, reverse from django.contrib.auth.decorators import login_required, permission_required from django.utils.translation import gettext_lazy as _ -from django.http import HttpResponseBadRequest, HttpResponseServerError +from django.http import HttpResponseBadRequest, HttpResponseServerError, HttpResponse from django.core.exceptions import ObjectDoesNotExist, PermissionDenied from django.db import IntegrityError, transaction from django.db.models import Count from django.utils import timezone from django.core.paginator import Paginator from mastodon import mastodon_request_included -from mastodon.api import check_visibility, post_toot, TootVisibilityEnum -from mastodon.utils import rating_to_emoji +from mastodon.models import MastodonApplication +from mastodon.api import share_mark, share_review from common.utils import PageLinksGenerator -from common.views import PAGE_LINK_NUMBER, jump_or_scrape +from common.views import PAGE_LINK_NUMBER, jump_or_scrape, go_relogin from common.models import SourceSiteEnum from .models import * from .forms import * -from boofilsic.settings import MASTODON_TAGS +from django.conf import settings +from collection.models import CollectionItem +from common.scraper import get_scraper_by_url, get_normalized_url logger = logging.getLogger(__name__) @@ -87,6 +89,18 @@ def create(request): return HttpResponseBadRequest() +@login_required +def rescrape(request, id): + if request.method != 'POST': + return HttpResponseBadRequest() + item = get_object_or_404(Movie, pk=id) + url = get_normalized_url(item.source_url) + scraper = get_scraper_by_url(url) + scraper.scrape(url) + form = scraper.save(request_user=request.user, instance=item) + return redirect(reverse("movies:retrieve", args=[form.instance.id])) + + @login_required def update(request, id): if request.method == 'GET': @@ -98,6 +112,7 @@ def update(request, id): 'movies/create_update.html', { 'form': form, + 'is_update': True, 'title': page_title, 'submit_url': reverse("movies:update", args=[movie.id]), # provided for frontend js @@ -127,6 +142,7 @@ def update(request, id): 'movies/create_update.html', { 'form': form, + 'is_update': True, 'title': page_title, 'submit_url': reverse("movies:update", args=[movie.id]), # provided for frontend js @@ -167,6 +183,7 @@ def retrieve(request, id): else: mark_form = MovieMarkForm(initial={ 'movie': movie, + 'visibility': request.user.get_preference().default_visibility if request.user.is_authenticated else 0, 'tags': mark_tags }) @@ -185,10 +202,8 @@ def retrieve(request, id): mark_list_more = None review_list_more = None else: - mark_list = MovieMark.get_available( - movie, request.user, request.session['oauth_token']) - review_list = MovieReview.get_available( - movie, request.user, request.session['oauth_token']) + mark_list = MovieMark.get_available_for_identicals(movie, request.user) + review_list = MovieReview.get_available_for_identicals(movie, request.user) mark_list_more = True if len(mark_list) > MARK_NUMBER else False mark_list = mark_list[:MARK_NUMBER] for m in mark_list: @@ -196,6 +211,7 @@ def retrieve(request, id): review_list_more = True if len( review_list) > REVIEW_NUMBER else False review_list = review_list[:REVIEW_NUMBER] + collection_list = filter(lambda c: c.is_visible_to(request.user), map(lambda i: i.collection, CollectionItem.objects.filter(movie=movie))) # def strip_html_tags(text): # import re @@ -220,6 +236,7 @@ def retrieve(request, id): 'review_list_more': review_list_more, 'movie_tag_list': movie_tag_list, 'mark_tags': mark_tags, + 'collection_list': collection_list, } ) else: @@ -264,12 +281,19 @@ def create_update_mark(request): pk = request.POST.get('id') old_rating = None old_tags = None + if not pk: + movie_id = request.POST.get('movie') + mark = MovieMark.objects.filter(movie_id=movie_id, owner=request.user).first() + if mark: + pk = mark.id if pk: mark = get_object_or_404(MovieMark, pk=pk) if request.user != mark.owner: return HttpResponseBadRequest() old_rating = mark.rating old_tags = mark.moviemark_tags.all() + if mark.status != request.POST.get('status'): + mark.created_time = timezone.now() # update form = MovieMarkForm(request.POST, instance=mark) else: @@ -277,7 +301,7 @@ def create_update_mark(request): form = MovieMarkForm(request.POST) if form.is_valid(): - if form.instance.status == MarkStatusEnum.WISH.value: + if form.instance.status == MarkStatusEnum.WISH.value or form.instance.rating == 0: form.instance.rating = None form.cleaned_data['rating'] = None form.instance.owner = request.user @@ -305,28 +329,10 @@ def create_update_mark(request): return HttpResponseServerError("integrity error") if form.cleaned_data['share_to_mastodon']: - if form.cleaned_data['is_private']: - visibility = TootVisibilityEnum.PRIVATE - else: - visibility = TootVisibilityEnum.UNLISTED - url = "https://" + request.get_host() + reverse("movies:retrieve", - args=[movie.id]) - words = MovieMarkStatusTranslator(form.cleaned_data['status']) +\ - f"《{movie.title}》" + \ - rating_to_emoji(form.cleaned_data['rating']) - - # tags = MASTODON_TAGS % {'category': '书', 'type': '标记'} - tags = '' - content = words + '\n' + url + '\n' + \ - form.cleaned_data['text'] + '\n' + tags - response = post_toot(request.user.mastodon_site, content, visibility, - request.session['oauth_token']) - if response.status_code != 200: - mastodon_logger.error( - f"CODE:{response.status_code} {response.text}") - return HttpResponseServerError("publishing mastodon status failed") + if not share_mark(form.instance): + return go_relogin(request) else: - return HttpResponseBadRequest("invalid form data") + return HttpResponseBadRequest(f"invalid form data {form.errors}") return redirect(reverse("movies:retrieve", args=[form.instance.movie.id])) else: @@ -335,11 +341,30 @@ def create_update_mark(request): @mastodon_request_included @login_required -def retrieve_mark_list(request, movie_id): +def wish(request, id): + if request.method == 'POST': + movie = get_object_or_404(Movie, pk=id) + params = { + 'owner': request.user, + 'status': MarkStatusEnum.WISH, + 'visibility': 0, + 'movie': movie, + } + try: + MovieMark.objects.create(**params) + except Exception: + pass + return HttpResponse("✔️") + else: + return HttpResponseBadRequest("invalid method") + + +@mastodon_request_included +@login_required +def retrieve_mark_list(request, movie_id, following_only=False): if request.method == 'GET': movie = get_object_or_404(Movie, pk=movie_id) - queryset = MovieMark.get_available( - movie, request.user, request.session['oauth_token']) + queryset = MovieMark.get_available_for_identicals(movie, request.user, following_only=following_only) paginator = Paginator(queryset, MARK_PER_PAGE) page_number = request.GET.get('page', default=1) marks = paginator.get_page(page_number) @@ -400,23 +425,8 @@ def create_review(request, movie_id): form.instance.owner = request.user form.save() if form.cleaned_data['share_to_mastodon']: - if form.cleaned_data['is_private']: - visibility = TootVisibilityEnum.PRIVATE - else: - visibility = TootVisibilityEnum.UNLISTED - url = "https://" + request.get_host() + reverse("movies:retrieve_review", - args=[form.instance.id]) - words = "发布了关于" + f"《{form.instance.movie.title}》" + "的评论" - # tags = MASTODON_TAGS % {'category': '书', 'type': '评论'} - tags = '' - content = words + '\n' + url + \ - '\n' + form.cleaned_data['title'] + '\n' + tags - response = post_toot(request.user.mastodon_site, content, visibility, - request.session['oauth_token']) - if response.status_code != 200: - mastodon_logger.error( - f"CODE:{response.status_code} {response.text}") - return HttpResponseServerError("publishing mastodon status failed") + if not share_review(form.instance): + return go_relogin(request) return redirect(reverse("movies:retrieve_review", args=[form.instance.id])) else: return HttpResponseBadRequest() @@ -452,23 +462,8 @@ def update_review(request, id): form.instance.edited_time = timezone.now() form.save() if form.cleaned_data['share_to_mastodon']: - if form.cleaned_data['is_private']: - visibility = TootVisibilityEnum.PRIVATE - else: - visibility = TootVisibilityEnum.UNLISTED - url = "https://" + request.get_host() + reverse("movies:retrieve_review", - args=[form.instance.id]) - words = "发布了关于" + f"《{form.instance.movie.title}》" + "的评论" - # tags = MASTODON_TAGS % {'category': '书', 'type': '评论'} - tags = '' - content = words + '\n' + url + \ - '\n' + form.cleaned_data['title'] + '\n' + tags - response = post_toot(request.user.mastodon_site, content, visibility, - request.session['oauth_token']) - if response.status_code != 200: - mastodon_logger.error( - f"CODE:{response.status_code} {response.text}") - return HttpResponseServerError("publishing mastodon status failed") + if not share_review(form.instance): + return go_relogin(request) return redirect(reverse("movies:retrieve_review", args=[form.instance.id])) else: return HttpResponseBadRequest() @@ -503,11 +498,10 @@ def delete_review(request, id): @mastodon_request_included -@login_required def retrieve_review(request, id): if request.method == 'GET': review = get_object_or_404(MovieReview, pk=id) - if not check_visibility(review, request.session['oauth_token'], request.user): + if not review.is_visible_to(request.user): msg = _("你没有访问这个页面的权限😥") return render( request, @@ -542,8 +536,7 @@ def retrieve_review(request, id): def retrieve_review_list(request, movie_id): if request.method == 'GET': movie = get_object_or_404(Movie, pk=movie_id) - queryset = MovieReview.get_available( - movie, request.user, request.session['oauth_token']) + queryset = MovieReview.get_available_for_identicals(movie, request.user) paginator = Paginator(queryset, REVIEW_PER_PAGE) page_number = request.GET.get('page', default=1) reviews = paginator.get_page(page_number) diff --git a/music/admin.py b/music/admin.py index eb770458..33eb056d 100644 --- a/music/admin.py +++ b/music/admin.py @@ -1,11 +1,12 @@ from django.contrib import admin from .models import * +from simple_history.admin import SimpleHistoryAdmin -admin.site.register(Song) +admin.site.register(Song, SimpleHistoryAdmin) admin.site.register(SongMark) admin.site.register(SongReview) admin.site.register(SongTag) -admin.site.register(Album) +admin.site.register(Album, SimpleHistoryAdmin) admin.site.register(AlbumMark) admin.site.register(AlbumReview) admin.site.register(AlbumTag) diff --git a/music/apps.py b/music/apps.py index d909c7fb..6fb97b37 100644 --- a/music/apps.py +++ b/music/apps.py @@ -3,3 +3,9 @@ from django.apps import AppConfig class MusicConfig(AppConfig): name = 'music' + + def ready(self): + from common.index import Indexer + from .models import Album, Song + Indexer.update_model_indexable(Album) + Indexer.update_model_indexable(Song) diff --git a/music/forms.py b/music/forms.py index 3eec5646..9e487592 100644 --- a/music/forms.py +++ b/music/forms.py @@ -7,12 +7,7 @@ from common.forms import * def MusicMarkStatusTranslator(status): - trans_dict = { - MarkStatusEnum.DO.value: _("在听"), - MarkStatusEnum.WISH.value: _("想听"), - MarkStatusEnum.COLLECT.value: _("听过") - } - return trans_dict[status] + return MusicMarkStatusTranslation[status] class SongForm(forms.ModelForm): @@ -65,11 +60,8 @@ class SongMarkForm(MarkForm): 'status', 'rating', 'text', - 'is_private', + 'visibility', ] - labels = { - 'rating': _("评分"), - } widgets = { 'song': forms.TextInput(attrs={"hidden": ""}), } @@ -84,14 +76,8 @@ class SongReviewForm(ReviewForm): 'song', 'title', 'content', - 'is_private' + 'visibility' ] - labels = { - 'song': "", - 'title': _("标题"), - 'content': _("正文"), - 'share_to_mastodon': _("分享到长毛象") - } widgets = { 'song': forms.TextInput(attrs={"hidden": ""}), } @@ -148,11 +134,8 @@ class AlbumMarkForm(MarkForm): 'status', 'rating', 'text', - 'is_private', + 'visibility', ] - labels = { - 'rating': _("评分"), - } widgets = { 'album': forms.TextInput(attrs={"hidden": ""}), } @@ -167,14 +150,8 @@ class AlbumReviewForm(ReviewForm): 'album', 'title', 'content', - 'is_private' + 'visibility' ] - labels = { - 'album': "", - 'title': _("标题"), - 'content': _("正文"), - 'share_to_mastodon': _("分享到长毛象") - } widgets = { 'album': forms.TextInput(attrs={"hidden": ""}), } diff --git a/music/management/commands/fix-album-cover.py b/music/management/commands/fix-album-cover.py new file mode 100644 index 00000000..9b9f4d3e --- /dev/null +++ b/music/management/commands/fix-album-cover.py @@ -0,0 +1,199 @@ +from django.core.management.base import BaseCommand +from django.core.files.uploadedfile import SimpleUploadedFile +from common.scraper import * +from django.conf import settings +from music.models import Album +from music.forms import AlbumForm +import requests +import re +import filetype +from lxml import html +from PIL import Image +from io import BytesIO + + +class DoubanPatcherMixin: + @classmethod + def download_page(cls, url, headers): + url = cls.get_effective_url(url) + r = None + error = 'DoubanScrapper: error occured when downloading ' + url + content = None + + def get(url, timeout): + nonlocal r + # print('Douban GET ' + url) + try: + r = requests.get(url, timeout=timeout) + except Exception as e: + r = requests.Response() + r.status_code = f"Exception when GET {url} {e}" + url + # print('Douban CODE ' + str(r.status_code)) + return r + + def check_content(): + nonlocal r, error, content + content = None + if r.status_code == 200: + content = r.content.decode('utf-8') + if content.find('关于豆瓣') == -1: + content = None + error = error + 'Content not authentic' # response is garbage + elif re.search('不存在[^<]+</title>', content, re.MULTILINE): + content = None + error = error + 'Not found or hidden by Douban' + else: + error = error + str(r.status_code) + + def fix_wayback_links(): + nonlocal content + # fix links + content = re.sub(r'href="http[^"]+http', r'href="http', content) + # https://img9.doubanio.com/view/subject/{l|m|s}/public/s1234.jpg + content = re.sub(r'src="[^"]+/(s\d+\.\w+)"', + r'src="https://img9.doubanio.com/view/subject/m/public/\1"', content) + # https://img9.doubanio.com/view/photo/s_ratio_poster/public/p2681329386.jpg + # https://img9.doubanio.com/view/photo/{l|m|s}/public/p1234.webp + content = re.sub(r'src="[^"]+/(p\d+\.\w+)"', + r'src="https://img9.doubanio.com/view/photo/m/public/\1"', content) + + # Wayback Machine: get latest available + def wayback(): + nonlocal r, error, content + error = error + '\nWayback: ' + get('http://archive.org/wayback/available?url=' + url, 10) + if r.status_code == 200: + w = r.json() + if w['archived_snapshots'] and w['archived_snapshots']['closest']: + get(w['archived_snapshots']['closest']['url'], 10) + check_content() + if content is not None: + fix_wayback_links() + else: + error = error + 'No snapshot available' + else: + error = error + str(r.status_code) + + # Wayback Machine: guess via CDX API + def wayback_cdx(): + nonlocal r, error, content + error = error + '\nWayback: ' + get('http://web.archive.org/cdx/search/cdx?url=' + url, 10) + if r.status_code == 200: + dates = re.findall(r'[^\s]+\s+(\d+)\s+[^\s]+\s+[^\s]+\s+\d+\s+[^\s]+\s+\d{5,}', + r.content.decode('utf-8')) + # assume snapshots whose size >9999 contain real content, use the latest one of them + if len(dates) > 0: + get('http://web.archive.org/web/' + dates[-1] + '/' + url, 10) + check_content() + if content is not None: + fix_wayback_links() + else: + error = error + 'No snapshot available' + else: + error = error + str(r.status_code) + + def latest(): + nonlocal r, error, content + if settings.SCRAPESTACK_KEY is None: + error = error + '\nDirect: ' + get(url, 60) + else: + error = error + '\nScraperAPI: ' + # get(f'http://api.scraperapi.com?api_key={settings.SCRAPERAPI_KEY}&url={url}', 60) + get(f'http://api.scrapestack.com/scrape?access_key={settings.SCRAPESTACK_KEY}&url={url}', 60) + check_content() + + wayback_cdx() + if content is None: + latest() + + if content is None: + logger.error(error) + content = '<html />' + return html.fromstring(content) + + @classmethod + def download_image(cls, url, item_url=None): + if url is None: + return None, None + raw_img = None + ext = None + + dl_url = url + if settings.SCRAPESTACK_KEY is not None: + dl_url = f'http://api.scrapestack.com/scrape?access_key={settings.SCRAPESTACK_KEY}&url={url}' + # f'http://api.scraperapi.com?api_key={settings.SCRAPERAPI_KEY}&url={url}' + + try: + img_response = requests.get(dl_url, timeout=90) + if img_response.status_code == 200: + raw_img = img_response.content + img = Image.open(BytesIO(raw_img)) + img.load() # corrupted image will trigger exception + content_type = img_response.headers.get('Content-Type') + ext = filetype.get_type(mime=content_type.partition(';')[0].strip()).extension + else: + logger.error(f"Douban: download image failed {img_response.status_code} {dl_url} {item_url}") + # raise RuntimeError(f"Douban: download image failed {img_response.status_code} {dl_url}") + except Exception as e: + raw_img = None + ext = None + logger.error(f"Douban: download image failed {e} {dl_url} {item_url}") + if raw_img is None and settings.SCRAPESTACK_KEY is not None: + try: + img_response = requests.get(dl_url, timeout=90) + if img_response.status_code == 200: + raw_img = img_response.content + img = Image.open(BytesIO(raw_img)) + img.load() # corrupted image will trigger exception + content_type = img_response.headers.get('Content-Type') + ext = filetype.get_type(mime=content_type.partition(';')[0].strip()).extension + else: + logger.error(f"Douban: download image failed {img_response.status_code} {dl_url} {item_url}") + except Exception as e: + raw_img = None + ext = None + logger.error(f"Douban: download image failed {e} {dl_url} {item_url}") + return raw_img, ext + + +class DoubanAlbumPatcher(DoubanPatcherMixin, AbstractScraper): + site_name = SourceSiteEnum.DOUBAN.value + host = 'music.douban.com' + data_class = Album + form_class = AlbumForm + + regex = re.compile(r"https://music\.douban\.com/subject/\d+/{0,1}") + + def scrape(self, url): + headers = DEFAULT_REQUEST_HEADERS.copy() + headers['Host'] = self.host + content = self.download_page(url, headers) + img_url_elem = content.xpath("//div[@id='mainpic']//img/@src") + img_url = img_url_elem[0].strip() if img_url_elem else None + raw_img, ext = self.download_image(img_url, url) + return raw_img, ext + + +class Command(BaseCommand): + help = 'fix cover image' + + def add_arguments(self, parser): + parser.add_argument('threadId', type=int, help='% 8') + + def handle(self, *args, **options): + t = int(options['threadId']) + for m in Album.objects.filter(cover='album/default.svg', source_site='douban'): + if m.id % 8 == t: + self.stdout.write(f'Re-fetching {m.source_url}') + try: + raw_img, img_ext = DoubanAlbumPatcher.scrape(m.source_url) + if img_ext is not None: + m.cover = SimpleUploadedFile('temp.' + img_ext, raw_img) + m.save() + self.stdout.write(self.style.SUCCESS(f'Saved {m.source_url}')) + else: + self.stdout.write(self.style.ERROR(f'Skipped {m.source_url}')) + except Exception as e: + print(e) diff --git a/music/models.py b/music/models.py index c738db5b..131f9a14 100644 --- a/music/models.py +++ b/music/models.py @@ -1,21 +1,29 @@ import uuid import django.contrib.postgres.fields as postgres -from django.utils.translation import ugettext_lazy as _ +from django.utils.translation import gettext_lazy as _ from django.db import models from django.core.serializers.json import DjangoJSONEncoder from django.shortcuts import reverse -from common.models import Entity, Mark, Review, Tag +from common.models import Entity, Mark, Review, Tag, SourceSiteEnum, MarkStatusEnum from common.utils import ChoicesDictGenerator, GenerateDateUUIDMediaFilePath -from boofilsic.settings import SONG_MEDIA_PATH_ROOT, DEFAULT_SONG_IMAGE, ALBUM_MEDIA_PATH_ROOT, DEFAULT_ALBUM_IMAGE from django.utils import timezone +from django.conf import settings +from simple_history.models import HistoricalRecords + + +MusicMarkStatusTranslation = { + MarkStatusEnum.DO.value: _("在听"), + MarkStatusEnum.WISH.value: _("想听"), + MarkStatusEnum.COLLECT.value: _("听过") +} def song_cover_path(instance, filename): - return GenerateDateUUIDMediaFilePath(instance, filename, SONG_MEDIA_PATH_ROOT) + return GenerateDateUUIDMediaFilePath(instance, filename, settings.SONG_MEDIA_PATH_ROOT) def album_cover_path(instance, filename): - return GenerateDateUUIDMediaFilePath(instance, filename, ALBUM_MEDIA_PATH_ROOT) + return GenerateDateUUIDMediaFilePath(instance, filename, settings.ALBUM_MEDIA_PATH_ROOT) class Album(Entity): @@ -23,11 +31,11 @@ class Album(Entity): release_date = models.DateField( _('发行日期'), auto_now=False, auto_now_add=False, null=True, blank=True) cover = models.ImageField( - _("封面"), upload_to=album_cover_path, default=DEFAULT_ALBUM_IMAGE, blank=True) + _("封面"), upload_to=album_cover_path, default=settings.DEFAULT_ALBUM_IMAGE, blank=True) duration = models.PositiveIntegerField(_("时长"), null=True, blank=True) artist = postgres.ArrayField( models.CharField(_("artist"), blank=True, - default='', max_length=100), + default='', max_length=200), null=True, blank=True, default=list, @@ -45,12 +53,36 @@ class Album(Entity): ) track_list = models.TextField(_("曲目"), blank=True, default="") + history = HistoricalRecords() + def __str__(self): return self.title + def get_json(self): + r = { + 'artist': self.artist, + 'release_date': self.release_date, + 'genre': self.genre, + 'publisher': self.company, + } + r.update(super().get_json()) + return r + + def get_embed_link(self): + if self.source_site == SourceSiteEnum.SPOTIFY.value: + return self.source_url.replace("open.spotify.com/", "open.spotify.com/embed/") + elif self.source_site == SourceSiteEnum.BANDCAMP.value and self.other_info and 'bandcamp_album_id' in self.other_info: + return f"https://bandcamp.com/EmbeddedPlayer/album={self.other_info['bandcamp_album_id']}/size=large/bgcol=ffffff/linkcol=19A2CA/artwork=small/transparent=true/" + else: + return None + def get_absolute_url(self): return reverse("music:retrieve_album", args=[self.id]) + @property + def wish_url(self): + return reverse("music:wish_album", args=[self.id]) + def get_tags_manager(self): return self.album_tags @@ -58,6 +90,14 @@ class Album(Entity): def verbose_category_name(self): return _("专辑") + @property + def mark_class(self): + return AlbumMark + + @property + def tag_class(self): + return AlbumTag + class Song(Entity): ''' @@ -70,7 +110,7 @@ class Song(Entity): # duration in ms duration = models.PositiveIntegerField(_("时长"), null=True, blank=True) cover = models.ImageField( - _("封面"), upload_to=song_cover_path, default=DEFAULT_SONG_IMAGE, blank=True) + _("封面"), upload_to=song_cover_path, default=settings.DEFAULT_SONG_IMAGE, blank=True) artist = postgres.ArrayField( models.CharField(blank=True, default='', max_length=100), @@ -84,19 +124,46 @@ class Song(Entity): album = models.ForeignKey( Album, models.SET_NULL, "album_songs", null=True, blank=True, verbose_name=_("所属专辑")) + history = HistoricalRecords() + def __str__(self): return self.title + def get_json(self): + r = { + 'artist': self.artist, + 'release_date': self.release_date, + 'genre': self.genre, + } + r.update(super().get_json()) + return r + + def get_embed_link(self): + return self.source_url.replace("open.spotify.com/", "open.spotify.com/embed/") if self.source_site == SourceSiteEnum.SPOTIFY.value else None + def get_absolute_url(self): return reverse("music:retrieve_song", args=[self.id]) + @property + def wish_url(self): + return reverse("music:wish_song", args=[self.id]) + def get_tags_manager(self): return self.song_tags - + @property def verbose_category_name(self): return _("单曲") + @property + def mark_class(self): + return SongMark + + @property + def tag_class(self): + return SongTag + + class SongMark(Mark): song = models.ForeignKey( Song, on_delete=models.CASCADE, related_name='song_marks', null=True) @@ -107,6 +174,10 @@ class SongMark(Mark): fields=['owner', 'song'], name='unique_song_mark') ] + @property + def translated_status(self): + return MusicMarkStatusTranslation[self.status] + class SongReview(Review): song = models.ForeignKey( @@ -118,6 +189,14 @@ class SongReview(Review): fields=['owner', 'song'], name='unique_song_review') ] + @property + def url(self): + return settings.APP_WEBSITE + reverse("music:retrieve_song_review", args=[self.id]) + + @property + def item(self): + return self.song + class SongTag(Tag): song = models.ForeignKey( @@ -131,6 +210,10 @@ class SongTag(Tag): fields=['content', 'mark'], name="unique_songmark_tag") ] + @property + def item(self): + return self.song + class AlbumMark(Mark): album = models.ForeignKey( @@ -142,6 +225,10 @@ class AlbumMark(Mark): fields=['owner', 'album'], name='unique_album_mark') ] + @property + def translated_status(self): + return MusicMarkStatusTranslation[self.status] + class AlbumReview(Review): album = models.ForeignKey( @@ -153,6 +240,14 @@ class AlbumReview(Review): fields=['owner', 'album'], name='unique_album_review') ] + @property + def url(self): + return settings.APP_WEBSITE + reverse("music:retrieve_album_review", args=[self.id]) + + @property + def item(self): + return self.album + class AlbumTag(Tag): album = models.ForeignKey( @@ -165,3 +260,7 @@ class AlbumTag(Tag): models.UniqueConstraint( fields=['content', 'mark'], name="unique_albummark_tag") ] + + @property + def item(self): + return self.album diff --git a/music/templates/music/album_detail.html b/music/templates/music/album_detail.html index 68fe1143..7de3d0ae 100644 --- a/music/templates/music/album_detail.html +++ b/music/templates/music/album_detail.html @@ -6,6 +6,7 @@ {% load mastodon %} {% load oauth_token %} {% load truncate %} +{% load strip_scheme %} {% load thumb %} <!DOCTYPE html> <html lang="en"> @@ -13,21 +14,19 @@ <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> - <meta property="og:title" content="NiceDB音乐 - {{ album.title }}"> + <meta property="og:title" content="{{ site_name }}音乐 - {{ album.title }}"> <meta property="og:type" content="music.album"> <meta property="og:url" content="{{ request.build_absolute_uri }}"> <meta property="og:image" content="{{ request.scheme }}://{{ request.get_host }}{{ album.cover.url }}"> - <meta property="og:site_name" content="NiceDB"> - <meta property="og:description"content="{{ album.brief }}"> + <meta property="og:site_name" content="{{ site_name }}"> + <meta property="og:description" content="{{ album.brief }}"> - <title>{% trans 'NiceDB - 音乐详情' %} | {{ album.title }}</title> + <title>{{ site_name }} - {% trans '音乐详情' %} | {{ album.title }}</title> - <script src="https://cdnjs.cloudflare.com/ajax/libs/jquery/3.5.1/jquery.min.js"></script> + {% include "partial/_common_libs.html" with jquery=1 %} + <script src="{% static 'lib/js/rating-star.js' %}"></script> <script src="{% static 'js/detail.js' %}"></script> - <link rel="stylesheet" href="{% static 'css/boofilsic.css' %}"> - <link rel="stylesheet" href="{% static 'lib/css/rating-star.css' %}"> - </head> <body> @@ -55,11 +54,12 @@ <div class="entity-detail__fields"> <div class="entity-detail__rating"> - {% if album.rating %} + {% if album.rating and album.rating_number >= 5 %} <span class="entity-detail__rating-star rating-star" data-rating-score="{{ album.rating | floatformat:"0" }}"></span> <span class="entity-detail__rating-score"> {{ album.rating }} </span> + <small>({{ album.rating_number }}人评分)</small> {% else %} - <span> {% trans '评分:暂无评分' %}</span> + <span> {% trans '评分:评分人数不足' %}</span> {% endif %} </div> <div>{% if album.artist %}{% trans '艺术家:' %} @@ -72,7 +72,7 @@ {% if album.artist|length > 5 %} <a href="javascript:void(0);" id="artistMore">{% trans '更多' %}</a> <script> - $("#artistMore").click(function (e) { + $("#artistMore").on('click', function (e) { $("span.artist:not(:visible)").each(function (e) { $(this).parent().removeAttr('style'); }); @@ -91,7 +91,7 @@ {% if album.company|length > 5 %} <a href="javascript:void(0);" id="companyMore">{% trans '更多' %}</a> <script> - $("#companyMore").click(function (e) { + $("#companyMore").on('click', function (e) { $("span.company:not(:visible)").each(function (e) { $(this).parent().removeAttr('style'); }); @@ -126,7 +126,7 @@ {% if album.last_editor %} - <div>{% trans '最近编辑者:' %}<a href="{% url 'users:home' album.last_editor.id %}">{{ album.last_editor | default:"" }}</a></div> + <div>{% trans '最近编辑者:' %}<a href="{% url 'users:home' album.last_editor.mastodon_username %}">{{ album.last_editor | default:"" }}</a></div> {% endif %} <div> @@ -213,43 +213,23 @@ <h5 class="entity-marks__title">{% trans '这部作品的标记' %}</h5> {% if mark_list_more %} - <a href="{% url 'music:retrieve_album_mark_list' album.id %}" class="entity-marks__more-link">{% trans '更多' %}</a> - {% endif %} - {% if mark_list %} - <ul class="entity-marks__mark-list"> - {% for others_mark in mark_list %} - <li class="entity-marks__mark"> - <a href="{% url 'users:home' others_mark.owner.id %}" class="entity-marks__owner-link">{{ others_mark.owner.username }}</a> - <span>{{ others_mark.get_status_display }}</span> - {% if others_mark.rating %} - <span class="entity-marks__rating-star rating-star" data-rating-score="{{ others_mark.rating | floatformat:"0" }}"></span> - {% endif %} - {% if others_mark.is_private %} - <span class="icon-lock"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20"><path d="M17,8.48h-.73V6.27a6.27,6.27,0,1,0-12.53,0V8.48H3a.67.67,0,0,0-.67.67V19.33A.67.67,0,0,0,3,20H17a.67.67,0,0,0,.67-.67V9.15A.67.67,0,0,0,17,8.48ZM6.42,6.27h0a3.57,3.57,0,0,1,7.14,0h0V8.48H6.42Z"/></svg></span> - {% endif %} - <span class="entity-marks__mark-time">{{ others_mark.edited_time }}</span> - {% if others_mark.text %} - <p class="entity-marks__mark-content">{{ others_mark.text }}</p> - {% endif %} - </li> - {% endfor %} - </ul> - {% else %} - <div>{% trans '暂无标记' %}</div> + <a href="{% url 'music:retrieve_album_mark_list' album.id %}" class="entity-marks__more-link">{% trans '全部标记' %}</a> {% endif %} + <a href="{% url 'music:retrieve_album_mark_list' album.id 1 %}" class="entity-marks__more-link">关注的人的标记</a> + {% include "partial/mark_list.html" with mark_list=mark_list current_item=album %} </div> <div class="entity-reviews"> <h5 class="entity-reviews__title">{% trans '这部作品的评论' %}</h5> {% if review_list_more %} - <a href="{% url 'music:retrieve_album_review_list' album.id %}" class="entity-reviews__more-link">{% trans '更多' %}</a> + <a href="{% url 'music:retrieve_album_review_list' album.id %}" class="entity-reviews__more-link">{% trans '全部评论' %}</a> {% endif %} {% if review_list %} <ul class="entity-reviews__review-list"> {% for others_review in review_list %} <li class="entity-reviews__review"> - <a href="{% url 'users:home' others_review.owner.id %}" class="entity-reviews__owner-link">{{ others_review.owner.username }}</a> - {% if others_review.is_private %} + <a href="{% url 'users:home' others_review.owner.mastodon_username %}" class="entity-reviews__owner-link">{{ others_review.owner.username }}</a> + {% if others_review.visibility > 0 %} <span class="icon-lock"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20"><path d="M17,8.48h-.73V6.27a6.27,6.27,0,1,0-12.53,0V8.48H3a.67.67,0,0,0-.67.67V19.33A.67.67,0,0,0,3,20H17a.67.67,0,0,0,.67-.67V9.15A.67.67,0,0,0,17,8.48ZM6.42,6.27h0a3.57,3.57,0,0,1,7.14,0h0V8.48H6.42Z"/></svg></span> {% endif %} <span class="entity-reviews__review-time">{{ others_review.edited_time }}</span> @@ -277,7 +257,7 @@ <span class="mark-panel__rating-star rating-star" data-rating-score="{{ mark.rating | floatformat:"0" }}"></span> {% endif %} {% endif %} - {% if mark.is_private %} + {% if mark.visibility > 0 %} <span class="icon-lock"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20"><path d="M17,8.48h-.73V6.27a6.27,6.27,0,1,0-12.53,0V8.48H3a.67.67,0,0,0-.67.67V19.33A.67.67,0,0,0,3,20H17a.67.67,0,0,0,.67-.67V9.15A.67.67,0,0,0,17,8.48ZM6.42,6.27h0a3.57,3.57,0,0,1,7.14,0h0V8.48H6.42Z"/></svg></span> {% endif %} <span class="mark-panel__actions"> @@ -289,7 +269,7 @@ </span> <div class="mark-panel__clear"></div> - <div class="mark-panel__time">{{ mark.edited_time }}</div> + <div class="mark-panel__time">{{ mark.created_time }}</div> {% if mark.text %} <p class="mark-panel__text">{{ mark.text }}</p> @@ -320,7 +300,7 @@ <div class="review-panel"> <span class="review-panel__label">{% trans '我的评论' %}</span> - {% if review.is_private %} + {% if review.visibility > 0 %} <span class="icon-lock"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20"><path d="M17,8.48h-.73V6.27a6.27,6.27,0,1,0-12.53,0V8.48H3a.67.67,0,0,0-.67.67V19.33A.67.67,0,0,0,3,20H17a.67.67,0,0,0,.67-.67V9.15A.67.67,0,0,0,17,8.48ZM6.42,6.27h0a3.57,3.57,0,0,1,7.14,0h0V8.48H6.42Z"/></svg></span> {% endif %} @@ -349,7 +329,28 @@ {% endif %} </div> + + {% if collection_list %} + <div class="aside-section-wrapper"> + <div class="action-panel"> + <div class="action-panel__label">{% trans '相关收藏单' %}</div> + <div > + {% for c in collection_list %} + <p> + <a href="{% url 'collection:retrieve' c.id %}">{{ c.title }}</a> + </p> + {% endfor %} + <div class="action-panel__button-group action-panel__button-group--center"> + <button class="action-panel__button add-to-list" hx-get="{% url 'collection:add_to_list' 'album' album.id %}" hx-target="body" hx-swap="beforeend">{% trans '添加到收藏单' %}</button> + </div> + </div> + </div> + </div> + {% endif %} + {% if album.get_embed_link %} + <iframe src="{{ album.get_embed_link }}" height="320" frameborder="0" allowtransparency="true" allow="encrypted-media"></iframe> + {% endif %} </div> </div> </section> @@ -411,8 +412,8 @@ <div class="mark-modal__option"> <div class="mark-modal__visibility-radio"> - <span>{{ mark_form.is_private.label }}:</span> - {{ mark_form.is_private }} + <span>{{ mark_form.visibility.label }}:</span> + {{ mark_form.visibility }} </div> <div class="mark-modal__share-checkbox"> {{ mark_form.share_to_mastodon }}{{ mark_form.share_to_mastodon.label }} diff --git a/music/templates/music/album_mark_list.html b/music/templates/music/album_mark_list.html index cc9c7821..49e4b670 100644 --- a/music/templates/music/album_mark_list.html +++ b/music/templates/music/album_mark_list.html @@ -14,8 +14,8 @@ <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> - <title>{% trans 'NiceDB - ' %}{{ album.title }}{% trans '的标记' %}</title> - <script src="https://cdnjs.cloudflare.com/ajax/libs/jquery/3.5.1/jquery.min.js"></script> + <title>{{ site_name }} - {{ album.title }}{% trans '的标记' %}</title> + <script src="https://static.neodb.social/cdnjs.cloudflare.com/ajax/libs/jquery/3.6.0/jquery.min.js"></script> <script src="{% static 'lib/js/rating-star.js' %}"></script> <script src="{% static 'js/rating-star-readonly.js' %}"></script> <link rel="stylesheet" href="{% static 'css/boofilsic.min.css' %}"> @@ -35,38 +35,7 @@ <h5 class="entity-marks__title entity-marks__title--stand-alone"> <a href="{% url 'music:retrieve_album' album.id %}">{{ album.title }}</a>{% trans '的标记' %} </h5> - <ul class="entity-marks__mark-list"> - - {% for mark in marks %} - - <li class="entity-marks__mark entity-marks__mark--wider"> - <a href="{% url 'users:home' mark.owner.id %}" - class="entity-marks__owner-link">{{ mark.owner.username }}</a> - <span>{{ mark.get_status_display }}</span> - {% if mark.rating %} - <span class="entity-marks__rating-star rating-star" - data-rating-score="{{ mark.rating | floatformat:" 0" }}"></span> - {% endif %} - {% if mark.is_private %} - <span class="icon-lock"><svg xmlns="http://www.w3.org/2000/svg" - viewBox="0 0 20 20"> - <path - d="M17,8.48h-.73V6.27a6.27,6.27,0,1,0-12.53,0V8.48H3a.67.67,0,0,0-.67.67V19.33A.67.67,0,0,0,3,20H17a.67.67,0,0,0,.67-.67V9.15A.67.67,0,0,0,17,8.48ZM6.42,6.27h0a3.57,3.57,0,0,1,7.14,0h0V8.48H6.42Z" /> - </svg></span> - {% endif %} - <span class="entity-marks__mark-time">{{ mark.edited_time }}</span> - {% if mark.text %} - <p class="entity-marks__mark-content">{{ mark.text }}</p> - {% endif %} - </li> - - {% empty %} - <div> - {% trans '无结果' %} - </div> - {% endfor %} - - </ul> + {% include "partial/mark_list.html" with mark_list=marks current_item=album %} </div> <div class="pagination"> @@ -124,7 +93,7 @@ {% if album.artist|length > 5 %} <a href="javascript:void(0);" id="artistMore">{% trans '更多' %}</a> <script> - $("#artistMore").click(function (e) { + $("#artistMore").on('click', function (e) { $("span.artist:not(:visible)").each(function (e) { $(this).parent().removeAttr('style'); }); @@ -154,12 +123,6 @@ </div> - {% comment %} - <div id="oauth2Token" hidden="true">{% oauth_token %}</div> - <div id="mastodonURI" hidden="true">{% mastodon request.user.mastodon_site %}</div> - <!--current user mastodon id--> - <div id="userMastodonID" hidden="true">{{ user.mastodon_id }}</div> - {% endcomment %} <script> diff --git a/music/templates/music/album_review_detail.html b/music/templates/music/album_review_detail.html index fb3b7b5a..3e6e8fdd 100644 --- a/music/templates/music/album_review_detail.html +++ b/music/templates/music/album_review_detail.html @@ -13,17 +13,18 @@ <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> - <meta property="og:title" content="NiceDB乐评 - {{ review.title }}"> + <meta property="og:title" content="{{ site_name }}乐评 - {{ review.title }}"> <meta property="og:type" content="article"> <meta property="og:article:author" content="{{ review.owner.username }}"> <meta property="og:url" content="{{ request.build_absolute_uri }}"> - <meta property="og:image" content="{{ request.scheme }}://{{ request.get_host }}{% static 'img/logo_square.svg' %}"> - <title>{% trans 'NiceDB - 评论详情' %}</title> - <script src="https://cdnjs.cloudflare.com/ajax/libs/jquery/3.5.1/jquery.min.js"></script> + <meta property="og:image" content="{{ album.cover|thumb:'normal' }}"> + <title>{{ site_name }}乐评 - {{ review.title }}</title> + <script src="https://static.neodb.social/cdnjs.cloudflare.com/ajax/libs/jquery/3.6.0/jquery.min.js"></script> <script src="{% static 'lib/js/rating-star.js' %}"></script> <script src="{% static 'js/rating-star-readonly.js' %}"></script> <link rel="stylesheet" href="{% static 'lib/css/rating-star.css' %}"> <link rel="stylesheet" href="{% static 'css/boofilsic.min.css' %}"> + <link rel="stylesheet" href="{% static 'lib/css/neo.css' %}"> </head> <body> @@ -39,7 +40,7 @@ <h5 class="review-head__title"> {{ review.title }} </h5> - {% if review.is_private %} + {% if review.visibility > 0 %} <span class="icon-lock"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20"> <path d="M17,8.48h-.73V6.27a6.27,6.27,0,1,0-12.53,0V8.48H3a.67.67,0,0,0-.67.67V19.33A.67.67,0,0,0,3,20H17a.67.67,0,0,0,.67-.67V9.15A.67.67,0,0,0,17,8.48ZM6.42,6.27h0a3.57,3.57,0,0,1,7.14,0h0V8.48H6.42Z" /> @@ -48,7 +49,7 @@ <div class="review-head__body"> <div class="review-head__info"> - <a href="{% url 'users:home' review.owner.id %}" + <a href="{% url 'users:home' review.owner.mastodon_username %}" class="review-head__owner-link">{{ review.owner.username }}</a> {% if mark %} @@ -77,6 +78,7 @@ {{ form.content }} </div> {{ form.media }} + {% csrf_token %} </div> </div> @@ -109,7 +111,7 @@ {% if album.artist|length > 5 %} <a href="javascript:void(0);" id="artistMore">{% trans '更多' %}</a> <script> - $("#artistMore").click(function (e) { + $("#artistMore").on('click', function (e) { $("span.artist:not(:visible)").each(function (e) { $(this).parent().removeAttr('style'); }); @@ -139,16 +141,8 @@ </div> - {% comment %} - <div id="oauth2Token" hidden="true">{% oauth_token %}</div> - <div id="mastodonURI" hidden="true">{% mastodon request.user.mastodon_site %}</div> - <!--current user mastodon id--> - <div id="userMastodonID" hidden="true">{{ user.mastodon_id }}</div> - {% endcomment %} <script> - - $(".markdownx textarea").hide(); </script> </body> diff --git a/music/templates/music/album_review_list.html b/music/templates/music/album_review_list.html index a4e4f04a..40ce5b6d 100644 --- a/music/templates/music/album_review_list.html +++ b/music/templates/music/album_review_list.html @@ -13,8 +13,8 @@ <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> - <title>{% trans 'NiceDB - ' %}{{ album.title }}{% trans '的评论' %}</title> - <script src="https://cdnjs.cloudflare.com/ajax/libs/jquery/3.5.1/jquery.min.js"></script> + <title>{{ site_name }} - {{ album.title }}{% trans '的评论' %}</title> + <script src="https://static.neodb.social/cdnjs.cloudflare.com/ajax/libs/jquery/3.6.0/jquery.min.js"></script> <script src="{% static 'lib/js/rating-star.js' %}"></script> <script src="{% static 'js/rating-star-readonly.js' %}"></script> <link rel="stylesheet" href="{% static 'lib/css/rating-star.css' %}"> @@ -40,9 +40,9 @@ <li class="entity-reviews__review entity-reviews__review--wider"> - <a href="{% url 'users:home' review.owner.id %}" + <a href="{% url 'users:home' review.owner.mastodon_username %}" class="entity-reviews__owner-link">{{ review.owner.username }}</a> - {% if review.is_private %} + {% if review.visibility > 0 %} <span class="icon-lock"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20"> <path @@ -120,7 +120,7 @@ {% if album.artist|length > 5 %} <a href="javascript:void(0);" id="artistMore">{% trans '更多' %}</a> <script> - $("#artistMore").click(function (e) { + $("#artistMore").on('click', function (e) { $("span.artist:not(:visible)").each(function (e) { $(this).parent().removeAttr('style'); }); @@ -150,12 +150,6 @@ </div> - {% comment %} - <div id="oauth2Token" hidden="true">{% oauth_token %}</div> - <div id="mastodonURI" hidden="true">{% mastodon request.user.mastodon_site %}</div> - <!--current user mastodon id--> - <div id="userMastodonID" hidden="true">{{ user.mastodon_id }}</div> - {% endcomment %} <script> diff --git a/music/templates/music/create_update_album.html b/music/templates/music/create_update_album.html index 841eab79..111c60a1 100644 --- a/music/templates/music/create_update_album.html +++ b/music/templates/music/create_update_album.html @@ -10,8 +10,8 @@ <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> - <title>{% trans 'NiceDB - ' %}{{ title }}</title> - <script src="https://cdnjs.cloudflare.com/ajax/libs/jquery/3.5.1/jquery.min.js"></script> + <title>{{ site_name }} - {{ title }}</title> + <script src="https://static.neodb.social/cdnjs.cloudflare.com/ajax/libs/jquery/3.6.0/jquery.min.js"></script> <link rel="stylesheet" href="{% static 'css/boofilsic.min.css' %}"> </head> @@ -22,9 +22,24 @@ <section id="content" class="container"> <div class="grid"> + {% if is_update and form.source_site.value != 'in-site' %} + <div style="float:right;padding-left:16px"> + <div class="aside-section-wrapper"> + <div class="action-panel"> + <div class="action-panel__label">{% trans '源网站' %}: <a href="{{ form.source_url.value }}">{{ form.source_site.value }}</a></div> + <div class="action-panel__button-group"> + <form method="post" action="{% url 'music:rescrape' form.id.value %}"> + {% csrf_token %} + <input class="button" type="submit" value="{% trans '从源网站重新抓取' %}"> + </form> + </div> + </div> + </div> + </div> + {% endif %} + <div class="single-section-wrapper" id="main"> - <a href="{% url 'music:scrape_album' %}" - class="single-section-wrapper__link single-section-wrapper__link--secondary">{% trans '>>> 试试一键剽取~ <<<' %}</a> + {% comment %} <a href="{% url 'music:scrape_album' %}" class="single-section-wrapper__link single-section-wrapper__link--secondary">{% trans '>>> 试试一键剽取~ <<<' %}</a> {% endcomment %} <form class="entity-form" action="{{ submit_url }}" method="post" enctype="multipart/form-data"> {% csrf_token %} {{ form.media }} @@ -54,12 +69,6 @@ </div> - {% comment %} - <div id="oauth2Token" hidden="true">{% oauth_token %}</div> - <div id="mastodonURI" hidden="true">{% mastodon request.user.mastodon_site %}</div> - <!--current user mastodon id--> - <div id="userMastodonID" hidden="true">{{ user.mastodon_id }}</div> - {% endcomment %} <script> // mark required diff --git a/music/templates/music/create_update_album_review.html b/music/templates/music/create_update_album_review.html index db1f11f8..440bce52 100644 --- a/music/templates/music/create_update_album_review.html +++ b/music/templates/music/create_update_album_review.html @@ -13,8 +13,8 @@ <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> - <title>{% trans 'NiceDB - ' %}{{ title }}</title> - <script src="https://cdnjs.cloudflare.com/ajax/libs/jquery/3.5.1/jquery.min.js"></script> + <title>{{ site_name }} - {{ title }}</title> + <script src="https://static.neodb.social/cdnjs.cloudflare.com/ajax/libs/jquery/3.6.0/jquery.min.js"></script> <script src="{% static 'js/create_update_review.js' %}"></script> <script src="{% static 'lib/js/rating-star.js' %}"></script> <link rel="stylesheet" href="{% static 'lib/css/rating-star.css' %}"> @@ -52,7 +52,7 @@ {% if album.artist|length > 5 %} <a href="javascript:void(0);" id="artistMore">{% trans '更多' %}</a> <script> - $("#artistMore").click(function (e) { + $("#artistMore").on('click', function (e) { $("span.artist:not(:visible)").each(function (e) { $(this).parent().removeAttr('style'); }); @@ -96,7 +96,7 @@ <div class="review-form__option"> <div class="review-form__visibility-radio"> - {{ form.is_private.label }}{{ form.is_private }} + {{ form.visibility.label }}{{ form.visibility }} </div> <div class="review-form__share-checkbox"> {{ form.share_to_mastodon }}{{ form.share_to_mastodon.label }} @@ -116,12 +116,6 @@ {% include "partial/_footer.html" %} </div> - {% comment %} - <div id="oauth2Token" hidden="true">{% oauth_token %}</div> - <div id="mastodonURI" hidden="true">{% mastodon request.user.mastodon_site %}</div> - <!--current user mastodon id--> - <div id="userMastodonID" hidden="true">{{ user.mastodon_id }}</div> - {% endcomment %} <script> diff --git a/music/templates/music/create_update_song.html b/music/templates/music/create_update_song.html index a694c891..4a4e8b49 100644 --- a/music/templates/music/create_update_song.html +++ b/music/templates/music/create_update_song.html @@ -10,8 +10,8 @@ <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> - <title>{% trans 'NiceDB - ' %}{{ title }}</title> - <script src="https://cdnjs.cloudflare.com/ajax/libs/jquery/3.5.1/jquery.min.js"></script> + <title>{{ site_name }} - {{ title }}</title> + <script src="https://static.neodb.social/cdnjs.cloudflare.com/ajax/libs/jquery/3.6.0/jquery.min.js"></script> <link rel="stylesheet" href="{% static 'css/boofilsic.min.css' %}"> </head> @@ -57,12 +57,6 @@ </div> - {% comment %} - <div id="oauth2Token" hidden="true">{% oauth_token %}</div> - <div id="mastodonURI" hidden="true">{% mastodon request.user.mastodon_site %}</div> - <!--current user mastodon id--> - <div id="userMastodonID" hidden="true">{{ user.mastodon_id }}</div> - {% endcomment %} <script> // mark required diff --git a/music/templates/music/create_update_song_review.html b/music/templates/music/create_update_song_review.html index 03e8271f..ec74d0bb 100644 --- a/music/templates/music/create_update_song_review.html +++ b/music/templates/music/create_update_song_review.html @@ -13,8 +13,8 @@ <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> - <title>{% trans 'NiceDB - ' %}{{ title }}</title> - <script src="https://cdnjs.cloudflare.com/ajax/libs/jquery/3.5.1/jquery.min.js"></script> + <title>{{ site_name }} - {{ title }}</title> + <script src="https://static.neodb.social/cdnjs.cloudflare.com/ajax/libs/jquery/3.6.0/jquery.min.js"></script> <script src="{% static 'js/create_update_review.js' %}"></script> <script src="{% static 'lib/js/rating-star.js' %}"></script> <link rel="stylesheet" href="{% static 'lib/css/rating-star.css' %}"> @@ -52,7 +52,7 @@ {% if song.artist|length > 5 %} <a href="javascript:void(0);" id="artistMore">{% trans '更多' %}</a> <script> - $("#artistMore").click(function (e) { + $("#artistMore").on('click', function (e) { $("span.artist:not(:visible)").each(function (e) { $(this).parent().removeAttr('style'); }); @@ -100,7 +100,7 @@ <div class="review-form__option"> <div class="review-form__visibility-radio"> - {{ form.is_private.label }}{{ form.is_private }} + {{ form.visibility.label }}{{ form.visibility }} </div> <div class="review-form__share-checkbox"> {{ form.share_to_mastodon }}{{ form.share_to_mastodon.label }} @@ -120,12 +120,6 @@ {% include "partial/_footer.html" %} </div> - {% comment %} - <div id="oauth2Token" hidden="true">{% oauth_token %}</div> - <div id="mastodonURI" hidden="true">{% mastodon request.user.mastodon_site %}</div> - <!--current user mastodon id--> - <div id="userMastodonID" hidden="true">{{ user.mastodon_id }}</div> - {% endcomment %} <script> diff --git a/music/templates/music/delete_album.html b/music/templates/music/delete_album.html index bbc15964..8539d736 100644 --- a/music/templates/music/delete_album.html +++ b/music/templates/music/delete_album.html @@ -13,8 +13,8 @@ <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> - <title>{% trans 'NiceDB - 删除音乐' %}</title> - <script src="https://cdnjs.cloudflare.com/ajax/libs/jquery/3.5.1/jquery.min.js"></script> + <title>{{ site_name }} - {% trans '删除音乐' %}</title> + <script src="https://static.neodb.social/cdnjs.cloudflare.com/ajax/libs/jquery/3.6.0/jquery.min.js"></script> <script src="{% static 'lib/js/rating-star.js' %}"></script> <script src="{% static 'js/rating-star-readonly.js' %}"></script> <link rel="stylesheet" href="{% static 'lib/css/rating-star.css' %}"> @@ -55,7 +55,7 @@ {% if album.last_editor %} <div> {% trans '最近编辑者:' %} - <a href="{% url 'users:home' album.last_editor.id %}"> + <a href="{% url 'users:home' album.last_editor.mastodon_username %}"> <span>{{ album.last_editor | default:"" }}</span> </a> </div> @@ -89,12 +89,6 @@ </div> - {% comment %} - <div id="oauth2Token" hidden="true">{% oauth_token %}</div> - <div id="mastodonURI" hidden="true">{% mastodon request.user.mastodon_site %}</div> - <!--current user mastodon id--> - <div id="userMastodonID" hidden="true">{{ user.mastodon_id }}</div> - {% endcomment %} <script> diff --git a/music/templates/music/delete_album_review.html b/music/templates/music/delete_album_review.html index 7deaaa49..b67d065e 100644 --- a/music/templates/music/delete_album_review.html +++ b/music/templates/music/delete_album_review.html @@ -10,8 +10,8 @@ <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> - <title>{% trans 'NiceDB - 删除评论' %}</title> - <script src="https://cdnjs.cloudflare.com/ajax/libs/jquery/3.5.1/jquery.min.js"></script> + <title>{{ site_name }} - {% trans '删除评论' %}</title> + <script src="https://static.neodb.social/cdnjs.cloudflare.com/ajax/libs/jquery/3.6.0/jquery.min.js"></script> <link rel="stylesheet" href="{% static 'lib/css/rating-star.css' %}"> <link rel="stylesheet" href="{% static 'css/boofilsic.min.css' %}"> @@ -35,7 +35,7 @@ <h5 class="review-head__title"> {{ review.title }} </h5> - {% if review.is_private %} + {% if review.visibility > 0 %} <span class="icon-lock"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20"> <path @@ -46,7 +46,7 @@ <div class="review-head__body"> <div class="review-head__info"> - <a href="{% url 'users:home' review.owner.id %}" + <a href="{% url 'users:home' review.owner.mastodon_username %}" class="review-head__owner-link">{{ review.owner.username }}</a> {% if mark %} @@ -89,12 +89,6 @@ </div> - {% comment %} - <div id="oauth2Token" hidden="true">{% oauth_token %}</div> - <div id="mastodonURI" hidden="true">{% mastodon request.user.mastodon_site %}</div> - <!--current user mastodon id--> - <div id="userMastodonID" hidden="true">{{ user.mastodon_id }}</div> - {% endcomment %} <script> diff --git a/music/templates/music/delete_song.html b/music/templates/music/delete_song.html index 4d3825af..1be0112c 100644 --- a/music/templates/music/delete_song.html +++ b/music/templates/music/delete_song.html @@ -13,8 +13,8 @@ <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> - <title>{% trans 'NiceDB - 删除音乐' %}</title> - <script src="https://cdnjs.cloudflare.com/ajax/libs/jquery/3.5.1/jquery.min.js"></script> + <title>{{ site_name }} - {% trans '删除音乐' %}</title> + <script src="https://static.neodb.social/cdnjs.cloudflare.com/ajax/libs/jquery/3.6.0/jquery.min.js"></script> <script src="{% static 'lib/js/rating-star.js' %}"></script> <script src="{% static 'js/rating-star-readonly.js' %}"></script> <link rel="stylesheet" href="{% static 'lib/css/rating-star.css' %}"> @@ -55,7 +55,7 @@ {% if song.last_editor %} <div> {% trans '最近编辑者:' %} - <a href="{% url 'users:home' song.last_editor.id %}"> + <a href="{% url 'users:home' song.last_editor.mastodon_username %}"> <span>{{ song.last_editor | default:"" }}</span> </a> </div> @@ -89,12 +89,6 @@ </div> - {% comment %} - <div id="oauth2Token" hidden="true">{% oauth_token %}</div> - <div id="mastodonURI" hidden="true">{% mastodon request.user.mastodon_site %}</div> - <!--current user mastodon id--> - <div id="userMastodonID" hidden="true">{{ user.mastodon_id }}</div> - {% endcomment %} <script> diff --git a/music/templates/music/delete_song_review.html b/music/templates/music/delete_song_review.html index 9e515b02..d2d610f3 100644 --- a/music/templates/music/delete_song_review.html +++ b/music/templates/music/delete_song_review.html @@ -10,8 +10,8 @@ <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> - <title>{% trans 'NiceDB - 删除评论' %}</title> - <script src="https://cdnjs.cloudflare.com/ajax/libs/jquery/3.5.1/jquery.min.js"></script> + <title>{{ site_name }} - {% trans '删除评论' %}</title> + <script src="https://static.neodb.social/cdnjs.cloudflare.com/ajax/libs/jquery/3.6.0/jquery.min.js"></script> <link rel="stylesheet" href="{% static 'lib/css/rating-star.css' %}"> <link rel="stylesheet" href="{% static 'css/boofilsic.min.css' %}"> @@ -35,7 +35,7 @@ <h5 class="review-head__title"> {{ review.title }} </h5> - {% if review.is_private %} + {% if review.visibility > 0 %} <span class="icon-lock"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20"> <path @@ -46,7 +46,7 @@ <div class="review-head__body"> <div class="review-head__info"> - <a href="{% url 'users:home' review.owner.id %}" + <a href="{% url 'users:home' review.owner.mastodon_username %}" class="review-head__owner-link">{{ review.owner.username }}</a> {% if mark %} @@ -89,12 +89,6 @@ </div> - {% comment %} - <div id="oauth2Token" hidden="true">{% oauth_token %}</div> - <div id="mastodonURI" hidden="true">{% mastodon request.user.mastodon_site %}</div> - <!--current user mastodon id--> - <div id="userMastodonID" hidden="true">{{ user.mastodon_id }}</div> - {% endcomment %} <script> diff --git a/music/templates/music/scrape_album.html b/music/templates/music/scrape_album.html index 8079aa9e..b4fc9aba 100644 --- a/music/templates/music/scrape_album.html +++ b/music/templates/music/scrape_album.html @@ -10,8 +10,8 @@ <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> - <title>{% trans 'NiceDB - 从豆瓣获取数据' %}</title> - <script src="https://cdnjs.cloudflare.com/ajax/libs/jquery/3.5.1/jquery.min.js"></script> + <title>{{ site_name }} - {% trans '从豆瓣获取数据' %}</title> + <script src="https://static.neodb.social/cdnjs.cloudflare.com/ajax/libs/jquery/3.6.0/jquery.min.js"></script> <script src="{% static 'js/scrape.js' %}"></script> <link rel="stylesheet" href="{% static 'css/boofilsic.min.css' %}"> </head> diff --git a/music/templates/music/scrape_song.html b/music/templates/music/scrape_song.html index e2cd4730..2ffa1b3b 100644 --- a/music/templates/music/scrape_song.html +++ b/music/templates/music/scrape_song.html @@ -10,8 +10,8 @@ <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> - <title>{% trans 'NiceDB - 从豆瓣获取数据' %}</title> - <script src="https://cdnjs.cloudflare.com/ajax/libs/jquery/3.5.1/jquery.min.js"></script> + <title>{{ site_name }} - {% trans '从豆瓣获取数据' %}</title> + <script src="https://static.neodb.social/cdnjs.cloudflare.com/ajax/libs/jquery/3.6.0/jquery.min.js"></script> <script src="{% static 'js/scrape.js' %}"></script> <link rel="stylesheet" href="{% static 'css/boofilsic.min.css' %}"> </head> diff --git a/music/templates/music/song_detail.html b/music/templates/music/song_detail.html index 6aa9b87b..48a8c7be 100644 --- a/music/templates/music/song_detail.html +++ b/music/templates/music/song_detail.html @@ -6,6 +6,7 @@ {% load mastodon %} {% load oauth_token %} {% load truncate %} +{% load strip_scheme %} {% load thumb %} <!DOCTYPE html> <html lang="en"> @@ -13,21 +14,19 @@ <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> - <meta property="og:title" content="NiceDB音乐 - {{ song.title }}"> + <meta property="og:title" content="{{ site_name }}音乐 - {{ song.title }}"> <meta property="og:type" content="music.song"> <meta property="og:url" content="{{ request.build_absolute_uri }}"> <meta property="og:image" content="{{ request.scheme }}://{{ request.get_host }}{{ song.cover.url }}"> - <meta property="og:site_name" content="NiceDB"> - <meta property="og:description"content="{{ song.brief }}"> + <meta property="og:site_name" content="{{ site_name }}"> + <meta property="og:description" content="{{ song.brief }}"> - <title>{% trans 'NiceDB - 音乐详情' %} | {{ song.title }}</title> + <title>{{ site_name }} - {% trans '音乐详情' %} | {{ song.title }}</title> - <script src="https://cdnjs.cloudflare.com/ajax/libs/jquery/3.5.1/jquery.min.js"></script> + {% include "partial/_common_libs.html" with jquery=1 %} + <script src="{% static 'lib/js/rating-star.js' %}"></script> <script src="{% static 'js/detail.js' %}"></script> - <link rel="stylesheet" href="{% static 'css/boofilsic.css' %}"> - <link rel="stylesheet" href="{% static 'lib/css/rating-star.css' %}"> - </head> <body> @@ -54,11 +53,12 @@ <div class="entity-detail__fields"> <div class="entity-detail__rating"> - {% if song.rating %} + {% if song.rating and song.rating_number >= 5 %} <span class="entity-detail__rating-star rating-star" data-rating-score="{{ song.rating | floatformat:"0" }}"></span> <span class="entity-detail__rating-score"> {{ song.rating }} </span> + <small>({{ song.rating_number }}人评分)</small> {% else %} - <span> {% trans '评分:暂无评分' %}</span> + <span> {% trans '评分:评分人数不足' %}</span> {% endif %} </div> <div>{% if song.artist %}{% trans '艺术家:' %} @@ -71,7 +71,7 @@ {% if song.artist|length > 5 %} <a href="javascript:void(0);" id="artistMore">{% trans '更多' %}</a> <script> - $("#artistMore").click(function (e) { + $("#artistMore").on('click', function (e) { $("span.artist:not(:visible)").each(function (e) { $(this).parent().removeAttr('style'); }); @@ -114,7 +114,7 @@ {% if song.last_editor %} - <div>{% trans '最近编辑者:' %}<a href="{% url 'users:home' song.last_editor.id %}">{{ song.last_editor | default:"" }}</a></div> + <div>{% trans '最近编辑者:' %}<a href="{% url 'users:home' song.last_editor.mastodon_username %}">{{ song.last_editor | default:"" }}</a></div> {% endif %} <div> @@ -159,43 +159,23 @@ <h5 class="entity-marks__title">{% trans '这部作品的标记' %}</h5> {% if mark_list_more %} - <a href="{% url 'music:retrieve_song_mark_list' song.id %}" class="entity-marks__more-link">{% trans '更多' %}</a> - {% endif %} - {% if mark_list %} - <ul class="entity-marks__mark-list"> - {% for others_mark in mark_list %} - <li class="entity-marks__mark"> - <a href="{% url 'users:home' others_mark.owner.id %}" class="entity-marks__owner-link">{{ others_mark.owner.username }}</a> - <span>{{ others_mark.get_status_display }}</span> - {% if others_mark.rating %} - <span class="entity-marks__rating-star rating-star" data-rating-score="{{ others_mark.rating | floatformat:"0" }}"></span> - {% endif %} - {% if others_mark.is_private %} - <span class="icon-lock"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20"><path d="M17,8.48h-.73V6.27a6.27,6.27,0,1,0-12.53,0V8.48H3a.67.67,0,0,0-.67.67V19.33A.67.67,0,0,0,3,20H17a.67.67,0,0,0,.67-.67V9.15A.67.67,0,0,0,17,8.48ZM6.42,6.27h0a3.57,3.57,0,0,1,7.14,0h0V8.48H6.42Z"/></svg></span> - {% endif %} - <span class="entity-marks__mark-time">{{ others_mark.edited_time }}</span> - {% if others_mark.text %} - <p class="entity-marks__mark-content">{{ others_mark.text }}</p> - {% endif %} - </li> - {% endfor %} - </ul> - {% else %} - <div>{% trans '暂无标记' %}</div> + <a href="{% url 'music:retrieve_song_mark_list' song.id %}" class="entity-marks__more-link">{% trans '全部标记' %}</a> {% endif %} + <a href="{% url 'music:retrieve_song_mark_list' song.id 1 %}" class="entity-marks__more-link">关注的人的标记</a> + {% include "partial/mark_list.html" with mark_list=mark_list current_item=song %} </div> <div class="entity-reviews"> <h5 class="entity-reviews__title">{% trans '这部作品的评论' %}</h5> {% if review_list_more %} - <a href="{% url 'music:retrieve_song_review_list' song.id %}" class="entity-reviews__more-link">{% trans '更多' %}</a> + <a href="{% url 'music:retrieve_song_review_list' song.id %}" class="entity-reviews__more-link">{% trans '全部评论' %}</a> {% endif %} {% if review_list %} <ul class="entity-reviews__review-list"> {% for others_review in review_list %} <li class="entity-reviews__review"> - <a href="{% url 'users:home' others_review.owner.id %}" class="entity-reviews__owner-link">{{ others_review.owner.username }}</a> - {% if others_review.is_private %} + <a href="{% url 'users:home' others_review.owner.mastodon_username %}" class="entity-reviews__owner-link">{{ others_review.owner.username }}</a> + {% if others_review.visibility > 0 %} <span class="icon-lock"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20"><path d="M17,8.48h-.73V6.27a6.27,6.27,0,1,0-12.53,0V8.48H3a.67.67,0,0,0-.67.67V19.33A.67.67,0,0,0,3,20H17a.67.67,0,0,0,.67-.67V9.15A.67.67,0,0,0,17,8.48ZM6.42,6.27h0a3.57,3.57,0,0,1,7.14,0h0V8.48H6.42Z"/></svg></span> {% endif %} <span class="entity-reviews__review-time">{{ others_review.edited_time }}</span> @@ -223,7 +203,7 @@ <span class="mark-panel__rating-star rating-star" data-rating-score="{{ mark.rating | floatformat:"0" }}"></span> {% endif %} {% endif %} - {% if mark.is_private %} + {% if mark.visibility > 0 %} <span class="icon-lock"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20"><path d="M17,8.48h-.73V6.27a6.27,6.27,0,1,0-12.53,0V8.48H3a.67.67,0,0,0-.67.67V19.33A.67.67,0,0,0,3,20H17a.67.67,0,0,0,.67-.67V9.15A.67.67,0,0,0,17,8.48ZM6.42,6.27h0a3.57,3.57,0,0,1,7.14,0h0V8.48H6.42Z"/></svg></span> {% endif %} <span class="mark-panel__actions"> @@ -235,7 +215,7 @@ </span> <div class="mark-panel__clear"></div> - <div class="mark-panel__time">{{ mark.edited_time }}</div> + <div class="mark-panel__time">{{ mark.created_time }}</div> {% if mark.text %} <p class="mark-panel__text">{{ mark.text }}</p> @@ -266,7 +246,7 @@ <div class="review-panel"> <span class="review-panel__label">{% trans '我的评论' %}</span> - {% if review.is_private %} + {% if review.visibility > 0 %} <span class="icon-lock"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20"><path d="M17,8.48h-.73V6.27a6.27,6.27,0,1,0-12.53,0V8.48H3a.67.67,0,0,0-.67.67V19.33A.67.67,0,0,0,3,20H17a.67.67,0,0,0,.67-.67V9.15A.67.67,0,0,0,17,8.48ZM6.42,6.27h0a3.57,3.57,0,0,1,7.14,0h0V8.48H6.42Z"/></svg></span> {% endif %} @@ -295,7 +275,28 @@ {% endif %} </div> - + + {% if collection_list %} + <div class="aside-section-wrapper"> + <div class="action-panel"> + <div class="action-panel__label">{% trans '相关收藏单' %}</div> + <div > + {% for c in collection_list %} + <p> + <a href="{% url 'collection:retrieve' c.id %}">{{ c.title }}</a> + </p> + {% endfor %} + <div class="action-panel__button-group action-panel__button-group--center"> + <button class="action-panel__button add-to-list" hx-get="{% url 'collection:add_to_list' 'song' song.id %}" hx-target="body" hx-swap="beforeend">{% trans '添加到收藏单' %}</button> + </div> + </div> + </div> + </div> + {% endif %} + + {% if song.source_site == "spotify" %} + <iframe src="{{ song.get_embed_link }}" height="80" frameborder="0" allowtransparency="true" allow="encrypted-media"></iframe> + {% endif %} </div> </div> </section> @@ -357,8 +358,8 @@ <div class="mark-modal__option"> <div class="mark-modal__visibility-radio"> - <span>{{ mark_form.is_private.label }}:</span> - {{ mark_form.is_private }} + <span>{{ mark_form.visibility.label }}:</span> + {{ mark_form.visibility }} </div> <div class="mark-modal__share-checkbox"> {{ mark_form.share_to_mastodon }}{{ mark_form.share_to_mastodon.label }} diff --git a/music/templates/music/song_mark_list.html b/music/templates/music/song_mark_list.html index 6314df96..a5fcf18b 100644 --- a/music/templates/music/song_mark_list.html +++ b/music/templates/music/song_mark_list.html @@ -6,6 +6,7 @@ {% load mastodon %} {% load oauth_token %} {% load truncate %} +{% load highlight %} {% load thumb %} <!DOCTYPE html> <html lang="en"> @@ -13,8 +14,8 @@ <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> - <title>{% trans 'NiceDB - ' %}{{ song.title }}{% trans '的标记' %}</title> - <script src="https://cdnjs.cloudflare.com/ajax/libs/jquery/3.5.1/jquery.min.js"></script> + <title>{{ site_name }} - {{ song.title }}{% trans '的标记' %}</title> + <script src="https://static.neodb.social/cdnjs.cloudflare.com/ajax/libs/jquery/3.6.0/jquery.min.js"></script> <script src="{% static 'lib/js/rating-star.js' %}"></script> <script src="{% static 'js/rating-star-readonly.js' %}"></script> <link rel="stylesheet" href="{% static 'css/boofilsic.min.css' %}"> @@ -32,41 +33,9 @@ <div class="main-section-wrapper"> <div class="entity-marks"> <h5 class="entity-marks__title entity-marks__title--stand-alone"> - <a href="{% url 'music:retrieve_song' song.id %}">{{ song.title }}</a>{% trans ' - 的标记' %} + <a href="{% url 'music:retrieve_song' song.id %}">{{ song.title }}</a>{% trans '的标记' %} </h5> - <ul class="entity-marks__mark-list"> - - {% for mark in marks %} - - <li class="entity-marks__mark entity-marks__mark--wider"> - <a href="{% url 'users:home' mark.owner.id %}" - class="entity-marks__owner-link">{{ mark.owner.username }}</a> - <span>{{ mark.get_status_display }}</span> - {% if mark.rating %} - <span class="entity-marks__rating-star rating-star" - data-rating-score="{{ mark.rating | floatformat:" 0" }}"></span> - {% endif %} - {% if mark.is_private %} - <span class="icon-lock"><svg xmlns="http://www.w3.org/2000/svg" - viewBox="0 0 20 20"> - <path - d="M17,8.48h-.73V6.27a6.27,6.27,0,1,0-12.53,0V8.48H3a.67.67,0,0,0-.67.67V19.33A.67.67,0,0,0,3,20H17a.67.67,0,0,0,.67-.67V9.15A.67.67,0,0,0,17,8.48ZM6.42,6.27h0a3.57,3.57,0,0,1,7.14,0h0V8.48H6.42Z" /> - </svg></span> - {% endif %} - <span class="entity-marks__mark-time">{{ mark.edited_time }}</span> - {% if mark.text %} - <p class="entity-marks__mark-content">{{ mark.text }}</p> - {% endif %} - </li> - - {% empty %} - <div> - {% trans '无结果' %} - </div> - {% endfor %} - - </ul> + {% include "partial/mark_list.html" with mark_list=marks current_item=song %} </div> <div class="pagination"> @@ -110,8 +79,7 @@ {{ song.title }} </a> <a href="{{ song.source_url }}"><span - class="source-label source-label__{{ song.source_site }}">{{ - song.get_source_site_display }}</span></a> + class="source-label source-label__{{ song.source_site }}">{{song.get_source_site_display }}</span></a> </h5> <div>{% if song.artist %}{% trans '艺术家:' %} @@ -124,7 +92,7 @@ {% if song.artist|length > 5 %} <a href="javascript:void(0);" id="artistMore">{% trans '更多' %}</a> <script> - $("#artistMore").click(function (e) { + $("#artistMore").on('click', function (e) { $("span.artist:not(:visible)").each(function (e) { $(this).parent().removeAttr('style'); }); @@ -159,12 +127,6 @@ </div> - {% comment %} - <div id="oauth2Token" hidden="true">{% oauth_token %}</div> - <div id="mastodonURI" hidden="true">{% mastodon request.user.mastodon_site %}</div> - <!--current user mastodon id--> - <div id="userMastodonID" hidden="true">{{ user.mastodon_id }}</div> - {% endcomment %} <script> diff --git a/music/templates/music/song_review_detail.html b/music/templates/music/song_review_detail.html index ee7c2704..e261fa91 100644 --- a/music/templates/music/song_review_detail.html +++ b/music/templates/music/song_review_detail.html @@ -13,17 +13,18 @@ <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> - <meta property="og:title" content="NiceDB乐评 - {{ review.title }}"> + <meta property="og:title" content="{{ site_name }}乐评 - {{ review.title }}"> <meta property="og:type" content="article"> <meta property="og:article:author" content="{{ review.owner.username }}"> <meta property="og:url" content="{{ request.build_absolute_uri }}"> - <meta property="og:image" content="{{ request.scheme }}://{{ request.get_host }}{% static 'img/logo_square.svg' %}"> - <title>{% trans 'NiceDB - 评论详情' %}</title> - <script src="https://cdnjs.cloudflare.com/ajax/libs/jquery/3.5.1/jquery.min.js"></script> + <meta property="og:image" content="{{ song.cover|thumb:'normal' }}"> + <title>{{ site_name }}乐评 - {{ review.title }}</title> + <script src="https://static.neodb.social/cdnjs.cloudflare.com/ajax/libs/jquery/3.6.0/jquery.min.js"></script> <script src="{% static 'lib/js/rating-star.js' %}"></script> <script src="{% static 'js/rating-star-readonly.js' %}"></script> <link rel="stylesheet" href="{% static 'lib/css/rating-star.css' %}"> <link rel="stylesheet" href="{% static 'css/boofilsic.min.css' %}"> + <link rel="stylesheet" href="{% static 'lib/css/neo.css' %}"> </head> <body> @@ -39,7 +40,7 @@ <h5 class="review-head__title"> {{ review.title }} </h5> - {% if review.is_private %} + {% if review.visibility > 0 %} <span class="icon-lock"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20"> <path d="M17,8.48h-.73V6.27a6.27,6.27,0,1,0-12.53,0V8.48H3a.67.67,0,0,0-.67.67V19.33A.67.67,0,0,0,3,20H17a.67.67,0,0,0,.67-.67V9.15A.67.67,0,0,0,17,8.48ZM6.42,6.27h0a3.57,3.57,0,0,1,7.14,0h0V8.48H6.42Z" /> @@ -48,7 +49,7 @@ <div class="review-head__body"> <div class="review-head__info"> - <a href="{% url 'users:home' review.owner.id %}" class="review-head__owner-link">{{ review.owner.username }}</a> + <a href="{% url 'users:home' review.owner.mastodon_username %}" class="review-head__owner-link">{{ review.owner.username }}</a> {% if mark %} @@ -73,6 +74,7 @@ {{ form.content }} </div> {{ form.media }} + {% csrf_token %} </div> </div> @@ -101,7 +103,7 @@ {% if song.artist|length > 5 %} <a href="javascript:void(0);" id="artistMore">{% trans '更多' %}</a> <script> - $("#artistMore").click(function (e) { + $("#artistMore").on('click', function (e) { $("span.artist:not(:visible)").each(function (e) { $(this).parent().removeAttr('style'); }); @@ -135,16 +137,8 @@ </div> - {% comment %} - <div id="oauth2Token" hidden="true">{% oauth_token %}</div> - <div id="mastodonURI" hidden="true">{% mastodon request.user.mastodon_site %}</div> - <!--current user mastodon id--> - <div id="userMastodonID" hidden="true">{{ user.mastodon_id }}</div> - {% endcomment %} <script> - - $(".markdownx textarea").hide(); </script> </body> diff --git a/music/templates/music/song_review_list.html b/music/templates/music/song_review_list.html index b8078275..5926eba6 100644 --- a/music/templates/music/song_review_list.html +++ b/music/templates/music/song_review_list.html @@ -13,8 +13,8 @@ <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> - <title>{% trans 'NiceDB - ' %}{{ song.title }}{% trans '的评论' %}</title> - <script src="https://cdnjs.cloudflare.com/ajax/libs/jquery/3.5.1/jquery.min.js"></script> + <title>{{ site_name }} - {{ song.title }}{% trans '的评论' %}</title> + <script src="https://static.neodb.social/cdnjs.cloudflare.com/ajax/libs/jquery/3.6.0/jquery.min.js"></script> <script src="{% static 'lib/js/rating-star.js' %}"></script> <script src="{% static 'js/rating-star-readonly.js' %}"></script> <link rel="stylesheet" href="{% static 'lib/css/rating-star.css' %}"> @@ -40,8 +40,8 @@ <li class="entity-reviews__review entity-reviews__review--wider"> - <a href="{% url 'users:home' review.owner.id %}" class="entity-reviews__owner-link">{{ review.owner.username }}</a> - {% if review.is_private %} + <a href="{% url 'users:home' review.owner.mastodon_username %}" class="entity-reviews__owner-link">{{ review.owner.username }}</a> + {% if review.visibility > 0 %} <span class="icon-lock"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20"><path d="M17,8.48h-.73V6.27a6.27,6.27,0,1,0-12.53,0V8.48H3a.67.67,0,0,0-.67.67V19.33A.67.67,0,0,0,3,20H17a.67.67,0,0,0,.67-.67V9.15A.67.67,0,0,0,17,8.48ZM6.42,6.27h0a3.57,3.57,0,0,1,7.14,0h0V8.48H6.42Z"/></svg></span> {% endif %} <span class="entity-reviews__review-time">{{ review.edited_time }}</span> @@ -108,7 +108,7 @@ {% if song.artist|length > 5 %} <a href="javascript:void(0);" id="artistMore">{% trans '更多' %}</a> <script> - $("#artistMore").click(function (e) { + $("#artistMore").on('click', function (e) { $("span.artist:not(:visible)").each(function (e) { $(this).parent().removeAttr('style'); }); @@ -142,12 +142,6 @@ </div> - {% comment %} - <div id="oauth2Token" hidden="true">{% oauth_token %}</div> - <div id="mastodonURI" hidden="true">{% mastodon request.user.mastodon_site %}</div> - <!--current user mastodon id--> - <div id="userMastodonID" hidden="true">{{ user.mastodon_id }}</div> - {% endcomment %} <script> diff --git a/music/urls.py b/music/urls.py index 4acab46d..09b180ca 100644 --- a/music/urls.py +++ b/music/urls.py @@ -1,4 +1,4 @@ -from django.urls import path +from django.urls import path, re_path from .views import * @@ -9,6 +9,7 @@ urlpatterns = [ path('song/update/<int:id>/', update_song, name='update_song'), path('song/delete/<int:id>/', delete_song, name='delete_song'), path('song/mark/', create_update_song_mark, name='create_update_song_mark'), + path('song/wish/<int:id>/', wish_song, name='wish_song'), path('song/<int:song_id>/mark/list/', retrieve_song_mark_list, name='retrieve_song_mark_list'), path('song/mark/delete/<int:id>/', delete_song_mark, name='delete_song_mark'), @@ -16,18 +17,18 @@ urlpatterns = [ path('song/review/update/<int:id>/', update_song_review, name='update_song_review'), path('song/review/delete/<int:id>/', delete_song_review, name='delete_song_review'), path('song/review/<int:id>/', retrieve_song_review, name='retrieve_song_review'), - path('song/<int:song_id>/review/list/', - retrieve_song_review_list, name='retrieve_song_review_list'), + re_path('song/(?P<song_id>[0-9]+)/mark/list/(?:(?P<following_only>\\d+))?', retrieve_song_mark_list, name='retrieve_song_mark_list'), # path('song/scrape/', scrape_song, name='scrape_song'), path('song/click_to_scrape/', click_to_scrape_song, name='click_to_scrape_song'), - + path('album/create/', create_album, name='create_album'), path('album/<int:id>/', retrieve_album, name='retrieve_album'), path('album/update/<int:id>/', update_album, name='update_album'), path('album/delete/<int:id>/', delete_album, name='delete_album'), + path('rescrape/<int:id>/', rescrape, name='rescrape'), path('album/mark/', create_update_album_mark, name='create_update_album_mark'), - path('album/<int:album_id>/mark/list/', - retrieve_album_mark_list, name='retrieve_album_mark_list'), + path('album/wish/<int:id>/', wish_album, name='wish_album'), + re_path('album/(?P<album_id>[0-9]+)/mark/list/(?:(?P<following_only>\\d+))?', retrieve_album_mark_list, name='retrieve_album_mark_list'), path('album/mark/delete/<int:id>/', delete_album_mark, name='delete_album_mark'), path('album/<int:album_id>/review/create/', create_album_review, name='create_album_review'), path('album/review/update/<int:id>/', update_album_review, name='update_album_review'), diff --git a/music/views.py b/music/views.py index 993dae35..af37c2f2 100644 --- a/music/views.py +++ b/music/views.py @@ -1,24 +1,24 @@ -# from boofilsic.settings import MASTODON_TAGS from .forms import * from .models import * from common.models import SourceSiteEnum -from common.views import PAGE_LINK_NUMBER, jump_or_scrape +from common.views import PAGE_LINK_NUMBER, jump_or_scrape, go_relogin from common.utils import PageLinksGenerator -from mastodon.utils import rating_to_emoji -from mastodon.api import check_visibility, post_toot, TootVisibilityEnum +from mastodon.models import MastodonApplication +from mastodon.api import share_mark, share_review from mastodon import mastodon_request_included from django.core.paginator import Paginator from django.utils import timezone from django.db.models import Count from django.db import IntegrityError, transaction from django.core.exceptions import ObjectDoesNotExist, PermissionDenied -from django.http import HttpResponseBadRequest, HttpResponseServerError +from django.http import HttpResponseBadRequest, HttpResponseServerError, HttpResponse from django.utils.translation import gettext_lazy as _ from django.contrib.auth.decorators import login_required, permission_required from django.shortcuts import render, get_object_or_404, redirect, reverse import logging from django.shortcuts import render - +from collection.models import CollectionItem +from common.scraper import get_scraper_by_url, get_normalized_url logger = logging.getLogger(__name__) @@ -100,6 +100,7 @@ def update_song(request, id): 'music/create_update_song.html', { 'form': form, + 'is_update': True, 'title': page_title, 'submit_url': reverse("music:update_song", args=[song.id]), # provided for frontend js @@ -129,6 +130,7 @@ def update_song(request, id): 'music/create_update_song.html', { 'form': form, + 'is_update': True, 'title': page_title, 'submit_url': reverse("music:update_song", args=[song.id]), # provided for frontend js @@ -187,6 +189,7 @@ def retrieve_song(request, id): else: mark_form = SongMarkForm(initial={ 'song': song, + 'visibility': request.user.get_preference().default_visibility if request.user.is_authenticated else 0, 'tags': mark_tags }) @@ -206,10 +209,8 @@ def retrieve_song(request, id): mark_list_more = None review_list_more = None else: - mark_list = SongMark.get_available( - song, request.user, request.session['oauth_token']) - review_list = SongReview.get_available( - song, request.user, request.session['oauth_token']) + mark_list = SongMark.get_available(song, request.user) + review_list = SongReview.get_available(song, request.user) mark_list_more = True if len(mark_list) > MARK_NUMBER else False mark_list = mark_list[:MARK_NUMBER] for m in mark_list: @@ -217,6 +218,7 @@ def retrieve_song(request, id): review_list_more = True if len( review_list) > REVIEW_NUMBER else False review_list = review_list[:REVIEW_NUMBER] + collection_list = filter(lambda c: c.is_visible_to(request.user), map(lambda i: i.collection, CollectionItem.objects.filter(song=song))) # def strip_html_tags(text): # import re @@ -241,6 +243,7 @@ def retrieve_song(request, id): 'review_list_more': review_list_more, 'song_tag_list': song_tag_list, 'mark_tags': mark_tags, + 'collection_list': collection_list, } ) else: @@ -285,12 +288,19 @@ def create_update_song_mark(request): pk = request.POST.get('id') old_rating = None old_tags = None + if not pk: + song_id = request.POST.get('song') + mark = SongMark.objects.filter(song_id=song_id, owner=request.user).first() + if mark: + pk = mark.id if pk: mark = get_object_or_404(SongMark, pk=pk) if request.user != mark.owner: return HttpResponseBadRequest() old_rating = mark.rating old_tags = mark.songmark_tags.all() + if mark.status != request.POST.get('status'): + mark.created_time = timezone.now() # update form = SongMarkForm(request.POST, instance=mark) else: @@ -298,7 +308,7 @@ def create_update_song_mark(request): form = SongMarkForm(request.POST) if form.is_valid(): - if form.instance.status == MarkStatusEnum.WISH.value: + if form.instance.status == MarkStatusEnum.WISH.value or form.instance.rating == 0: form.instance.rating = None form.cleaned_data['rating'] = None form.instance.owner = request.user @@ -326,28 +336,10 @@ def create_update_song_mark(request): return HttpResponseServerError("integrity error") if form.cleaned_data['share_to_mastodon']: - if form.cleaned_data['is_private']: - visibility = TootVisibilityEnum.PRIVATE - else: - visibility = TootVisibilityEnum.UNLISTED - url = "https://" + request.get_host() + reverse("music:retrieve_song", - args=[song.id]) - words = MusicMarkStatusTranslator(form.cleaned_data['status']) +\ - f"《{song.title}》" + \ - rating_to_emoji(form.cleaned_data['rating']) - - # tags = MASTODON_TAGS % {'category': '书', 'type': '标记'} - tags = '' - content = words + '\n' + url + '\n' + \ - form.cleaned_data['text'] + '\n' + tags - response = post_toot(request.user.mastodon_site, content, visibility, - request.session['oauth_token']) - if response.status_code != 200: - mastodon_logger.error( - f"CODE:{response.status_code} {response.text}") - return HttpResponseServerError("publishing mastodon status failed") + if not share_mark(form.instance): + return go_relogin(request) else: - return HttpResponseBadRequest("invalid form data") + return HttpResponseBadRequest(f"invalid form data {form.errors}") return redirect(reverse("music:retrieve_song", args=[form.instance.song.id])) else: @@ -356,11 +348,30 @@ def create_update_song_mark(request): @mastodon_request_included @login_required -def retrieve_song_mark_list(request, song_id): +def wish_song(request, id): + if request.method == 'POST': + song = get_object_or_404(Song, pk=id) + params = { + 'owner': request.user, + 'status': MarkStatusEnum.WISH, + 'visibility': 0, + 'song': song, + } + try: + SongMark.objects.create(**params) + except Exception: + pass + return HttpResponse("✔️") + else: + return HttpResponseBadRequest("invalid method") + + +@mastodon_request_included +@login_required +def retrieve_song_mark_list(request, song_id, following_only=False): if request.method == 'GET': song = get_object_or_404(Song, pk=song_id) - queryset = SongMark.get_available( - song, request.user, request.session['oauth_token']) + queryset = SongMark.get_available(song, request.user, following_only=following_only) paginator = Paginator(queryset, MARK_PER_PAGE) page_number = request.GET.get('page', default=1) marks = paginator.get_page(page_number) @@ -421,23 +432,8 @@ def create_song_review(request, song_id): form.instance.owner = request.user form.save() if form.cleaned_data['share_to_mastodon']: - if form.cleaned_data['is_private']: - visibility = TootVisibilityEnum.PRIVATE - else: - visibility = TootVisibilityEnum.UNLISTED - url = "https://" + request.get_host() + reverse("music:retrieve_song_review", - args=[form.instance.id]) - words = "发布了关于" + f"《{form.instance.song.title}》" + "的评论" - # tags = MASTODON_TAGS % {'category': '书', 'type': '评论'} - tags = '' - content = words + '\n' + url + \ - '\n' + form.cleaned_data['title'] + '\n' + tags - response = post_toot(request.user.mastodon_site, content, visibility, - request.session['oauth_token']) - if response.status_code != 200: - mastodon_logger.error( - f"CODE:{response.status_code} {response.text}") - return HttpResponseServerError("publishing mastodon status failed") + if not share_review(form.instance): + return go_relogin(request) return redirect(reverse("music:retrieve_song_review", args=[form.instance.id])) else: return HttpResponseBadRequest() @@ -473,23 +469,8 @@ def update_song_review(request, id): form.instance.edited_time = timezone.now() form.save() if form.cleaned_data['share_to_mastodon']: - if form.cleaned_data['is_private']: - visibility = TootVisibilityEnum.PRIVATE - else: - visibility = TootVisibilityEnum.UNLISTED - url = "https://" + request.get_host() + reverse("music:retrieve_song_review", - args=[form.instance.id]) - words = "发布了关于" + f"《{form.instance.song.title}》" + "的评论" - # tags = MASTODON_TAGS % {'category': '书', 'type': '评论'} - tags = '' - content = words + '\n' + url + \ - '\n' + form.cleaned_data['title'] + '\n' + tags - response = post_toot(request.user.mastodon_site, content, visibility, - request.session['oauth_token']) - if response.status_code != 200: - mastodon_logger.error( - f"CODE:{response.status_code} {response.text}") - return HttpResponseServerError("publishing mastodon status failed") + if not share_review(form.instance): + return go_relogin(request) return redirect(reverse("music:retrieve_song_review", args=[form.instance.id])) else: return HttpResponseBadRequest() @@ -524,11 +505,10 @@ def delete_song_review(request, id): @mastodon_request_included -@login_required def retrieve_song_review(request, id): if request.method == 'GET': review = get_object_or_404(SongReview, pk=id) - if not check_visibility(review, request.session['oauth_token'], request.user): + if not review.is_visible_to(request.user): msg = _("你没有访问这个页面的权限😥") return render( request, @@ -563,8 +543,7 @@ def retrieve_song_review(request, id): def retrieve_song_review_list(request, song_id): if request.method == 'GET': song = get_object_or_404(Song, pk=song_id) - queryset = SongReview.get_available( - song, request.user, request.session['oauth_token']) + queryset = SongReview.get_available(song, request.user) paginator = Paginator(queryset, REVIEW_PER_PAGE) page_number = request.GET.get('page', default=1) reviews = paginator.get_page(page_number) @@ -661,6 +640,18 @@ def create_album(request): return HttpResponseBadRequest() +@login_required +def rescrape(request, id): + if request.method != 'POST': + return HttpResponseBadRequest() + item = get_object_or_404(Album, pk=id) + url = get_normalized_url(item.source_url) + scraper = get_scraper_by_url(url) + scraper.scrape(url) + form = scraper.save(request_user=request.user, instance=item) + return redirect(reverse("music:retrieve_album", args=[form.instance.id])) + + @login_required def update_album(request, id): if request.method == 'GET': @@ -672,6 +663,7 @@ def update_album(request, id): 'music/create_update_album.html', { 'form': form, + 'is_update': True, 'title': page_title, 'submit_url': reverse("music:update_album", args=[album.id]), # provided for frontend js @@ -701,6 +693,7 @@ def update_album(request, id): 'music/create_update_album.html', { 'form': form, + 'is_update': True, 'title': page_title, 'submit_url': reverse("music:update_album", args=[album.id]), # provided for frontend js @@ -758,6 +751,7 @@ def retrieve_album(request, id): else: mark_form = AlbumMarkForm(initial={ 'album': album, + 'visibility': request.user.get_preference().default_visibility if request.user.is_authenticated else 0, 'tags': mark_tags }) @@ -777,10 +771,8 @@ def retrieve_album(request, id): mark_list_more = None review_list_more = None else: - mark_list = AlbumMark.get_available( - album, request.user, request.session['oauth_token']) - review_list = AlbumReview.get_available( - album, request.user, request.session['oauth_token']) + mark_list = AlbumMark.get_available(album, request.user) + review_list = AlbumReview.get_available(album, request.user) mark_list_more = True if len(mark_list) > MARK_NUMBER else False mark_list = mark_list[:MARK_NUMBER] for m in mark_list: @@ -788,6 +780,7 @@ def retrieve_album(request, id): review_list_more = True if len( review_list) > REVIEW_NUMBER else False review_list = review_list[:REVIEW_NUMBER] + collection_list = filter(lambda c: c.is_visible_to(request.user), map(lambda i: i.collection, CollectionItem.objects.filter(album=album))) # def strip_html_tags(text): # import re @@ -812,6 +805,7 @@ def retrieve_album(request, id): 'review_list_more': review_list_more, 'album_tag_list': album_tag_list, 'mark_tags': mark_tags, + 'collection_list': collection_list, } ) else: @@ -856,12 +850,19 @@ def create_update_album_mark(request): pk = request.POST.get('id') old_rating = None old_tags = None + if not pk: + album_id = request.POST.get('album') + mark = AlbumMark.objects.filter(album_id=album_id, owner=request.user).first() + if mark: + pk = mark.id if pk: mark = get_object_or_404(AlbumMark, pk=pk) if request.user != mark.owner: return HttpResponseBadRequest() old_rating = mark.rating old_tags = mark.albummark_tags.all() + if mark.status != request.POST.get('status'): + mark.created_time = timezone.now() # update form = AlbumMarkForm(request.POST, instance=mark) else: @@ -869,7 +870,7 @@ def create_update_album_mark(request): form = AlbumMarkForm(request.POST) if form.is_valid(): - if form.instance.status == MarkStatusEnum.WISH.value: + if form.instance.status == MarkStatusEnum.WISH.value or form.instance.rating == 0: form.instance.rating = None form.cleaned_data['rating'] = None form.instance.owner = request.user @@ -897,28 +898,10 @@ def create_update_album_mark(request): return HttpResponseServerError("integrity error") if form.cleaned_data['share_to_mastodon']: - if form.cleaned_data['is_private']: - visibility = TootVisibilityEnum.PRIVATE - else: - visibility = TootVisibilityEnum.UNLISTED - url = "https://" + request.get_host() + reverse("music:retrieve_album", - args=[album.id]) - words = MusicMarkStatusTranslator(form.cleaned_data['status']) +\ - f"《{album.title}》" + \ - rating_to_emoji(form.cleaned_data['rating']) - - # tags = MASTODON_TAGS % {'category': '书', 'type': '标记'} - tags = '' - content = words + '\n' + url + '\n' + \ - form.cleaned_data['text'] + '\n' + tags - response = post_toot(request.user.mastodon_site, content, visibility, - request.session['oauth_token']) - if response.status_code != 200: - mastodon_logger.error( - f"CODE:{response.status_code} {response.text}") - return HttpResponseServerError("publishing mastodon status failed") + if not share_mark(form.instance): + return go_relogin(request) else: - return HttpResponseBadRequest("invalid form data") + return HttpResponseBadRequest(f"invalid form data {form.errors}") return redirect(reverse("music:retrieve_album", args=[form.instance.album.id])) else: @@ -927,11 +910,30 @@ def create_update_album_mark(request): @mastodon_request_included @login_required -def retrieve_album_mark_list(request, album_id): +def wish_album(request, id): + if request.method == 'POST': + album = get_object_or_404(Album, pk=id) + params = { + 'owner': request.user, + 'status': MarkStatusEnum.WISH, + 'visibility': 0, + 'album': album, + } + try: + AlbumMark.objects.create(**params) + except Exception: + pass + return HttpResponse("✔️") + else: + return HttpResponseBadRequest("invalid method") + + +@mastodon_request_included +@login_required +def retrieve_album_mark_list(request, album_id, following_only=False): if request.method == 'GET': album = get_object_or_404(Album, pk=album_id) - queryset = AlbumMark.get_available( - album, request.user, request.session['oauth_token']) + queryset = AlbumMark.get_available(album, request.user, following_only=following_only) paginator = Paginator(queryset, MARK_PER_PAGE) page_number = request.GET.get('page', default=1) marks = paginator.get_page(page_number) @@ -992,23 +994,8 @@ def create_album_review(request, album_id): form.instance.owner = request.user form.save() if form.cleaned_data['share_to_mastodon']: - if form.cleaned_data['is_private']: - visibility = TootVisibilityEnum.PRIVATE - else: - visibility = TootVisibilityEnum.UNLISTED - url = "https://" + request.get_host() + reverse("music:retrieve_album_review", - args=[form.instance.id]) - words = "发布了关于" + f"《{form.instance.album.title}》" + "的评论" - # tags = MASTODON_TAGS % {'category': '书', 'type': '评论'} - tags = '' - content = words + '\n' + url + \ - '\n' + form.cleaned_data['title'] + '\n' + tags - response = post_toot(request.user.mastodon_site, content, visibility, - request.session['oauth_token']) - if response.status_code != 200: - mastodon_logger.error( - f"CODE:{response.status_code} {response.text}") - return HttpResponseServerError("publishing mastodon status failed") + if not share_review(form.instance): + return go_relogin(request) return redirect(reverse("music:retrieve_album_review", args=[form.instance.id])) else: return HttpResponseBadRequest() @@ -1044,23 +1031,8 @@ def update_album_review(request, id): form.instance.edited_time = timezone.now() form.save() if form.cleaned_data['share_to_mastodon']: - if form.cleaned_data['is_private']: - visibility = TootVisibilityEnum.PRIVATE - else: - visibility = TootVisibilityEnum.UNLISTED - url = "https://" + request.get_host() + reverse("music:retrieve_album_review", - args=[form.instance.id]) - words = "发布了关于" + f"《{form.instance.album.title}》" + "的评论" - # tags = MASTODON_TAGS % {'category': '书', 'type': '评论'} - tags = '' - content = words + '\n' + url + \ - '\n' + form.cleaned_data['title'] + '\n' + tags - response = post_toot(request.user.mastodon_site, content, visibility, - request.session['oauth_token']) - if response.status_code != 200: - mastodon_logger.error( - f"CODE:{response.status_code} {response.text}") - return HttpResponseServerError("publishing mastodon status failed") + if not share_review(form.instance): + return go_relogin(request) return redirect(reverse("music:retrieve_album_review", args=[form.instance.id])) else: return HttpResponseBadRequest() @@ -1095,11 +1067,10 @@ def delete_album_review(request, id): @mastodon_request_included -@login_required def retrieve_album_review(request, id): if request.method == 'GET': review = get_object_or_404(AlbumReview, pk=id) - if not check_visibility(review, request.session['oauth_token'], request.user): + if not review.is_visible_to(request.user): msg = _("你没有访问这个页面的权限😥") return render( request, @@ -1134,8 +1105,7 @@ def retrieve_album_review(request, id): def retrieve_album_review_list(request, album_id): if request.method == 'GET': album = get_object_or_404(Album, pk=album_id) - queryset = AlbumReview.get_available( - album, request.user, request.session['oauth_token']) + queryset = AlbumReview.get_available(album, request.user) paginator = Paginator(queryset, REVIEW_PER_PAGE) page_number = request.GET.get('page', default=1) reviews = paginator.get_page(page_number) diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 00000000..d3177ab4 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,26 @@ +dateparser +django~=3.2.14 +django-hstore +django-markdownx @ git+https://github.com/alphatownsman/django-markdownx.git@e69480c64ad9c5d0499f4a8625da78cf2bb7691b +django-sass +django-rq +django-simple-history +django-hijack +django-user-messages +django-slack +meilisearch +easy-thumbnails +lxml +openpyxl +psycopg2 +requests +filetype +setproctitle +tqdm +opencc +dnspython +typesense +markdownify +sentry-sdk +gitpython +igdb-api-v4 diff --git a/sync/apps.py b/sync/apps.py index 6e952eab..84c86ccf 100644 --- a/sync/apps.py +++ b/sync/apps.py @@ -1,9 +1,6 @@ from django.apps import AppConfig +from django.conf import settings class SyncConfig(AppConfig): name = 'sync' - - def ready(self): - from sync.jobs import sync_task_manager - sync_task_manager.start() \ No newline at end of file diff --git a/sync/jobs.py b/sync/jobs.py index 0b43c902..e2ce4759 100644 --- a/sync/jobs.py +++ b/sync/jobs.py @@ -1,12 +1,8 @@ import logging import pytz -import signal -import sys -import queue -import threading -import time from dataclasses import dataclass from datetime import datetime +from django.conf import settings from django.utils import timezone from django.core.exceptions import ObjectDoesNotExist from openpyxl import load_workbook @@ -18,69 +14,17 @@ from common.scraper import DoubanAlbumScraper, DoubanBookScraper, DoubanGameScra from common.models import MarkStatusEnum from .models import SyncTask -__all__ = ['sync_task_manager'] logger = logging.getLogger(__name__) -class SyncTaskManger: +def __import_should_stop(): + # TODO: using queue.connection.set(job.key + b':should_stop', 1, ex=30) on the caller side and connection.get(job.key + b':should_stop') on the worker side. + pass - # in seconds - __CHECK_NEW_TASK_TIME_INTERVAL = 0.05 - MAX_WORKERS = 256 - def __init__(self): - self.__task_queue = queue.Queue(0) - self.__stop_event = threading.Event() - self.__worker_threads = [] - - def __listen_for_new_task(self): - while not self.__stop_event.is_set(): - time.sleep(self.__CHECK_NEW_TASK_TIME_INTERVAL) - while not self.__task_queue.empty() and not self.is_full(): - task = self.__task_queue.get_nowait() - self.__start_new_worker(task) - - def __start_new_worker(self, task): - new_worker = threading.Thread( - target=sync_doufen_job, args=[task, self.is_stopped], daemon=True - ) - self.__worker_threads.append(new_worker) - new_worker.start() - - def __enqueue_existing_tasks(self): - for task in SyncTask.objects.filter(is_finished=False): - self.__task_queue.put_nowait(task) - - def is_full(self): - return len(self.__worker_threads) >= self.MAX_WORKERS - - def add_task(self, task): - self.__task_queue.put_nowait(task) - - def stop(self, signum, frame): - print('rceived signal ', signum) - logger.info(f'rceived signal {signum}') - - self.__stop_event.set() - # for worker_thread in self.__worker_threads: - # worker_thread.join() - - print("stopped") - logger.info(f'stopped') - - def is_stopped(self): - return self.__stop_event.is_set() - - def start(self): - self.__enqueue_existing_tasks() # enqueue - - listen_new_task_thread = threading.Thread( - target=self.__listen_for_new_task, daemon=True) - - self.__worker_threads.append(listen_new_task_thread) - - listen_new_task_thread.start() +def import_doufen_task(synctask): + sync_doufen_job(synctask, __import_should_stop) class DoufenParser: @@ -96,7 +40,7 @@ class DoufenParser: self.__file_path = task.file.path self.__progress_sheet, self.__progress_row = task.get_breakpoint() self.__is_new_task = True - if not self.__progress_sheet is None: + if self.__progress_sheet is not None: self.__is_new_task = False if self.__progress_row is None: self.__progress_row = 2 @@ -157,10 +101,14 @@ class DoufenParser: is_first_sheet = True for mapping in item_classes_mappings: + if mapping['sheet'] not in self.__wb: + print(f"Sheet not found: {mapping['sheet']}") + continue ws = self.__wb[mapping['sheet']] + max_row = ws.max_row # empty sheet - if ws.max_row <= 1: + if max_row <= 1: continue # decide starting position @@ -169,29 +117,26 @@ class DoufenParser: start_row_index = self.__progress_row # parse data - for i in range(start_row_index, ws.max_row + 1): - # url definitely exists - url = ws.cell(row=i, column=self.URL_INDEX).value - - tags = ws.cell(row=i, column=self.TAG_INDEX).value - tags = tags.split(',') if tags else None - - time = ws.cell(row=i, column=self.TIME_INDEX).value - if time: + tz = pytz.timezone('Asia/Shanghai') + i = start_row_index + for row in ws.iter_rows(min_row=start_row_index, max_row=max_row, values_only=True): + cells = [cell for cell in row] + url = cells[self.URL_INDEX - 1] + tags = cells[self.TAG_INDEX - 1] + tags = list(set(tags.lower().split(','))) if tags else None + time = cells[self.TIME_INDEX - 1] + if time and type(time) == str: time = datetime.strptime(time, "%Y-%m-%d %H:%M:%S") - tz = pytz.timezone('Asia/Shanghai') + time = time.replace(tzinfo=tz) + elif time and type(time) == datetime: time = time.replace(tzinfo=tz) else: time = None - - content = ws.cell(row=i, column=self.CONTENT_INDEX).value + content = cells[self.CONTENT_INDEX - 1] if not content: content = "" - - rating = ws.cell(row=i, column=self.RATING_INDEX).value + rating = cells[self.RATING_INDEX - 1] rating = int(rating) * 2 if rating else None - - # store result self.items.append({ 'data': DoufenRowData(url, tags, time, content, rating), 'entity_class': mapping['entity_class'], @@ -201,18 +146,20 @@ class DoufenParser: 'sheet': mapping['sheet'], 'row_index': i, }) + i = i + 1 # set first sheet flag is_first_sheet = False def __get_item_number(self): - assert not self.__wb is None, 'workbook not found' - assert not self.__mappings is None, 'mappings not found' + assert self.__wb is not None, 'workbook not found' + assert self.__mappings is not None, 'mappings not found' sheets = [mapping['sheet'] for mapping in self.__mappings] item_number = 0 for sheet in sheets: - item_number += self.__wb[sheet].max_row - 1 + if sheet in self.__wb: + item_number += self.__wb[sheet].max_row - 1 return item_number @@ -229,13 +176,12 @@ class DoufenParser: self.__update_total_items() self.__close_file() return self.items - except Exception as e: - logger.error(e) - raise e - + logger.error(f'Error parsing {self.__file_path} {e}') + self.task.is_failed = True finally: self.__close_file() + return [] @dataclass @@ -247,7 +193,7 @@ class DoufenRowData: rating: int -def add_new_mark(data, user, entity, entity_class, mark_class, tag_class, sheet, is_private): +def add_new_mark(data, user, entity, entity_class, mark_class, tag_class, sheet, default_public): params = { 'owner': user, 'created_time': data.time, @@ -255,7 +201,7 @@ def add_new_mark(data, user, entity, entity_class, mark_class, tag_class, sheet, 'rating': data.rating, 'text': data.content, 'status': translate_status(sheet), - 'is_private': not is_private, + 'visibility': 0 if default_public else 1, entity_class.__name__.lower(): entity, } mark = mark_class.objects.create(**params) @@ -267,12 +213,15 @@ def add_new_mark(data, user, entity, entity_class, mark_class, tag_class, sheet, entity_class.__name__.lower(): entity, 'mark': mark } - tag_class.objects.create(**params) + try: + tag_class.objects.create(**params) + except Exception as e: + logger.error(f'Error creating tag {tag} {mark}: {e}') def overwrite_mark(entity, entity_class, mark, mark_class, tag_class, data, sheet): old_rating = mark.rating - old_tags = getattr(mark, mark_class.__name__.lower()+'_tags').all() + old_tags = getattr(mark, mark_class.__name__.lower() + '_tags').all() # update mark logic mark.created_time = data.time mark.edited_time = data.time @@ -291,7 +240,10 @@ def overwrite_mark(entity, entity_class, mark, mark_class, tag_class, data, shee entity_class.__name__.lower(): entity, 'mark': mark } - tag_class.objects.create(**params) + try: + tag_class.objects.create(**params) + except Exception as e: + logger.error(f'Error creating tag {tag} {mark}: {e}') def sync_doufen_job(task, stop_check_func): @@ -302,6 +254,7 @@ def sync_doufen_job(task, stop_check_func): if task.is_finished: return + print(f'Task {task.pk}: loading') parser = DoufenParser(task) items = parser.parse() @@ -322,15 +275,17 @@ def sync_doufen_job(task, stop_check_func): # scrape the entity if not exists try: entity = entity_class.objects.get(source_url=data.url) + print(f'Task {task.pk}: {len(items)+1} remaining; matched {data.url}') except ObjectDoesNotExist: try: + print(f'Task {task.pk}: {len(items)+1} remaining; scraping {data.url}') scraper.scrape(data.url) form = scraper.save(request_user=task.user) entity = form.instance except Exception as e: - logger.error(f"Scrape Failed URL: {data.url}") - logger.error( - "Expections during scraping data:", exc_info=e) + logger.error(f"Task {task.pk}: scrape failed: {data.url} {e}") + if settings.DEBUG: + logger.error("Expections during scraping data:", exc_info=e) task.failed_urls.append(data.url) task.finished_items += 1 task.save(update_fields=['failed_urls', 'finished_items']) @@ -360,7 +315,7 @@ def sync_doufen_job(task, stop_check_func): except Exception as e: logger.error( - "Unknown exception when syncing marks", exc_info=e) + f"Task {task.pk}: error when syncing marks", exc_info=e) task.failed_urls.append(data.url) task.finished_items += 1 task.save(update_fields=['failed_urls', 'finished_items']) @@ -371,6 +326,7 @@ def sync_doufen_job(task, stop_check_func): task.save(update_fields=['success_items', 'finished_items']) # if task finish + print(f'Task {task.pk}: stopping') if len(items) == 0: task.is_finished = True task.clear_breakpoint() @@ -386,13 +342,3 @@ def translate_status(sheet_name): return MarkStatusEnum.COLLECT raise ValueError("Not valid status") - - -sync_task_manager = SyncTaskManger() - -# sync_task_manager.start() - -signal.signal(signal.SIGTERM, sync_task_manager.stop) -if sys.platform.startswith('linux'): - signal.signal(signal.SIGHUP, sync_task_manager.stop) -signal.signal(signal.SIGINT, sync_task_manager.stop) diff --git a/sync/management/commands/resync.py b/sync/management/commands/resync.py new file mode 100644 index 00000000..a5a0a73c --- /dev/null +++ b/sync/management/commands/resync.py @@ -0,0 +1,91 @@ +from django.core.management.base import BaseCommand +from common.scraper import get_scraper_by_url, get_normalized_url +import pprint +from sync.models import SyncTask +from users.models import User +from django.core.exceptions import ObjectDoesNotExist, PermissionDenied +from tqdm import tqdm +from django.conf import settings +import requests +import os + + +class Command(BaseCommand): + help = 'Re-scrape failed urls (via local proxy)' + + def add_arguments(self, parser): + parser.add_argument('action', type=str, help='list/download') + + def handle(self, *args, **options): + if options['action'] == 'list': + self.do_list() + else: + self.do_download() + + def do_list(self): + tasks = SyncTask.objects.filter(failed_urls__isnull=False) + urls = [] + for task in tqdm(tasks): + for url in task.failed_urls: + if url not in urls and url not in urls: + url = get_normalized_url(str(url)) + scraper = get_scraper_by_url(url) + if scraper is not None: + try: + url = scraper.get_effective_url(url) + entity = scraper.data_class.objects.get(source_url=url) + except ObjectDoesNotExist: + urls.append(url) + f = open("/tmp/resync_todo.txt", "w") + f.write("\n".join(urls)) + f.close() + + def do_download(self): + self.stdout.write(f'Checking local proxy...{settings.LOCAL_PROXY}') + url = f'{settings.LOCAL_PROXY}?url=https://www.douban.com/doumail/' + try: + r = requests.get(url, timeout=settings.SCRAPING_TIMEOUT) + except Exception as e: + self.stdout.write(self.style.ERROR(e)) + return + content = r.content.decode('utf-8') + if content.find('我的豆邮') == -1: + self.stdout.write(self.style.ERROR(f'Proxy check failed.')) + return + + self.stdout.write(f'Loading urls...') + with open("/tmp/resync_todo.txt") as file: + todos = file.readlines() + todos = [line.strip() for line in todos] + with open("/tmp/resync_success.txt") as file: + skips = file.readlines() + skips = [line.strip() for line in skips] + f_f = open("/tmp/resync_failed.txt", "a") + f_i = open("/tmp/resync_ignore.txt", "a") + f_s = open("/tmp/resync_success.txt", "a") + user = User.objects.get(id=1) + + for url in tqdm(todos): + scraper = get_scraper_by_url(url) + url = scraper.get_effective_url(url) + if url in skips: + self.stdout.write(f'Skip {url}') + elif scraper is None: + self.stdout.write(self.style.ERROR(f'Unable to find scraper for {url}')) + f_i.write(url + '\n') + else: + try: + entity = scraper.data_class.objects.get(source_url=url) + f_i.write(url + '\n') + except ObjectDoesNotExist: + try: + # self.stdout.write(f'Fetching {url} via {scraper.__name__}') + scraper.scrape(url) + form = scraper.save(request_user=user) + f_s.write(url + '\n') + f_s.flush() + os.fsync(f_s.fileno()) + self.stdout.write(self.style.SUCCESS(f'Saved {url}')) + except Exception as e: + f_f.write(url + '\n') + self.stdout.write(self.style.ERROR(f'Error {url}')) diff --git a/sync/models.py b/sync/models.py index e5264477..ae3993e7 100644 --- a/sync/models.py +++ b/sync/models.py @@ -1,13 +1,13 @@ from django.db import models -from django.utils.translation import ugettext_lazy as _ +from django.utils.translation import gettext_lazy as _ import django.contrib.postgres.fields as postgres from users.models import User -from boofilsic.settings import SYNC_FILE_PATH_ROOT from common.utils import GenerateDateUUIDMediaFilePath +from django.conf import settings def sync_file_path(instance, filename): - return GenerateDateUUIDMediaFilePath(instance, filename, SYNC_FILE_PATH_ROOT) + return GenerateDateUUIDMediaFilePath(instance, filename, settings.SYNC_FILE_PATH_ROOT) class SyncTask(models.Model): @@ -69,7 +69,7 @@ class SyncTask(models.Model): def __str__(self): """Unicode representation of SyncTask.""" - return str(self.user.username) + '@' + str(self.started_time) + self.get_status_emoji() + return f'{self.id} {self.user} {self.file} {self.get_status_emoji()} {self.success_items}/{self.finished_items}/{self.total_items}' def get_status_emoji(self): return ("❌" if self.is_failed else "✔") if self.is_finished else "⚡" diff --git a/sync/views.py b/sync/views.py index 69aa7de6..aecd96e8 100644 --- a/sync/views.py +++ b/sync/views.py @@ -3,7 +3,7 @@ from django.contrib.auth.decorators import login_required from django.http import HttpResponseBadRequest, JsonResponse, HttpResponse from .models import SyncTask from .forms import SyncTaskForm -from .jobs import sync_task_manager +from .jobs import import_doufen_task import tempfile import os from threading import Thread @@ -11,6 +11,7 @@ import openpyxl from django.utils.datastructures import MultiValueDictKeyError from openpyxl.utils.exceptions import InvalidFileException from zipfile import BadZipFile +import django_rq @login_required @@ -25,7 +26,7 @@ def sync_douban(request): wb = openpyxl.open(uploaded_file, read_only=True, data_only=True, keep_links=False) wb.close() - except (MultiValueDictKeyError, InvalidFileException, BadZipFile) as e : + except (MultiValueDictKeyError, InvalidFileException, BadZipFile) as e: # raise e return HttpResponseBadRequest(content="invalid excel file") @@ -35,8 +36,7 @@ def sync_douban(request): # stop all preivous task SyncTask.objects.filter(user=request.user, is_finished=False).update(is_finished=True) form.save() - sync_task_manager.add_task(form.instance) - + django_rq.get_queue('doufen').enqueue(import_doufen_task, form.instance, job_id=f'SyncTask_{form.instance.id}') return HttpResponse(status=204) else: return HttpResponseBadRequest() @@ -55,6 +55,7 @@ def query_progress(request): return JsonResponse() +@login_required def query_last_task(request): task = request.user.user_synctasks.order_by('-id').first() if task is not None: diff --git a/timeline/__init__.py b/timeline/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/timeline/admin.py b/timeline/admin.py new file mode 100644 index 00000000..8c38f3f3 --- /dev/null +++ b/timeline/admin.py @@ -0,0 +1,3 @@ +from django.contrib import admin + +# Register your models here. diff --git a/timeline/apps.py b/timeline/apps.py new file mode 100644 index 00000000..00df970d --- /dev/null +++ b/timeline/apps.py @@ -0,0 +1,15 @@ +from django.apps import AppConfig + + +class TimelineConfig(AppConfig): + name = 'timeline' + + def ready(self): + from .models import init_post_save_handler + from books.models import BookMark, BookReview + from movies.models import MovieMark, MovieReview + from games.models import GameMark, GameReview + from music.models import AlbumMark, AlbumReview, SongMark, SongReview + from collection.models import Collection, CollectionMark + for m in [BookMark, BookReview, MovieMark, MovieReview, GameMark, GameReview, AlbumMark, AlbumReview, SongMark, SongReview, Collection, CollectionMark]: + init_post_save_handler(m) diff --git a/timeline/management/commands/regen_activity.py b/timeline/management/commands/regen_activity.py new file mode 100644 index 00000000..4dbb702c --- /dev/null +++ b/timeline/management/commands/regen_activity.py @@ -0,0 +1,20 @@ +from django.core.management.base import BaseCommand +from users.models import User +from datetime import timedelta +from django.utils import timezone +from timeline.models import Activity +from books.models import BookMark, BookReview +from movies.models import MovieMark, MovieReview +from games.models import GameMark, GameReview +from music.models import AlbumMark, AlbumReview, SongMark, SongReview +from collection.models import Collection, CollectionMark +from tqdm import tqdm + + +class Command(BaseCommand): + help = 'Re-populating activity for timeline' + + def handle(self, *args, **options): + for cl in [BookMark, BookReview, MovieMark, MovieReview, GameMark, GameReview, AlbumMark, AlbumReview, SongMark, SongReview, Collection, CollectionMark]: + for a in tqdm(cl.objects.filter(created_time__gt='2022-1-1 00:00+0800'), desc=f'Populating {cl.__name__}'): + Activity.upsert_item(a) diff --git a/timeline/models.py b/timeline/models.py new file mode 100644 index 00000000..d7959b04 --- /dev/null +++ b/timeline/models.py @@ -0,0 +1,63 @@ +from django.db import models +from common.models import UserOwnedEntity +from books.models import BookMark, BookReview +from movies.models import MovieMark, MovieReview +from games.models import GameMark, GameReview +from music.models import AlbumMark, AlbumReview, SongMark, SongReview +from collection.models import Collection, CollectionMark +from django.db.models.signals import post_save, post_delete + + +class Activity(UserOwnedEntity): + bookmark = models.ForeignKey(BookMark, models.CASCADE, null=True) + bookreview = models.ForeignKey(BookReview, models.CASCADE, null=True) + moviemark = models.ForeignKey(MovieMark, models.CASCADE, null=True) + moviereview = models.ForeignKey(MovieReview, models.CASCADE, null=True) + gamemark = models.ForeignKey(GameMark, models.CASCADE, null=True) + gamereview = models.ForeignKey(GameReview, models.CASCADE, null=True) + albummark = models.ForeignKey(AlbumMark, models.CASCADE, null=True) + albumreview = models.ForeignKey(AlbumReview, models.CASCADE, null=True) + songmark = models.ForeignKey(SongMark, models.CASCADE, null=True) + songreview = models.ForeignKey(SongReview, models.CASCADE, null=True) + collection = models.ForeignKey(Collection, models.CASCADE, null=True) + collectionmark = models.ForeignKey(CollectionMark, models.CASCADE, null=True) + + @property + def target(self): + items = [self.bookmark, self.bookreview, self.moviemark, self.moviereview, self.gamemark, self.gamereview, + self.songmark, self.songreview, self.albummark, self.albumreview, self.collection, self.collectionmark] + return next((x for x in items if x is not None), None) + + @property + def mark(self): + items = [self.bookmark, self.moviemark, self.gamemark, self.songmark, self.albummark] + return next((x for x in items if x is not None), None) + + @property + def review(self): + items = [self.bookreview, self.moviereview, self.gamereview, self.songreview, self.albumreview] + return next((x for x in items if x is not None), None) + + @classmethod + def upsert_item(self, item): + attr = item.__class__.__name__.lower() + f = {'owner': item.owner, attr: item} + activity = Activity.objects.filter(**f).first() + if not activity: + activity = Activity.objects.create(**f) + activity.created_time = item.created_time + activity.visibility = item.visibility + activity.save() + + +def _post_save_handler(sender, instance, created, **kwargs): + Activity.upsert_item(instance) + + +# def activity_post_delete_handler(sender, instance, **kwargs): +# pass + + +def init_post_save_handler(model): + post_save.connect(_post_save_handler, sender=model) + # post_delete.connect(activity_post_delete_handler, sender=model) # delete handled by database diff --git a/timeline/templates/timeline.html b/timeline/templates/timeline.html new file mode 100644 index 00000000..17e89fd8 --- /dev/null +++ b/timeline/templates/timeline.html @@ -0,0 +1,83 @@ +{% load static %} +{% load i18n %} +{% load l10n %} +{% load admin_url %} +{% load mastodon %} +{% load oauth_token %} +{% load truncate %} +{% load thumb %} +<!DOCTYPE html> +<html lang="en"> + +<head> + <meta charset="UTF-8"> + <meta name="viewport" content="width=device-width, initial-scale=1.0"> + <title>{{ site_name }}</title> + + {% include "partial/_common_libs.html" with jquery=1 %} + + <script src="{% static 'lib/js/rating-star.js' %}"></script> + <script> + $(document).ready( function() { + let render = function() { + let ratingLabels = $(".rating-star"); + $(ratingLabels).each( function(index, value) { + let ratingScore = $(this).data("rating-score") / 2; + $(this).starRating({ + initialRating: ratingScore, + readOnly: true, + starSize: 16, + }); + }); + }; + document.body.addEventListener('htmx:load', function(evt) { + render(); + }); + render(); + }); + </script> + <script src="{% static 'js/mastodon.js' %}"></script> + <script src="{% static 'js/home.js' %}"></script> +</head> + +<body> + <div id="page-wrapper"> + <div id="content-wrapper"> + {% include "partial/_navbar.html" %} + + <section id="content" class="container"> + <div class="grid grid--reverse-order"> + <div class="grid__main grid__main--reverse-order"> + <div class="main-section-wrapper"> + <div class="entity-list"> + + <!-- <div class="set"> + <h5 class="entity-list__title"> + 我的时间轴 + </h5> + </div> --> + <ul class="entity-list__entities"> + <div hx-get="{% url 'timeline:data' %}" hx-trigger="revealed" hx-swap="outerHTML"></div> + </ul> + </div> + </div> + </div> + + {% include "partial/_sidebar.html" %} + </div> + </section> + </div> + {% include "partial/_footer.html" %} + </div> + + <script> + document.body.addEventListener('htmx:configRequest', (event) => { + event.detail.headers['X-CSRFToken'] = '{{ csrf_token }}'; + }) + </script> + +{% if unread_announcements %} +{% include "partial/_announcement.html" %} +{% endif %} +</body> +</html> diff --git a/timeline/templates/timeline_data.html b/timeline/templates/timeline_data.html new file mode 100644 index 00000000..5b014f39 --- /dev/null +++ b/timeline/templates/timeline_data.html @@ -0,0 +1,124 @@ +{% load static %} +{% load i18n %} +{% load l10n %} +{% load admin_url %} +{% load mastodon %} +{% load oauth_token %} +{% load truncate %} +{% load thumb %} +{% load neo %} + +{% for activity in activities %} +{% current_user_marked_item activity.target.item as marked %} +<li class="entity-list__entity"> + <div class="entity-list__entity-img-wrapper"> + <a href="{{ activity.target.item.url }}"> + <img src="{{ activity.target.item.cover|thumb:'normal' }}" alt="" class="entity-list__entity-img" style="min-width:80px;max-width:80px"> + </a> + {% if not marked %} + <a class="entity-list__entity-action-icon" hx-post="{{ activity.target.item.wish_url }}">➕</a> + {% endif %} + </div> + <div class="entity-list__entity-text"> + <div class="collection-item-position-edit"> + <span class="entity-marks__mark-time"> + {% if activity.target.shared_link %} + <a href="{{ activity.target.shared_link }}" target="_blank"> + <img src="{% static 'img/fediverse.svg' %}" style="filter: invert(93%) sepia(1%) saturate(53%) hue-rotate(314deg) brightness(95%) contrast(80%); vertical-align:text-top; max-width:14px; margin-right:6px;" /> + <span class="entity-marks__mark-time">{{ activity.target.created_time|prettydate }}</span></a> + {% else %} + <a><span class="entity-marks__mark-time">{{ activity.target.created_time|prettydate }}</span></a> + {% endif %} + </span> + </div> + <span class="entity-list__entity-info" style="top:0px;"> + <a href="{% url 'users:home' activity.owner.mastodon_username %}">{{ activity.owner.display_name }}</a> {{ activity.target.translated_status }} + </span> + <div class="entity-list__entity-title"> + <a href="{{ activity.target.item.url }}" class="entity-list__entity-link" style="font-weight:bold;">{{ activity.target.item.title }} + {% if activity.target.item.year %}<small style="font-weight: lighter">({{ activity.target.item.year }})</small>{% endif %} + </a> + {% if activity.target.item.source_url %} + <a href="{{ activity.target.item.source_url }}"> + <span class="source-label source-label__{{ activity.target.item.source_site }}" style="font-size:xx-small;">{{ activity.target.item.get_source_site_display }}</span> + </a> + {% endif %} + </div> + <p class="entity-list__entity-brief"> + {% if activity.review %} + <a href="{{ activity.review.url }}">{{ activity.review.title }}</a> + {% endif %} + {% if activity.mark %} + {% if activity.mark.rating %} + <span class="entity-marks__rating-star rating-star" data-rating-score="{{ activity.mark.rating | floatformat:"0" }}" style=""></span> + {% endif %} + + {% if activity.mark.text %} + <p class="entity-marks__mark-content">{{ activity.mark.text }}</p> + {% endif %} + {% endif %} + </p> + </div> +</li> +{% if forloop.last %} +<div class="htmx-indicator" style="margin-left: 60px;" + hx-get="{% url 'timeline:data' %}?last={{ activity.created_time|date:'Y-m-d H:i:s.uO'|urlencode }}" + hx-trigger="revealed" + hx-swap="outerHTML"> +<svg width="16" height="16" viewBox="0 0 135 140" xmlns="http://www.w3.org/2000/svg" fill="#ccc"> + <rect y="10" width="15" height="120" rx="6"> + <animate attributeName="height" + begin="0.5s" dur="1s" + values="120;110;100;90;80;70;60;50;40;140;120" calcMode="linear" + repeatCount="indefinite" /> + <animate attributeName="y" + begin="0.5s" dur="1s" + values="10;15;20;25;30;35;40;45;50;0;10" calcMode="linear" + repeatCount="indefinite" /> + </rect> + <rect x="30" y="10" width="15" height="120" rx="6"> + <animate attributeName="height" + begin="0.25s" dur="1s" + values="120;110;100;90;80;70;60;50;40;140;120" calcMode="linear" + repeatCount="indefinite" /> + <animate attributeName="y" + begin="0.25s" dur="1s" + values="10;15;20;25;30;35;40;45;50;0;10" calcMode="linear" + repeatCount="indefinite" /> + </rect> + <rect x="60" width="15" height="140" rx="6"> + <animate attributeName="height" + begin="0s" dur="1s" + values="120;110;100;90;80;70;60;50;40;140;120" calcMode="linear" + repeatCount="indefinite" /> + <animate attributeName="y" + begin="0s" dur="1s" + values="10;15;20;25;30;35;40;45;50;0;10" calcMode="linear" + repeatCount="indefinite" /> + </rect> + <rect x="90" y="10" width="15" height="120" rx="6"> + <animate attributeName="height" + begin="0.25s" dur="1s" + values="120;110;100;90;80;70;60;50;40;140;120" calcMode="linear" + repeatCount="indefinite" /> + <animate attributeName="y" + begin="0.25s" dur="1s" + values="10;15;20;25;30;35;40;45;50;0;10" calcMode="linear" + repeatCount="indefinite" /> + </rect> + <rect x="120" y="10" width="15" height="120" rx="6"> + <animate attributeName="height" + begin="0.5s" dur="1s" + values="120;110;100;90;80;70;60;50;40;140;120" calcMode="linear" + repeatCount="indefinite" /> + <animate attributeName="y" + begin="0.5s" dur="1s" + values="10;15;20;25;30;35;40;45;50;0;10" calcMode="linear" + repeatCount="indefinite" /> + </rect> +</svg> +</div> +{% endif %} +{% empty %} +<div>{% trans '目前没有更多内容了' %}</div> +{% endfor %} \ No newline at end of file diff --git a/timeline/tests.py b/timeline/tests.py new file mode 100644 index 00000000..7ce503c2 --- /dev/null +++ b/timeline/tests.py @@ -0,0 +1,3 @@ +from django.test import TestCase + +# Create your tests here. diff --git a/timeline/urls.py b/timeline/urls.py new file mode 100644 index 00000000..b501f1e8 --- /dev/null +++ b/timeline/urls.py @@ -0,0 +1,9 @@ +from django.urls import path, re_path +from .views import * + + +app_name = 'timeline' +urlpatterns = [ + path('', timeline, name='timeline'), + path('data', data, name='data'), +] diff --git a/timeline/views.py b/timeline/views.py new file mode 100644 index 00000000..0d9142e0 --- /dev/null +++ b/timeline/views.py @@ -0,0 +1,71 @@ +import logging +from django.shortcuts import render, get_object_or_404, redirect, reverse +from django.contrib.auth.decorators import login_required, permission_required +from django.utils.translation import gettext_lazy as _ +from django.http import HttpResponseBadRequest, HttpResponseServerError +from django.core.exceptions import ObjectDoesNotExist, PermissionDenied +from django.db import IntegrityError, transaction +from django.db.models import Count +from django.utils import timezone +from django.core.paginator import Paginator +from mastodon import mastodon_request_included +from mastodon.models import MastodonApplication +from mastodon.api import post_toot, TootVisibilityEnum +from common.utils import PageLinksGenerator +from .models import * +from books.models import BookTag +from movies.models import MovieTag +from games.models import GameTag +from music.models import AlbumTag +from django.conf import settings +import re +from users.models import User +from django.http import HttpResponseRedirect +from django.db.models import Q +import time +from management.models import Announcement + + +logger = logging.getLogger(__name__) +mastodon_logger = logging.getLogger("django.mastodon") +PAGE_SIZE = 20 + + +@login_required +def timeline(request): + if request.method != 'GET': + return + user = request.user + unread = Announcement.objects.filter(pk__gt=user.read_announcement_index).order_by('-pk') + if unread: + user.read_announcement_index = Announcement.objects.latest('pk').pk + user.save(update_fields=['read_announcement_index']) + return render( + request, + 'timeline.html', + { + 'book_tags': BookTag.all_by_user(user)[:10], + 'movie_tags': MovieTag.all_by_user(user)[:10], + 'music_tags': AlbumTag.all_by_user(user)[:10], + 'game_tags': GameTag.all_by_user(user)[:10], + 'unread_announcements': unread, + } + ) + + +@login_required +def data(request): + if request.method != 'GET': + return + q = Q(owner_id__in=request.user.following, visibility__lt=2) | Q(owner_id=request.user.id) + last = request.GET.get('last') + if last: + q = q & Q(created_time__lt=last) + activities = Activity.objects.filter(q).order_by('-created_time')[:PAGE_SIZE] + return render( + request, + 'timeline_data.html', + { + 'activities': activities, + } + ) diff --git a/users/account.py b/users/account.py new file mode 100644 index 00000000..6bc38e71 --- /dev/null +++ b/users/account.py @@ -0,0 +1,255 @@ +from django.shortcuts import reverse, redirect, render, get_object_or_404 +from django.http import HttpResponseBadRequest, HttpResponse +from django.contrib.auth.decorators import login_required +from django.contrib import auth +from django.contrib.auth import authenticate +from django.core.paginator import Paginator +from django.utils.translation import gettext_lazy as _ +from django.core.exceptions import ObjectDoesNotExist +from django.db.models import Count +from .models import User, Report, Preference +from .forms import ReportForm +from mastodon.api import * +from mastodon import mastodon_request_included +from common.config import * +from common.models import MarkStatusEnum +from common.utils import PageLinksGenerator +from management.models import Announcement +from books.models import * +from movies.models import * +from music.models import * +from games.models import * +from books.forms import BookMarkStatusTranslator +from movies.forms import MovieMarkStatusTranslator +from music.forms import MusicMarkStatusTranslator +from games.forms import GameMarkStatusTranslator +from mastodon.models import MastodonApplication +from mastodon.api import verify_account +from django.conf import settings +from urllib.parse import quote +import django_rq +from .account import * +from .tasks import * +from datetime import timedelta +from django.utils import timezone +import json +from django.contrib import messages +from books.models import BookMark, BookReview +from movies.models import MovieMark, MovieReview +from games.models import GameMark, GameReview +from music.models import AlbumMark, SongMark, AlbumReview, SongReview +from collection.models import Collection, CollectionMark +from common.importers.goodreads import GoodreadsImporter +from common.importers.douban import DoubanImporter + + +# the 'login' page that user can see +def login(request): + if request.method == 'GET': + selected_site = request.GET.get('site', default='') + + sites = MastodonApplication.objects.all().order_by("domain_name") + + # store redirect url in the cookie + if request.GET.get('next'): + request.session['next_url'] = request.GET.get('next') + + return render( + request, + 'users/login.html', + { + 'sites': sites, + 'scope': quote(settings.MASTODON_CLIENT_SCOPE), + 'selected_site': selected_site, + 'allow_any_site': settings.MASTODON_ALLOW_ANY_SITE, + } + ) + else: + return HttpResponseBadRequest() + + +# connect will redirect to mastodon server +def connect(request): + login_domain = request.session['swap_domain'] if request.session.get('swap_login') else request.GET.get('domain') + if not login_domain: + return render(request, 'common/error.html', {'msg': '未指定实例域名', 'secondary_msg': "", }) + login_domain = login_domain.strip().lower().split('//')[-1].split('/')[0].split('@')[-1] + domain, version = get_instance_info(login_domain) + app, error_msg = get_mastodon_application(domain) + if app is None: + return render(request, 'common/error.html', {'msg': error_msg, 'secondary_msg': "", }) + else: + login_url = get_mastodon_login_url(app, login_domain, version, request) + resp = redirect(login_url) + resp.set_cookie('mastodon_domain', domain) + return resp + + +# mastodon server redirect back to here +@mastodon_request_included +def OAuth2_login(request): + if request.method != 'GET': + return HttpResponseBadRequest() + + code = request.GET.get('code') + site = request.COOKIES.get('mastodon_domain') + try: + token, refresh_token = obtain_token(site, request, code) + except ObjectDoesNotExist: + return HttpResponseBadRequest("Mastodon site not registered") + if not token: + return render( + request, + 'common/error.html', + { + 'msg': _("认证失败😫") + } + ) + + if request.session.get('swap_login', False) and request.user.is_authenticated: # swap login for existing user + return swap_login(request, token, site, refresh_token) + + user = authenticate(request, token=token, site=site) + if user: # existing user + user.mastodon_token = token + user.mastodon_refresh_token = refresh_token + user.save(update_fields=['mastodon_token', 'mastodon_refresh_token']) + auth_login(request, user) + if request.session.get('next_url') is not None: + response = redirect(request.session.get('next_url')) + del request.session['next_url'] + else: + response = redirect(reverse('common:home')) + return response + else: # newly registered user + code, user_data = verify_account(site, token) + if code != 200 or user_data is None: + return render( + request, + 'common/error.html', + { + 'msg': _("联邦网络访问失败😫") + } + ) + new_user = User( + username=user_data['username'], + mastodon_id=user_data['id'], + mastodon_site=site, + mastodon_token=token, + mastodon_refresh_token=refresh_token, + mastodon_account=user_data, + ) + new_user.save() + Preference.objects.create(user=new_user) + request.session['new_user'] = True + auth_login(request, new_user) + return redirect(reverse('users:register')) + + +@mastodon_request_included +@login_required +def logout(request): + if request.method == 'GET': + # revoke_token(request.user.mastodon_site, request.user.mastodon_token) + auth_logout(request) + return redirect(reverse("users:login")) + else: + return HttpResponseBadRequest() + + +@mastodon_request_included +@login_required +def reconnect(request): + if request.method == 'POST': + request.session['swap_login'] = True + request.session['swap_domain'] = request.POST['domain'] + return connect(request) + else: + return HttpResponseBadRequest() + + +@mastodon_request_included +def register(request): + if request.session.get('new_user'): + del request.session['new_user'] + return render(request, 'users/register.html') + else: + return redirect(reverse('common:home')) + + +def swap_login(request, token, site, refresh_token): + del request.session['swap_login'] + del request.session['swap_domain'] + code, data = verify_account(site, token) + current_user = request.user + if code == 200 and data is not None: + username = data['username'] + if username == current_user.username and site == current_user.mastodon_site: + messages.add_message(request, messages.ERROR, _(f'该身份 {username}@{site} 与当前账号相同。')) + else: + try: + existing_user = User.objects.get(username=username, mastodon_site=site) + messages.add_message(request, messages.ERROR, _(f'该身份 {username}@{site} 已被用于其它账号。')) + except ObjectDoesNotExist: + current_user.username = username + current_user.mastodon_id = data['id'] + current_user.mastodon_site = site + current_user.mastodon_token = token + current_user.mastodon_refresh_token = refresh_token + current_user.mastodon_account = data + current_user.save(update_fields=['username', 'mastodon_id', 'mastodon_site', 'mastodon_token', 'mastodon_refresh_token', 'mastodon_account']) + django_rq.get_queue('mastodon').enqueue(refresh_mastodon_data_task, current_user, token) + messages.add_message(request, messages.INFO, _(f'账号身份已更新为 {username}@{site}。')) + else: + messages.add_message(request, messages.ERROR, _('连接联邦网络获取身份信息失败。')) + return redirect(reverse('users:data')) + + +def auth_login(request, user): + """ Decorates django ``login()``. Attach token to session.""" + auth.login(request, user) + if user.mastodon_last_refresh < timezone.now() - timedelta(hours=1) or user.mastodon_account == {}: + django_rq.get_queue('mastodon').enqueue(refresh_mastodon_data_task, user) + + +def auth_logout(request): + """ Decorates django ``logout()``. Release token in session.""" + auth.logout(request) + + +@login_required +def clear_data(request): + if request.method == 'POST': + if request.POST.get('verification') == request.user.mastodon_username: + BookMark.objects.filter(owner=request.user).delete() + MovieMark.objects.filter(owner=request.user).delete() + GameMark.objects.filter(owner=request.user).delete() + AlbumMark.objects.filter(owner=request.user).delete() + SongMark.objects.filter(owner=request.user).delete() + BookReview.objects.filter(owner=request.user).delete() + MovieReview.objects.filter(owner=request.user).delete() + GameReview.objects.filter(owner=request.user).delete() + AlbumReview.objects.filter(owner=request.user).delete() + SongReview.objects.filter(owner=request.user).delete() + CollectionMark.objects.filter(owner=request.user).delete() + Collection.objects.filter(owner=request.user).delete() + request.user.first_name = request.user.username + request.user.last_name = request.user.mastodon_site + request.user.is_active = False + request.user.username = 'removed_' + str(request.user.id) + request.user.mastodon_id = 0 + request.user.mastodon_site = 'removed' + request.user.mastodon_token = '' + request.user.mastodon_locked = False + request.user.mastodon_followers = [] + request.user.mastodon_following = [] + request.user.mastodon_mutes = [] + request.user.mastodon_blocks = [] + request.user.mastodon_domain_blocks = [] + request.user.mastodon_account = {} + request.user.save() + auth_logout(request) + return redirect(reverse("users:login")) + else: + messages.add_message(request, messages.ERROR, _('验证信息不符。')) + return redirect(reverse("users:data")) diff --git a/users/data.py b/users/data.py new file mode 100644 index 00000000..505344c8 --- /dev/null +++ b/users/data.py @@ -0,0 +1,142 @@ +from django.shortcuts import reverse, redirect, render, get_object_or_404 +from django.http import HttpResponseBadRequest, HttpResponse +from django.contrib.auth.decorators import login_required +from django.contrib import auth +from django.contrib.auth import authenticate +from django.core.paginator import Paginator +from django.utils.translation import gettext_lazy as _ +from django.core.exceptions import ObjectDoesNotExist +from django.db.models import Count +from .models import User, Report, Preference +from .forms import ReportForm +from mastodon.api import * +from mastodon import mastodon_request_included +from common.config import * +from common.models import MarkStatusEnum +from common.utils import PageLinksGenerator +from management.models import Announcement +from books.models import * +from movies.models import * +from music.models import * +from games.models import * +from books.forms import BookMarkStatusTranslator +from movies.forms import MovieMarkStatusTranslator +from music.forms import MusicMarkStatusTranslator +from games.forms import GameMarkStatusTranslator +from mastodon.models import MastodonApplication +from mastodon.api import verify_account +from django.conf import settings +from urllib.parse import quote +import django_rq +from .account import * +from .tasks import * +from datetime import timedelta +from django.utils import timezone +import json +from django.contrib import messages +from books.models import BookMark, BookReview +from movies.models import MovieMark, MovieReview +from games.models import GameMark, GameReview +from music.models import AlbumMark, SongMark, AlbumReview, SongReview +from timeline.models import Activity +from collection.models import Collection +from common.importers.goodreads import GoodreadsImporter +from common.importers.douban import DoubanImporter + + +@mastodon_request_included +@login_required +def preferences(request): + preference = request.user.get_preference() + if request.method == 'POST': + preference.default_visibility = int(request.POST.get('default_visibility')) + preference.classic_homepage = bool(request.POST.get('classic_homepage')) + preference.mastodon_publish_public = bool(request.POST.get('mastodon_publish_public')) + preference.mastodon_append_tag = request.POST.get('mastodon_append_tag', '').strip() + preference.save(update_fields=['default_visibility', 'classic_homepage', 'mastodon_publish_public', 'mastodon_append_tag']) + return render(request, 'users/preferences.html') + + +@mastodon_request_included +@login_required +def data(request): + return render(request, 'users/data.html', { + 'allow_any_site': settings.MASTODON_ALLOW_ANY_SITE, + 'latest_task': request.user.user_synctasks.order_by("-id").first(), + 'import_status': request.user.get_preference().import_status, + 'export_status': request.user.get_preference().export_status + }) + + +@mastodon_request_included +@login_required +def export_reviews(request): + if request.method != 'POST': + return redirect(reverse("users:data")) + return render(request, 'users/data.html') + + +@mastodon_request_included +@login_required +def export_marks(request): + if request.method == 'POST': + if not request.user.preference.export_status.get('marks_pending'): + django_rq.get_queue('export').enqueue(export_marks_task, request.user) + request.user.preference.export_status['marks_pending'] = True + request.user.preference.save() + messages.add_message(request, messages.INFO, _('导出已开始。')) + return redirect(reverse("users:data")) + else: + try: + with open(request.user.preference.export_status['marks_file'], 'rb') as fh: + response = HttpResponse(fh.read(), content_type="application/vnd.ms-excel") + response['Content-Disposition'] = 'attachment;filename="marks.xlsx"' + return response + except Exception: + messages.add_message(request, messages.ERROR, _('导出文件已过期,请重新导出')) + return redirect(reverse("users:data")) + + +@login_required +def sync_mastodon(request): + if request.method == 'POST': + django_rq.get_queue('mastodon').enqueue(refresh_mastodon_data_task, request.user) + messages.add_message(request, messages.INFO, _('同步已开始。')) + return redirect(reverse("users:data")) + + +@login_required +def reset_visibility(request): + if request.method == 'POST': + visibility = int(request.POST.get('visibility')) + visibility = visibility if visibility >= 0 and visibility <= 2 else 0 + BookMark.objects.filter(owner=request.user).update(visibility=visibility) + MovieMark.objects.filter(owner=request.user).update(visibility=visibility) + GameMark.objects.filter(owner=request.user).update(visibility=visibility) + AlbumMark.objects.filter(owner=request.user).update(visibility=visibility) + SongMark.objects.filter(owner=request.user).update(visibility=visibility) + Activity.objects.filter(owner=request.user).update(visibility=visibility) + messages.add_message(request, messages.INFO, _('已重置。')) + return redirect(reverse("users:data")) + + +@login_required +def import_goodreads(request): + if request.method == 'POST': + raw_url = request.POST.get('url') + if GoodreadsImporter.import_from_url(raw_url, request.user): + messages.add_message(request, messages.INFO, _('链接已保存,等待后台导入。')) + else: + messages.add_message(request, messages.ERROR, _('无法识别链接。')) + return redirect(reverse("users:data")) + + +@login_required +def import_douban(request): + if request.method == 'POST': + importer = DoubanImporter(request.user, request.POST.get('visibility')) + if importer.import_from_file(request.FILES['file']): + messages.add_message(request, messages.INFO, _('文件上传成功,等待后台导入。')) + else: + messages.add_message(request, messages.ERROR, _('无法识别文件。')) + return redirect(reverse("users:data")) diff --git a/users/management/commands/backfill_mastodon.py b/users/management/commands/backfill_mastodon.py new file mode 100644 index 00000000..55f4dea6 --- /dev/null +++ b/users/management/commands/backfill_mastodon.py @@ -0,0 +1,21 @@ +from django.core.management.base import BaseCommand +from users.models import User +from django.contrib.sessions.models import Session + + +class Command(BaseCommand): + help = 'Backfill Mastodon data if missing' + + def handle(self, *args, **options): + for session in Session.objects.order_by('-expire_date'): + uid = session.get_decoded().get('_auth_user_id') + token = session.get_decoded().get('oauth_token') + if uid and token: + user = User.objects.get(pk=uid) + if user.mastodon_token: + print(f'skip {user}') + continue + user.mastodon_token = token + user.refresh_mastodon_data() + user.save() + print(f"Refreshed {user}") diff --git a/users/management/commands/disable_user.py b/users/management/commands/disable_user.py new file mode 100644 index 00000000..ac28b6a5 --- /dev/null +++ b/users/management/commands/disable_user.py @@ -0,0 +1,19 @@ +from django.core.management.base import BaseCommand +from users.models import User +from datetime import timedelta +from django.utils import timezone + + +class Command(BaseCommand): + help = 'disable user' + + def add_arguments(self, parser): + parser.add_argument('id', type=int, help='user id') + + def handle(self, *args, **options): + h = int(options['id']) + u = User.objects.get(id=h) + u.username = '(duplicated)'+u.username + u.is_active = False + u.save() + print(f'{u} updated') diff --git a/users/management/commands/refresh_following.py b/users/management/commands/refresh_following.py new file mode 100644 index 00000000..b29f4f0c --- /dev/null +++ b/users/management/commands/refresh_following.py @@ -0,0 +1,19 @@ +from django.core.management.base import BaseCommand +from users.models import User +from datetime import timedelta +from django.utils import timezone +from tqdm import tqdm + + +class Command(BaseCommand): + help = 'Refresh following data for all users' + + def handle(self, *args, **options): + count = 0 + for user in tqdm(User.objects.all()): + user.following = user.get_following_ids() + if user.following: + count += 1 + user.save(update_fields=['following']) + + print(f'{count} users updated') diff --git a/users/management/commands/refresh_mastodon.py b/users/management/commands/refresh_mastodon.py new file mode 100644 index 00000000..ff79d5fc --- /dev/null +++ b/users/management/commands/refresh_mastodon.py @@ -0,0 +1,25 @@ +from django.core.management.base import BaseCommand +from users.models import User +from datetime import timedelta +from django.utils import timezone +from tqdm import tqdm + + +class Command(BaseCommand): + help = 'Refresh Mastodon data for all users if not updated in last 24h' + + def handle(self, *args, **options): + count = 0 + for user in tqdm(User.objects.filter(mastodon_last_refresh__lt=timezone.now() - timedelta(hours=24), is_active=True)): + if user.mastodon_token or user.mastodon_refresh_token: + tqdm.write(f"Refreshing {user}") + if user.refresh_mastodon_data(): + tqdm.write(f"Refreshed {user}") + count += 1 + else: + tqdm.write(f"Refresh failed for {user}") + user.save() + else: + tqdm.write(f'Missing token for {user}') + + print(f'{count} users updated') diff --git a/users/models.py b/users/models.py index 7d35433f..2cc92e83 100644 --- a/users/models.py +++ b/users/models.py @@ -3,21 +3,40 @@ import django.contrib.postgres.fields as postgres from django.db import models from django.contrib.auth.models import AbstractUser from django.utils import timezone -from boofilsic.settings import REPORT_MEDIA_PATH_ROOT, DEFAULT_PASSWORD from django.core.serializers.json import DjangoJSONEncoder -from django.utils.translation import ugettext_lazy as _ +from django.utils.translation import gettext_lazy as _ from common.utils import GenerateDateUUIDMediaFilePath +from django.conf import settings +from mastodon.api import * def report_image_path(instance, filename): - return GenerateDateUUIDMediaFilePath(instance, filename, REPORT_MEDIA_PATH_ROOT) + return GenerateDateUUIDMediaFilePath(instance, filename, settings.REPORT_MEDIA_PATH_ROOT) class User(AbstractUser): - mastodon_id = models.IntegerField(blank=False) + if settings.MASTODON_ALLOW_ANY_SITE: + username = models.CharField( + _('username'), + max_length=150, + unique=False, + help_text=_('Required. 150 characters or fewer. Letters, digits and @/./+/-/_ only.'), + ) + following = models.JSONField(default=list) + mastodon_id = models.CharField(max_length=100, blank=False) # mastodon domain name, eg donotban.com mastodon_site = models.CharField(max_length=100, blank=False) - # store the latest read announcement id, + mastodon_token = models.CharField(max_length=2048, default='') + mastodon_refresh_token = models.CharField(max_length=2048, default='') + mastodon_locked = models.BooleanField(default=False) + mastodon_followers = models.JSONField(default=list) + mastodon_following = models.JSONField(default=list) + mastodon_mutes = models.JSONField(default=list) + mastodon_blocks = models.JSONField(default=list) + mastodon_domain_blocks = models.JSONField(default=list) + mastodon_account = models.JSONField(default=dict) + mastodon_last_refresh = models.DateTimeField(default=timezone.now) + # store the latest read announcement id, # every time user read the announcement update this field read_announcement_index = models.PositiveIntegerField(default=0) @@ -27,13 +46,111 @@ class User(AbstractUser): fields=['username', 'mastodon_site'], name="unique_user_identity") ] - def save(self, *args, **kwargs): - """ Automatically populate password field with DEFAULT_PASSWORD before saving.""" - self.set_password(DEFAULT_PASSWORD) - return super().save(*args, **kwargs) + # def save(self, *args, **kwargs): + # """ Automatically populate password field with settings.DEFAULT_PASSWORD before saving.""" + # self.set_password(settings.DEFAULT_PASSWORD) + # return super().save(*args, **kwargs) + + @property + def mastodon_username(self): + return self.username + '@' + self.mastodon_site + + @property + def display_name(self): + return self.mastodon_account['display_name'] if self.mastodon_account and 'display_name' in self.mastodon_account and self.mastodon_account['display_name'] else self.mastodon_username def __str__(self): - return self.username + '@' + self.mastodon_site + return self.mastodon_username + + def get_preference(self): + pref = Preference.objects.filter(user=self).first() # self.preference + if not pref: + pref = Preference.objects.create(user=self) + return pref + + def refresh_mastodon_data(self): + """ Try refresh account data from mastodon server, return true if refreshed successfully, note it will not save to db """ + self.mastodon_last_refresh = timezone.now() + code, mastodon_account = verify_account(self.mastodon_site, self.mastodon_token) + if code == 401 and self.mastodon_refresh_token: + self.mastodon_token = refresh_access_token(self.mastodon_site, self.mastodon_refresh_token) + if self.mastodon_token: + code, mastodon_account = verify_account(self.mastodon_site, self.mastodon_token) + updated = False + if mastodon_account: + self.mastodon_account = mastodon_account + self.mastodon_locked = mastodon_account['locked'] + if self.username != mastodon_account['username']: + print(f"username changed from {self} to {mastodon_account['username']}") + self.username = mastodon_account['username'] + # self.mastodon_token = token + # user.mastodon_id = mastodon_account['id'] + self.mastodon_followers = get_related_acct_list(self.mastodon_site, self.mastodon_token, f'/api/v1/accounts/{self.mastodon_id}/followers') + self.mastodon_following = get_related_acct_list(self.mastodon_site, self.mastodon_token, f'/api/v1/accounts/{self.mastodon_id}/following') + self.mastodon_mutes = get_related_acct_list(self.mastodon_site, self.mastodon_token, '/api/v1/mutes') + self.mastodon_blocks = get_related_acct_list(self.mastodon_site, self.mastodon_token, '/api/v1/blocks') + self.mastodon_domain_blocks = get_related_acct_list(self.mastodon_site, self.mastodon_token, '/api/v1/domain_blocks') + self.following = self.get_following_ids() + updated = True + elif code == 401: + print(f'401 {self}') + self.mastodon_token = '' + return updated + + def get_following_ids(self): + fl = [] + for m in self.mastodon_following: + target = User.get(m) + if target and ((not target.mastodon_locked) or self.mastodon_username in target.mastodon_followers): + fl.append(target.id) + return fl + + def is_blocking(self, target): + return target.mastodon_username in self.mastodon_blocks or target.mastodon_site in self.mastodon_domain_blocks + + def is_blocked_by(self, target): + return target.is_blocking(self) + + def is_muting(self, target): + return target.mastodon_username in self.mastodon_mutes + + def is_following(self, target): + return self.mastodon_username in target.mastodon_followers if target.mastodon_locked else self.mastodon_username in target.mastodon_followers or target.mastodon_username in self.mastodon_following + + def is_followed_by(self, target): + return target.is_following(self) + + def get_mark_for_item(self, item): + params = {item.__class__.__name__.lower() + '_id': item.id, 'owner': self} + mark = item.mark_class.objects.filter(**params).first() + return mark + + def get_max_visibility(self, viewer): + if not viewer.is_authenticated: + return 0 + elif viewer == self: + return 2 + elif viewer.is_blocked_by(self): + return -1 + elif viewer.is_following(self): + return 1 + else: + return 0 + + @classmethod + def get(self, id): + if isinstance(id, str): + try: + username = id.split('@')[0] + site = id.split('@')[1] + except IndexError as e: + return None + query_kwargs = {'username': username, 'mastodon_site': site} + elif isinstance(id, int): + query_kwargs = {'pk': id} + else: + return None + return User.objects.filter(**query_kwargs).first() class Preference(models.Model): @@ -43,9 +160,15 @@ class Preference(models.Model): blank=True, default=list, ) + export_status = models.JSONField(blank=True, null=True, encoder=DjangoJSONEncoder, default=dict) + import_status = models.JSONField(blank=True, null=True, encoder=DjangoJSONEncoder, default=dict) + default_visibility = models.PositiveSmallIntegerField(default=0) + classic_homepage = models.BooleanField(null=False, default=False) + mastodon_publish_public = models.BooleanField(null=False, default=False) + mastodon_append_tag = models.CharField(max_length=2048, default='') def get_serialized_home_layout(self): - return str(self.home_layout).replace("\'","\"") + return str(self.home_layout).replace("\'", "\"") def __str__(self): return str(self.user) diff --git a/users/static/js/followers_list.js b/users/static/js/followers_list.js index 0c355f60..44680865 100644 --- a/users/static/js/followers_list.js +++ b/users/static/js/followers_list.js @@ -36,7 +36,8 @@ $(document).ready( function() { } $("#userInfoCard .mast-avatar").attr("src", userData.avatar); $("#userInfoCard .mast-displayname").html(userName); - $("#userInfoCard .mast-brief").text($(userData.note).text()); + $("#userInfoCard .mast-brief").text($("<div>"+userData.note.replace(/\<br/g,'\n<br').replace(/\<p/g,'\n<p')+"</div>").text()); + $("#userInfoCard .mast-brief").html($("#userInfoCard .mast-brief").html().replace(/\n/g,'<br/>')); $(userInfoSpinner).remove(); } ); @@ -45,7 +46,7 @@ $(document).ready( function() { id, mast_uri, token, - function(userList, request) { + function(userList, nextPage) { let subUserList = null; if (userList.length == 0) { $(".mast-followers").hide(); @@ -101,12 +102,7 @@ $(document).ready( function() { }); mainSpinner.hide(); - request.getResponseHeader('link').split(',').forEach(link => { - if (link.includes('next')) { - let regex = /<(.*?)>/; - nextUrl = link.match(regex)[1]; - } - }); + nextUrl = nextPage; } ); @@ -206,7 +202,12 @@ $(document).ready( function() { } else { temp.find(".mast-displayname").text(data.username); } - let url = $("#userPageURL").text().replace('0', data.id); + let url; + if (data.acct.includes('@')) { + url = $("#userPageURL").text().replace('0', data.acct); + } else { + url = $("#userPageURL").text().replace('0', data.acct + '@' + mast_domain); + } temp.find("a").attr('href', url); temp.find(".mast-brief").text(data.note.replace(/(<([^>]+)>)/ig, "")); // console.log($(temp).html()) diff --git a/users/static/js/following_list.js b/users/static/js/following_list.js index 0e6ca0ba..6b3ba278 100644 --- a/users/static/js/following_list.js +++ b/users/static/js/following_list.js @@ -36,7 +36,8 @@ $(document).ready( function() { } $("#userInfoCard .mast-avatar").attr("src", userData.avatar); $("#userInfoCard .mast-displayname").html(userName); - $("#userInfoCard .mast-brief").text($(userData.note).text()); + $("#userInfoCard .mast-brief").text($("<div>"+userData.note.replace(/\<br/g,'\n<br').replace(/\<p/g,'\n<p')+"</div>").text()); + $("#userInfoCard .mast-brief").html($("#userInfoCard .mast-brief").html().replace(/\n/g,'<br/>')); $(userInfoSpinner).remove(); } ); @@ -82,7 +83,7 @@ $(document).ready( function() { id, mast_uri, token, - function(userList, request) { + function(userList, nextPage) { // aside let subUserList = null; if (userList.length == 0) { @@ -139,12 +140,7 @@ $(document).ready( function() { }); mainSpinner.hide(); - request.getResponseHeader('link').split(',').forEach(link => { - if (link.includes('next')) { - let regex = /<(.*?)>/; - nextUrl = link.match(regex)[1]; - } - }); + nextUrl = nextPage; } ); diff --git a/users/static/lib/js/js.cookie.min.js b/users/static/lib/js/js.cookie.min.js deleted file mode 100644 index 1549485f..00000000 --- a/users/static/lib/js/js.cookie.min.js +++ /dev/null @@ -1,2 +0,0 @@ -/*! js-cookie v3.0.0-rc.1 | MIT */ -!function(e,t){"object"==typeof exports&&"undefined"!=typeof module?module.exports=t():"function"==typeof define&&define.amd?define(t):(e=e||self,function(){var n=e.Cookies,r=e.Cookies=t();r.noConflict=function(){return e.Cookies=n,r}}())}(this,function(){"use strict";function e(e){for(var t=1;t<arguments.length;t++){var n=arguments[t];for(var r in n)e[r]=n[r]}return e}var t={read:function(e){return e.replace(/(%[\dA-F]{2})+/gi,decodeURIComponent)},write:function(e){return encodeURIComponent(e).replace(/%(2[346BF]|3[AC-F]|40|5[BDE]|60|7[BCD])/g,decodeURIComponent)}};return function n(r,o){function i(t,n,i){if("undefined"!=typeof document){"number"==typeof(i=e({},o,i)).expires&&(i.expires=new Date(Date.now()+864e5*i.expires)),i.expires&&(i.expires=i.expires.toUTCString()),t=encodeURIComponent(t).replace(/%(2[346B]|5E|60|7C)/g,decodeURIComponent).replace(/[()]/g,escape),n=r.write(n,t);var c="";for(var u in i)i[u]&&(c+="; "+u,!0!==i[u]&&(c+="="+i[u].split(";")[0]));return document.cookie=t+"="+n+c}}return Object.create({set:i,get:function(e){if("undefined"!=typeof document&&(!arguments.length||e)){for(var n=document.cookie?document.cookie.split("; "):[],o={},i=0;i<n.length;i++){var c=n[i].split("="),u=c.slice(1).join("=");'"'===u[0]&&(u=u.slice(1,-1));try{var f=t.read(c[0]);if(o[f]=r.read(u,f),e===f)break}catch(e){}}return e?o[e]:o}},remove:function(t,n){i(t,"",e({},n,{expires:-1}))},withAttributes:function(t){return n(this.converter,e({},this.attributes,t))},withConverter:function(t){return n(e({},this.converter,t),this.attributes)}},{attributes:{value:Object.freeze(o)},converter:{value:Object.freeze(r)}})}(t,{path:"/"})}); diff --git a/users/tasks.py b/users/tasks.py new file mode 100644 index 00000000..a7c17f82 --- /dev/null +++ b/users/tasks.py @@ -0,0 +1,146 @@ +from django.shortcuts import reverse, redirect, render, get_object_or_404 +from django.http import HttpResponseBadRequest, HttpResponse +from django.contrib.auth.decorators import login_required +from django.contrib import auth +from django.contrib.auth import authenticate +from django.core.paginator import Paginator +from django.utils.translation import gettext_lazy as _ +from django.core.exceptions import ObjectDoesNotExist +from django.db.models import Count +from .models import User, Report, Preference +from .forms import ReportForm +from mastodon.api import * +from mastodon import mastodon_request_included +from common.config import * +from common.models import MarkStatusEnum +from common.utils import PageLinksGenerator +from management.models import Announcement +from books.models import * +from movies.models import * +from music.models import * +from games.models import * +from books.forms import BookMarkStatusTranslator +from movies.forms import MovieMarkStatusTranslator +from music.forms import MusicMarkStatusTranslator +from games.forms import GameMarkStatusTranslator +from mastodon.models import MastodonApplication +from django.conf import settings +from urllib.parse import quote +from openpyxl import Workbook +from common.utils import GenerateDateUUIDMediaFilePath +from datetime import datetime +import os + + +def refresh_mastodon_data_task(user, token=None): + if token: + user.mastodon_token = token + if user.refresh_mastodon_data(): + user.save() + print(f"{user} mastodon data refreshed") + else: + print(f"{user} mastodon data refresh failed") + + +def export_marks_task(user): + user.preference.export_status['marks_pending'] = True + user.preference.save(update_fields=['export_status']) + filename = GenerateDateUUIDMediaFilePath(None, 'f.xlsx', settings.MEDIA_ROOT + settings.EXPORT_FILE_PATH_ROOT) + if not os.path.exists(os.path.dirname(filename)): + os.makedirs(os.path.dirname(filename)) + heading = ['标题', '简介', '豆瓣评分', '链接', '创建时间', '我的评分', '标签', '评论', 'NeoDB链接', '其它ID'] + wb = Workbook() # adding write_only=True will speed up but corrupt the xlsx and won't be importable + for status, label in [('collect', '看过'), ('do', '在看'), ('wish', '想看')]: + ws = wb.create_sheet(title=label) + marks = MovieMark.objects.filter(owner=user, status=status).order_by("-edited_time") + ws.append(heading) + for mark in marks: + movie = mark.movie + title = movie.title + summary = str(movie.year) + ' / ' + ','.join(movie.area) + ' / ' + ','.join(map(lambda x: str(MovieGenreTranslator[x]), movie.genre)) + ' / ' + ','.join(movie.director) + ' / ' + ','.join(movie.actor) + tags = ','.join(list(map(lambda m: m.content, mark.tags))) + world_rating = (movie.rating / 2) if movie.rating else None + timestamp = mark.edited_time.strftime('%Y-%m-%d %H:%M:%S') + my_rating = (mark.rating / 2) if mark.rating else None + text = mark.text + source_url = movie.source_url + url = settings.APP_WEBSITE + movie.get_absolute_url() + line = [title, summary, world_rating, source_url, timestamp, my_rating, tags, text, url, movie.imdb_code] + ws.append(line) + + for status, label in [('collect', '听过'), ('do', '在听'), ('wish', '想听')]: + ws = wb.create_sheet(title=label) + marks = AlbumMark.objects.filter(owner=user, status=status).order_by("-edited_time") + ws.append(heading) + for mark in marks: + album = mark.album + title = album.title + summary = ','.join(album.artist) + ' / ' + (album.release_date.strftime('%Y') if album.release_date else '') + tags = ','.join(list(map(lambda m: m.content, mark.tags))) + world_rating = (album.rating / 2) if album.rating else None + timestamp = mark.edited_time.strftime('%Y-%m-%d %H:%M:%S') + my_rating = (mark.rating / 2) if mark.rating else None + text = mark.text + source_url = album.source_url + url = settings.APP_WEBSITE + album.get_absolute_url() + line = [title, summary, world_rating, source_url, timestamp, my_rating, tags, text, url, ''] + ws.append(line) + + for status, label in [('collect', '读过'), ('do', '在读'), ('wish', '想读')]: + ws = wb.create_sheet(title=label) + marks = BookMark.objects.filter(owner=user, status=status).order_by("-edited_time") + ws.append(heading) + for mark in marks: + book = mark.book + title = book.title + summary = ','.join(book.author) + ' / ' + str(book.pub_year) + ' / ' + book.pub_house + tags = ','.join(list(map(lambda m: m.content, mark.tags))) + world_rating = (book.rating / 2) if book.rating else None + timestamp = mark.edited_time.strftime('%Y-%m-%d %H:%M:%S') + my_rating = (mark.rating / 2) if mark.rating else None + text = mark.text + source_url = book.source_url + url = settings.APP_WEBSITE + book.get_absolute_url() + line = [title, summary, world_rating, source_url, timestamp, my_rating, tags, text, url, book.isbn] + ws.append(line) + + for status, label in [('collect', '玩过'), ('do', '在玩'), ('wish', '想玩')]: + ws = wb.create_sheet(title=label) + marks = GameMark.objects.filter(owner=user, status=status).order_by("-edited_time") + ws.append(heading) + for mark in marks: + game = mark.game + title = game.title + summary = ','.join(game.genre) + ' / ' + ','.join(game.platform) + ' / ' + (game.release_date.strftime('%Y-%m-%d') if game.release_date else '') + tags = ','.join(list(map(lambda m: m.content, mark.tags))) + world_rating = (game.rating / 2) if game.rating else None + timestamp = mark.edited_time.strftime('%Y-%m-%d %H:%M:%S') + my_rating = (mark.rating / 2) if mark.rating else None + text = mark.text + source_url = game.source_url + url = settings.APP_WEBSITE + game.get_absolute_url() + line = [title, summary, world_rating, source_url, timestamp, my_rating, tags, text, url, ''] + ws.append(line) + + review_heading = ['标题', '评论对象', '链接', '创建时间', '我的评分', '类型', '内容', '评论对象原始链接', '评论对象NeoDB链接'] + for ReviewModel, label in [(MovieReview, '影评'), (BookReview, '书评'), (AlbumReview, '乐评'), (GameReview, '游戏评论')]: + ws = wb.create_sheet(title=label) + reviews = ReviewModel.objects.filter(owner=user).order_by("-edited_time") + ws.append(review_heading) + for review in reviews: + title = review.title + target = "《" + review.item.title + "》" + url = review.url + timestamp = review.edited_time.strftime('%Y-%m-%d %H:%M:%S') + my_rating = None # (mark.rating / 2) if mark.rating else None + content = review.content + target_source_url = review.item.source_url + target_url = review.item.url + line = [title, target, url, timestamp, my_rating, label, content, target_source_url, target_url] + ws.append(line) + + wb.save(filename=filename) + user.preference.export_status['marks_pending'] = False + user.preference.export_status['marks_file'] = filename + user.preference.export_status['marks_date'] = datetime.now().strftime("%Y-%m-%d %H:%M") + user.preference.save(update_fields=['export_status']) diff --git a/users/templates/users/book_list.html b/users/templates/users/book_list.html deleted file mode 100644 index ff2dfea9..00000000 --- a/users/templates/users/book_list.html +++ /dev/null @@ -1,282 +0,0 @@ -{% load static %} -{% load i18n %} -{% load l10n %} -{% load admin_url %} -{% load mastodon %} -{% load oauth_token %} -{% load truncate %} -{% load thumb %} -<!DOCTYPE html> -<html lang="en"> - -<head> - <meta charset="UTF-8"> - <meta name="viewport" content="width=device-width, initial-scale=1.0"> - <title>{% trans 'NiceDB - ' %}{{ user.username }}{{ list_title }}</title> - <script src="https://cdnjs.cloudflare.com/ajax/libs/jquery/3.5.1/jquery.min.js"></script> - <script src="{% static 'lib/js/rating-star.js' %}"></script> - <script src="{% static 'js/rating-star-readonly.js' %}"></script> - <script src="{% static 'js/mastodon.js' %}"></script> - <script src="{% static 'js/home.js' %}"></script> - <link rel="stylesheet" href="{% static 'lib/css/rating-star.css' %}"> - <link rel="stylesheet" href="{% static 'css/boofilsic.min.css' %}"> -</head> - -<body> - <div id="page-wrapper"> - <div id="content-wrapper"> - {% include "partial/_navbar.html" %} - - <section id="content" class="container"> - <div class="grid grid--reverse-order"> - <div class="grid__main grid__main--reverse-order"> - <div class="main-section-wrapper"> - <div class="entity-list"> - - <div class="set"> - <h5 class="entity-list__title"> - {{ user.username }}{{ list_title }} - </h5> - </div> - <ul class="entity-list__entities"> - - {% for mark in marks %} - - <li class="entity-list__entity"> - <div class="entity-list__entity-img-wrapper"> - <a href="{% url 'books:retrieve' mark.book.id %}"> - <img src="{{ mark.book.cover|thumb:'normal' }}" alt="" class="entity-list__entity-img"> - </a> - </div> - <div class="entity-list__entity-text"> - <div class="entity-list__entity-title"> - <a href="{% url 'books:retrieve' mark.book.id %}" class="entity-list__entity-link"> - {{ mark.book.title }} - </a> - <a href="{{ mark.book.source_url }}"> - <span class="source-label source-label__{{ mark.book.source_site }}">{{ mark.book.get_source_site_display }}</span> - </a> - </div> - {% comment %} - <!-- {% if mark.book.rating %} - <div class="rating-star entity-list__rating-star" data-rating-score="{{ mark.book.rating | floatformat:"0" }}"></div> - <span class="entity-list__rating-score rating-score"> - {{ mark.book.rating }} - </span> - {% else %} - <div class="entity-list__rating entity-list__rating--empty"> {% trans '暂无评分' %}</div> - {% endif %} --> - {% endcomment %} - <span class="entity-list__entity-info entity-list__entity-info--full-length"> - {% if mark.book.pub_year %} - {{ mark.book.pub_year }}{% trans '年' %} / - {% if mark.book.pub_month %} - {{ mark.book.pub_month }}{% trans '月' %} / - {% endif %} - {% endif %} - - {% if mark.book.author %} - {% trans '作者' %} - {% for author in mark.book.author %} - {{ author }}{% if not forloop.last %},{% endif %} - {% endfor %}/ - {% endif %} - - {% if mark.book.translator %} - {% trans '译者' %} - {% for translator in mark.book.translator %} - {{ translator }}{% if not forloop.last %},{% endif %} - {% endfor %}/ - {% endif %} - - {% if mark.book.orig_title %} - {% trans '原名' %} - {{ mark.book.orig_title }} - {% endif %} - </span> - <p class="entity-list__entity-brief"> - {{ mark.book.brief }} - </p> - <div class="tag-collection"> - {% for tag_dict in mark.book.tag_list %} - {% for k, v in tag_dict.items %} - {% if k == 'content' %} - <span class="tag-collection__tag"> - <a href="{% url 'common:search' %}?tag={{ v }}">{{ v }}</a> - </span> - {% endif %} - {% endfor %} - {% endfor %} - </div> - <div class="clearfix"></div> - <div class="dividing-line dividing-line--dashed"></div> - <div class="entity-marks" style="margin-bottom: 0;"> - <ul class="entity-marks__mark-list"> - <li class="entity-marks__mark"> - - {% if mark.rating %} - <span class="entity-marks__rating-star rating-star" - data-rating-score="{{ mark.rating | floatformat:"0" }}" style="left: -4px;"></span> - {% endif %} - {% if mark.is_private %} - <span class="icon-lock"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20"> - <path - d="M17,8.48h-.73V6.27a6.27,6.27,0,1,0-12.53,0V8.48H3a.67.67,0,0,0-.67.67V19.33A.67.67,0,0,0,3,20H17a.67.67,0,0,0,.67-.67V9.15A.67.67,0,0,0,17,8.48ZM6.42,6.27h0a3.57,3.57,0,0,1,7.14,0h0V8.48H6.42Z" /> - </svg></span> - {% endif %} - <span class="entity-marks__mark-time">{% trans '于' %} {{ mark.edited_time }} {% trans '标记' %}</span> - {% if mark.text %} - <p class="entity-marks__mark-content">{{ mark.text }}</p> - {% endif %} - </li> - </ul> - </div> - </div> - - </li> - {% empty %} - <div>{% trans '无结果' %}</div> - {% endfor %} - <!-- user mark --> - - - </ul> - </div> - <div class="pagination"> - - {% if marks.pagination.has_prev %} - <a href="?page=1" class="pagination__nav-link pagination__nav-link">«</a> - <a href="?page={{ marks.previous_page_number }}" - class="pagination__nav-link pagination__nav-link--right-margin pagination__nav-link">‹</a> - {% endif %} - - {% for page in marks.pagination.page_range %} - - {% if page == marks.pagination.current_page %} - <a href="?page={{ page }}" class="pagination__page-link pagination__page-link--current">{{ page }}</a> - {% else %} - <a href="?page={{ page }}" class="pagination__page-link">{{ page }}</a> - {% endif %} - - {% endfor %} - - {% if marks.pagination.has_next %} - <a href="?page={{ marks.next_page_number }}" - class="pagination__nav-link pagination__nav-link--left-margin">›</a> - <a href="?page={{ marks.pagination.last_page }}" class="pagination__nav-link">»</a> - {% endif %} - - </div> - </div> - </div> - - <div class="grid__aside grid__aside--reverse-order grid__aside--tablet-column"> - <div class="aside-section-wrapper aside-section-wrapper--no-margin"> - <div class="user-profile" id="userInfoCard"> - <div class="user-profile__header"> - <!-- <img src="" class="user-profile__avatar mast-avatar" alt="{{ user.username }}"> --> - <img src="" class="user-profile__avatar mast-avatar"> - <a href="{% url 'users:home' user.id %}"> - <h5 class="user-profile__username mast-displayname"></h5> - </a> - </div> - <p class="user-profile__bio mast-brief"></p> - <!-- <a href="#" class="follow">{% trans '关注TA' %}</a> --> - - {% if request.user != user %} - <a href="{% url 'users:report' %}?user_id={{ user.id }}" - class="user-profile__report-link">{% trans '举报用户' %}</a> - {% endif %} - - </div> - </div> - - <div class="relation-dropdown"> - <div class="relation-dropdown__button"> - <span class="icon-arrow"> - <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 10 10"> - <path d="M8.12,3.29,5,6.42,1.86,3.29H.45L5,7.84,9.55,3.29Z" /> - </svg> - </span> - </div> - <div class="relation-dropdown__body"> - <div class="aside-section-wrapper aside-section-wrapper--transparent aside-section-wrapper--collapse"> - - <div class="user-relation" id="followings"> - <h5 class="user-relation__label"> - {% trans '关注的人' %} - </h5> - <a href="{% url 'users:following' user.id %}" - class="user-relation__more-link mast-following-more">{% trans '更多' %}</a> - <ul class="user-relation__related-user-list mast-following"> - <li class="user-relation__related-user"> - <a> - <img src="" alt="" class="user-relation__related-user-avatar"> - <div class="user-relation__related-user-name mast-displayname"> - </div> - </a> - </li> - </ul> - </div> - - <div class="user-relation" id="followers"> - <h5 class="user-relation__label"> - {% trans '被他们关注' %} - </h5> - <a href="{% url 'users:followers' user.id %}" - class="user-relation__more-link mast-followers-more">{% trans '更多' %}</a> - <ul class="user-relation__related-user-list mast-followers"> - <li class="user-relation__related-user"> - <a> - <img src="" alt="" class="user-relation__related-user-avatar"> - <div class="user-relation__related-user-name mast-displayname"> - </div> - </a> - </li> - </ul> - </div> - - </div> - </div> - </div> - - </div> - </div> - </section> - </div> - {% include "partial/_footer.html" %} - </div> - - - <div id="oauth2Token" hidden="true">{% oauth_token %}</div> - <div id="mastodonURI" hidden="true">{% mastodon request.user.mastodon_site %}</div> - <!--current user mastodon id--> - {% if user == request.user %} - <div id="userMastodonID" hidden="true">{{ user.mastodon_id }}</div> - {% else %} - <div id="userMastodonID" hidden="true">{{ user.target_site_id }}</div> - {% endif %} - <div id="userPageURL" hidden="true">{% url 'users:home' 0 %}</div> - <div id="spinner" hidden> - <div class="spinner"> - <div></div> - <div></div> - <div></div> - <div></div> - <div></div> - <div></div> - <div></div> - <div></div> - <div></div> - <div></div> - <div></div> - <div></div> - </div> - </div> - <script> - - </script> -</body> - - -</html> diff --git a/users/templates/users/data.html b/users/templates/users/data.html new file mode 100644 index 00000000..d9f6a19a --- /dev/null +++ b/users/templates/users/data.html @@ -0,0 +1,337 @@ +{% load static %} +{% load i18n %} +{% load admin_url %} +{% load mastodon %} +{% load oauth_token %} +{% load truncate %} +{% load thumb %} +<!DOCTYPE html> +<html lang="en"> + +<head> + <meta charset="UTF-8"> + <meta name="viewport" content="width=device-width, initial-scale=1.0"> + <title>{{ site_name }} - 数据管理</title> + {% include "partial/_common_libs.html" with jquery=1 %} + <script src="{% static 'js/mastodon.js' %}"></script> + <script src="{% static 'js/home.js' %}"></script> +</head> + +<body> + <div id="page-wrapper"> + <div id="content-wrapper"> + {% include "partial/_navbar.html" %} + + <section id="content"> + <div class="grid grid--reverse-order"> + <div class="grid__main grid__main--reverse-order"> + <div class="main-section-wrapper"> + {% if messages %} + <ul class="messages"> + {% for message in messages %} + <li{% if message.tags %} class="{{ message.tags }}"{% endif %}>{{ message }}</li> + {% endfor %} + </ul> + {% endif %} + </div> + + <div class="main-section-wrapper"> + <div class="tools-section-wrapper"> + <div class="import-panel"> + <h5 class="import-panel__label">{% trans '导入豆瓣标记和短评' %}</h5> + <div class="import-panel__body"> + <form action="{% url 'sync:douban' %}" method="POST" enctype="multipart/form-data" > + {% csrf_token %} + <input type="hidden" name="user" value="{{ request.user.id }}"> + <span>{% trans '导入:' %}</span> + <div class="import-panel__checkbox"> + <input type="checkbox" name="sync_book" id="syncBook" checked> + <label for="syncBook">{% trans '书' %}</label> + </div> + <div class="import-panel__checkbox"> + <input type="checkbox" name="sync_movie" id="syncMovie" checked> + <label for="syncMovie">{% trans '电影' %}</label> + </div> + <div class="import-panel__checkbox"> + <input type="checkbox" name="sync_music" id="syncMusic" checked> + <label for="syncMusic">{% trans '音乐' %}</label> + </div> + <div class="import-panel__checkbox"> + <input type="checkbox" name="sync_game" id="syncGame" checked> + <label for="syncGame">{% trans '游戏' %}</label> + </div> + <div></div> + <span>{% trans '覆盖:' %}</span> + <div class="import-panel__checkbox import-panel__checkbox--last"> + <input type="checkbox" name="overwrite" id="overwrite"> + <label for="overwrite">{% trans '选中会覆盖现有标记' %}</label> + </div> + <div></div> + <span>{% trans '可见性:' %}</span> + <div class="import-panel__checkbox import-panel__checkbox--last"> + <input type="checkbox" name="default_public" id="visibility" checked> + <label for="visibility">{% trans '选中后导入标记对其他用户可见;标记可见性在导入后也可更改。' %}</label> + </div> + <div></div> + <div class="import-panel__file-input"> + 从<a href="https://doufen.org" target="_blank">豆伴(豆坟)</a>备份导出的.xlsx文件,<strong>请勿手动修改该文件</strong>: + <input type="file" name="file" id="excelFile" required accept=".xlsx"> + </div> + <input type="submit" class="import-panel__button" value="{% trans '导入' %}" id="uploadBtn" + {% if not latest_task is None and not latest_task.is_finished %} + disabled + {% endif %} + > + </form> + <div class="import-panel__progress" + {% if latest_task.is_finished or latest_task is None %} + style="display: none;" + {% endif %} + > + <label for="importProgress">{% trans '进度' %}</label> + <progress id="importProgress" value="{{ latest_task.finished_items }}" max="{{ latest_task.total_items }}"></progress> + <span class="float-right" id="progressPercent">{{ latest_task.get_progress | floatformat:"0" }}%</span> + <span class="clearfix"></span> + </div> + <div class="import-panel__last-task" + {% if not latest_task.is_finished %}` + style="display: none;" + {% endif %} + > + {% trans '上次导入:' %} + <span class="index">{% trans '总数' %} <span id="lastTaskTotalItems">{{ latest_task.total_items }}</span></span> + <span class="index">{% trans '同步' %} <span id="lastTaskSuccessItems">{{ latest_task.success_items }}</span></span> + <span class="index">{% trans '状态' %} <span id="lastTaskStatus">{{ latest_task.get_status_emoji }}</span></span> + <div class="import-panel__fail-urls" + {% if not latest_task.failed_urls %} + style="display: none;" + {% endif %} + > + <span> + {% trans '失败条目链接' %} + </span> + <a class="float-right" style="cursor: pointer;" id="failedUrlsBtn"> + ▶ + </a> + <script> + $("#failedUrlsBtn").data("collapse", true); + $("#failedUrlsBtn").on('click', ()=>{ + const btn = $("#failedUrlsBtn"); + if(btn.data("collapse") == true) { + btn.data("collapse", false); + btn.text("▼"); + $("#failedUrls").show(); + } else { + btn.data("collapse", true); + btn.text("▶"); + $("#failedUrls").hide(); + } + }); + </script> + <span class="clearfix"></span> + <ul id="failedUrls" style="display: none;"> + {% for url in latest_task.failed_urls %} + <li>{{ url }}</li> + {% endfor %} + </ul> + </div> + </div> + </div> + </div> + </div> + </div> + + <div class="main-section-wrapper"> + <div class="tools-section-wrapper"> + <div class="import-panel"> + <h5 class="import-panel__label">{% trans '导入豆瓣评论' %}</h5> + <div class="import-panel__body"> + <form action="{% url 'users:import_douban' %}" method="POST" enctype="multipart/form-data" > + {% csrf_token %} + <div class="import-panel__checkbox"> + <p>从<a href="https://doufen.org" target="_blank">豆伴(豆坟)</a>备份导出的.xlsx文件: + <input type="file" name="file" id="excel" required accept=".xlsx"> + </p> + <p>可见性: + <label for="id_visibility_0"><input type="radio" name="visibility" value="0" required="" id="id_visibility_0" checked> + 公开</label> + <label for="id_visibility_1"><input type="radio" name="visibility" value="1" required="" id="id_visibility_1"> + 仅关注者</label> + <label for="id_visibility_2"><input type="radio" name="visibility" value="2" required="" id="id_visibility_2"> + 仅自己</label> + </p> + {% if import_status.douban_pending %} + <input type="submit" class="import-panel__button" value="{% trans '备份文件已上传,请等待导入完成或刷新页面查看最新进度' %}" disabled /> + {% else %} + <input type="submit" class="import-panel__button" value="{% trans '导入' %}"/> + {% endif %} + + {% if import_status.douban_pending == 2 %} + 正在等待 + {% elif import_status.douban_pending == 1 %} + 正在导入 + {% if import_status.douban_total %} + 共{{ import_status.douban_total }}篇,目前已处理{{ import_status.douban_processed }}篇,其中已存在{{ import_status.douban_skipped }}篇,新增{{ import_status.douban_imported }}篇 + {% endif %} + {% elif import_status.douban_file %} + 上次结果 + 共计{{ import_status.douban_total }}篇,处理{{ import_status.douban_processed }}篇,其中已存在{{ import_status.douban_skipped }}篇,新增{{ import_status.douban_imported }}篇 + {% endif %} + </div> + <div> + 请务必在豆伴(豆坟)导出时勾选「书影音游剧」和「评论」;已经存在的评论不会被覆盖。 + </div> + </form> + </div> + </div> + </div> + </div> + + <div class="main-section-wrapper"> + <div class="tools-section-wrapper"> + <div class="import-panel"> + <h5 class="import-panel__label">{% trans '导入Goodreads帐号或书单' %}</h5> + <div class="import-panel__body"> + <form action="{% url 'users:import_goodreads' %}" method="POST" > + {% csrf_token %} + <div class="import-panel__checkbox">输入Goodreads链接 + <input type="url" name="url" value="" placeholder="例如 https://www.goodreads.com/user/show/12345-janedoe"> + <input type="submit" class="import-panel__button" value="{% trans '导入' %}" id="uploadBtn" /> + </div> + <div> + Goodreads用户主页链接形如 https://www.goodreads.com/user/show/12345-janedoe 将自动导入到当前用户的想读、在读、已读列表,每本书的评论导入为本站短评; + <br /> + Goodreads书单链接形如 https://www.goodreads.com/review/list/12345-janedoe?shelf=name 或 https://www.goodreads.com/list/show/155086.Popular_Highlights 将自动导入成为收藏单,每本书的评论导入为收藏单条目备注。 + <br /> + 欲导入的Goodreads用户需将Who Can View My Profile设置为anyone,导入后可改回原设置。 + </div> + </form> + </div> + </div> + </div> + </div> + + <div class="main-section-wrapper"> + <div class="tools-section-wrapper"> + <div class="import-panel"> + <h5 class="import-panel__label">{% trans '导出个人数据' %}</h5> + <div class="import-panel__body"> + <form action="{% url 'users:export_marks' %}" method="POST" enctype="multipart/form-data" > + {% csrf_token %} + {% if export_status.marks_pending %} + <input type="submit" class="import-panel__button" value="{% trans '正在导出兼容豆伴(doufen)和NiceDB的标记、短评和评论' %}" id="uploadBtn" disabled /> + {% else %} + <input type="submit" class="import-panel__button" value="{% trans '导出兼容豆伴(doufen)和NiceDB的标记、短评和评论' %}" id="uploadBtn" /> + {% endif %} + {% if export_status.marks_file %} + <a href="{% url 'users:export_marks' %}" download>下载 {{ export_status.marks_date }} 的导出</a> + {% endif %} + </form> + <!-- <form action="{% url 'users:export_reviews' %}" method="POST" enctype="multipart/form-data" > + {% csrf_token %} + <input type="submit" class="import-panel__button" value="{% trans '导出评论' %}" id="uploadBtn" + > + </form> --> + </div> + </div> + </div> + </div> + + <div class="main-section-wrapper"> + <div class="tools-section-wrapper"> + <div class="import-panel"> + <h5 class="import-panel__label">{% trans '重置所有标记和短评可见性' %}</h5> + <div class="import-panel__body"> + <form action="{% url 'users:reset_visibility' %}" method="POST" > + {% csrf_token %} + <input type="submit" class="import-panel__button" value="{% trans '重置' %}" id="uploadBtn" /> + <div class="import-panel__checkbox"> + <input type="radio" name="visibility" id="visPublic" value="0" checked> + <label for="visPublic">{% trans '公开' %}</label> + <input type="radio" name="visibility" id="visFollower" value="1"> + <label for="visFollower">{% trans '仅关注者' %}</label> + <input type="radio" name="visibility" id="visSelf" value="2"> + <label for="visSelf">{% trans '仅自己' %}</label> + </div> + </form> + </div> + </div> + </div> + </div> + + <div class="main-section-wrapper"> + <div class="tools-section-wrapper"> + <div class="import-panel"> + <h5 class="import-panel__label">{% trans '更新社交关系数据' %}</h5> + <div class="import-panel__body"> + <form action="{% url 'users:sync_mastodon' %}" method="POST" enctype="multipart/form-data" > + {% csrf_token %} + <input type="submit" class="import-panel__button" value="{% trans '同步' %}" id="uploadBtn" /> 上次更新时间 {{ user.mastodon_last_refresh }} + <div> + 为了正确高效的展示短评和评论,{{ site_name }}会缓存你在联邦网络的关注、屏蔽和静音列表。如果你刚刚更新过帐户的上锁状态、增减过关注、静音或屏蔽,希望立即生效,可以点击这里立刻更新;这类信息也会每天自动同步。 + </div> + </form> + </div> + </div> + </div> + </div> + + {% if allow_any_site %} + <div class="main-section-wrapper"> + <div class="tools-section-wrapper"> + <div class="import-panel"> + <h5 class="import-panel__label">{% trans '替换社交账号' %}</h5> + <div class="import-panel__body"> + <form action="{% url 'users:reconnect' %}" method="POST" > + {% csrf_token %} + <div class="import-panel__checkbox">输入新社交账号所在的实例域名 + <input type="input" name="domain" value="" placeholder="例如mastodon.online"> + <input type="submit" class="import-panel__button" value="{% trans '登录新账号' %}" id="uploadBtn" /> + </div> + <div> + 替换后可使用新的联邦网络身份来登录{{ site_name }}和控制数据可见性,已有的标记评论收藏单等数据不受影响。 + </div> + </form> + </div> + </div> + </div> + </div> + {% endif %} + + <div class="main-section-wrapper"> + <div class="tools-section-wrapper"> + <div class="import-panel"> + <h5 class="import-panel__label">{% trans '删除数据和帐号信息' %}</h5> + <div class="import-panel__body"> + <form action="{% url 'users:clear_data' %}" method="POST" > + {% csrf_token %} + <div class="import-panel__checkbox">输入完整的 用户名@实例名 以确认删除 + <input type="input" name="verification" value="" placeholder="user@mastodon.social"> + <input type="submit" class="import-panel__button" value="{% trans '永久删除' %}" id="uploadBtn" /> + </div> + <div> + 删除将无法撤销 + </div> + </form> + </div> + </div> + </div> + </div> + + </div> + + {% include "partial/_sidebar.html" %} + </div> + </section> + + </div> + + {% include "partial/_footer.html" %} + </div> + + <div id="queryProgressURL" data-url="{% url 'sync:progress' %}"></div> + <div id="querySyncInfoURL" data-url="{% url 'sync:last' %}"></div> +</body> + + +</html> diff --git a/users/templates/users/game_list.html b/users/templates/users/game_list.html deleted file mode 100644 index f4e97145..00000000 --- a/users/templates/users/game_list.html +++ /dev/null @@ -1,272 +0,0 @@ -{% load static %} -{% load i18n %} -{% load l10n %} -{% load humanize %} -{% load admin_url %} -{% load mastodon %} -{% load oauth_token %} -{% load truncate %} -{% load thumb %} -<!DOCTYPE html> -<html lang="en"> - -<head> - <meta charset="UTF-8"> - <meta name="viewport" content="width=device-width, initial-scale=1.0"> - <title>{% trans 'NiceDB - ' %}{{ user.username }}{{ list_title }}</title> - <script src="https://cdnjs.cloudflare.com/ajax/libs/jquery/3.5.1/jquery.min.js"></script> - <script src="{% static 'lib/js/rating-star.js' %}"></script> - <script src="{% static 'js/rating-star-readonly.js' %}"></script> - <script src="{% static 'js/mastodon.js' %}"></script> - <script src="{% static 'js/home.js' %}"></script> - <link rel="stylesheet" href="{% static 'lib/css/rating-star.css' %}"> - <link rel="stylesheet" href="{% static 'css/boofilsic.min.css' %}"> -</head> - -<body> - <div id="page-wrapper"> - <div id="content-wrapper"> - {% include "partial/_navbar.html" %} - - <section id="content" class="container"> - <div class="grid grid--reverse-order"> - <div class="grid__main grid__main--reverse-order"> - <div class="main-section-wrapper"> - <div class="entity-list"> - - <div class="set"> - <h5 class="entity-list__title"> - {{ user.username }}{{ list_title }} - </h5> - </div> - <ul class="entity-list__entities"> - - {% for mark in marks %} - - <li class="entity-list__entity"> - <div class="entity-list__entity-img-wrapper"> - <a href="{% url 'games:retrieve' mark.game.id %}"> - <img src="{{ mark.game.cover|thumb:'normal' }}" alt="" class="entity-list__entity-img"> - </a> - </div> - <div class="entity-list__entity-text"> - <div class="entity-list__entity-title"> - <a href="{% url 'games:retrieve' mark.game.id %}" class="entity-list__entity-link"> - {{ mark.game.title }} - </a> - <a href="{{ mark.game.source_url }}"> - <span class="source-label source-label__{{ mark.game.source_site }}">{{ mark.game.get_source_site_display }}</span> - </a> - </div> - <span class="entity-list__entity-info entity-list__entity-info--full-length"> - {% if mark.game.other_title %}{% trans '别名' %} - {% for other_title in mark.game.other_title %} - {{ other_title }}{% if not forloop.last %} {% endif %} - {% endfor %}/ - {% endif %} - - {% if mark.game.developer %}{% trans '开发商' %} - {% for developer in mark.game.developer %} - {{ developer }}{% if not forloop.last %} {% endif %} - {% endfor %}/ - {% endif %} - - {% if mark.game.genre %}{% trans '类型' %} - {% for genre in mark.game.genre %} - {{ genre }}{% if not forloop.last %} {% endif %} - {% endfor %}/ - {% endif %} - - {% if mark.game.platform %}{% trans '平台' %} - {% for platform in mark.game.platform %} - {{ platform }}{% if not forloop.last %} {% endif %} - {% endfor %}/ - {% endif %} - </span> - - <p class="entity-list__entity-brief"> - {{ mark.game.brief }} - </p> - <div class="tag-collection"> - {% for tag_dict in mark.game.tag_list %} - {% for k, v in tag_dict.items %} - {% if k == 'content' %} - <span class="tag-collection__tag"> - <a href="{% url 'common:search' %}?tag={{ v }}">{{ v }}</a> - </span> - {% endif %} - {% endfor %} - {% endfor %} - </div> - <div class="clearfix"></div> - <div class="dividing-line dividing-line--dashed"></div> - <div class="entity-marks" style="margin-bottom: 0;"> - <ul class="entity-marks__mark-list"> - <li class="entity-marks__mark"> - - {% if mark.rating %} - <span class="entity-marks__rating-star rating-star" - data-rating-score="{{ mark.rating | floatformat:"0" }}" style="left: -4px;"></span> - {% endif %} - {% if mark.is_private %} - <span class="icon-lock"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20"> - <path - d="M17,8.48h-.73V6.27a6.27,6.27,0,1,0-12.53,0V8.48H3a.67.67,0,0,0-.67.67V19.33A.67.67,0,0,0,3,20H17a.67.67,0,0,0,.67-.67V9.15A.67.67,0,0,0,17,8.48ZM6.42,6.27h0a3.57,3.57,0,0,1,7.14,0h0V8.48H6.42Z" /> - </svg></span> - {% endif %} - <span class="entity-marks__mark-time">{% trans '于' %} {{ mark.edited_time }} {% trans '标记' %}</span> - {% if mark.text %} - <p class="entity-marks__mark-content">{{ mark.text }}</p> - {% endif %} - </li> - </ul> - </div> - </div> - - </li> - {% empty %} - <div>{% trans '无结果' %}</div> - {% endfor %} - <!-- user mark --> - - - </ul> - </div> - <div class="pagination"> - - {% if marks.pagination.has_prev %} - <a href="?page=1" class="pagination__nav-link pagination__nav-link">«</a> - <a href="?page={{ marks.previous_page_number }}" - class="pagination__nav-link pagination__nav-link--right-margin pagination__nav-link">‹</a> - {% endif %} - - {% for page in marks.pagination.page_range %} - - {% if page == marks.pagination.current_page %} - <a href="?page={{ page }}" class="pagination__page-link pagination__page-link--current">{{ page }}</a> - {% else %} - <a href="?page={{ page }}" class="pagination__page-link">{{ page }}</a> - {% endif %} - - {% endfor %} - - {% if marks.pagination.has_next %} - <a href="?page={{ marks.next_page_number }}" - class="pagination__nav-link pagination__nav-link--left-margin">›</a> - <a href="?page={{ marks.pagination.last_page }}" class="pagination__nav-link">»</a> - {% endif %} - - </div> - </div> - </div> - - <div class="grid__aside grid__aside--reverse-order grid__aside--tablet-column"> - <div class="aside-section-wrapper aside-section-wrapper--no-margin"> - <div class="user-profile" id="userInfoCard"> - <div class="user-profile__header"> - <!-- <img src="" class="user-profile__avatar mast-avatar" alt="{{ user.username }}"> --> - <img src="" class="user-profile__avatar mast-avatar"> - <a href="{% url 'users:home' user.id %}"> - <h5 class="user-profile__username mast-displayname"></h5> - </a> - </div> - <p class="user-profile__bio mast-brief"></p> - <!-- <a href="#" class="follow">{% trans '关注TA' %}</a> --> - - {% if request.user != user %} - <a href="{% url 'users:report' %}?user_id={{ user.id }}" - class="user-profile__report-link">{% trans '举报用户' %}</a> - {% endif %} - - </div> - </div> - - <div class="relation-dropdown"> - <div class="relation-dropdown__button"> - <span class="icon-arrow"> - <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 10 10"> - <path d="M8.12,3.29,5,6.42,1.86,3.29H.45L5,7.84,9.55,3.29Z" /> - </svg> - </span> - </div> - <div class="relation-dropdown__body"> - <div class="aside-section-wrapper aside-section-wrapper--transparent aside-section-wrapper--collapse"> - - <div class="user-relation" id="followings"> - <h5 class="user-relation__label"> - {% trans '关注的人' %} - </h5> - <a href="{% url 'users:following' user.id %}" - class="user-relation__more-link mast-following-more">{% trans '更多' %}</a> - <ul class="user-relation__related-user-list mast-following"> - <li class="user-relation__related-user"> - <a> - <img src="" alt="" class="user-relation__related-user-avatar"> - <div class="user-relation__related-user-name mast-displayname"> - </div> - </a> - </li> - </ul> - </div> - - <div class="user-relation" id="followers"> - <h5 class="user-relation__label"> - {% trans '被他们关注' %} - </h5> - <a href="{% url 'users:followers' user.id %}" - class="user-relation__more-link mast-followers-more">{% trans '更多' %}</a> - <ul class="user-relation__related-user-list mast-followers"> - <li class="user-relation__related-user"> - <a> - <img src="" alt="" class="user-relation__related-user-avatar"> - <div class="user-relation__related-user-name mast-displayname"> - </div> - </a> - </li> - </ul> - </div> - - </div> - </div> - </div> - - </div> - </div> - </section> - </div> - {% include "partial/_footer.html" %} - </div> - - - <div id="oauth2Token" hidden="true">{% oauth_token %}</div> - <div id="mastodonURI" hidden="true">{% mastodon request.user.mastodon_site %}</div> - <!--current user mastodon id--> - {% if user == request.user %} - <div id="userMastodonID" hidden="true">{{ user.mastodon_id }}</div> - {% else %} - <div id="userMastodonID" hidden="true">{{ user.target_site_id }}</div> - {% endif %} - <div id="userPageURL" hidden="true">{% url 'users:home' 0 %}</div> - <div id="spinner" hidden> - <div class="spinner"> - <div></div> - <div></div> - <div></div> - <div></div> - <div></div> - <div></div> - <div></div> - <div></div> - <div></div> - <div></div> - <div></div> - <div></div> - </div> - </div> - <script> - - </script> -</body> - - -</html> diff --git a/users/templates/users/home.html b/users/templates/users/home.html index 1d49c7f7..ddad07d3 100644 --- a/users/templates/users/home.html +++ b/users/templates/users/home.html @@ -11,18 +11,16 @@ <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> - {% if user == request.user %} - <title>{% trans 'NiceDB - 我的主页' %}</title> + <title>{{ site_name }} - {% trans '我的个人主页' %}</title> {% else %} - <title>{% trans 'NiceDB - ' %}{{user.username}}{% trans '的主页' %}</title> + <title>{{ site_name }} - {{user.display_name}}</title> {% endif %} - - <script src="https://cdnjs.cloudflare.com/ajax/libs/jquery/3.5.1/jquery.min.js"></script> + + {% include "partial/_common_libs.html" with jquery=1 %} + <script src="{% static 'js/mastodon.js' %}"></script> <script src="{% static 'js/home.js' %}"></script> - <link rel="stylesheet" href="{% static 'css/boofilsic.min.css' %}"> - </head> <body> @@ -44,7 +42,7 @@ {{ wish_book_count }} </span> {% if wish_book_more %} - <a href="{% url 'users:book_list' user.id 'wish' %}" + <a href="{% url 'users:book_list' user.mastodon_username 'wish' %}" class="entity-sort__more-link">{% trans '更多' %}</a> {% endif %} <ul class="entity-sort__entity-list"> @@ -72,7 +70,7 @@ {{ do_book_count }} </span> {% if do_book_more %} - <a href="{% url 'users:book_list' user.id 'do' %}" + <a href="{% url 'users:book_list' user.mastodon_username 'do' %}" class="entity-sort__more-link">{% trans '更多' %}</a> {% endif %} @@ -101,7 +99,7 @@ {{ collect_book_count }} </span> {% if collect_book_more %} - <a href="{% url 'users:book_list' user.id 'collect' %}" + <a href="{% url 'users:book_list' user.mastodon_username 'collect' %}" class="entity-sort__more-link">{% trans '更多' %}</a> {% endif %} @@ -122,6 +120,35 @@ </ul> </div> + <div class="entity-sort" id="bookReviewed"> + <h5 class="entity-sort__label"> + {% trans '评论过的书籍' %} + </h5> + <span class="entity-sort__count"> + {{ book_reviews_count }} + </span> + {% if book_reviews_more %} + <a href="{% url 'users:book_list' user.mastodon_username 'reviewed' %}" + class="entity-sort__more-link">{% trans '更多' %}</a> + {% endif %} + + <ul class="entity-sort__entity-list"> + {% for book_review in book_reviews %} + <li class="entity-sort__entity"> + + <a href="{% url 'books:retrieve' book_review.book.id %}"> + <img src="{{ book_review.book.cover|thumb:'normal' }}" + alt="{{book_review.book.title}}" class="entity-sort__entity-img"> + <span class="entity-sort__entity-name" + title="{{book_review.book.title}}">{{ book_review.book.title }}</span> + </a> + </li> + {% empty %} + <div>暂无记录</div> + {% endfor %} + </ul> + </div> + <div class="entity-sort" id="movieWish"> <h5 class="entity-sort__label"> {% trans '想看的电影/剧集' %} @@ -130,7 +157,7 @@ {{ wish_movie_count }} </span> {% if wish_movie_more %} - <a href="{% url 'users:movie_list' user.id 'wish' %}" + <a href="{% url 'users:movie_list' user.mastodon_username 'wish' %}" class="entity-sort__more-link">{% trans '更多' %}</a> {% endif %} @@ -159,7 +186,7 @@ {{ do_movie_count }} </span> {% if do_movie_more %} - <a href="{% url 'users:movie_list' user.id 'do' %}" + <a href="{% url 'users:movie_list' user.mastodon_username 'do' %}" class="entity-sort__more-link">{% trans '更多' %}</a> {% endif %} @@ -188,7 +215,7 @@ {{ collect_movie_count }} </span> {% if collect_movie_more %} - <a href="{% url 'users:movie_list' user.id 'collect' %}" + <a href="{% url 'users:movie_list' user.mastodon_username 'collect' %}" class="entity-sort__more-link">{% trans '更多' %}</a> {% endif %} @@ -209,6 +236,35 @@ </ul> </div> + <div class="entity-sort" id="movieReviewed"> + <h5 class="entity-sort__label"> + {% trans '评论过的电影/剧集' %} + </h5> + <span class="entity-sort__count"> + {{ movie_reviews_count }} + </span> + {% if movie_reviews_more %} + <a href="{% url 'users:movie_list' user.mastodon_username 'reviewed' %}" + class="entity-sort__more-link">{% trans '更多' %}</a> + {% endif %} + + <ul class="entity-sort__entity-list"> + {% for movie_review in movie_reviews %} + <li class="entity-sort__entity"> + + <a href="{% url 'movies:retrieve' movie_review.movie.id %}"> + <img src="{{ movie_review.movie.cover|thumb:'normal' }}" + alt="{{movie_review.movie.title}}" class="entity-sort__entity-img"> + <span class="entity-sort__entity-name" + title="{{movie_review.movie.title}}">{{ movie_review.movie.title }}</span> + </a> + </li> + {% empty %} + <div>暂无记录</div> + {% endfor %} + </ul> + </div> + <div class="entity-sort" id="musicWish"> <h5 class="entity-sort__label"> {% trans '想听的音乐' %} @@ -217,7 +273,7 @@ {{ wish_music_count }} </span> {% if wish_music_more %} - <a href="{% url 'users:music_list' user.id 'wish' %}" + <a href="{% url 'users:music_list' user.mastodon_username 'wish' %}" class="entity-sort__more-link">{% trans '更多' %}</a> {% endif %} @@ -258,7 +314,7 @@ {{ do_music_count }} </span> {% if do_music_more %} - <a href="{% url 'users:music_list' user.id 'do' %}" + <a href="{% url 'users:music_list' user.mastodon_username 'do' %}" class="entity-sort__more-link">{% trans '更多' %}</a> {% endif %} @@ -295,7 +351,7 @@ {{ collect_music_count }} </span> {% if collect_music_more %} - <a href="{% url 'users:music_list' user.id 'collect' %}" + <a href="{% url 'users:music_list' user.mastodon_username 'collect' %}" class="entity-sort__more-link">{% trans '更多' %}</a> {% endif %} @@ -324,6 +380,43 @@ </ul> </div> + <div class="entity-sort" id="musicReviewed"> + <h5 class="entity-sort__label"> + {% trans '评论过的音乐' %} + </h5> + <span class="entity-sort__count"> + {{ music_reviews_count }} + </span> + {% if music_reviews_more %} + <a href="{% url 'users:music_list' user.mastodon_username 'reviewed' %}" + class="entity-sort__more-link">{% trans '更多' %}</a> + {% endif %} + + <ul class="entity-sort__entity-list"> + {% for music_review in music_reviews %} + <li class="entity-sort__entity"> + {% if music_review.type == 'album' %} + <a href="{% url 'music:retrieve_album' music_review.album.id %}"> + <img src="{{ music_review.album.cover|thumb:'normal' }}" + alt="{{music_review.album.title}}" class="entity-sort__entity-img"> + <span class="entity-sort__entity-name" + title="{{music_review.album.title}}">{{ music_review.album.title }}</span> + </a> + {% else %} + <a href="{% url 'music:retrieve_song' music_review.song.id %}"> + <img src="{{ music_review.song.cover|thumb:'normal' }}" + alt="{{music_review.song.title}}" class="entity-sort__entity-img"> + <span class="entity-sort__entity-name" + title="{{music_review.song.title}}">{{ music_review.song.title }}</span> + </a> + {% endif %} + </li> + {% empty %} + <div>暂无记录</div> + {% endfor %} + </ul> + </div> + <div class="entity-sort" id="gameWish"> <h5 class="entity-sort__label"> {% trans '想玩的游戏' %} @@ -332,7 +425,7 @@ {{ wish_game_count }} </span> {% if wish_game_more %} - <a href="{% url 'users:game_list' user.id 'wish' %}" class="entity-sort__more-link">{% trans '更多' %}</a> + <a href="{% url 'users:game_list' user.mastodon_username 'wish' %}" class="entity-sort__more-link">{% trans '更多' %}</a> {% endif %} <ul class="entity-sort__entity-list"> @@ -360,7 +453,7 @@ {{ do_game_count }} </span> {% if do_game_more %} - <a href="{% url 'users:game_list' user.id 'do' %}" class="entity-sort__more-link">{% trans '更多' %}</a> + <a href="{% url 'users:game_list' user.mastodon_username 'do' %}" class="entity-sort__more-link">{% trans '更多' %}</a> {% endif %} <ul class="entity-sort__entity-list"> @@ -388,7 +481,7 @@ {{ collect_game_count }} </span> {% if collect_game_more %} - <a href="{% url 'users:game_list' user.id 'collect' %}" class="entity-sort__more-link">{% trans '更多' %}</a> + <a href="{% url 'users:game_list' user.mastodon_username 'collect' %}" class="entity-sort__more-link">{% trans '更多' %}</a> {% endif %} <ul class="entity-sort__entity-list"> @@ -407,411 +500,174 @@ </ul> </div> - </div> - - {% if user == request.user %} - - <div class="entity-sort-control"> - <div class="entity-sort-control__button" id="sortEditButton"> - <span class="entity-sort-control__text" id="sortEditText"> - {% trans '编辑布局' %} - </span> - <span class="entity-sort-control__text" id="sortSaveText" style="display: none;"> - {% trans '保存' %} - </span> - <span class="icon-edit" id="sortEditIcon"> - <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 383.947 383.947"> - <polygon points="0,303.947 0,383.947 80,383.947 316.053,147.893 236.053,67.893 " /> - <path - d="M377.707,56.053L327.893,6.24c-8.32-8.32-21.867-8.32-30.187,0l-39.04,39.04l80,80l39.04-39.04 C386.027,77.92,386.027,64.373,377.707,56.053z" /> - </svg> - </span> - <span class="icon-save" id="sortSaveIcon" style="display: none;"> - <svg xmlns="http://www.w3.org/2000/svg" version="1.1" viewBox="0 0 384 384" > - <path - d="M298.667,0h-256C19.093,0,0,19.093,0,42.667v298.667C0,364.907,19.093,384,42.667,384h298.667 C364.907,384,384,364.907,384,341.333v-256L298.667,0z M192,341.333c-35.307,0-64-28.693-64-64c0-35.307,28.693-64,64-64 s64,28.693,64,64C256,312.64,227.307,341.333,192,341.333z M256,128H42.667V42.667H256V128z" /> - </svg> - </span> - </div> - <div class="entity-sort-control__button" id="sortExitButton" style="display: none;"> - <span class="entity-sort-control__text"> - {% trans '取消' %} - </span> - </div> - </div> - <div class="entity-sort-control__button entity-sort-control__button--float-right" id="toggleDisplayButtonTemplate" style="display: none;"> - <span class="showText" style="display: none;"> - {% trans '显示' %} + <div class="entity-sort" id="gameReviewed"> + <h5 class="entity-sort__label"> + {% trans '评论过的游戏' %} + </h5> + <span class="entity-sort__count"> + {{ game_reviews_count }} </span> - <span class="hideText" style="display: none;"> - {% trans '隐藏' %} - </span> - </div> - <form action="{% url 'users:set_layout' %}" method="post" id="sortForm"> - {% csrf_token %} - <input type="hidden" name="layout"> - </form> - <script src="https://cdnjs.cloudflare.com/ajax/libs/html5sortable/0.10.0/html5sortable.min.js" - integrity="sha512-tBlVMq89XaEC9iU5LyRjP2Vxs8SmVhEHGbv2Co6SbGa14Wsxy2qZN0jadrN+Xn5AifORaUbvZcG21/ExcNfWDA==" - crossorigin="anonymous"></script> - <script src="{% static 'js/sort_layout.js' %}"></script> - {% endif %} - <script> - const initialLayoutData = JSON.parse("{{ layout|escapejs }}"); - // initialize sort element visibility and order - initialLayoutData.forEach(elem => { - // False to false, True to true - if (elem.visibility === "False") { - elem.visibility = false; - } else { - elem.visibility = true; - } - // set visiblity - $('#' + elem.id).data('visibility', elem.visibility); - if (!elem.visibility) { - $('#' + elem.id).hide(); - } - // order - $('#' + elem.id).appendTo('.main-section-wrapper'); - }); - </script> - - </div> - - <div class="grid__aside grid__aside--reverse-order grid__aside--tablet-column"> - <div class="aside-section-wrapper aside-section-wrapper--no-margin"> - <div class="user-profile" id="userInfoCard"> - <div class="user-profile__header"> - <!-- <img src="" class="user-profile__avatar mast-avatar" alt="{{ user.username }}"> --> - <img src="" class="user-profile__avatar mast-avatar"> - <a href="{% url 'users:home' user.id %}"> - <h5 class="user-profile__username mast-displayname"></h5> - </a> - </div> - <p class="user-profile__bio mast-brief"></p> - <!-- <a href="#" class="follow">{% trans '关注TA' %}</a> --> - - {% if request.user != user %} - <a href="{% url 'users:report' %}?user_id={{ user.id }}" - class="user-profile__report-link">{% trans '举报用户' %}</a> + {% if game_reviews_more %} + <a href="{% url 'users:game_list' user.mastodon_username 'reviewed' %}" + class="entity-sort__more-link">{% trans '更多' %}</a> {% endif %} - </div> - </div> + <ul class="entity-sort__entity-list"> + {% for game_review in game_reviews %} + <li class="entity-sort__entity"> - <!-- contains relations, reports, and upload excel entry --> - <div class="relation-dropdown"> - <div class="relation-dropdown__button"> - <span class="icon-arrow"> - <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 10 10"> - <path d="M8.12,3.29,5,6.42,1.86,3.29H.45L5,7.84,9.55,3.29Z" /> + <a href="{% url 'games:retrieve' game_review.game.id %}"> + <img src="{{ game_review.game.cover|thumb:'normal' }}" + alt="{{game_review.game.title}}" class="entity-sort__entity-img"> + <span class="entity-sort__entity-name" + title="{{game_review.game.title}}">{{ game_review.game.title }}</span> + </a> + </li> + {% empty %} + <div>暂无记录</div> + {% endfor %} + </ul> + </div> + + <div class="entity-sort" id="collectionCreated"> + <h5 class="entity-sort__label"> + {% trans '创建的收藏单' %} + </h5> + <span class="entity-sort__count"> + {{ collections_count }} + </span> + {% if collections_more %} + <a href="{% url 'users:collection_list' user.mastodon_username %}" + class="entity-sort__more-link">{% trans '更多' %}</a> + {% endif %} + {% if user == request.user %} + <a href="{% url 'collection:create' %}"class="entity-sort__more-link">{% trans '新建' %}</a> + {% endif %} + + <ul class="entity-sort__entity-list"> + {% for collection in collections %} + <li class="entity-sort__entity"> + + <a href="{% url 'collection:retrieve' collection.id %}"> + <img src="{{ collection.cover|thumb:'normal' }}" + alt="{{collection.title}}" class="entity-sort__entity-img"> + <span class="entity-sort__entity-name" + title="{{collection.title}}">{{ collection.title }}</span> + </a> + </li> + {% empty %} + <div>暂无记录</div> + {% endfor %} + </ul> + </div> + + <div class="entity-sort" id="collectionMarked"> + <h5 class="entity-sort__label"> + {% trans '关注的收藏单' %} + </h5> + <span class="entity-sort__count"> + {{ marked_collections_count }} + </span> + {% if marked_collections_more %} + <a href="{% url 'users:marked_collection_list' user.mastodon_username %}" + class="entity-sort__more-link">{% trans '更多' %}</a> + {% endif %} + + <ul class="entity-sort__entity-list"> + {% for collection in marked_collections %} + <li class="entity-sort__entity"> + + <a href="{% url 'collection:retrieve' collection.id %}"> + <img src="{{ collection.cover|thumb:'normal' }}" + alt="{{collection.title}}" class="entity-sort__entity-img"> + <span class="entity-sort__entity-name" + title="{{collection.title}}">{{ collection.title }}</span> + </a> + </li> + {% empty %} + <div>暂无记录</div> + {% endfor %} + </ul> + </div> + + </div> + + {% if user == request.user %} + + <div class="entity-sort-control"> + <div class="entity-sort-control__button" id="sortEditButton"> + <span class="entity-sort-control__text" id="sortEditText"> + {% trans '编辑布局' %} + </span> + <span class="entity-sort-control__text" id="sortSaveText" style="display: none;"> + {% trans '保存' %} + </span> + <span class="icon-edit" id="sortEditIcon"> + <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 383.947 383.947"> + <polygon points="0,303.947 0,383.947 80,383.947 316.053,147.893 236.053,67.893 " /> + <path + d="M377.707,56.053L327.893,6.24c-8.32-8.32-21.867-8.32-30.187,0l-39.04,39.04l80,80l39.04-39.04 C386.027,77.92,386.027,64.373,377.707,56.053z" /> + </svg> + </span> + <span class="icon-save" id="sortSaveIcon" style="display: none;"> + <svg xmlns="http://www.w3.org/2000/svg" version="1.1" viewBox="0 0 384 384" > + <path + d="M298.667,0h-256C19.093,0,0,19.093,0,42.667v298.667C0,364.907,19.093,384,42.667,384h298.667 C364.907,384,384,364.907,384,341.333v-256L298.667,0z M192,341.333c-35.307,0-64-28.693-64-64c0-35.307,28.693-64,64-64 s64,28.693,64,64C256,312.64,227.307,341.333,192,341.333z M256,128H42.667V42.667H256V128z" /> </svg> </span> </div> - <div class="relation-dropdown__body"> - <div - class="aside-section-wrapper aside-section-wrapper--transparent aside-section-wrapper--collapse"> - - <div class="user-relation" id="followings"> - <h5 class="user-relation__label"> - {% trans '关注的人' %} - </h5> - <a href="{% url 'users:following' user.id %}" - class="user-relation__more-link mast-following-more">{% trans '更多' %}</a> - <ul class="user-relation__related-user-list mast-following"> - <li class="user-relation__related-user"> - <a> - <img src="" alt="" class="user-relation__related-user-avatar"> - <div class="user-relation__related-user-name mast-displayname"> - </div> - </a> - </li> - </ul> - </div> - - <div class="user-relation" id="followers"> - <h5 class="user-relation__label"> - {% trans '被他们关注' %} - </h5> - <a href="{% url 'users:followers' user.id %}" - class="user-relation__more-link mast-followers-more">{% trans '更多' %}</a> - <ul class="user-relation__related-user-list mast-followers"> - <li class="user-relation__related-user"> - <a> - <img src="" alt="" class="user-relation__related-user-avatar"> - <div class="user-relation__related-user-name mast-displayname"> - </div> - </a> - </li> - </ul> - </div> - - </div> - - <!-- import douban data --> - {% if user == request.user %} - <div class="aside-section-wrapper aside-section-wrapper--transparent aside-section-wrapper--collapse"> - <div class="import-panel"> - <h5 class="import-panel__label">{% trans '导入豆瓣标记数据' %}</h5> - <span id="importHelp" class="import-panel__help">?</span> - <div class="import-panel__body"> - <form action="{% url 'sync:douban' %}" method="POST" enctype="multipart/form-data" > - - {% csrf_token %} - <input type="hidden" name="user" value="{{ request.user.id }}"> - <span>{% trans '导入:' %}</span> - <div class="import-panel__checkbox"> - <input type="checkbox" name="sync_book" id="syncBook"> - <label for="syncBook">{% trans '书' %}</label> - </div> - <div class="import-panel__checkbox"> - <input type="checkbox" name="sync_movie" id="syncMovie"> - <label for="syncMovie">{% trans '电影' %}</label> - </div> - <div class="import-panel__checkbox"> - <input type="checkbox" name="sync_music" id="syncMusic"> - <label for="syncMusic">{% trans '音乐' %}</label> - </div> - <div class="import-panel__checkbox"> - <input type="checkbox" name="sync_game" id="syncGame"> - <label for="syncGame">{% trans '游戏' %}</label> - </div> - <div></div> - <span>{% trans '覆盖:' %}</span> - <div class="import-panel__checkbox import-panel__checkbox--last"> - <input type="checkbox" name="overwrite" id="overwrite"> - <label for="overwrite">{% trans '覆盖原有标记' %}</label> - </div> - <span id="overwriteHelp" class="import-panel__help">?</span> - <div></div> - <span>{% trans '可见性:' %}</span> - <div class="import-panel__checkbox import-panel__checkbox--last"> - <input type="checkbox" name="default_public" id="visibility"> - <label for="visibility">{% trans '公开' %}</label> - </div> - <span id="visibilityHelp" class="import-panel__help">?</span> - <div></div> - <div class="import-panel__file-input"> - <input type="file" name="file" id="excelFile" required accept=".xlsx"> - </div> - <input type="submit" class="import-panel__button" value="{% trans '导入' %}" id="uploadBtn" - {% if not latest_task is None and not latest_task.is_finished %} - disabled - {% endif %} - > - </form> - <div class="import-panel__progress" - {% if latest_task.is_finished or latest_task is None %} - style="display: none;" - {% endif %} - > - <label for="importProgress">{% trans '进度' %}</label> - <progress id="importProgress" value="{{ latest_task.finished_items }}" max="{{ latest_task.total_items }}"></progress> - <span class="float-right" id="progressPercent">{{ latest_task.get_progress | floatformat:"0" }}%</span> - <span class="clearfix"></span> - </div> - <div class="import-panel__last-task" - {% if not latest_task.is_finished %}` - style="display: none;" - {% endif %} - > - {% trans '上次导入:' %} - <span class="index">{% trans '总数' %} <span id="lastTaskTotalItems">{{ latest_task.total_items }}</span></span> - <span class="index">{% trans '同步' %} <span id="lastTaskSuccessItems">{{ latest_task.success_items }}</span></span> - <span class="index">{% trans '状态' %} <span id="lastTaskStatus">{{ latest_task.get_status_emoji }}</span></span> - <div class="import-panel__fail-urls" - {% if not latest_task.failed_urls %} - style="display: none;" - {% endif %} - > - <span> - {% trans '失败条目链接' %} - </span> - <a class="float-right" style="cursor: pointer;" id="failedUrlsBtn"> - ▶ - </a> - <script> - $("#failedUrlsBtn").data("collapse", true); - $("#failedUrlsBtn").click(()=>{ - const btn = $("#failedUrlsBtn"); - if(btn.data("collapse") == true) { - btn.data("collapse", false); - btn.text("▼"); - $("#failedUrls").show(); - } else { - btn.data("collapse", true); - btn.text("▶"); - $("#failedUrls").hide(); - } - }); - </script> - <span class="clearfix"></span> - <ul id="failedUrls" style="display: none;"> - {% for url in latest_task.failed_urls %} - <li>{{ url }}</li> - {% endfor %} - </ul> - </div> - </div> - </div> - </div> - </div> - {% endif %} - - <div - class="aside-section-wrapper aside-section-wrapper--transparent aside-section-wrapper--collapse"> - {% if request.user.is_staff and request.user == user%} - <div class="report-panel"> - <h5 class="report-panel__label">{% trans '举报信息' %}</h5> - <a class="report-panel__all-link" - href="{% url 'users:manage_report' %}">全部举报</a> - <div class="report-panel__body"> - <ul class="report-panel__report-list"> - {% for report in reports %} - <li class="report-panel__report"> - <a href="{% url 'users:home' report.submit_user.id %}" - class="report-panel__user-link">{{ report.submit_user }}</a>{% trans '举报了' %}<a - href="{% url 'users:home' report.reported_user.id %}" - class="report-panel__user-link">{{ report.reported_user }}</a> - </li> - {% empty %} - <div>{% trans '暂无新举报' %}</div> - {% endfor %} - - </ul> - </div> - </div> - {% endif %} - </div> + <div class="entity-sort-control__button" id="sortExitButton" style="display: none;"> + <span class="entity-sort-control__text"> + {% trans '取消' %} + </span> </div> </div> + <div class="entity-sort-control__button entity-sort-control__button--float-right" id="toggleDisplayButtonTemplate" style="display: none;"> + <span class="showText" style="display: none;"> + {% trans '显示' %} + </span> + <span class="hideText" style="display: none;"> + {% trans '隐藏' %} + </span> + </div> + <form action="{% url 'users:set_layout' %}" method="post" id="sortForm"> + {% csrf_token %} + <input type="hidden" name="layout"> + </form> + <script src="https://static.neodb.social/cdnjs.cloudflare.com/ajax/libs/html5sortable/0.13.3/html5sortable.min.js" crossorigin="anonymous"></script> + <script src="{% static 'js/sort_layout.js' %}"></script> + {% endif %} + <script> + const initialLayoutData = JSON.parse("{{ layout|escapejs }}"); + // initialize sort element visibility and order + initialLayoutData.forEach(elem => { + // False to false, True to true + if (elem.visibility === "False") { + elem.visibility = false; + } else { + elem.visibility = true; + } + // set visiblity + $('#' + elem.id).data('visibility', elem.visibility); + if (!elem.visibility) { + $('#' + elem.id).hide(); + } + // order + $('#' + elem.id).appendTo('.main-section-wrapper'); + }); + </script> </div> + + {% include "partial/_sidebar.html" %} </div> </section> - </div> - {% include "partial/_footer.html" %} </div> - <div id="oauth2Token" hidden="true">{% oauth_token %}</div> - <div id="mastodonURI" hidden="true">{% mastodon request.user.mastodon_site %}</div> - <div id="queryProgressURL" data-url="{% url 'sync:progress' %}"></div> - <div id="querySyncInfoURL" data-url="{% url 'sync:last' %}"></div> - <!--current user mastodon id--> - - {% if user == request.user %} - <div id="userMastodonID" hidden="true">{{ user.mastodon_id }}</div> - {% else %} - <div id="userMastodonID" hidden="true">{{ user.target_site_id }}</div> - {% endif %} - - <div id="userPageURL" hidden="true">{% url 'users:home' 0 %}</div> - - <div id="spinner" hidden> - <div class="spinner"> - <div></div> - <div></div> - <div></div> - <div></div> - <div></div> - <div></div> - <div></div> - <div></div> - <div></div> - <div></div> - <div></div> - <div></div> - </div> - </div> - {% if unread_announcements %} - <div id="modals"> - <style> - .bottom-link { - margin-top: 30px; text-align: center; margin-bottom: 5px; - } - .bottom-link a { - color: #ccc; - } - </style> - <div class="announcement-modal modal"> - <div class="announcement-modal__head"> - <h4 class="announcement-modal__title">{% trans '公告' %}</h4> - - <span class="announcement-modal__close-button modal-close"> - <span class="icon-cross"> - <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20"> - <polygon - points="20 2.61 17.39 0 10 7.39 2.61 0 0 2.61 7.39 10 0 17.39 2.61 20 10 12.61 17.39 20 20 17.39 12.61 10 20 2.61"> - </polygon> - </svg> - </span> - </span> - </div> - <div class="announcement-modal__body"> - <ul> - {% for ann in unread_announcements %} - <li class="announcement"> - <a href="{% url 'management:retrieve' ann.pk %}"> - <h5 class="announcement__title">{{ ann.title }}</h5> - </a> - <span class="announcement__datetime">{{ ann.created_time }}</span> - <p class="announcement__content">{{ ann.get_plain_content | truncate:200 }}</p> - </li> - {% if not forloop.last %} - <div class="dividing-line" style="border-top-style: dashed;"></div> - {% endif %} - {% endfor %} - </ul> - <div class="bottom-link"> - <a href="{% url 'management:list' %}">{% trans '查看全部公告' %}</a> - </div> - </div> - </div> - </div> - <div class="bg-mask"></div> + {% include "partial/_announcement.html" %} {% endif %} - - - <script> - // because the modal and mask elements only exist when there are new announcements - $(".announcement-modal").show(); - $(".bg-mask").show(); - $(".modal-close").on('click', function () { - $(this).parents(".modal").hide(); - $(".bg-mask").hide(); - }); - - </script> - <script src="https://cdnjs.cloudflare.com/ajax/libs/popper.js/2.9.2/umd/popper.min.js" - integrity="sha512-2rNj2KJ+D8s1ceNasTIex6z4HWyOnEYLVC3FigGOmyQCZc2eBXKgOxQmo3oKLHyfcj53uz4QMsRCWNbLd32Q1g==" - crossorigin="anonymous" referrerpolicy="no-referrer"></script> - <script src="https://cdnjs.cloudflare.com/ajax/libs/tippy.js/6.3.1/tippy.umd.min.js" - integrity="sha512-Ns7w8bjVjVcBVa+k3XLt0ObfsG2LQfr573HoIYtC4wh8gUKLvCx+rlggxfvsHqup6jvMAEmBtYXmhcKHL+6R5A==" - crossorigin="anonymous" referrerpolicy="no-referrer"></script> - <script> - tippy('#importHelp', { - content: "{% trans '上传导入由<a href=\"https://github.com/doufen-org/tofu\" target=\"_blank\">豆伴</a>(豆坟)导出的Excel文件,<strong>请勿手动修改该文件</strong>。部分条目由于需要登陆无法自动同步。' %}", - interactive: true, - allowHTML: true, - duration: 0, - }); - tippy('#overwriteHelp', { - content: "{% trans '在导入之前如果已经在本站标记了某一个条目,是否使用来自豆瓣的标记覆盖原有的。' %}", - interactive: true, - allowHTML: true, - duration: 0, - }); - tippy('#visibilityHelp', { - content: "{% trans '所同步的标记可见性是否为公开;对于已有的标记即便覆盖也不会改变可见性。' %}", - interactive: true, - allowHTML: true, - duration: 0, - }); - </script> </body> - - </html> \ No newline at end of file diff --git a/users/templates/users/home_anonymous.html b/users/templates/users/home_anonymous.html new file mode 100644 index 00000000..946ac96e --- /dev/null +++ b/users/templates/users/home_anonymous.html @@ -0,0 +1,17 @@ +{% load static %} +{% load i18n %} +<!DOCTYPE html> +<html lang="en"> +<head> + <meta charset="UTF-8"> + <meta http-equiv="refresh" content="0;URL={{ login_url }}" /> + <title>{{ site_name }} - {{ username }}@{{ site }}</title> + <meta property="og:title" content="{{ site_name }} - {{ username }}@{{ site }}的书影音"> + <meta property="og:type" content="website"> + <meta property="og:url" content="{{ request.build_absolute_uri }}"> + <meta property="og:image" content="{{ request.scheme }}://{{ request.get_host }}{% static 'img/logo_square.jpg' %}"> +</head> +<body> + <a href="https://{{ site }}/@{{ username }}" rel="me" style="display:none;">Mastodon homepage</a> +</body> +</html> \ No newline at end of file diff --git a/users/templates/users/item_list.html b/users/templates/users/item_list.html new file mode 100644 index 00000000..321ad0fe --- /dev/null +++ b/users/templates/users/item_list.html @@ -0,0 +1,94 @@ +{% load static %} +{% load i18n %} +{% load l10n %} +{% load admin_url %} +{% load mastodon %} +{% load oauth_token %} +{% load truncate %} +{% load thumb %} +<!DOCTYPE html> +<html lang="en"> + +<head> + <meta charset="UTF-8"> + <meta name="viewport" content="width=device-width, initial-scale=1.0"> + <title>{{ site_name }} - {{ user.mastodon_username }} {{ list_title }}</title> + <script src="https://static.neodb.social/cdnjs.cloudflare.com/ajax/libs/jquery/3.6.0/jquery.min.js"></script> + <script src="https://static.neodb.social/cdnjs.cloudflare.com/ajax/libs/htmx/1.8.0/htmx.min.js"></script> + <script src="{% static 'lib/js/rating-star.js' %}"></script> + <script src="{% static 'js/rating-star-readonly.js' %}"></script> + <script src="{% static 'js/mastodon.js' %}"></script> + <script src="{% static 'js/home.js' %}"></script> + <link rel="stylesheet" href="{% static 'lib/css/rating-star.css' %}"> + <link rel="stylesheet" href="{% static 'css/boofilsic.min.css' %}"> + <link rel="stylesheet" href="{% static 'lib/css/neo.css' %}"> +</head> + +<body> + <div id="page-wrapper"> + <div id="content-wrapper"> + {% include "partial/_navbar.html" %} + + <section id="content" class="container"> + <div class="grid grid--reverse-order"> + <div class="grid__main grid__main--reverse-order"> + <div class="main-section-wrapper"> + <div class="entity-list"> + + <div class="set"> + <h5 class="entity-list__title"> + {{ user.mastodon_username }} {{ list_title }} + </h5> + </div> + <ul class="entity-list__entities"> + {% for mark in marks %} + {% include "partial/list_item.html" with item=mark.item hide_category=True %} + {% empty %} + <div>{% trans '无结果' %}</div> + {% endfor %} + </ul> + </div> + <div class="pagination"> + + {% if marks.pagination.has_prev %} + <a href="?{% if request.GET.t %}t={{ request.GET.t }}&{% endif %}page=1" class="pagination__nav-link pagination__nav-link">«</a> + <a href="?{% if request.GET.t %}t={{ request.GET.t }}&{% endif %}page={{ marks.previous_page_number }}" + class="pagination__nav-link pagination__nav-link--right-margin pagination__nav-link">‹</a> + {% endif %} + + {% for page in marks.pagination.page_range %} + + {% if page == marks.pagination.current_page %} + <a href="?{% if request.GET.t %}t={{ request.GET.t }}&{% endif %}page={{ page }}" class="pagination__page-link pagination__page-link--current">{{ page }}</a> + {% else %} + <a href="?{% if request.GET.t %}t={{ request.GET.t }}&{% endif %}page={{ page }}" class="pagination__page-link">{{ page }}</a> + {% endif %} + + {% endfor %} + + {% if marks.pagination.has_next %} + <a href="?{% if request.GET.t %}t={{ request.GET.t }}&{% endif %}page={{ marks.next_page_number }}" + class="pagination__nav-link pagination__nav-link--left-margin">›</a> + <a href="?{% if request.GET.t %}t={{ request.GET.t }}&{% endif %}page={{ marks.pagination.last_page }}" class="pagination__nav-link">»</a> + {% endif %} + + </div> + </div> + </div> + + {% include "partial/_sidebar.html" %} + </div> + </section> + </div> + {% include "partial/_footer.html" %} + </div> + + <script> + document.body.addEventListener('htmx:configRequest', (event) => { + event.detail.headers['X-CSRFToken'] = '{{ csrf_token }}'; + }) + </script> +</body> + + +</html> diff --git a/users/templates/users/login.html b/users/templates/users/login.html index 27a0a0fa..d843543c 100644 --- a/users/templates/users/login.html +++ b/users/templates/users/login.html @@ -1,4 +1,3 @@ - {% load i18n %} {% load static %} <!DOCTYPE html> @@ -6,74 +5,81 @@ <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> - <meta property="og:title" content="{% trans 'NiceDB - 登录' %}"> + <meta property="og:title" content="{{ site_name }} - 联邦宇宙书影音游戏标注平台"> + <meta name="description" property="og:description" content="{{ site_name }}致力于为联邦宇宙居民提供一个自由、开放、互联的书籍、电影、音乐和游戏收藏评论空间"> <meta property="og:type" content="website"> <meta property="og:url" content="{{ request.build_absolute_uri }}"> <meta property="og:image" content="{{ request.scheme }}://{{ request.get_host }}{% static 'img/logo_square.jpg' %}"> - <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/skeleton-css@2.0.4/css/normalize.css"> - <link rel="stylesheet" href="{% static 'lib/css/milligram.css' %}"> + <title>{{ site_name }} - {% trans '登录' %}</title> + {% include "partial/_common_libs.html" %} + <link rel="stylesheet" href="https://static.neodb.social/cdnjs.cloudflare.com/ajax/libs/milligram/1.4.1/milligram.min.css"> <link rel="stylesheet" href="{% static 'css/boofilsic_edit.css' %}"> <link rel="stylesheet" href="{% static 'css/boofilsic_box.css' %}"> - <script src="https://cdnjs.cloudflare.com/ajax/libs/jquery/3.5.1/jquery.min.js"></script> - <script src="{% static 'lib/js/js.cookie.min.js' %}"></script> - <title>{% trans 'NiceDB - 登录' %}</title> -</head> -<body> - <style> + <script src="https://static.neodb.social/cdnjs.cloudflare.com/ajax/libs/js-cookie/3.0.1/js.cookie.min.js"></script> + <script> $(document).ready( function() { $('.delayed').remove(); $('#loginButton').prop("disabled", false); } ); </script> + <style type="text/css"> + .delayed { + animation: 10s fadeIn; + animation-fill-mode: forwards; + visibility: hidden; + } + @keyframes fadeIn { + 99% { + visibility: hidden; + } + 100% { + visibility: visible; + opacity: 1; + } + } + input, input[type='text']:focus, input[type='search']:focus{ + border: #84C2FB solid 1px; + } + input[type='submit'] { + background: #84C2FB; + border: #84C2FB solid 1px; + } + input:invalid#domain { + border: #F9A879 dashed 1px; + } + a { + color: #84C2FB; + } select { padding-left: 16px; padding-right: 16px; margin-bottom: 20px;; } </style> +</head> +<body> <div id="loginBox" class="box"> - - <img src="{% static 'img/logo.svg' %}" class="logo" alt="boofilsic logo"> - - <div id="loginButton"> - - + <img src="{% static 'img/logo.svg' %}" class="logo" alt="boofilsic logo"> + <div> {% if user.is_authenticated %} - <a href="{% url 'common:home' %}" class="button">{% trans '前往我的主页' %}</a> + <a href="{% url 'common:home' %}" class="button">{% trans '前往首页' %}</a> + {% else %} + <form action="/users/connect"> + {% if allow_any_site %} + <input type="search" name="domain" id="domain" + pattern="(([a-zA-Z0-9-])+\.)+([a-zA-Z0-9]{2,})" + title="实例域名(不含@和@之前的部分),如mastodon.social" + placeholder="实例域名(不含@和@之前的部分),如mastodon.social" + autocomplete="off" autocorrect="off" autocapitalize="off" spellcheck="false" /> + <input type='submit' value="{% trans '授权登录' %}" id="loginButton" disabled /> + <br><a target="_blank" href="https://about.neodb.social/doc/howto/">{% trans '了解更多' %}</a> + <script type="text/javascript">if (Cookies.get('mastodon_domain')) $('#domain').val(Cookies.get('mastodon_domain'));</script> {% else %} - <select name="sites" id="sitesSelect" placeholder="test"> + <select name="domain" placeholder="test"> {% for site in sites %} <option value="{{ site.domain_name }}" data-client-id="{{ site.client_id }}">@{{ site.domain_name }}</option> {% endfor %} </select> - <button name='login'>{% trans '授权登录' %}</button> + <input type='submit' value="{% trans '授权登录' %}" id="loginButton" /> {% endif %} - + </form> + {% endif %} + <div class="delayed">网页加载超时,请检查网络(翻墙)设置。</div> </div> - - </div> - {% if not user.is_authenticated %} - - - <script> - {% if selected_site %} - $("#sitesSelect").val("{{ selected_site }}"); - {% else %} - $("#sitesSelect").val($("#sitesSelect option:first").val()); - {% endif %} - $("button[name=login]").click(function(e) { - e.preventDefault(); - let selected = $("#sitesSelect").find(":selected"); - let client_id = selected.data("client-id"); - let domain = selected.val(); - - Cookies.set('mastodon_domain', domain); - {% if debug %} - location.href = "https://" + domain + "/oauth/authorize?client_id=" + client_id + - "&scope=read+write&redirect_uri=http://{{ request.get_host }}{% url 'users:OAuth2_login' %}" + - "&response_type=code"; - {% else %} - location.href = "https://" + domain + "/oauth/authorize?client_id=" + client_id + - "&scope=read+write&redirect_uri=https://{{ request.get_host }}{% url 'users:OAuth2_login' %}" + - "&response_type=code"; - {% endif %} - }); - </script> - {% endif %} </body> -</html> \ No newline at end of file +</html> diff --git a/users/templates/users/manage_report.html b/users/templates/users/manage_report.html index 35d3604b..f077a434 100644 --- a/users/templates/users/manage_report.html +++ b/users/templates/users/manage_report.html @@ -10,8 +10,8 @@ <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> - <title>{% trans 'NiceDB - 管理举报' %}</title> - <script src="https://cdnjs.cloudflare.com/ajax/libs/jquery/3.5.1/jquery.min.js"></script> + <title>{{ site_name }} - {% trans '管理举报' %}</title> + <script src="https://static.neodb.social/cdnjs.cloudflare.com/ajax/libs/jquery/3.6.0/jquery.min.js"></script> <script src="{% static 'js/create_update.js' %}"></script> <link rel="stylesheet" href="{% static 'css/boofilsic.min.css' %}"> </head> @@ -27,9 +27,9 @@ {% for report in reports %} <div class="report"> - <a href="{% url 'users:home' report.submit_user.id %}">{{ report.submit_user.username }}</a> + <a href="{% url 'users:home' report.submit_user.mastodon_username %}">{{ report.submit_user.username }}</a> {% trans '举报了' %} - <a href="{% url 'users:home' report.reported_user.id %}">{{ report.reported_user.username }}</a> + <a href="{% url 'users:home' report.reported_user.mastodon_username %}">{{ report.reported_user.username }}</a> @{{ report.submitted_time }} {% if report.image %} @@ -49,12 +49,6 @@ </div> - {% comment %} - <div id="oauth2Token" hidden="true">{% oauth_token %}</div> - <div id="mastodonURI" hidden="true">{% mastodon request.user.mastodon_site %}</div> - <!--current user mastodon id--> - <div id="userMastodonID" hidden="true">{{ user.mastodon_id }}</div> - {% endcomment %} <script> diff --git a/users/templates/users/movie_list.html b/users/templates/users/movie_list.html deleted file mode 100644 index fa21d29a..00000000 --- a/users/templates/users/movie_list.html +++ /dev/null @@ -1,285 +0,0 @@ -{% load static %} -{% load i18n %} -{% load l10n %} -{% load humanize %} -{% load admin_url %} -{% load mastodon %} -{% load oauth_token %} -{% load truncate %} -{% load thumb %} -<!DOCTYPE html> -<html lang="en"> - -<head> - <meta charset="UTF-8"> - <meta name="viewport" content="width=device-width, initial-scale=1.0"> - <title>{% trans 'NiceDB - ' %}{{ user.username }}{{ list_title }}</title> - <script src="https://cdnjs.cloudflare.com/ajax/libs/jquery/3.5.1/jquery.min.js"></script> - <script src="{% static 'lib/js/rating-star.js' %}"></script> - <script src="{% static 'js/rating-star-readonly.js' %}"></script> - <script src="{% static 'js/mastodon.js' %}"></script> - <script src="{% static 'js/home.js' %}"></script> - <link rel="stylesheet" href="{% static 'lib/css/rating-star.css' %}"> - <link rel="stylesheet" href="{% static 'css/boofilsic.min.css' %}"> -</head> - -<body> - <div id="page-wrapper"> - <div id="content-wrapper"> - {% include "partial/_navbar.html" %} - - <section id="content" class="container"> - <div class="grid grid--reverse-order"> - <div class="grid__main grid__main--reverse-order"> - <div class="main-section-wrapper"> - <div class="entity-list"> - - <div class="set"> - <h5 class="entity-list__title"> - {{ user.username }}{{ list_title }} - </h5> - </div> - <ul class="entity-list__entities"> - - {% for mark in marks %} - - <li class="entity-list__entity"> - <div class="entity-list__entity-img-wrapper"> - <a href="{% url 'movies:retrieve' mark.movie.id %}"> - <img src="{{ mark.movie.cover|thumb:'normal' }}" alt="" class="entity-list__entity-img"> - </a> - </div> - <div class="entity-list__entity-text"> - <div class="entity-list__entity-title"> - <a href="{% url 'movies:retrieve' mark.movie.id %}" class="entity-list__entity-link"> - {% if mark.movie.season %} - {{ mark.movie.title }} {% trans '第' %}{{ mark.movie.season|apnumber }}{% trans '季' %} {{ mark.movie.orig_title }} Season - {{ mark.movie.season }} - {% if mark.movie.year %}({{ mark.movie.year }}){% endif %} - - {% else %} - {{ mark.movie.title }} {{ mark.movie.orig_title }} - {% if mark.movie.year %}({{ mark.movie.year }}){% endif %} - {% endif %} - </a> - <a href="{{ mark.movie.source_url }}"> - <span class="source-label source-label__{{ mark.movie.source_site }}">{{ mark.movie.get_source_site_display }}</span> - </a> - </div> - <span class="entity-list__entity-info entity-list__entity-info--full-length"> - - - {% if mark.movie.director %}{% trans '导演' %} - {% for director in mark.movie.director %} - {{ director }}{% if not forloop.last %} {% endif %} - {% endfor %}/ - {% endif %} - - {% if mark.movie.genre %}{% trans '类型' %} - {% for genre in mark.movie.get_genre_display %} - {{ genre }}{% if not forloop.last %} {% endif %} - {% endfor %}/ - {% endif %} - - {% if mark.movie.other_title %}{% trans '又名' %} - {% for other_title in mark.movie.other_title %} - {{ other_title }}{% if not forloop.last %} {% endif %} - {% endfor %} - {% endif %} - </span> - <span class="entity-list__entity-info entity-list__entity-info--full-length"> - {% if mark.movie.actor %}{% trans '主演' %} - {% for actor in mark.movie.actor %} - <span {% if forloop.counter > 5 %}style="display: none;" {% endif %}>{{ actor }}</span> - {% if forloop.counter <= 5 %} - {% if not forloop.counter == 5 %} / {% endif %} - {% endif %} - {% endfor %} - {% endif %} - </span> - <p class="entity-list__entity-brief"> - {{ mark.movie.brief }} - </p> - <div class="tag-collection"> - {% for tag_dict in mark.movie.tag_list %} - {% for k, v in tag_dict.items %} - {% if k == 'content' %} - <span class="tag-collection__tag"> - <a href="{% url 'common:search' %}?tag={{ v }}">{{ v }}</a> - </span> - {% endif %} - {% endfor %} - {% endfor %} - </div> - <div class="clearfix"></div> - <div class="dividing-line dividing-line--dashed"></div> - <div class="entity-marks" style="margin-bottom: 0;"> - <ul class="entity-marks__mark-list"> - <li class="entity-marks__mark"> - - {% if mark.rating %} - <span class="entity-marks__rating-star rating-star" - data-rating-score="{{ mark.rating | floatformat:"0" }}" style="left: -4px;"></span> - {% endif %} - {% if mark.is_private %} - <span class="icon-lock"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20"> - <path - d="M17,8.48h-.73V6.27a6.27,6.27,0,1,0-12.53,0V8.48H3a.67.67,0,0,0-.67.67V19.33A.67.67,0,0,0,3,20H17a.67.67,0,0,0,.67-.67V9.15A.67.67,0,0,0,17,8.48ZM6.42,6.27h0a3.57,3.57,0,0,1,7.14,0h0V8.48H6.42Z" /> - </svg></span> - {% endif %} - <span class="entity-marks__mark-time">{% trans '于' %} {{ mark.edited_time }} {% trans '标记' %}</span> - {% if mark.text %} - <p class="entity-marks__mark-content">{{ mark.text }}</p> - {% endif %} - </li> - </ul> - </div> - </div> - - </li> - {% empty %} - <div>{% trans '无结果' %}</div> - {% endfor %} - <!-- user mark --> - - - </ul> - </div> - <div class="pagination"> - - {% if marks.pagination.has_prev %} - <a href="?page=1" class="pagination__nav-link pagination__nav-link">«</a> - <a href="?page={{ marks.previous_page_number }}" - class="pagination__nav-link pagination__nav-link--right-margin pagination__nav-link">‹</a> - {% endif %} - - {% for page in marks.pagination.page_range %} - - {% if page == marks.pagination.current_page %} - <a href="?page={{ page }}" class="pagination__page-link pagination__page-link--current">{{ page }}</a> - {% else %} - <a href="?page={{ page }}" class="pagination__page-link">{{ page }}</a> - {% endif %} - - {% endfor %} - - {% if marks.pagination.has_next %} - <a href="?page={{ marks.next_page_number }}" - class="pagination__nav-link pagination__nav-link--left-margin">›</a> - <a href="?page={{ marks.pagination.last_page }}" class="pagination__nav-link">»</a> - {% endif %} - - </div> - </div> - </div> - - <div class="grid__aside grid__aside--reverse-order grid__aside--tablet-column"> - <div class="aside-section-wrapper aside-section-wrapper--no-margin"> - <div class="user-profile" id="userInfoCard"> - <div class="user-profile__header"> - <!-- <img src="" class="user-profile__avatar mast-avatar" alt="{{ user.username }}"> --> - <img src="" class="user-profile__avatar mast-avatar"> - <a href="{% url 'users:home' user.id %}"> - <h5 class="user-profile__username mast-displayname"></h5> - </a> - </div> - <p class="user-profile__bio mast-brief"></p> - <!-- <a href="#" class="follow">{% trans '关注TA' %}</a> --> - - {% if request.user != user %} - <a href="{% url 'users:report' %}?user_id={{ user.id }}" - class="user-profile__report-link">{% trans '举报用户' %}</a> - {% endif %} - - </div> - </div> - - <div class="relation-dropdown"> - <div class="relation-dropdown__button"> - <span class="icon-arrow"> - <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 10 10"> - <path d="M8.12,3.29,5,6.42,1.86,3.29H.45L5,7.84,9.55,3.29Z" /> - </svg> - </span> - </div> - <div class="relation-dropdown__body"> - <div class="aside-section-wrapper aside-section-wrapper--transparent aside-section-wrapper--collapse"> - - <div class="user-relation" id="followings"> - <h5 class="user-relation__label"> - {% trans '关注的人' %} - </h5> - <a href="{% url 'users:following' user.id %}" - class="user-relation__more-link mast-following-more">{% trans '更多' %}</a> - <ul class="user-relation__related-user-list mast-following"> - <li class="user-relation__related-user"> - <a> - <img src="" alt="" class="user-relation__related-user-avatar"> - <div class="user-relation__related-user-name mast-displayname"> - </div> - </a> - </li> - </ul> - </div> - - <div class="user-relation" id="followers"> - <h5 class="user-relation__label"> - {% trans '被他们关注' %} - </h5> - <a href="{% url 'users:followers' user.id %}" - class="user-relation__more-link mast-followers-more">{% trans '更多' %}</a> - <ul class="user-relation__related-user-list mast-followers"> - <li class="user-relation__related-user"> - <a> - <img src="" alt="" class="user-relation__related-user-avatar"> - <div class="user-relation__related-user-name mast-displayname"> - </div> - </a> - </li> - </ul> - </div> - - </div> - </div> - </div> - - </div> - </div> - </section> - </div> - {% include "partial/_footer.html" %} - </div> - - - <div id="oauth2Token" hidden="true">{% oauth_token %}</div> - <div id="mastodonURI" hidden="true">{% mastodon request.user.mastodon_site %}</div> - <!--current user mastodon id--> - {% if user == request.user %} - <div id="userMastodonID" hidden="true">{{ user.mastodon_id }}</div> - {% else %} - <div id="userMastodonID" hidden="true">{{ user.target_site_id }}</div> - {% endif %} - <div id="userPageURL" hidden="true">{% url 'users:home' 0 %}</div> - <div id="spinner" hidden> - <div class="spinner"> - <div></div> - <div></div> - <div></div> - <div></div> - <div></div> - <div></div> - <div></div> - <div></div> - <div></div> - <div></div> - <div></div> - <div></div> - </div> - </div> - <script> - - </script> -</body> - - -</html> diff --git a/users/templates/users/music_list.html b/users/templates/users/music_list.html deleted file mode 100644 index 18c0fe74..00000000 --- a/users/templates/users/music_list.html +++ /dev/null @@ -1,290 +0,0 @@ -{% load static %} -{% load i18n %} -{% load l10n %} -{% load humanize %} -{% load admin_url %} -{% load mastodon %} -{% load oauth_token %} -{% load truncate %} -{% load thumb %} - -<!DOCTYPE html> -<html lang="en"> - -<head> - <meta charset="UTF-8"> - <meta name="viewport" content="width=device-width, initial-scale=1.0"> - <title>{% trans 'NiceDB - ' %}{{ user.username }}{{ list_title }}</title> - <script src="https://cdnjs.cloudflare.com/ajax/libs/jquery/3.5.1/jquery.min.js"></script> - <script src="{% static 'lib/js/rating-star.js' %}"></script> - <script src="{% static 'js/rating-star-readonly.js' %}"></script> - <script src="{% static 'js/mastodon.js' %}"></script> - <script src="{% static 'js/home.js' %}"></script> - <link rel="stylesheet" href="{% static 'lib/css/rating-star.css' %}"> - <link rel="stylesheet" href="{% static 'css/boofilsic.min.css' %}"> -</head> - -<body> - <div id="page-wrapper"> - <div id="content-wrapper"> - {% include "partial/_navbar.html" %} - - <section id="content" class="container"> - <div class="grid grid--reverse-order"> - <div class="grid__main grid__main--reverse-order"> - <div class="main-section-wrapper"> - <div class="entity-list"> - - <div class="set"> - <h5 class="entity-list__title"> - {{ user.username }}{{ list_title }} - </h5> - </div> - <ul class="entity-list__entities"> - - {% for mark in marks %} - - {% with mark.music as music %} - - <li class="entity-list__entity"> - <div class="entity-list__entity-img-wrapper"> - {% if music.category_name|lower == 'album' %} - <a href="{% url 'music:retrieve_album' music.id %}"> - <img src="{{ music.cover|thumb:'normal' }}" alt="" class="entity-list__entity-img"> - </a> - {% elif music.category_name|lower == 'song' %} - <a href="{% url 'music:retrieve_song' music.id %}"> - <img src="{{ music.cover|thumb:'normal' }}" alt="" class="entity-list__entity-img"> - </a> - {% endif %} - </div> - <div class="entity-list__entity-text"> - <div class="entity-list__entity-title"> - {% if music.category_name|lower == 'album' %} - <a href="{% url 'music:retrieve_album' music.id %}" class="entity-list__entity-link"> - {{ music.title }} - </a> - {% elif music.category_name|lower == 'song' %} - <a href="{% url 'music:retrieve_song' music.id %}" class="entity-list__entity-link"> - {{ music.title }} - </a> - {% endif %} - <a href="{{ music.source_url }}"> - <span class="source-label source-label__{{ music.source_site }}">{{ music.get_source_site_display }}</span> - </a> - </div> - <span class="entity-list__entity-info "> - {% if music.artist %}{% trans '艺术家' %} - {% for artist in music.artist %} - <span>{{ artist }}</span> - {% if not forloop.last %} {% endif %} - {% endfor %} - {% endif %} - - {% if music.genre %}/ {% trans '流派' %} - {{ music.genre }} - {% endif %} - - {% if music.release_date %}/ {% trans '发行日期' %} - {{ music.release_date }} - {% endif %} - </span> - {% if music.brief %} - <p class="entity-list__entity-brief"> - {{ music.brief }} - </p> - {% elif music.category_name|lower == 'album' %} - <p class="entity-list__entity-brief"> - {% trans '曲目:' %}{{ music.track_list }} - </p> - {% else %} - <!-- song --> - <p class="entity-list__entity-brief"> - {% trans '所属专辑:' %}{{ music.album }} - </p> - {% endif %} - <div class="tag-collection"> - {% for tag_dict in music.tag_list %} - {% for k, v in tag_dict.items %} - {% if k == 'content' %} - <span class="tag-collection__tag"> - <a href="{% url 'common:search' %}?tag={{ v }}">{{ v }}</a> - </span> - {% endif %} - {% endfor %} - {% endfor %} - </div> - <div class="clearfix"></div> - <div class="dividing-line dividing-line--dashed"></div> - <div class="entity-marks" style="margin-bottom: 0;"> - <ul class="entity-marks__mark-list"> - <li class="entity-marks__mark"> - - {% if mark.rating %} - <span class="entity-marks__rating-star rating-star" - data-rating-score="{{ mark.rating | floatformat:"0" }}" style="left: -4px;"></span> - {% endif %} - {% if mark.is_private %} - <span class="icon-lock"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20"> - <path d="M17,8.48h-.73V6.27a6.27,6.27,0,1,0-12.53,0V8.48H3a.67.67,0,0,0-.67.67V19.33A.67.67,0,0,0,3,20H17a.67.67,0,0,0,.67-.67V9.15A.67.67,0,0,0,17,8.48ZM6.42,6.27h0a3.57,3.57,0,0,1,7.14,0h0V8.48H6.42Z" /> - </svg></span> - {% endif %} - <span class="entity-marks__mark-time">{% trans '于' %} {{ mark.edited_time }} {% trans '标记' %}</span> - {% if mark.text %} - <p class="entity-marks__mark-content">{{ mark.text }}</p> - {% endif %} - </li> - </ul> - </div> - </div> - - </li> - - {% endwith %} - - {% empty %} - <div>{% trans '无结果' %}</div> - {% endfor %} - <!-- user mark --> - - - </ul> - </div> - <div class="pagination"> - - {% if marks.pagination.has_prev %} - <a href="?page=1" class="pagination__nav-link pagination__nav-link">«</a> - <a href="?page={{ marks.previous_page_number }}" - class="pagination__nav-link pagination__nav-link--right-margin pagination__nav-link">‹</a> - {% endif %} - - {% for page in marks.pagination.page_range %} - - {% if page == marks.pagination.current_page %} - <a href="?page={{ page }}" class="pagination__page-link pagination__page-link--current">{{ page }}</a> - {% else %} - <a href="?page={{ page }}" class="pagination__page-link">{{ page }}</a> - {% endif %} - - {% endfor %} - - {% if marks.pagination.has_next %} - <a href="?page={{ marks.next_page_number }}" - class="pagination__nav-link pagination__nav-link--left-margin">›</a> - <a href="?page={{ marks.pagination.last_page }}" class="pagination__nav-link">»</a> - {% endif %} - - </div> - </div> - </div> - - <div class="grid__aside grid__aside--reverse-order grid__aside--tablet-column"> - <div class="aside-section-wrapper aside-section-wrapper--no-margin"> - <div class="user-profile" id="userInfoCard"> - <div class="user-profile__header"> - <!-- <img src="" class="user-profile__avatar mast-avatar" alt="{{ user.username }}"> --> - <img src="" class="user-profile__avatar mast-avatar"> - <a href="{% url 'users:home' user.id %}"> - <h5 class="user-profile__username mast-displayname"></h5> - </a> - </div> - <p class="user-profile__bio mast-brief"></p> - <!-- <a href="#" class="follow">{% trans '关注TA' %}</a> --> - - {% if request.user != user %} - <a href="{% url 'users:report' %}?user_id={{ user.id }}" - class="user-profile__report-link">{% trans '举报用户' %}</a> - {% endif %} - - </div> - </div> - - <div class="relation-dropdown"> - <div class="relation-dropdown__button"> - <span class="icon-arrow"> - <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 10 10"> - <path d="M8.12,3.29,5,6.42,1.86,3.29H.45L5,7.84,9.55,3.29Z" /> - </svg> - </span> - </div> - <div class="relation-dropdown__body"> - <div class="aside-section-wrapper aside-section-wrapper--transparent aside-section-wrapper--collapse"> - - <div class="user-relation" id="followings"> - <h5 class="user-relation__label"> - {% trans '关注的人' %} - </h5> - <a href="{% url 'users:following' user.id %}" - class="user-relation__more-link mast-following-more">{% trans '更多' %}</a> - <ul class="user-relation__related-user-list mast-following"> - <li class="user-relation__related-user"> - <a> - <img src="" alt="" class="user-relation__related-user-avatar"> - <div class="user-relation__related-user-name mast-displayname"> - </div> - </a> - </li> - </ul> - </div> - - <div class="user-relation" id="followers"> - <h5 class="user-relation__label"> - {% trans '被他们关注' %} - </h5> - <a href="{% url 'users:followers' user.id %}" - class="user-relation__more-link mast-followers-more">{% trans '更多' %}</a> - <ul class="user-relation__related-user-list mast-followers"> - <li class="user-relation__related-user"> - <a> - <img src="" alt="" class="user-relation__related-user-avatar"> - <div class="user-relation__related-user-name mast-displayname"> - </div> - </a> - </li> - </ul> - </div> - - </div> - </div> - </div> - - </div> - </div> - </section> - </div> - {% include "partial/_footer.html" %} - </div> - - - <div id="oauth2Token" hidden="true">{% oauth_token %}</div> - <div id="mastodonURI" hidden="true">{% mastodon request.user.mastodon_site %}</div> - <!--current user mastodon id--> - {% if user == request.user %} - <div id="userMastodonID" hidden="true">{{ user.mastodon_id }}</div> - {% else %} - <div id="userMastodonID" hidden="true">{{ user.target_site_id }}</div> - {% endif %} - <div id="userPageURL" hidden="true">{% url 'users:home' 0 %}</div> - <div id="spinner" hidden> - <div class="spinner"> - <div></div> - <div></div> - <div></div> - <div></div> - <div></div> - <div></div> - <div></div> - <div></div> - <div></div> - <div></div> - <div></div> - <div></div> - </div> - </div> - <script> - - </script> -</body> - - -</html> diff --git a/users/templates/users/preferences.html b/users/templates/users/preferences.html new file mode 100644 index 00000000..cca66196 --- /dev/null +++ b/users/templates/users/preferences.html @@ -0,0 +1,93 @@ +{% load static %} +{% load i18n %} +{% load admin_url %} +{% load mastodon %} +{% load oauth_token %} +{% load truncate %} +{% load thumb %} +<!DOCTYPE html> +<html lang="en"> + +<head> + <meta charset="UTF-8"> + <meta name="viewport" content="width=device-width, initial-scale=1.0"> + <title>{{ site_name }} - 设置</title> + {% include "partial/_common_libs.html" %} + <script src="{% static 'js/mastodon.js' %}"></script> + <script src="{% static 'js/home.js' %}"></script> +</head> + +<body> + <div id="page-wrapper"> + <div id="content-wrapper"> + {% include "partial/_navbar.html" %} + + <section id="content"> + <div class="grid grid--reverse-order"> + <div class="grid__main grid__main--reverse-order"> + <div class="main-section-wrapper"> + <form action="{% url 'users:preferences' %}" method="POST"> + <div class="tools-section-wrapper"> + <div class="import-panel"> + <h5 class="import-panel__label">{% trans '使用偏好设置' %}</h5> + <div class="import-panel__body"> + {% csrf_token %} + <span>{% trans '新标记默认可见性:' %}</span> + <div class="import-panel__checkbox import-panel__checkbox--last"> + <label for="id_visibility_0"><input type="radio" name="default_visibility" value="0" required="" id="id_visibility_0" {%if request.user.preference.default_visibility == 0 %}checked{% endif %}> + 公开</label> + <label for="id_visibility_1"><input type="radio" name="default_visibility" value="1" required="" id="id_visibility_1" {%if request.user.preference.default_visibility == 1 %}checked{% endif %}> + 仅关注者</label> + <label for="id_visibility_2"><input type="radio" name="default_visibility" value="2" required="" id="id_visibility_2" {%if request.user.preference.default_visibility == 2 %}checked{% endif %}> + 仅自己</label> + </div> + <br> + <span>{% trans '登录后显示个人主页:' %}</span> + <div class="import-panel__checkbox import-panel__checkbox--last"> + <input type="checkbox" name="classic_homepage" id="classic_homepage" {%if request.user.preference.classic_homepage %}checked{% endif %}> + <label for="classic_homepage">{% trans '默认登录后显示好友动态,如果希望登录后显示原版风格个人主页可选中此处' %}</label> + </div> + </div> + </div> + </div> + + <div class="tools-section-wrapper" style="margin-top: 2em;"> + <div class="import-panel"> + <h5 class="import-panel__label">{% trans '社交网络分享相关设置' %}</h5> + <div class="import-panel__body"> + {% csrf_token %} + <span>{% trans '在联邦网络上以公开方式分享的帖文是否发布到公共时间轴上:' %}</span> + <div class="import-panel__checkbox import-panel__checkbox--last"> + <input type="checkbox" name="mastodon_publish_public" id="visibility" {%if request.user.preference.mastodon_publish_public %}checked{% endif %}> + <label for="visibility">{% trans '选中时为public,未选中时为unlisted' %}</label> + </div> + <br><br> + <span>{% trans '在联邦网络上分享帖文时附加标签:' %}</span> + <div class="import-panel__checkbox import-panel__checkbox--last"> + <input name="mastodon_append_tag" id="tag" placeholder="#我的书影音" value="{{ request.user.preference.mastodon_append_tag }}" > + <label for="tag">{% trans '输入标签文字会被添加到帖文结尾' %}</label> + </div> + </div> + </div> + </div> + + <div style="margin-top: 2em;"> + <input type="submit" class="import-panel__button" value="{% trans '保存' %}"> + </div> + </form> + </div> + </div> + + {% include "partial/_sidebar.html" %} + </div> + </section> + + </div> + + {% include "partial/_footer.html" %} + </div> + +</body> + + +</html> \ No newline at end of file diff --git a/users/templates/users/register.html b/users/templates/users/register.html index 9b750a66..cb63b5c6 100644 --- a/users/templates/users/register.html +++ b/users/templates/users/register.html @@ -6,11 +6,10 @@ <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> - <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/skeleton-css@2.0.4/css/normalize.css"> - <link rel="stylesheet" href="{% static 'lib/css/milligram.css' %}"> + <link rel="stylesheet" href="https://static.neodb.social/cdnjs.cloudflare.com/ajax/libs/milligram/1.4.1/milligram.min.css"> <link rel="stylesheet" href="{% static 'css/boofilsic_edit.css' %}"> <link rel="stylesheet" href="{% static 'css/boofilsic_box.css' %}"> - <title>{% trans 'NiceDB - 注册' %}</title> + <title>{{ site_name }} - {% trans '注册' %}</title> </head> <body> @@ -19,18 +18,18 @@ <img src="{% static 'img/logo.svg' %}" class="logo" alt="boofilsic logo"> <div id="loginButton"> - <p>欢迎来到NiceDB书影音!</p> + <p>欢迎来到{{ site_name }}!</p> <p> - NiceDB书影音继承了长毛象的用户关系,比如您在里瓣屏蔽了某人,那您将不会在书影音的公共区域看到TA的痕迹。 - 这里仍是一片处女地,丰富的内容需要大家共同创造。 - 请注意虽然您可以随意发表任何言论,但试图添加垃圾数据到公共数据领域(如添加不存在的乱码的书籍)将会受到制裁! - BTW欧盟惯例本站使用了Cookie,请理解! + {{ site_name }}还在不断完善中,丰富的内容需要大家共同创造。 + 试图添加垃圾数据(如添加信息混乱或缺失的书籍、以推广为主要目的的评论)将会受到严肃处理。 + {{ site_name }}继承了联邦宇宙的用户关系,比如您在联邦宇宙屏蔽了某人,那您将不会在书影音的公共区域看到TA的痕迹。 + 本站为非盈利站点,cookie和其他数据保管使用原则请参阅<a href="/announcement/data-policy/">站内公告</a>。 </p> <p> - 此外NiceDB书影音现处于“公开阿尔法测试”阶段,您的数据存在丢失的可能。使用过程中遇到的问题或者Bug欢迎向<a href="https://donotban.com/@whitiewhite">作者</a>提出。 + 此外,{{ site_name }}现处于测试阶段,疏漏在所难免,请妥善备份您的数据。 + 使用过程中遇到的问题或者错误欢迎向<a href="{{ support_link }}">维护者</a>提出。感谢理解和支持! </p> - <form action="{% url 'users:register' %}" method="post"> - {% csrf_token %} + <form action="{% url 'common:home' %}"> <input type="submit" class="button" value="{% trans 'Cut the sh*t and get me in!' %}"> </form> diff --git a/users/templates/users/relation_list.html b/users/templates/users/relation_list.html index 6984bbd3..ae14e2ea 100644 --- a/users/templates/users/relation_list.html +++ b/users/templates/users/relation_list.html @@ -11,11 +11,13 @@ <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> {% if is_followers_page %} - <title>{% trans 'NiceDB - 被他们关注' %}</title> + <title>{{ site_name }} - {% trans '被他们关注' %}</title> {% else %} - <title>{% trans 'NiceDB - 关注的人' %}</title> + <title>{{ site_name }} - {% trans '关注的人' %}</title> {% endif %} - <script src="https://cdnjs.cloudflare.com/ajax/libs/jquery/3.5.1/jquery.min.js"></script> + + {% include "partial/_common_libs.html" with jquery=1 %} + <script src="{% static 'js/mastodon.js' %}"></script> {% if is_followers_page %} @@ -23,8 +25,6 @@ {% else %} <script src="{% static 'js/following_list.js' %}"></script> {% endif %} - - <link rel="stylesheet" href="{% static 'css/boofilsic.min.css' %}"> </head> <body> @@ -64,7 +64,7 @@ <div class="user-profile__header"> <!-- <img src="" class="user-profile__avatar mast-avatar" alt="{{ user.username }}"> --> <img src="" class="user-profile__avatar mast-avatar"> - <a href="{% url 'users:home' user.id %}"> + <a href="{% url 'users:home' user.mastodon_username %}"> <h5 class="user-profile__username mast-displayname"></h5> </a> </div> @@ -94,7 +94,7 @@ <h5 class="user-relation__label"> {% trans '关注的人' %} </h5> - <a href="{% url 'users:following' user.id %}" + <a href="{% url 'users:following' user.mastodon_username %}" class="user-relation__more-link mast-following-more">{% trans '更多' %}</a> <ul class="user-relation__related-user-list mast-following"> <li class="user-relation__related-user"> @@ -110,7 +110,7 @@ <h5 class="user-relation__label"> {% trans '被他们关注' %} </h5> - <a href="{% url 'users:followers' user.id %}" + <a href="{% url 'users:followers' user.mastodon_username %}" class="user-relation__more-link mast-followers-more">{% trans '更多' %}</a> <ul class="user-relation__related-user-list mast-followers"> <li class="user-relation__related-user"> @@ -140,7 +140,7 @@ {% if user == request.user %} <div id="userMastodonID" hidden="true">{{ user.mastodon_id }}</div> {% else %} - <div id="userMastodonID" hidden="true">{{ user.target_site_id }}</div> + <div id="userMastodonID" hidden="true"></div> {% endif %} <div id="userPageURL" hidden="true">{% url 'users:home' 0 %}</div> <div id="spinner" hidden> diff --git a/users/templates/users/report.html b/users/templates/users/report.html index 5f60738e..ea47b64a 100644 --- a/users/templates/users/report.html +++ b/users/templates/users/report.html @@ -10,8 +10,8 @@ <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> - <title>{% trans 'NiceDB - 举报用户' %}</title> - <script src="https://cdnjs.cloudflare.com/ajax/libs/jquery/3.5.1/jquery.min.js"></script> + <title>{{ site_name }} - {% trans '举报用户' %}</title> + <script src="https://static.neodb.social/cdnjs.cloudflare.com/ajax/libs/jquery/3.6.0/jquery.min.js"></script> <script src="{% static 'js/create_update.js' %}"></script> <link rel="stylesheet" href="{% static 'css/boofilsic.min.css' %}"> </head> @@ -38,12 +38,6 @@ </div> - {% comment %} - <div id="oauth2Token" hidden="true">{% oauth_token %}</div> - <div id="mastodonURI" hidden="true">{% mastodon request.user.mastodon_site %}</div> - <!--current user mastodon id--> - <div id="userMastodonID" hidden="true">{{ user.mastodon_id }}</div> - {% endcomment %} <script> diff --git a/users/templates/users/tags.html b/users/templates/users/tags.html new file mode 100644 index 00000000..2d9c738f --- /dev/null +++ b/users/templates/users/tags.html @@ -0,0 +1,110 @@ +{% load static %} +{% load i18n %} +{% load l10n %} +{% load humanize %} +{% load admin_url %} +{% load mastodon %} +{% load oauth_token %} +{% load truncate %} +{% load highlight %} +{% load thumb %} +<!DOCTYPE html> +<html lang="en"> + +<head> + <meta charset="UTF-8"> + <meta name="viewport" content="width=device-width, initial-scale=1.0"> + <title>{{ site_name }} - 我的标签</title> + <script src="https://static.neodb.social/cdnjs.cloudflare.com/ajax/libs/jquery/3.6.0/jquery.min.js"></script> + <script src="{% static 'lib/js/rating-star.js' %}"></script> + <script src="{% static 'js/rating-star-readonly.js' %}"></script> + <link rel="stylesheet" href="{% static 'lib/css/rating-star.css' %}"> + <link rel="stylesheet" href="{% static 'css/boofilsic.min.css' %}"> + <script src="{% static 'js/mastodon.js' %}"></script> + <script src="{% static 'js/home.js' %}"></script> +</head> + +<body> + <div id="page-wrapper"> + <div id="content-wrapper"> + {% include "partial/_navbar.html" %} + + <section id="content"> + <div class="grid"> + <div class="grid__main" id="main"> + <div class="main-section-wrapper"> + <div class="entity-reviews"> + <div class="tag-collection entity-reviews__review-list"> + <h5>{% trans '书籍' %}</h5> + {% for v in book_tags %} + <span style="display: inline-block;margin: 4px;"> + <span class="tag-collection__tag" style="display:inline;float:none;"> + <a href="{% url 'users:book_list' user.mastodon_username 'tagged' %}?t={{ v.content }}">{{ v.content }}</a> + </span> + <span class="entity-reviews__review-time">({{ v.total }})</span> + </span> + {% empty %} + {% trans '暂无标签' %} + {% endfor %} + <div class="clearfix" style="margin-bottom: 16px;"></div> + + <h5>{% trans '电影和剧集' %}</h5> + {% for v in movie_tags %} + <span style="display: inline-block;margin: 4px;"> + <span class="tag-collection__tag" style="display:inline;float:none;"> + <a href="{% url 'users:movie_list' user.mastodon_username 'tagged' %}?t={{ v.content }}">{{ v.content }}</a> + </span> + <span class="entity-reviews__review-time">({{ v.total }})</span> + </span> + {% empty %} + {% trans '暂无标签' %} + {% endfor %} + <div class="clearfix" style="margin-bottom: 16px;"></div> + + <h5>{% trans '音乐' %}</h5> + {% for v in music_tags %} + <span style="display: inline-block;margin: 4px;"> + <span class="tag-collection__tag" style="display:inline;float:none;"> + <a href="{% url 'users:music_list' user.mastodon_username 'tagged' %}?t={{ v.content }}">{{ v.content }}</a> + </span> + <span class="entity-reviews__review-time">({{ v.total }})</span> + </span> + {% empty %} + {% trans '暂无标签' %} + {% endfor %} + <div class="clearfix" style="margin-bottom: 16px;"></div> + + <h5>{% trans '游戏' %}</h5> + {% for v in game_tags %} + <span style="display: inline-block;margin: 4px;"> + <span class="tag-collection__tag" style="display:inline;float:none;"> + <a href="{% url 'users:game_list' user.mastodon_username 'tagged' %}?t={{ v.content }}">{{ v.content }}</a> + </span> + <span class="entity-reviews__review-time">({{ v.total }})</span> + </span> + {% empty %} + {% trans '暂无标签' %} + {% endfor %} + <div class="clearfix" style="margin-bottom: 16px;"></div> + </div> + </div> + </div> + </div> + + {% include "partial/_sidebar.html" %} + + </div> + </section> + </div> + {% include "partial/_footer.html" %} + </div> + + + + <script> + + </script> +</body> + + +</html> diff --git a/users/urls.py b/users/urls.py index 121c1afe..3b0d2b34 100644 --- a/users/urls.py +++ b/users/urls.py @@ -5,22 +5,38 @@ app_name = 'users' urlpatterns = [ path('login/', login, name='login'), path('register/', register, name='register'), + path('connect/', connect, name='connect'), + path('reconnect/', reconnect, name='reconnect'), + path('data/', data, name='data'), + path('data/import_goodreads', import_goodreads, name='import_goodreads'), + path('data/import_douban', import_douban, name='import_douban'), + path('data/export_reviews', export_reviews, name='export_reviews'), + path('data/export_marks', export_marks, name='export_marks'), + path('data/sync_mastodon', sync_mastodon, name='sync_mastodon'), + path('data/reset_visibility', reset_visibility, name='reset_visibility'), + path('data/clear_data', clear_data, name='clear_data'), + path('preferences/', preferences, name='preferences'), path('logout/', logout, name='logout'), - path('delete/', delete, name='delete'), path('layout/', set_layout, name='set_layout'), path('OAuth2_login/', OAuth2_login, name='OAuth2_login'), - path('<int:id>/', home, name='home'), - path('<int:id>/followers/', followers, name='followers'), - path('<int:id>/following/', following, name='following'), - path('<int:id>/book/<str:status>/', book_list, name='book_list'), - path('<int:id>/movie/<str:status>/', movie_list, name='movie_list'), - path('<int:id>/music/<str:status>/', music_list, name='music_list'), - path('<int:id>/game/<str:status>/', game_list, name='game_list'), + path('<int:id>/', home_redirect, name='home_redirect'), + # path('<int:id>/followers/', followers, name='followers'), + # path('<int:id>/following/', following, name='following'), + # path('<int:id>/collections/', collection_list, name='collection_list'), + # path('<int:id>/book/<str:status>/', book_list, name='book_list'), + # path('<int:id>/movie/<str:status>/', movie_list, name='movie_list'), + # path('<int:id>/music/<str:status>/', music_list, name='music_list'), + # path('<int:id>/game/<str:status>/', game_list, name='game_list'), path('<str:id>/', home, name='home'), path('<str:id>/followers/', followers, name='followers'), path('<str:id>/following/', following, name='following'), + path('<str:id>/tags/', tag_list, name='tag_list'), + path('<str:id>/collections/', collection_list, name='collection_list'), + path('<str:id>/collections/marked/', marked_collection_list, name='marked_collection_list'), path('<str:id>/book/<str:status>/', book_list, name='book_list'), path('<str:id>/movie/<str:status>/', movie_list, name='movie_list'), + path('<str:id>/music/<str:status>/', music_list, name='music_list'), + path('<str:id>/game/<str:status>/', game_list, name='game_list'), path('report/', report, name='report'), path('manage_report/', manage_report, name='manage_report'), ] diff --git a/users/views.py b/users/views.py index ff77d066..e216f806 100644 --- a/users/views.py +++ b/users/views.py @@ -5,11 +5,10 @@ from django.contrib import auth from django.contrib.auth import authenticate from django.core.paginator import Paginator from django.utils.translation import gettext_lazy as _ -from django.core.exceptions import ObjectDoesNotExist +from django.core.exceptions import ObjectDoesNotExist, PermissionDenied from django.db.models import Count from .models import User, Report, Preference from .forms import ReportForm -from mastodon.auth import * from mastodon.api import * from mastodon import mastodon_request_included from common.config import * @@ -25,156 +24,69 @@ from movies.forms import MovieMarkStatusTranslator from music.forms import MusicMarkStatusTranslator from games.forms import GameMarkStatusTranslator from mastodon.models import MastodonApplication +from mastodon.api import verify_account +from django.conf import settings +from urllib.parse import quote +import django_rq +from .account import * +from .data import * +from datetime import timedelta +from django.utils import timezone +import json +from django.contrib import messages +from books.models import BookMark, BookReview +from movies.models import MovieMark, MovieReview +from games.models import GameMark, GameReview +from music.models import AlbumMark, SongMark, AlbumReview, SongReview +from collection.models import Collection +from common.importers.goodreads import GoodreadsImporter +from common.importers.douban import DoubanImporter -# Views -######################################## - -# no page rendered -@mastodon_request_included -def OAuth2_login(request): - """ oauth authentication and logging user into django system """ - if request.method == 'GET': - code = request.GET.get('code') - site = request.COOKIES.get('mastodon_domain') - - # Network IO - try: - token = obtain_token(site, request, code) - except ObjectDoesNotExist: - return HttpResponseBadRequest("Mastodon site not registered") - if token: - # oauth is completed when token aquired - user = authenticate(request, token=token, site=site) - if user: - auth_login(request, user, token) - if request.session.get('next_url') is not None: - response = redirect(request.session.get('next_url')) - del request.session['next_url'] - else: - response = redirect(reverse('common:home')) - - response.delete_cookie('mastodon_domain') - return response - else: - # will be passed to register page - request.session['new_user_token'] = token - return redirect(reverse('users:register')) - else: - return render( - request, - 'common/error.html', - { - 'msg': _("认证失败😫") - } - ) - else: - return HttpResponseBadRequest() +def render_user_not_found(request): + msg = _("😖哎呀,这位用户还没有加入本站,快去联邦宇宙呼唤TA来注册吧!") + sec_msg = _("") + return render( + request, + 'common/error.html', + { + 'msg': msg, + 'secondary_msg': sec_msg, + } + ) -# the 'login' page that user can see -def login(request): - if request.method == 'GET': - selected_site = request.GET.get('site', default='') +def home_redirect(request, id): + try: + query_kwargs = {'pk': id} + user = User.objects.get(**query_kwargs) + return redirect(reverse("users:home", args=[user.mastodon_username])) + except Exception: + return redirect(settings.LOGIN_URL) - sites = MastodonApplication.objects.all().order_by("domain_name") - # store redirect url in the cookie - if request.GET.get('next'): - request.session['next_url'] = request.GET.get('next') - - return render( - request, - 'users/login.html', - { - 'sites': sites, - 'selected_site': selected_site, - } - ) - else: - return HttpResponseBadRequest() +def home_anonymous(request, id): + login_url = settings.LOGIN_URL + "?next=" + request.get_full_path() + try: + username = id.split('@')[0] + site = id.split('@')[1] + return render(request, 'users/home_anonymous.html', { + 'login_url': login_url, + 'username': username, + 'site': site, + }) + except Exception: + return redirect(login_url) @mastodon_request_included -@login_required -def logout(request): - if request.method == 'GET': - revoke_token(request.user.mastodon_site, request.session['oauth_token']) - auth_logout(request) - return redirect(reverse("users:login")) - else: - return HttpResponseBadRequest() - - -@mastodon_request_included -def register(request): - """ register confirm page """ - if request.method == 'GET': - if request.session.get('oauth_token'): - return redirect(reverse('common:home')) - elif request.session.get('new_user_token'): - return render( - request, - 'users/register.html' - ) - else: - return HttpResponseBadRequest() - elif request.method == 'POST': - token = request.session['new_user_token'] - user_data = get_user_data(request.COOKIES['mastodon_domain'], token) - if user_data is None: - return render( - request, - 'common/error.html', - { - 'msg': _("长毛象访问失败😫") - } - ) - new_user = User( - username=user_data['username'], - mastodon_id=user_data['id'], - mastodon_site=request.COOKIES['mastodon_domain'], - ) - new_user.save() - del request.session['new_user_token'] - auth_login(request, new_user, token) - response = redirect(reverse('common:home')) - response.delete_cookie('mastodon_domain') - return response - else: - return HttpResponseBadRequest() - - -def delete(request): - raise NotImplementedError - - -@mastodon_request_included -@login_required def home(request, id): + if not request.user.is_authenticated: + return home_anonymous(request, id) if request.method == 'GET': - if isinstance(id, str): - try: - username = id.split('@')[0] - site = id.split('@')[1] - except IndexError as e: - return HttpResponseBadRequest("Invalid user id") - query_kwargs = {'username': username, 'mastodon_site': site} - elif isinstance(id, int): - query_kwargs = {'pk': id} - try: - user = User.objects.get(**query_kwargs) - except ObjectDoesNotExist: - msg = _("😖哎呀这位老师还没有注册书影音呢,快去长毛象喊TA来吧!") - sec_msg = _("目前只开放本站用户注册") - return render( - request, - 'common/error.html', - { - 'msg': msg, - 'secondary_msg': sec_msg, - } - ) + user = User.get(id) + if user is None: + return render_user_not_found(request) # access one's own home page if user == request.user: @@ -194,23 +106,19 @@ def home(request, id): album_marks = request.user.user_albummarks.all() song_marks = request.user.user_songmarks.all() game_marks = request.user.user_gamemarks.all() - - latest_task = user.user_synctasks.order_by("-id").first() + book_reviews = request.user.user_bookreviews.all() + movie_reviews = request.user.user_moviereviews.all() + album_reviews = request.user.user_albumreviews.all() + song_reviews = request.user.user_songreviews.all() + game_reviews = request.user.user_gamereviews.all() # visit other's home page else: - latest_task = None # no these value on other's home page reports = None unread_announcements = None - # cross site info for visiting other's home page - user.target_site_id = get_cross_site_id( - user, request.user.mastodon_site, request.session['oauth_token']) - - # make queries - relation = get_relationship(request.user, user, request.session['oauth_token'])[0] - if relation['blocked_by']: + if request.user.is_blocked_by(user) or request.user.is_blocking(user): msg = _("你没有访问TA主页的权限😥") return render( request, @@ -219,20 +127,28 @@ def home(request, id): 'msg': msg, } ) - book_marks = BookMark.get_available_by_user(user, relation['following']) - movie_marks = MovieMark.get_available_by_user(user, relation['following']) - song_marks = SongMark.get_available_by_user(user, relation['following']) - album_marks = AlbumMark.get_available_by_user(user, relation['following']) - game_marks = GameMark.get_available_by_user(user, relation['following']) + is_following = request.user.is_following(user) + book_marks = BookMark.get_available_by_user(user, is_following) + movie_marks = MovieMark.get_available_by_user(user, is_following) + song_marks = SongMark.get_available_by_user(user, is_following) + album_marks = AlbumMark.get_available_by_user(user, is_following) + game_marks = GameMark.get_available_by_user(user, is_following) + book_reviews = BookReview.get_available_by_user(user, is_following) + movie_reviews = MovieReview.get_available_by_user(user, is_following) + song_reviews = SongReview.get_available_by_user(user, is_following) + album_reviews = AlbumReview.get_available_by_user(user, is_following) + game_reviews = GameReview.get_available_by_user(user, is_following) + collections = Collection.objects.filter(owner=user) + marked_collections = Collection.objects.filter(pk__in=CollectionMark.objects.filter(owner=user).values_list('collection', flat=True)) # book marks - filtered_book_marks = filter_marks(book_marks, BOOKS_PER_SET, 'book') + filtered_book_marks = filter_marks(book_marks, BOOKS_PER_SET, 'book') book_marks_count = count_marks(book_marks, "book") # movie marks filtered_movie_marks = filter_marks(movie_marks, MOVIES_PER_SET, 'movie') - movie_marks_count= count_marks(movie_marks, "movie") + movie_marks_count = count_marks(movie_marks, "movie") # game marks filtered_game_marks = filter_marks(game_marks, GAMES_PER_SET, 'game') @@ -241,7 +157,6 @@ def home(request, id): # music marks filtered_music_marks = filter_marks([song_marks, album_marks], MUSIC_PER_SET, 'music') music_marks_count = count_marks([song_marks, album_marks], "music") - for mark in filtered_music_marks["do_music_marks"] +\ filtered_music_marks["wish_music_marks"] +\ @@ -251,12 +166,12 @@ def home(request, id): mark.type = "album" else: mark.type = "song" - - try: - layout = user.preference.get_serialized_home_layout() - except ObjectDoesNotExist: - Preference.objects.create(user=user) - layout = user.preference.get_serialized_home_layout() + + music_reviews = list(album_reviews.order_by("-edited_time")) + list(song_reviews.order_by("-edited_time")) + for review in music_reviews: + review.type = 'album' if review.__class__ == AlbumReview else 'song' + + layout = user.get_preference().get_serialized_home_layout() return render( request, @@ -271,10 +186,36 @@ def home(request, id): **movie_marks_count, **music_marks_count, **game_marks_count, + + 'book_tags': BookTag.all_by_user(user)[:10] if user == request.user else [], + 'movie_tags': MovieTag.all_by_user(user)[:10] if user == request.user else [], + 'music_tags': AlbumTag.all_by_user(user)[:10] if user == request.user else [], + 'game_tags': GameTag.all_by_user(user)[:10] if user == request.user else [], + + 'book_reviews': book_reviews.order_by("-edited_time")[:BOOKS_PER_SET], + 'movie_reviews': movie_reviews.order_by("-edited_time")[:MOVIES_PER_SET], + 'music_reviews': music_reviews[:MUSIC_PER_SET], + 'game_reviews': game_reviews[:GAMES_PER_SET], + 'book_reviews_more': book_reviews.count() > BOOKS_PER_SET, + 'movie_reviews_more': movie_reviews.count() > MOVIES_PER_SET, + 'music_reviews_more': len(music_reviews) > MUSIC_PER_SET, + 'game_reviews_more': game_reviews.count() > GAMES_PER_SET, + 'book_reviews_count': book_reviews.count(), + 'movie_reviews_count': movie_reviews.count(), + 'music_reviews_count': len(music_reviews), + 'game_reviews_count': game_reviews.count(), + + 'collections': collections.order_by("-edited_time")[:BOOKS_PER_SET], + 'collections_count': collections.count(), + 'collections_more': collections.count() > BOOKS_PER_SET, + + 'marked_collections': marked_collections.order_by("-edited_time")[:BOOKS_PER_SET], + 'marked_collections_count': marked_collections.count(), + 'marked_collections_more': marked_collections.count() > BOOKS_PER_SET, + 'layout': layout, 'reports': reports, 'unread_announcements': unread_announcements, - 'latest_task': latest_task, } ) else: @@ -283,7 +224,7 @@ def home(request, id): def filter_marks(querysets, maximum, type_name): """ - Filter marks by amount limits and order them edited time, store results in a dict, + Filter marks by amount limits and order them edited time, store results in a dict, which could be directly used in template. @param querysets: one queryset or multiple querysets as a list """ @@ -295,9 +236,9 @@ def filter_marks(querysets, maximum, type_name): marks = [] count = 0 for queryset in querysets: - marks += list(queryset.filter(status=MarkStatusEnum[status.upper()]).order_by("-edited_time")[:maximum]) + marks += list(queryset.filter(status=MarkStatusEnum[status.upper()]).order_by("-created_time")[:maximum]) count += queryset.filter(status=MarkStatusEnum[status.upper()]).count() - + # marks marks = sorted(marks, key=lambda e: e.edited_time, reverse=True)[:maximum] result[f"{status}_{type_name}_marks"] = marks @@ -309,6 +250,7 @@ def filter_marks(querysets, maximum, type_name): return result + def count_marks(querysets, type_name): """ Count all available marks, then assembly a dict to be used in template @@ -327,44 +269,11 @@ def count_marks(querysets, type_name): @mastodon_request_included @login_required -def followers(request, id): +def followers(request, id): if request.method == 'GET': - if isinstance(id, str): - try: - username = id.split('@')[0] - site = id.split('@')[1] - except IndexError as e: - return HttpResponseBadRequest("Invalid user id") - query_kwargs = {'username': username, 'mastodon_site': site} - elif isinstance(id, int): - query_kwargs = {'pk': id} - try: - user = User.objects.get(**query_kwargs) - except ObjectDoesNotExist: - msg = _("😖哎呀这位老师还没有注册书影音呢,快去长毛象喊TA来吧!") - sec_msg = _("目前只开放本站用户注册") - return render( - request, - 'common/error.html', - { - 'msg': msg, - 'secondary_msg': sec_msg, - } - ) - # mastodon request - if not user == request.user: - relation = get_relationship(request.user, user, request.session['oauth_token'])[0] - if relation['blocked_by']: - msg = _("你没有访问TA主页的权限😥") - return render( - request, - 'common/error.html', - { - 'msg': msg, - } - ) - user.target_site_id = get_cross_site_id( - user, request.user.mastodon_site, request.session['oauth_token']) + user = User.get(id) + if user is None or user != request.user: + return render_user_not_found(request) return render( request, 'users/relation_list.html', @@ -381,42 +290,9 @@ def followers(request, id): @login_required def following(request, id): if request.method == 'GET': - if isinstance(id, str): - try: - username = id.split('@')[0] - site = id.split('@')[1] - except IndexError as e: - return HttpResponseBadRequest("Invalid user id") - query_kwargs = {'username': username, 'mastodon_site': site} - elif isinstance(id, int): - query_kwargs = {'pk': id} - try: - user = User.objects.get(**query_kwargs) - except ObjectDoesNotExist: - msg = _("😖哎呀这位老师还没有注册书影音呢,快去长毛象喊TA来吧!") - sec_msg = _("目前只开放本站用户注册") - return render( - request, - 'common/error.html', - { - 'msg': msg, - 'secondary_msg': sec_msg, - } - ) - # mastodon request - if not user == request.user: - relation = get_relationship(request.user, user, request.session['oauth_token'])[0] - if relation['blocked_by']: - msg = _("你没有访问TA主页的权限😥") - return render( - request, - 'common/error.html', - { - 'msg': msg, - } - ) - user.target_site_id = get_cross_site_id( - user, request.user.mastodon_site, request.session['oauth_token']) + user = User.get(id) + if user is None or user != request.user: + return render_user_not_found(request) return render( request, 'users/relation_list.html', @@ -433,35 +309,15 @@ def following(request, id): @login_required def book_list(request, id, status): if request.method == 'GET': - if not status.upper() in MarkStatusEnum.names: + if status.upper() not in MarkStatusEnum.names and status not in ['reviewed', 'tagged']: return HttpResponseBadRequest() - - if isinstance(id, str): - try: - username = id.split('@')[0] - site = id.split('@')[1] - except IndexError as e: - return HttpResponseBadRequest("Invalid user id") - query_kwargs = {'username': username, 'mastodon_site': site} - elif isinstance(id, int): - query_kwargs = {'pk': id} - try: - user = User.objects.get(**query_kwargs) - except ObjectDoesNotExist: - msg = _("😖哎呀这位老师还没有注册书影音呢,快去长毛象喊TA来吧!") - sec_msg = _("目前只开放本站用户注册") - return render( - request, - 'common/error.html', - { - 'msg': msg, - 'secondary_msg': sec_msg, - } - ) - if not user == request.user: - # mastodon request - relation = get_relationship(request.user, user, request.session['oauth_token'])[0] - if relation['blocked_by']: + + user = User.get(id) + if user is None: + return render_user_not_found(request) + tag = request.GET.get('t', default='') + if user != request.user: + if request.user.is_blocked_by(user) or request.user.is_blocking(user): msg = _("你没有访问TA主页的权限😥") return render( request, @@ -470,13 +326,22 @@ def book_list(request, id, status): 'msg': msg, } ) - queryset = BookMark.get_available_by_user(user, relation['following']).filter( - status=MarkStatusEnum[status.upper()]).order_by("-edited_time") - user.target_site_id = get_cross_site_id( - user, request.user.mastodon_site, request.session['oauth_token']) + is_following = request.user.is_following(user) + if status == 'reviewed': + queryset = BookReview.get_available_by_user(user, is_following).order_by("-edited_time") + elif status == 'tagged': + queryset = BookTag.find_by_user(tag, user, request.user).order_by("-mark__created_time") + else: + queryset = BookMark.get_available_by_user(user, is_following).filter( + status=MarkStatusEnum[status.upper()]).order_by("-created_time") else: - queryset = BookMark.objects.filter( - owner=user, status=MarkStatusEnum[status.upper()]).order_by("-edited_time") + if status == 'reviewed': + queryset = BookReview.objects.filter(owner=user).order_by("-edited_time") + elif status == 'tagged': + queryset = BookTag.objects.filter(content=tag, mark__owner=user).order_by("-mark__created_time") + else: + queryset = BookMark.objects.filter( + owner=user, status=MarkStatusEnum[status.upper()]).order_by("-created_time") paginator = Paginator(queryset, ITEMS_PER_PAGE) page_number = request.GET.get('page', default=1) marks = paginator.get_page(page_number) @@ -484,14 +349,20 @@ def book_list(request, id, status): mark.book.tag_list = mark.book.get_tags_manager().values('content').annotate( tag_frequency=Count('content')).order_by('-tag_frequency')[:TAG_NUMBER_ON_LIST] marks.pagination = PageLinksGenerator(PAGE_LINK_NUMBER, page_number, paginator.num_pages) - list_title = str(BookMarkStatusTranslator(MarkStatusEnum[status.upper()])) + str(_("的书")) + if status == 'reviewed': + list_title = str(_("评论过的书")) + elif status == 'tagged': + list_title = str(_(f"标记为「{tag}」的书")) + else: + list_title = str(BookMarkStatusTranslator(MarkStatusEnum[status.upper()])) + str(_("的书")) return render( request, - 'users/book_list.html', + 'users/item_list.html', { 'marks': marks, 'user': user, - 'list_title' : list_title, + 'status': status, + 'list_title': list_title, } ) else: @@ -502,35 +373,15 @@ def book_list(request, id, status): @login_required def movie_list(request, id, status): if request.method == 'GET': - if not status.upper() in MarkStatusEnum.names: + if status.upper() not in MarkStatusEnum.names and status not in ['reviewed', 'tagged']: return HttpResponseBadRequest() - if isinstance(id, str): - try: - username = id.split('@')[0] - site = id.split('@')[1] - except IndexError as e: - return HttpResponseBadRequest("Invalid user id") - query_kwargs = {'username': username, 'mastodon_site': site} - elif isinstance(id, int): - query_kwargs = {'pk': id} - try: - user = User.objects.get(**query_kwargs) - except ObjectDoesNotExist: - msg = _("😖哎呀这位老师还没有注册书影音呢,快去长毛象喊TA来吧!") - sec_msg = _("目前只开放本站用户注册") - return render( - request, - 'common/error.html', - { - 'msg': msg, - 'secondary_msg': sec_msg, - } - ) - if not user == request.user: - # mastodon request - relation = get_relationship(request.user, user, request.session['oauth_token'])[0] - if relation['blocked_by']: + user = User.get(id) + if user is None: + return render_user_not_found(request) + tag = request.GET.get('t', default='') + if user != request.user: + if request.user.is_blocked_by(user) or request.user.is_blocking(user): msg = _("你没有访问TA主页的权限😥") return render( request, @@ -539,14 +390,21 @@ def movie_list(request, id, status): 'msg': msg, } ) - user.target_site_id = get_cross_site_id( - user, request.user.mastodon_site, request.session['oauth_token']) - - queryset = MovieMark.get_available_by_user(user, relation['following']).filter( - status=MarkStatusEnum[status.upper()]).order_by("-edited_time") + is_following = request.user.is_following(user) + if status == 'reviewed': + queryset = MovieReview.get_available_by_user(user, is_following).order_by("-edited_time") + elif status == 'tagged': + queryset = MovieTag.find_by_user(tag, user, request.user).order_by("-mark__created_time") + else: + queryset = MovieMark.get_available_by_user(user, is_following).filter( + status=MarkStatusEnum[status.upper()]).order_by("-created_time") else: - queryset = MovieMark.objects.filter( - owner=user, status=MarkStatusEnum[status.upper()]).order_by("-edited_time") + if status == 'reviewed': + queryset = MovieReview.objects.filter(owner=user).order_by("-edited_time") + elif status == 'tagged': + queryset = MovieTag.objects.filter(content=tag, mark__owner=user).order_by("-mark__created_time") + else: + queryset = MovieMark.objects.filter(owner=user, status=MarkStatusEnum[status.upper()]).order_by("-created_time") paginator = Paginator(queryset, ITEMS_PER_PAGE) page_number = request.GET.get('page', default=1) marks = paginator.get_page(page_number) @@ -554,53 +412,40 @@ def movie_list(request, id, status): mark.movie.tag_list = mark.movie.get_tags_manager().values('content').annotate( tag_frequency=Count('content')).order_by('-tag_frequency')[:TAG_NUMBER_ON_LIST] marks.pagination = PageLinksGenerator(PAGE_LINK_NUMBER, page_number, paginator.num_pages) - list_title = str(MovieMarkStatusTranslator(MarkStatusEnum[status.upper()])) + str(_("的电影和剧集")) + if status == 'reviewed': + list_title = str(_("评论过的电影和剧集")) + elif status == 'tagged': + list_title = str(_(f"标记为「{tag}」的电影和剧集")) + else: + list_title = str(MovieMarkStatusTranslator(MarkStatusEnum[status.upper()])) + str(_("的电影和剧集")) + return render( request, - 'users/movie_list.html', + 'users/item_list.html', { 'marks': marks, 'user': user, - 'list_title' : list_title, + 'status': status, + 'list_title': list_title, } ) else: return HttpResponseBadRequest() - + @mastodon_request_included @login_required def game_list(request, id, status): if request.method == 'GET': - if not status.upper() in MarkStatusEnum.names: + if status.upper() not in MarkStatusEnum.names and status not in ['reviewed', 'tagged']: return HttpResponseBadRequest() - if isinstance(id, str): - try: - username = id.split('@')[0] - site = id.split('@')[1] - except IndexError as e: - return HttpResponseBadRequest("Invalid user id") - query_kwargs = {'username': username, 'mastodon_site': site} - elif isinstance(id, int): - query_kwargs = {'pk': id} - try: - user = User.objects.get(**query_kwargs) - except ObjectDoesNotExist: - msg = _("😖哎呀这位老师还没有注册书影音呢,快去长毛象喊TA来吧!") - sec_msg = _("目前只开放本站用户注册") - return render( - request, - 'common/error.html', - { - 'msg': msg, - 'secondary_msg': sec_msg, - } - ) - if not user == request.user: - # mastodon request - relation = get_relationship(request.user, user, request.session['oauth_token'])[0] - if relation['blocked_by']: + user = User.get(id) + if user is None: + return render_user_not_found(request) + tag = request.GET.get('t', default='') + if user != request.user: + if request.user.is_blocked_by(user) or request.user.is_blocking(user): msg = _("你没有访问TA主页的权限😥") return render( request, @@ -609,14 +454,22 @@ def game_list(request, id, status): 'msg': msg, } ) - user.target_site_id = get_cross_site_id( - user, request.user.mastodon_site, request.session['oauth_token']) - - queryset = GameMark.get_available_by_user(user, relation['following']).filter( - status=MarkStatusEnum[status.upper()]).order_by("-edited_time") + is_following = request.user.is_following(user) + if status == 'reviewed': + queryset = GameReview.get_available_by_user(user, is_following).order_by("-edited_time") + elif status == 'tagged': + queryset = GameTag.find_by_user(tag, user, request.user).order_by("-mark__created_time") + else: + queryset = GameMark.get_available_by_user(user, is_following).filter( + status=MarkStatusEnum[status.upper()]).order_by("-created_time") else: - queryset = GameMark.objects.filter( - owner=user, status=MarkStatusEnum[status.upper()]).order_by("-edited_time") + if status == 'reviewed': + queryset = GameReview.objects.filter(owner=user).order_by("-edited_time") + elif status == 'tagged': + queryset = GameTag.objects.filter(content=tag, mark__owner=user).order_by("-mark__created_time") + else: + queryset = GameMark.objects.filter( + owner=user, status=MarkStatusEnum[status.upper()]).order_by("-created_time") paginator = Paginator(queryset, ITEMS_PER_PAGE) page_number = request.GET.get('page', default=1) marks = paginator.get_page(page_number) @@ -624,53 +477,39 @@ def game_list(request, id, status): mark.game.tag_list = mark.game.get_tags_manager().values('content').annotate( tag_frequency=Count('content')).order_by('-tag_frequency')[:TAG_NUMBER_ON_LIST] marks.pagination = PageLinksGenerator(PAGE_LINK_NUMBER, page_number, paginator.num_pages) - list_title = str(GameMarkStatusTranslator(MarkStatusEnum[status.upper()])) + str(_("的游戏")) + if status == 'reviewed': + list_title = str(_("评论过的游戏")) + elif status == 'tagged': + list_title = str(_(f"标记为「{tag}」的游戏")) + else: + list_title = str(GameMarkStatusTranslator(MarkStatusEnum[status.upper()])) + str(_("的游戏")) return render( request, - 'users/game_list.html', + 'users/item_list.html', { 'marks': marks, 'user': user, - 'list_title' : list_title, + 'status': status, + 'list_title': list_title, } ) else: return HttpResponseBadRequest() - + @mastodon_request_included @login_required def music_list(request, id, status): if request.method == 'GET': - if not status.upper() in MarkStatusEnum.names: + if status.upper() not in MarkStatusEnum.names and status not in ['reviewed', 'tagged']: return HttpResponseBadRequest() - if isinstance(id, str): - try: - username = id.split('@')[0] - site = id.split('@')[1] - except IndexError as e: - return HttpResponseBadRequest("Invalid user id") - query_kwargs = {'username': username, 'mastodon_site': site} - elif isinstance(id, int): - query_kwargs = {'pk': id} - try: - user = User.objects.get(**query_kwargs) - except ObjectDoesNotExist: - msg = _("😖哎呀这位老师还没有注册书影音呢,快去长毛象喊TA来吧!") - sec_msg = _("目前只开放本站用户注册") - return render( - request, - 'common/error.html', - { - 'msg': msg, - 'secondary_msg': sec_msg, - } - ) + user = User.get(id) + if user is None: + return render_user_not_found(request) + tag = request.GET.get('t', default='') if not user == request.user: - # mastodon request - relation = get_relationship(request.user, user, request.session['oauth_token'])[0] - if relation['blocked_by']: + if request.user.is_blocked_by(user) or request.user.is_blocking(user): msg = _("你没有访问TA主页的权限😥") return render( request, @@ -679,39 +518,55 @@ def music_list(request, id, status): 'msg': msg, } ) - queryset = list(AlbumMark.get_available_by_user(user, relation['following']).filter( - status=MarkStatusEnum[status.upper()])) \ - + list(SongMark.get_available_by_user(user, relation['following']).filter( - status=MarkStatusEnum[status.upper()])) - - user.target_site_id = get_cross_site_id( - user, request.user.mastodon_site, request.session['oauth_token']) + is_following = request.user.is_following(user) + if status == 'reviewed': + queryset = list(AlbumReview.get_available_by_user(user, is_following).order_by("-edited_time")) + \ + list(SongReview.get_available_by_user(user, is_following).order_by("-edited_time")) + elif status == 'tagged': + queryset = list(AlbumTag.find_by_user(tag, user, request.user).order_by("-mark__created_time")) + else: + queryset = list(AlbumMark.get_available_by_user(user, is_following).filter( + status=MarkStatusEnum[status.upper()])) \ + + list(SongMark.get_available_by_user(user, is_following).filter( + status=MarkStatusEnum[status.upper()])) else: - queryset = list(AlbumMark.objects.filter(owner=user, status=MarkStatusEnum[status.upper()])) \ - + list(SongMark.objects.filter(owner=user, status=MarkStatusEnum[status.upper()])) + if status == 'reviewed': + queryset = list(AlbumReview.objects.filter(owner=user).order_by("-edited_time")) + \ + list(SongReview.objects.filter(owner=user).order_by("-edited_time")) + elif status == 'tagged': + queryset = list(AlbumTag.objects.filter(content=tag, mark__owner=user).order_by("-mark__created_time")) + else: + queryset = list(AlbumMark.objects.filter(owner=user, status=MarkStatusEnum[status.upper()])) \ + + list(SongMark.objects.filter(owner=user, status=MarkStatusEnum[status.upper()])) queryset = sorted(queryset, key=lambda e: e.edited_time, reverse=True) paginator = Paginator(queryset, ITEMS_PER_PAGE) page_number = request.GET.get('page', default=1) marks = paginator.get_page(page_number) for mark in marks: - if mark.__class__ == AlbumMark: + if mark.__class__ in [AlbumMark, AlbumReview, AlbumTag]: mark.music = mark.album mark.music.tag_list = mark.album.get_tags_manager().values('content').annotate( tag_frequency=Count('content')).order_by('-tag_frequency')[:TAG_NUMBER_ON_LIST] - elif mark.__class__ == SongMark: + elif mark.__class__ == SongMark or mark.__class__ == SongReview: mark.music = mark.song mark.music.tag_list = mark.song.get_tags_manager().values('content').annotate( tag_frequency=Count('content')).order_by('-tag_frequency')[:TAG_NUMBER_ON_LIST] marks.pagination = PageLinksGenerator(PAGE_LINK_NUMBER, page_number, paginator.num_pages) - list_title = str(MusicMarkStatusTranslator(MarkStatusEnum[status.upper()])) + str(_("的音乐")) + if status == 'reviewed': + list_title = str(_("评论过的音乐")) + elif status == 'tagged': + list_title = str(_(f"标记为「{tag}」的音乐")) + else: + list_title = str(MusicMarkStatusTranslator(MarkStatusEnum[status.upper()])) + str(_("的音乐")) return render( request, - 'users/music_list.html', + 'users/item_list.html', { 'marks': marks, 'user': user, - 'list_title' : list_title, + 'status': status, + 'list_title': list_title, } ) else: @@ -724,7 +579,7 @@ def set_layout(request): layout = json.loads(request.POST.get('layout')) request.user.preference.home_layout = layout request.user.preference.save() - return redirect(reverse("common:home")) + return redirect(reverse("users:home", args=[request.user.mastodon_username])) else: return HttpResponseBadRequest() @@ -751,14 +606,14 @@ def report(request): form.instance.is_read = False form.instance.submit_user = request.user form.save() - return redirect(reverse("users:home", args=[form.instance.reported_user.id])) + return redirect(reverse("users:home", args=[form.instance.reported_user.mastodon_username])) else: return render( request, 'users/report.html', { 'form': form, - } + } ) else: return HttpResponseBadRequest() @@ -782,15 +637,37 @@ def manage_report(request): return HttpResponseBadRequest() -# Utils -######################################## -def auth_login(request, user, token): - """ Decorates django ``login()``. Attach token to session.""" - request.session['oauth_token'] = token - auth.login(request, user) +@login_required +def collection_list(request, id): + from collection.views import list + user = User.get(id) + if user is None: + return render_user_not_found(request) + return list(request, user.id) -def auth_logout(request): - """ Decorates django ``logout()``. Release token in session.""" - del request.session['oauth_token'] - auth.logout(request) +@login_required +def marked_collection_list(request, id): + from collection.views import list + user = User.get(id) + if user is None: + return render_user_not_found(request) + return list(request, user.id, True) + + +@login_required +def tag_list(request, id): + user = User.get(id) + if user is None: + return render_user_not_found(request) + if user != request.user: + raise PermissionDenied() # tag list is for user's own view only, for now + return render( + request, + 'users/tags.html', { + 'book_tags': BookTag.all_by_user(user), + 'movie_tags': MovieTag.all_by_user(user), + 'music_tags': AlbumTag.all_by_user(user), + 'game_tags': GameTag.all_by_user(user), + } + )