diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 00000000..48e3403f --- /dev/null +++ b/.gitattributes @@ -0,0 +1,5 @@ +# Auto detect text files and perform LF normalization +* text=auto + +# Shell scripts require LF +*.sh text eol=lf diff --git a/.github/workflows/build-containers.yml b/.github/workflows/build-containers.yml new file mode 100644 index 00000000..c688a5f9 --- /dev/null +++ b/.github/workflows/build-containers.yml @@ -0,0 +1,108 @@ +name: Build Docker containers +on: + push: + branches: + - master +jobs: + build-client: + name: Build and push client/ Docker container + runs-on: ubuntu-latest + steps: + - name: Login to Docker Hub + uses: docker/login-action@v1 + with: + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_PASSWORD }} + + - name: Checkout + uses: actions/checkout@v2 + with: + fetch-depth: 0 + + - name: Determine metadata + run: | + CLOSEST_VER="$(git describe --tags --abbrev=0 $GITHUB_SHA)" + CLOSEST_MAJOR_VER="$(echo ${CLOSEST_VER} | cut -d'.' -f1)" + CLOSEST_MINOR_VER="$(echo ${CLOSEST_VER} | cut -d'.' -f2)" + SHORT_COMMIT=$(echo $GITHUB_SHA | cut -c1-8) + BUILD_INFO="v${CLOSEST_VER}-${SHORT_COMMIT}" + BUILD_DATE="$(date -u +'%Y-%m-%dT%H:%M:%SZ')" + + echo "major_tag=${CLOSEST_MAJOR_VER}" >> $GITHUB_ENV + echo "minor_tag=${CLOSEST_MAJOR_VER}.${CLOSEST_MINOR_VER}" >> $GITHUB_ENV + echo "build_info=${BUILD_INFO}" >> $GITHUB_ENV + echo "build_date=${BUILD_DATE}" >> $GITHUB_ENV + + echo "Build Info: ${BUILD_INFO}" + echo "Build Date: ${BUILD_DATE}" + + - name: Set up QEMU + uses: docker/setup-qemu-action@v1 + + - name: Set up Docker Buildx + id: buildx + uses: docker/setup-buildx-action@v1 + + - name: Build container + run: > + docker buildx build --push + --platform linux/amd64,linux/arm/v7,linux/arm64/v8 + --build-arg BUILD_INFO=${{ env.build_info }} + --build-arg BUILD_DATE=${{ env.build_date }} + --build-arg SOURCE_COMMIT=$GITHUB_SHA + --build-arg DOCKER_REPO=szurubooru/client + -t "szurubooru/client:latest" + -t "szurubooru/client:${{ env.major_tag }}" + -t "szurubooru/client:${{ env.minor_tag }}" + ./client + + build-server: + name: Build and push server/ Docker container + runs-on: ubuntu-latest + steps: + - name: Login to Docker Hub + uses: docker/login-action@v1 + with: + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_PASSWORD }} + + - name: Checkout + uses: actions/checkout@v2 + with: + fetch-depth: 0 + + - name: Determine metadata + run: | + CLOSEST_VER="$(git describe --tags --abbrev=0 $GITHUB_SHA)" + CLOSEST_MAJOR_VER="$(echo ${CLOSEST_VER} | cut -d'.' -f1)" + CLOSEST_MINOR_VER="$(echo ${CLOSEST_VER} | cut -d'.' -f2)" + SHORT_COMMIT=$(echo $GITHUB_SHA | cut -c1-8) + BUILD_INFO="v${CLOSEST_VER}-${SHORT_COMMIT}" + BUILD_DATE="$(date -u +'%Y-%m-%dT%H:%M:%SZ')" + + echo "major_tag=${CLOSEST_MAJOR_VER}" >> $GITHUB_ENV + echo "minor_tag=${CLOSEST_MAJOR_VER}.${CLOSEST_MINOR_VER}" >> $GITHUB_ENV + echo "build_info=${BUILD_INFO}" >> $GITHUB_ENV + echo "build_date=${BUILD_DATE}" >> $GITHUB_ENV + + echo "Build Info: ${BUILD_INFO}" + echo "Build Date: ${BUILD_DATE}" + + - name: Set up QEMU + uses: docker/setup-qemu-action@v1 + + - name: Set up Docker Buildx + id: buildx + uses: docker/setup-buildx-action@v1 + + - name: Build container + run: > + docker buildx build --push + --platform linux/amd64,linux/arm/v7,linux/arm64/v8 + --build-arg BUILD_DATE=${{ env.build_date }} + --build-arg SOURCE_COMMIT=$GITHUB_SHA + --build-arg DOCKER_REPO=szurubooru/server + -t "szurubooru/server:latest" + -t "szurubooru/server:${{ env.major_tag }}" + -t "szurubooru/server:${{ env.minor_tag }}" + ./server diff --git a/.github/workflows/run-unit-tests.yml b/.github/workflows/run-unit-tests.yml new file mode 100644 index 00000000..9ea14ed5 --- /dev/null +++ b/.github/workflows/run-unit-tests.yml @@ -0,0 +1,28 @@ +name: Run unit tests +on: [push, pull_request] +jobs: + test-server: + name: Run pytest for server/ + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v2 + + - name: Set up Docker Buildx + id: buildx + uses: docker/setup-buildx-action@v1 + + - name: Build test container + run: > + docker buildx build --load + --platform linux/amd64 --target testing + -t test_container + ./server + + - name: Run unit tests + run: > + docker run --rm -t test_container + --color=no + --cov-report=term-missing:skip-covered + --cov=szurubooru + szurubooru/ diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 5053b6e0..75a55d62 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,28 +1,29 @@ repos: - repo: https://github.com/pre-commit/pre-commit-hooks - rev: v3.2.0 + rev: v4.4.0 hooks: - id: trailing-whitespace - id: end-of-file-fixer - id: check-yaml - id: mixed-line-ending + - repo: https://github.com/Lucas-C/pre-commit-hooks - rev: v1.1.9 + rev: v1.4.2 hooks: - id: remove-tabs - repo: https://github.com/psf/black - rev: 20.8b1 + rev: '23.1.0' hooks: - id: black files: 'server/' types: [python] - language_version: python3.8 + language_version: python3.9 -- repo: https://github.com/timothycrosley/isort - rev: '5.4.2' +- repo: https://github.com/PyCQA/isort + rev: '5.12.0' hooks: - id: isort files: 'server/' @@ -31,8 +32,8 @@ repos: additional_dependencies: - toml -- repo: https://github.com/prettier/prettier - rev: '2.1.1' +- repo: https://github.com/pre-commit/mirrors-prettier + rev: v2.7.1 hooks: - id: prettier files: client/js/ @@ -40,7 +41,7 @@ repos: args: ['--config', 'client/.prettierrc.yml'] - repo: https://github.com/pre-commit/mirrors-eslint - rev: v7.8.0 + rev: v8.33.0 hooks: - id: eslint files: client/js/ @@ -48,8 +49,8 @@ repos: additional_dependencies: - eslint-config-prettier -- repo: https://gitlab.com/pycqa/flake8 - rev: '3.8.3' +- repo: https://github.com/PyCQA/flake8 + rev: '6.0.0' hooks: - id: flake8 files: server/szurubooru/ @@ -57,44 +58,5 @@ repos: - flake8-print args: ['--config=server/.flake8'] -- repo: local - hooks: - - id: docker-build-client - name: Docker - build client - entry: bash -c 'docker build client/' - language: system - types: [file] - files: client/ - pass_filenames: false - - - id: docker-build-server - name: Docker - build server - entry: bash -c 'docker build server/' - language: system - types: [file] - files: server/ - pass_filenames: false - - - id: pytest - name: pytest - entry: bash -c 'docker run --rm -t $(docker build --target testing -q server/) szurubooru/' - language: system - types: [python] - files: server/szurubooru/ - exclude: server/szurubooru/migrations/ - pass_filenames: false - stages: [push] - - - id: pytest-cov - name: pytest - entry: bash -c 'docker run --rm -t $(docker build --target testing -q server/) --cov-report=term-missing:skip-covered --cov=szurubooru szurubooru/' - language: system - types: [python] - files: server/szurubooru/ - exclude: server/szurubooru/migrations/ - pass_filenames: false - verbose: true - stages: [manual] - fail_fast: true exclude: LICENSE.md diff --git a/README.md b/README.md index a86ef795..93070557 100644 --- a/README.md +++ b/README.md @@ -3,7 +3,7 @@ Szurubooru is an image board engine inspired by services such as Danbooru, Gelbooru and Moebooru dedicated for small and medium communities. Its name [has its roots in Polish language and has onomatopeic meaning of scraping or -scrubbing](http://sjp.pwn.pl/sjp/;2527372). It is pronounced as *shoorubooru*. +scrubbing](https://sjp.pwn.pl/sjp/;2527372). It is pronounced as *shoorubooru*. ## Features diff --git a/client/Dockerfile b/client/Dockerfile index 425ab61a..a5a37c59 100644 --- a/client/Dockerfile +++ b/client/Dockerfile @@ -1,8 +1,7 @@ -FROM node:lts as builder +FROM --platform=$BUILDPLATFORM node:lts as builder WORKDIR /opt/app COPY package.json package-lock.json ./ -RUN npm install -g npm@lts RUN npm install COPY . ./ @@ -12,7 +11,7 @@ ARG CLIENT_BUILD_ARGS="" RUN BASE_URL="__BASEURL__" node build.js --gzip ${CLIENT_BUILD_ARGS} -FROM scratch as approot +FROM --platform=$BUILDPLATFORM scratch as approot COPY docker-start.sh / diff --git a/client/css/core-general.styl b/client/css/core-general.styl index d25c5f6e..7e5883aa 100644 --- a/client/css/core-general.styl +++ b/client/css/core-general.styl @@ -300,10 +300,10 @@ a .access-key background-size: 20px 20px img opacity: 0 - width: 100% + width: auto height: 100% video - width: 100% + width: auto height: 100% .flexbox-dummy diff --git a/client/css/post-list-view.styl b/client/css/post-list-view.styl index 0272ee15..7f6aa80c 100644 --- a/client/css/post-list-view.styl +++ b/client/css/post-list-view.styl @@ -114,6 +114,29 @@ &[data-disabled] background: rgba(200, 200, 200, 0.7) + .delete-flipper + display: inline-block + padding: 0.5em + box-sizing: border-box + border: 0 + &:after + display: inline-block + width: 1em + height: 1em + text-align: center + line-height: 1em + font-size: 2.2em + &.delete + background: rgba(255, 0, 0, 0.7) + &:after + color: white + font-family: FontAwesome; + content: "\f1f8"; // fa-trash + &:not(.delete) + background: rgba(200, 200, 200, 0.7) + &:after + color: white + content: '-' .thumbnail width: 100% @@ -215,7 +238,19 @@ .append @media (max-width: 1000px) margin-left: 0 - + .bulk-edit-delete + &.opened + .start + @media (max-width: 1000px) + margin-left: 0 + &:not(.opened) + .start + display: none + .append.open + @media (max-width: 1000px) + margin-left: 0 + .start + margin-left: 1em .safety margin-right: 0.25em &.safety-safe diff --git a/client/css/post-upload.styl b/client/css/post-upload.styl index 38362937..cb6b0067 100644 --- a/client/css/post-upload.styl +++ b/client/css/post-upload.styl @@ -14,9 +14,11 @@ $cancel-button-color = tomato &.inactive input[type=submit], &.inactive .skip-duplicates &.inactive .always-upload-similar + &.inactive .pause-remain-on-error &.uploading input[type=submit], &.uploading .skip-duplicates, &.uploading .always-upload-similar + &.uploading .pause-remain-on-error &:not(.uploading) .cancel display: none @@ -44,6 +46,9 @@ $cancel-button-color = tomato .always-upload-similar margin-left: 1em + .pause-remain-on-error + margin-left: 1em + form>.messages margin-top: 1em @@ -57,6 +62,14 @@ $cancel-button-color = tomato margin: 0 0 1.2em 0 padding-left: 13em + img + width: 100% + height: 100% + + video + width: 100% + height: 100% + &>.thumbnail-wrapper float: left width: 12em diff --git a/client/hooks/build b/client/hooks/build deleted file mode 100755 index 46443f40..00000000 --- a/client/hooks/build +++ /dev/null @@ -1,16 +0,0 @@ -#!/bin/sh - -CLOSEST_VER=$(git describe --tags --abbrev=0 ${SOURCE_COMMIT}) -if git describe --exact-match --abbrev=0 ${SOURCE_COMMIT} 2> /dev/null; then - BUILD_INFO="v${CLOSEST_VER}" -else - BUILD_INFO="v${CLOSEST_VER}-edge-$(git rev-parse --short ${SOURCE_COMMIT})" -fi - -echo "Using BUILD_INFO=${BUILD_INFO}" -docker build \ - --build-arg BUILD_INFO=${BUILD_INFO} \ - --build-arg BUILD_DATE=$(date -u +'%Y-%m-%dT%H:%M:%SZ') \ - --build-arg SOURCE_COMMIT \ - --build-arg DOCKER_REPO \ - -f $DOCKERFILE_PATH -t $IMAGE_NAME . diff --git a/client/hooks/post_push b/client/hooks/post_push deleted file mode 100755 index 1b1e0ad9..00000000 --- a/client/hooks/post_push +++ /dev/null @@ -1,19 +0,0 @@ -#!/bin/sh - -add_tag() { - echo "Also tagging image as ${DOCKER_REPO}:${1}" - docker tag $IMAGE_NAME $DOCKER_REPO:$1 - docker push $DOCKER_REPO:$1 -} - -CLOSEST_VER=$(git describe --tags --abbrev=0) -CLOSEST_MAJOR_VER=$(echo ${CLOSEST_VER} | cut -d'.' -f1) -CLOSEST_MINOR_VER=$(echo ${CLOSEST_VER} | cut -d'.' -f2) - -add_tag "${CLOSEST_MAJOR_VER}-edge" -add_tag "${CLOSEST_MAJOR_VER}.${CLOSEST_MINOR_VER}-edge" - -if git describe --exact-match --abbrev=0 2> /dev/null; then - add_tag "${CLOSEST_MAJOR_VER}" - add_tag "${CLOSEST_MAJOR_VER}.${CLOSEST_MINOR_VER}" -fi diff --git a/client/html/post_merge_side.tpl b/client/html/post_merge_side.tpl index fe705027..a08070f0 100644 --- a/client/html/post_merge_side.tpl +++ b/client/html/post_merge_side.tpl @@ -42,6 +42,7 @@ 'image/heic': 'HEIC', 'video/webm': 'WEBM', 'video/mp4': 'MPEG-4', + 'video/quicktime': 'MOV', 'application/x-shockwave-flash': 'SWF', }[ctx.post.mimeType] + ' (' + diff --git a/client/html/post_readonly_sidebar.tpl b/client/html/post_readonly_sidebar.tpl index 4f18624f..0f93ae3b 100644 --- a/client/html/post_readonly_sidebar.tpl +++ b/client/html/post_readonly_sidebar.tpl @@ -15,6 +15,7 @@ 'image/heic': 'HEIC', 'video/webm': 'WEBM', 'video/mp4': 'MPEG-4', + 'video/quicktime': 'MOV', 'application/x-shockwave-flash': 'SWF', }[ctx.post.mimeType] %> diff --git a/client/html/post_upload.tpl b/client/html/post_upload.tpl index 6374fe8c..3c1b2388 100644 --- a/client/html/post_upload.tpl +++ b/client/html/post_upload.tpl @@ -7,7 +7,7 @@ <%= ctx.makeCheckbox({ - text: 'Skip duplicates', + text: 'Skip duplicate', name: 'skip-duplicates', checked: false, }) %> @@ -15,12 +15,20 @@ <%= ctx.makeCheckbox({ - text: 'Always upload similar', + text: 'Force upload similar', name: 'always-upload-similar', checked: false, }) %> + + <%= ctx.makeCheckbox({ + text: 'Pause on error', + name: 'pause-remain-on-error', + checked: true, + }) %> + + diff --git a/client/html/posts_header.tpl b/client/html/posts_header.tpl index e0ba0eae..d1422d2c 100644 --- a/client/html/posts_header.tpl +++ b/client/html/posts_header.tpl @@ -28,4 +28,11 @@ %>Stop editing safety<% %><% %><% } %><% + %><% if (ctx.canBulkDelete) { %><% + %>
<% + %>Mass delete<% + %><% + %>Stop deleting<% + %>
<% + %><% } %><% %> diff --git a/client/html/posts_page.tpl b/client/html/posts_page.tpl index 78362787..52011ad1 100644 --- a/client/html/posts_page.tpl +++ b/client/html/posts_page.tpl @@ -50,6 +50,10 @@ <% } %>
<% } %> + <% if (ctx.canBulkDelete && ctx.parameters && ctx.parameters.delete) { %> + + + <% } %> <% } %> diff --git a/client/js/controllers/post_list_controller.js b/client/js/controllers/post_list_controller.js index 526d8f54..fdb7b844 100644 --- a/client/js/controllers/post_list_controller.js +++ b/client/js/controllers/post_list_controller.js @@ -44,6 +44,7 @@ class PostListController { enableSafety: api.safetyEnabled(), canBulkEditTags: api.hasPrivilege("posts:bulk-edit:tags"), canBulkEditSafety: api.hasPrivilege("posts:bulk-edit:safety"), + canBulkDelete: api.hasPrivilege("posts:bulk-edit:delete"), bulkEdit: { tags: this._bulkEditTags, }, @@ -52,6 +53,16 @@ class PostListController { this._evtNavigate(e) ); + if (this._headerView._bulkDeleteEditor) { + this._headerView._bulkDeleteEditor.addEventListener( + "deleteSelectedPosts", + (e) => { + this._evtDeleteSelectedPosts(e); + } + ); + } + + this._postsMarkedForDeletion = []; this._syncPageController(); } @@ -91,6 +102,38 @@ class PostListController { e.detail.post.save().catch((error) => window.alert(error.message)); } + _evtMarkForDeletion(e) { + const postId = e.detail; + + // Add or remove post from delete list + if (e.detail.delete) { + this._postsMarkedForDeletion.push(e.detail.post); + } else { + this._postsMarkedForDeletion = this._postsMarkedForDeletion.filter( + (x) => x.id != e.detail.post.id + ); + } + } + + _evtDeleteSelectedPosts(e) { + if (this._postsMarkedForDeletion.length == 0) return; + + if ( + confirm( + `Are you sure you want to delete ${this._postsMarkedForDeletion.length} posts?` + ) + ) { + Promise.all( + this._postsMarkedForDeletion.map((post) => post.delete()) + ) + .catch((error) => window.alert(error.message)) + .then(() => { + this._postsMarkedForDeletion = []; + this._headerView._navigate(); + }); + } + } + _syncPageController() { this._pageController.run({ parameters: this._ctx.parameters, @@ -117,8 +160,10 @@ class PostListController { canBulkEditSafety: api.hasPrivilege( "posts:bulk-edit:safety" ), + canBulkDelete: api.hasPrivilege("posts:bulk-edit:delete"), bulkEdit: { tags: this._bulkEditTags, + markedForDeletion: this._postsMarkedForDeletion, }, postFlow: settings.get().postFlow, }); @@ -128,6 +173,9 @@ class PostListController { view.addEventListener("changeSafety", (e) => this._evtChangeSafety(e) ); + view.addEventListener("markForDeletion", (e) => + this._evtMarkForDeletion(e) + ); return view; }, }); diff --git a/client/js/controllers/post_upload_controller.js b/client/js/controllers/post_upload_controller.js index a54baec7..720a116c 100644 --- a/client/js/controllers/post_upload_controller.js +++ b/client/js/controllers/post_upload_controller.js @@ -90,21 +90,30 @@ class PostUploadController { uploadable ); } + if (e.detail.pauseRemainOnError) { + return Promise.reject(); + } }) ), Promise.resolve() ) .then(() => { if (anyFailures) { - this._view.showError(genericErrorMessage); - this._view.enableForm(); - } else { + return Promise.reject(); + } + }) + .then( + () => { this._view.clearMessages(); misc.disableExitConfirmation(); const ctx = router.show(uri.formatClientLink("posts")); ctx.controller.showSuccess("Posts uploaded."); + }, + (error) => { + this._view.showError(genericErrorMessage); + this._view.enableForm(); } - }); + ); } _uploadSinglePost(uploadable, skipDuplicates, alwaysUploadSimilar) { diff --git a/client/js/controllers/user_controller.js b/client/js/controllers/user_controller.js index 326736b5..068d329e 100644 --- a/client/js/controllers/user_controller.js +++ b/client/js/controllers/user_controller.js @@ -31,9 +31,8 @@ class UserController { userTokenPromise = UserToken.get(userName).then( (userTokens) => { return userTokens.map((token) => { - token.isCurrentAuthToken = api.isCurrentAuthToken( - token - ); + token.isCurrentAuthToken = + api.isCurrentAuthToken(token); return token; }); }, diff --git a/client/js/controls/expander_control.js b/client/js/controls/expander_control.js index 11ad3ef5..ffb0e904 100644 --- a/client/js/controls/expander_control.js +++ b/client/js/controls/expander_control.js @@ -45,9 +45,8 @@ class ExpanderControl { // eslint-disable-next-line accessor-pairs set title(newTitle) { if (this._expanderNode) { - this._expanderNode.querySelector( - "header span" - ).textContent = newTitle; + this._expanderNode.querySelector("header span").textContent = + newTitle; } } diff --git a/client/js/controls/post_edit_sidebar_control.js b/client/js/controls/post_edit_sidebar_control.js index b8ad9dab..eabb98ae 100644 --- a/client/js/controls/post_edit_sidebar_control.js +++ b/client/js/controls/post_edit_sidebar_control.js @@ -203,9 +203,8 @@ class PostEditSidebarControl extends events.EventTarget { ); if (this._formNode) { - const inputNodes = this._formNode.querySelectorAll( - "input, textarea" - ); + const inputNodes = + this._formNode.querySelectorAll("input, textarea"); for (let node of inputNodes) { node.addEventListener("change", (e) => this.dispatchEvent(new CustomEvent("change")) diff --git a/client/js/controls/post_notes_overlay_control.js b/client/js/controls/post_notes_overlay_control.js index 030f7f28..e9aad045 100644 --- a/client/js/controls/post_notes_overlay_control.js +++ b/client/js/controls/post_notes_overlay_control.js @@ -727,9 +727,8 @@ class PostNotesOverlayControl extends events.EventTarget { } _showNoteText(note) { - this._textNode.querySelector( - ".wrapper" - ).innerHTML = misc.formatMarkdown(note.text); + this._textNode.querySelector(".wrapper").innerHTML = + misc.formatMarkdown(note.text); this._textNode.style.display = "block"; const bodyRect = document.body.getBoundingClientRect(); const noteRect = this._textNode.getBoundingClientRect(); diff --git a/client/js/util/markdown.js b/client/js/util/markdown.js index 22cdae50..e71e3263 100644 --- a/client/js/util/markdown.js +++ b/client/js/util/markdown.js @@ -65,17 +65,6 @@ class TagPermalinkFixWrapper extends BaseMarkdownWrapper { // post, user and tags permalinks class EntityPermalinkWrapper extends BaseMarkdownWrapper { preprocess(text) { - // URL-based permalinks - text = text.replace(new RegExp("\\b/post/(\\d+)/?\\b", "g"), "@$1"); - text = text.replace( - new RegExp("\\b/tag/([a-zA-Z0-9_-]+?)/?", "g"), - "#$1" - ); - text = text.replace( - new RegExp("\\b/user/([a-zA-Z0-9_-]+?)/?", "g"), - "+$1" - ); - text = text.replace( /(^|^\(|(?:[^\]])\(|[\s<>\[\]\)])([+#@][a-zA-Z0-9_-]+)/g, "$1[$2]($2)" @@ -136,12 +125,8 @@ function createRenderer() { const renderer = new marked.Renderer(); renderer.image = (href, title, alt) => { - let [ - _, - url, - width, - height, - ] = /^(.+?)(?:\s=\s*(\d*)\s*x\s*(\d*)\s*)?$/.exec(href); + let [_, url, width, height] = + /^(.+?)(?:\s=\s*(\d*)\s*x\s*(\d*)\s*)?$/.exec(href); let res = '' + sanitize(alt);
         if (width) {
             res += ' + this._evtFormSubmit(e) + ); + } + + _evtFormSubmit(e) { + e.preventDefault(); + this.dispatchEvent( + new CustomEvent("deleteSelectedPosts", { detail: {} }) + ); + } + + _evtOpenLinkClick(e) { + e.preventDefault(); + this.toggleOpen(true); + this.dispatchEvent(new CustomEvent("open", { detail: {} })); + } + + _evtCloseLinkClick(e) { + e.preventDefault(); + this.toggleOpen(false); + this.dispatchEvent(new CustomEvent("close", { detail: {} })); + } +} + class PostsHeaderView extends events.EventTarget { constructor(ctx) { super(); @@ -186,6 +214,13 @@ class PostsHeaderView extends events.EventTarget { this._bulkEditors.push(this._bulkSafetyEditor); } + if (this._bulkEditDeleteNode) { + this._bulkDeleteEditor = new BulkDeleteEditor( + this._bulkEditDeleteNode + ); + this._bulkEditors.push(this._bulkDeleteEditor); + } + for (let editor of this._bulkEditors) { editor.addEventListener("submit", (e) => { this._navigate(); @@ -204,6 +239,8 @@ class PostsHeaderView extends events.EventTarget { this._openBulkEditor(this._bulkTagEditor); } else if (ctx.parameters.safety && this._bulkSafetyEditor) { this._openBulkEditor(this._bulkSafetyEditor); + } else if (ctx.parameters.delete && this._bulkDeleteEditor) { + this._openBulkEditor(this._bulkDeleteEditor); } } @@ -227,6 +264,10 @@ class PostsHeaderView extends events.EventTarget { return this._hostNode.querySelector(".bulk-edit-safety"); } + get _bulkEditDeleteNode() { + return this._hostNode.querySelector(".bulk-edit-delete"); + } + _openBulkEditor(editor) { editor.toggleOpen(true); this._hideBulkEditorsExcept(editor); @@ -253,9 +294,8 @@ class PostsHeaderView extends events.EventTarget { e.target.classList.toggle("disabled"); const safety = e.target.getAttribute("data-safety"); let browsingSettings = settings.get(); - browsingSettings.listPosts[safety] = !browsingSettings.listPosts[ - safety - ]; + browsingSettings.listPosts[safety] = + !browsingSettings.listPosts[safety]; settings.save(browsingSettings, true); this.dispatchEvent( new CustomEvent("navigate", { @@ -294,6 +334,10 @@ class PostsHeaderView extends events.EventTarget { this._bulkSafetyEditor && this._bulkSafetyEditor.opened ? "1" : null; + parameters.delete = + this._bulkDeleteEditor && this._bulkDeleteEditor.opened + ? "1" + : null; this.dispatchEvent( new CustomEvent("navigate", { detail: { parameters: parameters } }) ); diff --git a/client/js/views/posts_page_view.js b/client/js/views/posts_page_view.js index ba07a63a..c4b19882 100644 --- a/client/js/views/posts_page_view.js +++ b/client/js/views/posts_page_view.js @@ -39,6 +39,13 @@ class PostsPageView extends events.EventTarget { ); } } + + const deleteFlipperNode = this._getDeleteFlipperNode(listItemNode); + if (deleteFlipperNode) { + deleteFlipperNode.addEventListener("click", (e) => + this._evtBulkToggleDeleteClick(e, post) + ); + } } this._syncBulkEditorsHighlights(); @@ -56,6 +63,10 @@ class PostsPageView extends events.EventTarget { return listItemNode.querySelector(".safety-flipper"); } + _getDeleteFlipperNode(listItemNode) { + return listItemNode.querySelector(".delete-flipper"); + } + _evtPostChange(e) { const listItemNode = this._postIdToListItemNode[e.detail.post.id]; for (let node of listItemNode.querySelectorAll("[data-disabled]")) { @@ -99,6 +110,20 @@ class PostsPageView extends events.EventTarget { ); } + _evtBulkToggleDeleteClick(e, post) { + e.preventDefault(); + const linkNode = e.target; + linkNode.classList.toggle("delete"); + this.dispatchEvent( + new CustomEvent("markForDeletion", { + detail: { + post, + delete: linkNode.classList.contains("delete"), + }, + }) + ); + } + _syncBulkEditorsHighlights() { for (let listItemNode of this._listItemNodes) { const postId = listItemNode.getAttribute("data-post-id"); @@ -123,6 +148,16 @@ class PostsPageView extends events.EventTarget { ); } } + + const deleteFlipperNode = this._getDeleteFlipperNode(listItemNode); + if (deleteFlipperNode) { + deleteFlipperNode.classList.toggle( + "delete", + this._ctx.bulkEdit.markedForDeletion.some( + (x) => x.id == postId + ) + ); + } } } } diff --git a/client/js/views/user_tokens_view.js b/client/js/views/user_tokens_view.js index 68707f08..9ba9e7cc 100644 --- a/client/js/views/user_tokens_view.js +++ b/client/js/views/user_tokens_view.js @@ -72,9 +72,8 @@ class UserTokenView extends events.EventTarget { _evtDelete(e) { e.preventDefault(); - const userToken = this._tokens[ - parseInt(e.target.getAttribute("data-token-id")) - ]; + const userToken = + this._tokens[parseInt(e.target.getAttribute("data-token-id"))]; this.dispatchEvent( new CustomEvent("delete", { detail: { @@ -110,9 +109,8 @@ class UserTokenView extends events.EventTarget { _evtChangeNoteClick(e) { e.preventDefault(); - const userToken = this._tokens[ - parseInt(e.target.getAttribute("data-token-id")) - ]; + const userToken = + this._tokens[parseInt(e.target.getAttribute("data-token-id"))]; const text = window.prompt( "Please enter the new name:", userToken.note !== null ? userToken.note : undefined diff --git a/client/package-lock.json b/client/package-lock.json index 14f94096..3aa4ca4a 100644 --- a/client/package-lock.json +++ b/client/package-lock.json @@ -10,7 +10,7 @@ "font-awesome": "^4.7.0", "ios-inner-height": "^1.0.3", "js-cookie": "^2.2.0", - "marked": "^0.7.0", + "marked": "^4.0.10", "mousetrap": "^1.6.2", "nprogress": "^0.2.0", "superagent": "^3.8.3" @@ -28,7 +28,7 @@ "jimp": "^0.13.0", "pretty-error": "^3.0.3", "stylus": "^0.54.8", - "terser": "^3.7.7", + "terser": "^4.8.1", "underscore": "^1.12.1", "watchify": "^4.0.0", "ws": "^7.4.6" @@ -476,24 +476,6 @@ "node": ">= 8" } }, - "node_modules/array-filter": { - "version": "0.0.1", - "resolved": "https://registry.npmjs.org/array-filter/-/array-filter-0.0.1.tgz", - "integrity": "sha1-fajPLiZijtcygDWB/SH2fKzS7uw=", - "dev": true - }, - "node_modules/array-map": { - "version": "0.0.0", - "resolved": "https://registry.npmjs.org/array-map/-/array-map-0.0.0.tgz", - "integrity": "sha1-iKK6tz0c97zVwbEYoAP2b2ZfpmI=", - "dev": true - }, - "node_modules/array-reduce": { - "version": "0.0.0", - "resolved": "https://registry.npmjs.org/array-reduce/-/array-reduce-0.0.0.tgz", - "integrity": "sha1-FziZ0//Rx9k4PkR5Ul2+J4yrXys=", - "dev": true - }, "node_modules/asn1.js": { "version": "4.10.1", "resolved": "https://registry.npmjs.org/asn1.js/-/asn1.js-4.10.1.tgz", @@ -1506,16 +1488,15 @@ "dev": true }, "node_modules/cached-path-relative": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/cached-path-relative/-/cached-path-relative-1.0.2.tgz", - "integrity": "sha512-5r2GqsoEb4qMTTN9J+WzXfjov+hjxT+j3u5K+kIVNIwAd99DLCJE9pBIMP1qVeybV6JiijL385Oz0DcYxfbOIg==", + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/cached-path-relative/-/cached-path-relative-1.1.0.tgz", + "integrity": "sha512-WF0LihfemtesFcJgO7xfOoOcnWzY/QHR4qeDqV44jPU3HTI54+LnfXK3SA27AVVGCdZFgjjFFaqUA9Jx7dMJZA==", "dev": true }, "node_modules/call-bind": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.2.tgz", "integrity": "sha512-7O+FbCihrB5WGbFYesctwmTKae6rOiIzmz1icreWJ+0aA7LJfuqhEso2T9ncpcFtzMQtzXf2QGGueWJGTYsqrA==", - "dev": true, "dependencies": { "function-bind": "^1.1.1", "get-intrinsic": "^1.0.2" @@ -1682,9 +1663,9 @@ "dev": true }, "node_modules/cookiejar": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/cookiejar/-/cookiejar-2.1.2.tgz", - "integrity": "sha512-Mw+adcfzPxcPeI+0WlvRrr/3lGVO0bD75SxX6811cxSh1Wbxx7xZBGK1eVtDf6si8rg2lhnUjsVLMFMfbRIuwA==" + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/cookiejar/-/cookiejar-2.1.4.tgz", + "integrity": "sha512-LDx6oHrK+PhzLKJU9j5S7/Y3jM/mUHvD/DeI1WQmJn652iPC5Y4TBzC9l+5OMOXlyTTA+SmVUPm0HQUwpD5Jqw==" }, "node_modules/core-js": { "version": "2.5.7", @@ -1850,9 +1831,9 @@ } }, "node_modules/decode-uri-component": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/decode-uri-component/-/decode-uri-component-0.2.0.tgz", - "integrity": "sha1-6zkTMzRYd1y4TNGh+uBiEGu4dUU=", + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/decode-uri-component/-/decode-uri-component-0.2.2.tgz", + "integrity": "sha512-FqUYQ+8o158GyGTrMFJms9qh3CqTKvAqgqsTnkLI8sKu0028orqBhxNMFkFen0zGyg6epACD32pjVk58ngIErQ==", "dev": true, "engines": { "node": ">=0.10" @@ -2254,8 +2235,7 @@ "node_modules/function-bind": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.1.tgz", - "integrity": "sha512-yIovAzMX49sF8Yl58fSCWJ5svSLuaibPxXQJFLmBObTuCr0Mf1KiPopGM9NiFjiYBCbfaa2Fh6breQ6ANVTI0A==", - "dev": true + "integrity": "sha512-yIovAzMX49sF8Yl58fSCWJ5svSLuaibPxXQJFLmBObTuCr0Mf1KiPopGM9NiFjiYBCbfaa2Fh6breQ6ANVTI0A==" }, "node_modules/get-assigned-identifiers": { "version": "1.2.0", @@ -2267,7 +2247,6 @@ "version": "1.1.1", "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.1.1.tgz", "integrity": "sha512-kWZrnVM42QCiEA2Ig1bG8zjoIMOgxWwYCEeNdwY6Tv/cOSeGpcoX4pXHfKUxNKVoArnrEr2e9srnAxxGIraS9Q==", - "dev": true, "dependencies": { "function-bind": "^1.1.1", "has": "^1.0.3", @@ -2351,7 +2330,6 @@ "version": "1.0.3", "resolved": "https://registry.npmjs.org/has/-/has-1.0.3.tgz", "integrity": "sha512-f2dvO0VU6Oej7RkWJGrehjbzMAjFp5/VKPp5tTpWIV4JHHZK1/BxbFRtf/siA2SWTe09caDmVtYYzWEIbBS4zw==", - "dev": true, "dependencies": { "function-bind": "^1.1.1" }, @@ -2384,7 +2362,6 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.0.2.tgz", "integrity": "sha512-chXa79rL/UC2KlX17jo3vRGz0azaWEx5tGqZg5pO3NUyEJVB17dMruQlzCCOfUvElghKcm5194+BCRvi2Rv/Gw==", - "dev": true, "engines": { "node": ">= 0.4" }, @@ -2859,9 +2836,9 @@ "dev": true }, "node_modules/jpeg-js": { - "version": "0.4.0", - "resolved": "https://registry.npmjs.org/jpeg-js/-/jpeg-js-0.4.0.tgz", - "integrity": "sha512-960VHmtN1vTpasX/1LupLohdP5odwAT7oK/VSm6mW0M58LbrBnowLAPWAZhWGhDAGjzbMnPXZxzB/QYgBwkN0w==", + "version": "0.4.4", + "resolved": "https://registry.npmjs.org/jpeg-js/-/jpeg-js-0.4.4.tgz", + "integrity": "sha512-WZzeDOEtTOBK4Mdsar0IqEU5sMr3vSV2RqkAIzUEV2BHnUfKGyswWFPFwK5EeDo93K3FohSHbLAjj0s1Wzd+dg==", "dev": true }, "node_modules/js-cookie": { @@ -2997,14 +2974,14 @@ "dev": true }, "node_modules/marked": { - "version": "0.7.0", - "resolved": "https://registry.npmjs.org/marked/-/marked-0.7.0.tgz", - "integrity": "sha512-c+yYdCZJQrsRjTPhUx7VKkApw9bwDkNbHUKo1ovgcfDjb2kc8rLuRbIFyXL5WOEUwzSSKo3IXpph2K6DqB/KZg==", + "version": "4.0.10", + "resolved": "https://registry.npmjs.org/marked/-/marked-4.0.10.tgz", + "integrity": "sha512-+QvuFj0nGgO970fySghXGmuw+Fd0gD2x3+MqCWLIPf5oxdv1Ka6b2q+z9RP01P/IaKPMEramy+7cNy/Lw8c3hw==", "bin": { - "marked": "bin/marked" + "marked": "bin/marked.js" }, "engines": { - "node": ">=0.10.0" + "node": ">= 12" } }, "node_modules/md5.js": { @@ -3108,9 +3085,9 @@ } }, "node_modules/minimist": { - "version": "1.2.5", - "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.5.tgz", - "integrity": "sha512-FM9nNUYrRBAELZQT3xeZQ7fmMOBg6nWNmJKTcgsJeaLstP/UODVpGsr5OhXhhXg6f+qtJ8uiZ+PUxkDWcgIXLw==", + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.6.tgz", + "integrity": "sha512-Jsjnk4bw3YJqYzbdyBiNsPWHPfO++UGG749Cxs6peCu5Xg4nrena6OVxOYxrQTqww0Jmwt+Ref8rggumkTLz9Q==", "dev": true }, "node_modules/mkdirp": { @@ -3224,7 +3201,6 @@ "version": "1.10.3", "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.10.3.tgz", "integrity": "sha512-e5mCJlSH7poANfC8z8S9s9S2IN5/4Zb3aZ33f5s8YqoazCFzNLloLU8r5VCG+G7WoqLvAAZoVMcy3tp/3X0Plw==", - "dev": true, "funding": { "url": "https://github.com/sponsors/ljharb" } @@ -3385,9 +3361,9 @@ } }, "node_modules/path-parse": { - "version": "1.0.6", - "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.6.tgz", - "integrity": "sha512-GSmOT2EbHrINBf9SR7CDELwlJ8AENk3Qn7OikK4nFYAu3Ote2+JYNVvkpAEQm3/TLNEJFD/xZJjzyxg3KBWOzw==", + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", + "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", "dev": true }, "node_modules/path-platform": { @@ -3507,11 +3483,17 @@ "dev": true }, "node_modules/qs": { - "version": "6.5.2", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.5.2.tgz", - "integrity": "sha512-N5ZAX4/LxJmF+7wN74pUD6qAh9/wnvdQcjq9TZjevvXzSUo7bfmw91saqMjzGS2xq91/odN2dW/WOl7qQHNDGA==", + "version": "6.11.0", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.11.0.tgz", + "integrity": "sha512-MvjoMCJwEarSbUYk5O+nmoSzSutSsTwF85zcHPQ9OrlFoZOYIjaqBAJIqIXjptyD5vThxGq52Xu/MaJzRkIk4Q==", + "dependencies": { + "side-channel": "^1.0.4" + }, "engines": { "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, "node_modules/querystring": { @@ -3770,15 +3752,22 @@ } }, "node_modules/shell-quote": { - "version": "1.6.1", - "resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.6.1.tgz", - "integrity": "sha1-9HgZSczkAmlxJ0MOo7PFR29IF2c=", - "dev": true, + "version": "1.7.3", + "resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.7.3.tgz", + "integrity": "sha512-Vpfqwm4EnqGdlsBFNmHhxhElJYrdfcxPThu+ryKS5J8L/fhAwLazFZtq+S+TWZ9ANj2piSQLGj6NQg+lKPmxrw==", + "dev": true + }, + "node_modules/side-channel": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.0.4.tgz", + "integrity": "sha512-q5XPytqFEIKHkGdiMIrY10mvLRvnQh42/+GoBlFW3b2LXLE2xxJpZFdm94we0BaoV3RwJyGqg5wS7epxTv0Zvw==", "dependencies": { - "array-filter": "~0.0.0", - "array-map": "~0.0.0", - "array-reduce": "~0.0.0", - "jsonify": "~0.0.0" + "call-bind": "^1.0.0", + "get-intrinsic": "^1.0.2", + "object-inspect": "^1.9.0" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, "node_modules/simple-concat": { @@ -3983,12 +3972,6 @@ "minimist": "^1.1.0" } }, - "node_modules/subarg/node_modules/minimist": { - "version": "1.2.5", - "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.5.tgz", - "integrity": "sha512-FM9nNUYrRBAELZQT3xeZQ7fmMOBg6nWNmJKTcgsJeaLstP/UODVpGsr5OhXhhXg6f+qtJ8uiZ+PUxkDWcgIXLw==", - "dev": true - }, "node_modules/superagent": { "version": "3.8.3", "resolved": "https://registry.npmjs.org/superagent/-/superagent-3.8.3.tgz", @@ -4028,26 +4011,26 @@ } }, "node_modules/terser": { - "version": "3.7.7", - "resolved": "https://registry.npmjs.org/terser/-/terser-3.7.7.tgz", - "integrity": "sha512-RRLIxE7S52vSOI9cEbOaisgBd2y6MNgfg2ihUkidsFnuP1eDmZ79+lBWbyvgfFTAc/r8nSjL0k3cpZDDIYiYiA==", + "version": "4.8.1", + "resolved": "https://registry.npmjs.org/terser/-/terser-4.8.1.tgz", + "integrity": "sha512-4GnLC0x667eJG0ewJTa6z/yXrbLGv80D9Ru6HIpCQmO+Q4PfEtBFi0ObSckqwL6VyQv/7ENJieXHo2ANmdQwgw==", "dev": true, "dependencies": { - "commander": "~2.14.1", + "commander": "^2.20.0", "source-map": "~0.6.1", - "source-map-support": "~0.5.6" + "source-map-support": "~0.5.12" }, "bin": { - "terser": "bin/uglifyjs" + "terser": "bin/terser" }, "engines": { - "node": ">=0.8.0" + "node": ">=6.0.0" } }, "node_modules/terser/node_modules/commander": { - "version": "2.14.1", - "resolved": "https://registry.npmjs.org/commander/-/commander-2.14.1.tgz", - "integrity": "sha512-+YR16o3rK53SmWHU3rEM3tPAh2rwb1yPcQX5irVn7mb0gXbwuCCrnkbV5+PBfETdfg1vui07nM6PCG1zndcjQw==", + "version": "2.20.3", + "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz", + "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==", "dev": true }, "node_modules/terser/node_modules/source-map": { @@ -4060,9 +4043,9 @@ } }, "node_modules/terser/node_modules/source-map-support": { - "version": "0.5.6", - "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.6.tgz", - "integrity": "sha512-N4KXEz7jcKqPf2b2vZF11lQIz9W5ZMuUcIOGj243lduidkf2fjkVKJS9vNxVWn3u/uxX38AcE8U9nnH9FPcq+g==", + "version": "0.5.21", + "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.21.tgz", + "integrity": "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==", "dev": true, "dependencies": { "buffer-from": "^1.0.0", @@ -5047,24 +5030,6 @@ "picomatch": "^2.0.4" } }, - "array-filter": { - "version": "0.0.1", - "resolved": "https://registry.npmjs.org/array-filter/-/array-filter-0.0.1.tgz", - "integrity": "sha1-fajPLiZijtcygDWB/SH2fKzS7uw=", - "dev": true - }, - "array-map": { - "version": "0.0.0", - "resolved": "https://registry.npmjs.org/array-map/-/array-map-0.0.0.tgz", - "integrity": "sha1-iKK6tz0c97zVwbEYoAP2b2ZfpmI=", - "dev": true - }, - "array-reduce": { - "version": "0.0.0", - "resolved": "https://registry.npmjs.org/array-reduce/-/array-reduce-0.0.0.tgz", - "integrity": "sha1-FziZ0//Rx9k4PkR5Ul2+J4yrXys=", - "dev": true - }, "asn1.js": { "version": "4.10.1", "resolved": "https://registry.npmjs.org/asn1.js/-/asn1.js-4.10.1.tgz", @@ -6053,16 +6018,15 @@ "dev": true }, "cached-path-relative": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/cached-path-relative/-/cached-path-relative-1.0.2.tgz", - "integrity": "sha512-5r2GqsoEb4qMTTN9J+WzXfjov+hjxT+j3u5K+kIVNIwAd99DLCJE9pBIMP1qVeybV6JiijL385Oz0DcYxfbOIg==", + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/cached-path-relative/-/cached-path-relative-1.1.0.tgz", + "integrity": "sha512-WF0LihfemtesFcJgO7xfOoOcnWzY/QHR4qeDqV44jPU3HTI54+LnfXK3SA27AVVGCdZFgjjFFaqUA9Jx7dMJZA==", "dev": true }, "call-bind": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.2.tgz", "integrity": "sha512-7O+FbCihrB5WGbFYesctwmTKae6rOiIzmz1icreWJ+0aA7LJfuqhEso2T9ncpcFtzMQtzXf2QGGueWJGTYsqrA==", - "dev": true, "requires": { "function-bind": "^1.1.1", "get-intrinsic": "^1.0.2" @@ -6211,9 +6175,9 @@ "dev": true }, "cookiejar": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/cookiejar/-/cookiejar-2.1.2.tgz", - "integrity": "sha512-Mw+adcfzPxcPeI+0WlvRrr/3lGVO0bD75SxX6811cxSh1Wbxx7xZBGK1eVtDf6si8rg2lhnUjsVLMFMfbRIuwA==" + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/cookiejar/-/cookiejar-2.1.4.tgz", + "integrity": "sha512-LDx6oHrK+PhzLKJU9j5S7/Y3jM/mUHvD/DeI1WQmJn652iPC5Y4TBzC9l+5OMOXlyTTA+SmVUPm0HQUwpD5Jqw==" }, "core-js": { "version": "2.5.7", @@ -6363,9 +6327,9 @@ } }, "decode-uri-component": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/decode-uri-component/-/decode-uri-component-0.2.0.tgz", - "integrity": "sha1-6zkTMzRYd1y4TNGh+uBiEGu4dUU=", + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/decode-uri-component/-/decode-uri-component-0.2.2.tgz", + "integrity": "sha512-FqUYQ+8o158GyGTrMFJms9qh3CqTKvAqgqsTnkLI8sKu0028orqBhxNMFkFen0zGyg6epACD32pjVk58ngIErQ==", "dev": true }, "define-properties": { @@ -6697,8 +6661,7 @@ "function-bind": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.1.tgz", - "integrity": "sha512-yIovAzMX49sF8Yl58fSCWJ5svSLuaibPxXQJFLmBObTuCr0Mf1KiPopGM9NiFjiYBCbfaa2Fh6breQ6ANVTI0A==", - "dev": true + "integrity": "sha512-yIovAzMX49sF8Yl58fSCWJ5svSLuaibPxXQJFLmBObTuCr0Mf1KiPopGM9NiFjiYBCbfaa2Fh6breQ6ANVTI0A==" }, "get-assigned-identifiers": { "version": "1.2.0", @@ -6710,7 +6673,6 @@ "version": "1.1.1", "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.1.1.tgz", "integrity": "sha512-kWZrnVM42QCiEA2Ig1bG8zjoIMOgxWwYCEeNdwY6Tv/cOSeGpcoX4pXHfKUxNKVoArnrEr2e9srnAxxGIraS9Q==", - "dev": true, "requires": { "function-bind": "^1.1.1", "has": "^1.0.3", @@ -6778,7 +6740,6 @@ "version": "1.0.3", "resolved": "https://registry.npmjs.org/has/-/has-1.0.3.tgz", "integrity": "sha512-f2dvO0VU6Oej7RkWJGrehjbzMAjFp5/VKPp5tTpWIV4JHHZK1/BxbFRtf/siA2SWTe09caDmVtYYzWEIbBS4zw==", - "dev": true, "requires": { "function-bind": "^1.1.1" } @@ -6801,8 +6762,7 @@ "has-symbols": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.0.2.tgz", - "integrity": "sha512-chXa79rL/UC2KlX17jo3vRGz0azaWEx5tGqZg5pO3NUyEJVB17dMruQlzCCOfUvElghKcm5194+BCRvi2Rv/Gw==", - "dev": true + "integrity": "sha512-chXa79rL/UC2KlX17jo3vRGz0azaWEx5tGqZg5pO3NUyEJVB17dMruQlzCCOfUvElghKcm5194+BCRvi2Rv/Gw==" }, "hash-base": { "version": "3.0.4", @@ -7158,9 +7118,9 @@ } }, "jpeg-js": { - "version": "0.4.0", - "resolved": "https://registry.npmjs.org/jpeg-js/-/jpeg-js-0.4.0.tgz", - "integrity": "sha512-960VHmtN1vTpasX/1LupLohdP5odwAT7oK/VSm6mW0M58LbrBnowLAPWAZhWGhDAGjzbMnPXZxzB/QYgBwkN0w==", + "version": "0.4.4", + "resolved": "https://registry.npmjs.org/jpeg-js/-/jpeg-js-0.4.4.tgz", + "integrity": "sha512-WZzeDOEtTOBK4Mdsar0IqEU5sMr3vSV2RqkAIzUEV2BHnUfKGyswWFPFwK5EeDo93K3FohSHbLAjj0s1Wzd+dg==", "dev": true }, "js-cookie": { @@ -7280,9 +7240,9 @@ "dev": true }, "marked": { - "version": "0.7.0", - "resolved": "https://registry.npmjs.org/marked/-/marked-0.7.0.tgz", - "integrity": "sha512-c+yYdCZJQrsRjTPhUx7VKkApw9bwDkNbHUKo1ovgcfDjb2kc8rLuRbIFyXL5WOEUwzSSKo3IXpph2K6DqB/KZg==" + "version": "4.0.10", + "resolved": "https://registry.npmjs.org/marked/-/marked-4.0.10.tgz", + "integrity": "sha512-+QvuFj0nGgO970fySghXGmuw+Fd0gD2x3+MqCWLIPf5oxdv1Ka6b2q+z9RP01P/IaKPMEramy+7cNy/Lw8c3hw==" }, "md5.js": { "version": "1.3.4", @@ -7364,9 +7324,9 @@ } }, "minimist": { - "version": "1.2.5", - "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.5.tgz", - "integrity": "sha512-FM9nNUYrRBAELZQT3xeZQ7fmMOBg6nWNmJKTcgsJeaLstP/UODVpGsr5OhXhhXg6f+qtJ8uiZ+PUxkDWcgIXLw==", + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.6.tgz", + "integrity": "sha512-Jsjnk4bw3YJqYzbdyBiNsPWHPfO++UGG749Cxs6peCu5Xg4nrena6OVxOYxrQTqww0Jmwt+Ref8rggumkTLz9Q==", "dev": true }, "mkdirp": { @@ -7466,8 +7426,7 @@ "object-inspect": { "version": "1.10.3", "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.10.3.tgz", - "integrity": "sha512-e5mCJlSH7poANfC8z8S9s9S2IN5/4Zb3aZ33f5s8YqoazCFzNLloLU8r5VCG+G7WoqLvAAZoVMcy3tp/3X0Plw==", - "dev": true + "integrity": "sha512-e5mCJlSH7poANfC8z8S9s9S2IN5/4Zb3aZ33f5s8YqoazCFzNLloLU8r5VCG+G7WoqLvAAZoVMcy3tp/3X0Plw==" }, "object-keys": { "version": "1.1.1", @@ -7607,9 +7566,9 @@ "dev": true }, "path-parse": { - "version": "1.0.6", - "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.6.tgz", - "integrity": "sha512-GSmOT2EbHrINBf9SR7CDELwlJ8AENk3Qn7OikK4nFYAu3Ote2+JYNVvkpAEQm3/TLNEJFD/xZJjzyxg3KBWOzw==", + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", + "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", "dev": true }, "path-platform": { @@ -7705,9 +7664,12 @@ "dev": true }, "qs": { - "version": "6.5.2", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.5.2.tgz", - "integrity": "sha512-N5ZAX4/LxJmF+7wN74pUD6qAh9/wnvdQcjq9TZjevvXzSUo7bfmw91saqMjzGS2xq91/odN2dW/WOl7qQHNDGA==" + "version": "6.11.0", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.11.0.tgz", + "integrity": "sha512-MvjoMCJwEarSbUYk5O+nmoSzSutSsTwF85zcHPQ9OrlFoZOYIjaqBAJIqIXjptyD5vThxGq52Xu/MaJzRkIk4Q==", + "requires": { + "side-channel": "^1.0.4" + } }, "querystring": { "version": "0.2.0", @@ -7936,15 +7898,19 @@ } }, "shell-quote": { - "version": "1.6.1", - "resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.6.1.tgz", - "integrity": "sha1-9HgZSczkAmlxJ0MOo7PFR29IF2c=", - "dev": true, + "version": "1.7.3", + "resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.7.3.tgz", + "integrity": "sha512-Vpfqwm4EnqGdlsBFNmHhxhElJYrdfcxPThu+ryKS5J8L/fhAwLazFZtq+S+TWZ9ANj2piSQLGj6NQg+lKPmxrw==", + "dev": true + }, + "side-channel": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.0.4.tgz", + "integrity": "sha512-q5XPytqFEIKHkGdiMIrY10mvLRvnQh42/+GoBlFW3b2LXLE2xxJpZFdm94we0BaoV3RwJyGqg5wS7epxTv0Zvw==", "requires": { - "array-filter": "~0.0.0", - "array-map": "~0.0.0", - "array-reduce": "~0.0.0", - "jsonify": "~0.0.0" + "call-bind": "^1.0.0", + "get-intrinsic": "^1.0.2", + "object-inspect": "^1.9.0" } }, "simple-concat": { @@ -8116,14 +8082,6 @@ "dev": true, "requires": { "minimist": "^1.1.0" - }, - "dependencies": { - "minimist": { - "version": "1.2.5", - "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.5.tgz", - "integrity": "sha512-FM9nNUYrRBAELZQT3xeZQ7fmMOBg6nWNmJKTcgsJeaLstP/UODVpGsr5OhXhhXg6f+qtJ8uiZ+PUxkDWcgIXLw==", - "dev": true - } } }, "superagent": { @@ -8159,20 +8117,20 @@ } }, "terser": { - "version": "3.7.7", - "resolved": "https://registry.npmjs.org/terser/-/terser-3.7.7.tgz", - "integrity": "sha512-RRLIxE7S52vSOI9cEbOaisgBd2y6MNgfg2ihUkidsFnuP1eDmZ79+lBWbyvgfFTAc/r8nSjL0k3cpZDDIYiYiA==", + "version": "4.8.1", + "resolved": "https://registry.npmjs.org/terser/-/terser-4.8.1.tgz", + "integrity": "sha512-4GnLC0x667eJG0ewJTa6z/yXrbLGv80D9Ru6HIpCQmO+Q4PfEtBFi0ObSckqwL6VyQv/7ENJieXHo2ANmdQwgw==", "dev": true, "requires": { - "commander": "~2.14.1", + "commander": "^2.20.0", "source-map": "~0.6.1", - "source-map-support": "~0.5.6" + "source-map-support": "~0.5.12" }, "dependencies": { "commander": { - "version": "2.14.1", - "resolved": "https://registry.npmjs.org/commander/-/commander-2.14.1.tgz", - "integrity": "sha512-+YR16o3rK53SmWHU3rEM3tPAh2rwb1yPcQX5irVn7mb0gXbwuCCrnkbV5+PBfETdfg1vui07nM6PCG1zndcjQw==", + "version": "2.20.3", + "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz", + "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==", "dev": true }, "source-map": { @@ -8182,9 +8140,9 @@ "dev": true }, "source-map-support": { - "version": "0.5.6", - "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.6.tgz", - "integrity": "sha512-N4KXEz7jcKqPf2b2vZF11lQIz9W5ZMuUcIOGj243lduidkf2fjkVKJS9vNxVWn3u/uxX38AcE8U9nnH9FPcq+g==", + "version": "0.5.21", + "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.21.tgz", + "integrity": "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==", "dev": true, "requires": { "buffer-from": "^1.0.0", diff --git a/client/package.json b/client/package.json index a83a9cb4..76376f82 100644 --- a/client/package.json +++ b/client/package.json @@ -3,14 +3,15 @@ "private": true, "scripts": { "build": "node build.js", - "watch": "node build.js --watch" + "watch": "node build.js --watch", + "build-container": "docker build -t szurubooru/client:dev ." }, "dependencies": { "dompurify": "^2.0.17", "font-awesome": "^4.7.0", "ios-inner-height": "^1.0.3", "js-cookie": "^2.2.0", - "marked": "^0.7.0", + "marked": "^4.0.10", "mousetrap": "^1.6.2", "nprogress": "^0.2.0", "superagent": "^3.8.3" @@ -28,7 +29,7 @@ "jimp": "^0.13.0", "pretty-error": "^3.0.3", "stylus": "^0.54.8", - "terser": "^3.7.7", + "terser": "^4.8.1", "underscore": "^1.12.1", "watchify": "^4.0.0", "ws": "^7.4.6" diff --git a/doc/API.md b/doc/API.md index 3d280fd1..f588c89d 100644 --- a/doc/API.md +++ b/doc/API.md @@ -37,6 +37,7 @@ - [Creating post](#creating-post) - [Updating post](#updating-post) - [Getting post](#getting-post) + - [Getting around post](#getting-around-post) - [Deleting post](#deleting-post) - [Merging posts](#merging-posts) - [Rating post](#rating-post) @@ -322,7 +323,7 @@ data. { "name": , "color": , - "order": // optional + "order": } ``` @@ -951,6 +952,29 @@ data. Retrieves information about an existing post. +## Getting around post +- **Request** + + `GET /post//around` + +- **Output** + + ```json5 + { + "prev": , + "next": + } + ``` + +- **Errors** + + - the post does not exist + - privileges are too low + +- **Description** + + Retrieves information about posts that are before or after an existing post. + ## Deleting post - **Request** @@ -2467,7 +2491,7 @@ One file together with its metadata posted to the site. ## Micro post **Description** -A [post resource](#post) stripped down to `name` and `thumbnailUrl` fields. +A [post resource](#post) stripped down to `id` and `thumbnailUrl` fields. ## Note **Description** diff --git a/doc/INSTALL.md b/doc/INSTALL.md index d978e4a8..ca0212bf 100644 --- a/doc/INSTALL.md +++ b/doc/INSTALL.md @@ -34,33 +34,79 @@ and Docker Compose (version 1.6.0 or greater) already installed. Read the comments to guide you. Note that `.env` should be in the root directory of this repository. -### Running the Application +4. Pull the containers: -Download containers: -```console -user@host:szuru$ docker-compose pull -``` + This pulls the latest containers from docker.io: + ```console + user@host:szuru$ docker-compose pull + ``` -For first run, it is recommended to start the database separately: -```console -user@host:szuru$ docker-compose up -d sql -``` + If you have modified the application's source and would like to manually + build it, follow the instructions in [**Building**](#Building) instead, + then read here once you're done. -To start all containers: -```console -user@host:szuru$ docker-compose up -d -``` +5. Run it! -To view/monitor the application logs: -```console -user@host:szuru$ docker-compose logs -f -# (CTRL+C to exit) -``` + For first run, it is recommended to start the database separately: + ```console + user@host:szuru$ docker-compose up -d sql + ``` + + To start all containers: + ```console + user@host:szuru$ docker-compose up -d + ``` + + To view/monitor the application logs: + ```console + user@host:szuru$ docker-compose logs -f + # (CTRL+C to exit) + ``` + +### Building + +1. Edit `docker-compose.yml` to tell Docker to build instead of pull containers: + + ```diff yaml + ... + server: + - image: szurubooru/server:latest + + build: server + ... + client: + - image: szurubooru/client:latest + + build: client + ... + ``` + + You can choose to build either one from source. + +2. Build the containers: + + ```console + user@host:szuru$ docker-compose build + ``` + + That will attempt to build both containers, but you can specify `client` + or `server` to make it build only one. + + If `docker-compose build` spits out: + + ``` + ERROR: Service 'server' failed to build: failed to parse platform : "" is an invalid component of "": platform specifier component must match "^[A-Za-z0-9_-]+$": invalid argument + ``` + + ...you will need to export Docker BuildKit flags: + + ```console + user@host:szuru$ export DOCKER_BUILDKIT=1; export COMPOSE_DOCKER_CLI_BUILD=1 + ``` + + ...and run `docker-compose build` again. + +*Note: If your changes are not taking effect in your builds, consider building +with `--no-cache`.* -To stop all containers: -```console -user@host:szuru$ docker-compose down -``` ### Additional Features diff --git a/doc/example.env b/doc/example.env index 59e1e859..303a25e6 100644 --- a/doc/example.env +++ b/doc/example.env @@ -10,6 +10,12 @@ BUILD_INFO=latest # otherwise the port specified here will be publicly accessible PORT=8080 +# How many waitress threads to start +# 4 is the default amount of threads. If you experience performance +# degradation with a large number of posts, increasing this may +# improve performance, since waitress is most likely clogging up with Tasks. +THREADS=4 + # URL base to run szurubooru under # See "Additional Features" section in INSTALL.md BASE_URL=/ diff --git a/docker-compose.yml b/docker-compose.yml index 1da23bd6..38e08b97 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -21,6 +21,7 @@ services: #POSTGRES_DB: defaults to same as POSTGRES_USER #POSTGRES_PORT: 5432 #LOG_SQL: 0 (1 for verbose SQL logs) + THREADS: volumes: - "${MOUNT_DATA}:/data" - "./server/config.yaml:/opt/app/config.yaml" diff --git a/server/Dockerfile b/server/Dockerfile index 4beec1cf..487f1923 100644 --- a/server/Dockerfile +++ b/server/Dockerfile @@ -7,8 +7,13 @@ WORKDIR /opt/app RUN apk --no-cache add \ python3 \ python3-dev \ - ffmpeg \ py3-pip \ + build-base \ + libheif \ + libheif-dev \ + libavif \ + libavif-dev \ + ffmpeg \ # from requirements.txt: py3-yaml \ py3-psycopg2 \ @@ -19,25 +24,20 @@ RUN apk --no-cache add \ py3-pynacl \ py3-tz \ py3-pyrfc3339 \ - build-base \ - && apk --no-cache add \ - libheif \ - libavif \ - libheif-dev \ - libavif-dev \ && pip3 install --no-cache-dir --disable-pip-version-check \ - alembic \ + "alembic>=0.8.5" \ "coloredlogs==5.0" \ + "pyheif==0.6.1" \ + "heif-image-plugin>=0.3.2" \ youtube_dl \ - pillow-avif-plugin \ - pyheif-pillow-opener \ + "pillow-avif-plugin>=1.1.0" \ && apk --no-cache del py3-pip COPY ./ /opt/app/ RUN rm -rf /opt/app/szurubooru/tests -FROM prereqs as testing +FROM --platform=$BUILDPLATFORM prereqs as testing WORKDIR /opt/app RUN apk --no-cache add \ @@ -83,6 +83,9 @@ ARG PORT=6666 ENV PORT=${PORT} EXPOSE ${PORT} +ARG THREADS=4 +ENV THREADS=${THREADS} + VOLUME ["/data/"] ARG DOCKER_REPO diff --git a/server/config.yaml.dist b/server/config.yaml.dist index bc4e3630..193aac3a 100644 --- a/server/config.yaml.dist +++ b/server/config.yaml.dist @@ -115,6 +115,7 @@ privileges: 'posts:favorite': regular 'posts:bulk-edit:tags': power 'posts:bulk-edit:safety': power + 'posts:bulk-edit:delete': power 'tags:create': regular 'tags:edit:names': power diff --git a/server/docker-start.sh b/server/docker-start.sh index 34a0e498..eebef1c7 100755 --- a/server/docker-start.sh +++ b/server/docker-start.sh @@ -4,5 +4,5 @@ cd /opt/app alembic upgrade head -echo "Starting szurubooru API on port ${PORT}" -exec waitress-serve-3 --port ${PORT} szurubooru.facade:app +echo "Starting szurubooru API on port ${PORT} - Running on ${THREADS} threads" +exec waitress-serve-3 --port ${PORT} --threads ${THREADS} szurubooru.facade:app diff --git a/server/hooks/build b/server/hooks/build deleted file mode 100755 index b5e914b2..00000000 --- a/server/hooks/build +++ /dev/null @@ -1,7 +0,0 @@ -#!/bin/sh - -docker build \ - --build-arg BUILD_DATE=$(date -u +'%Y-%m-%dT%H:%M:%SZ') \ - --build-arg SOURCE_COMMIT \ - --build-arg DOCKER_REPO \ - -f $DOCKERFILE_PATH -t $IMAGE_NAME . diff --git a/server/hooks/post_push b/server/hooks/post_push deleted file mode 100755 index 1b1e0ad9..00000000 --- a/server/hooks/post_push +++ /dev/null @@ -1,19 +0,0 @@ -#!/bin/sh - -add_tag() { - echo "Also tagging image as ${DOCKER_REPO}:${1}" - docker tag $IMAGE_NAME $DOCKER_REPO:$1 - docker push $DOCKER_REPO:$1 -} - -CLOSEST_VER=$(git describe --tags --abbrev=0) -CLOSEST_MAJOR_VER=$(echo ${CLOSEST_VER} | cut -d'.' -f1) -CLOSEST_MINOR_VER=$(echo ${CLOSEST_VER} | cut -d'.' -f2) - -add_tag "${CLOSEST_MAJOR_VER}-edge" -add_tag "${CLOSEST_MAJOR_VER}.${CLOSEST_MINOR_VER}-edge" - -if git describe --exact-match --abbrev=0 2> /dev/null; then - add_tag "${CLOSEST_MAJOR_VER}" - add_tag "${CLOSEST_MAJOR_VER}.${CLOSEST_MINOR_VER}" -fi diff --git a/server/hooks/test b/server/hooks/test deleted file mode 100755 index b3251864..00000000 --- a/server/hooks/test +++ /dev/null @@ -1,8 +0,0 @@ -#!/bin/sh -set -e - -docker run --rm \ - -t $(docker build --target testing -q .) \ - --color=no szurubooru/ - -exit $? diff --git a/server/requirements.txt b/server/requirements.txt index 2a09b24b..16b29fff 100644 --- a/server/requirements.txt +++ b/server/requirements.txt @@ -1,14 +1,15 @@ alembic>=0.8.5 -pyyaml>=3.11 -psycopg2-binary>=2.6.1 -SQLAlchemy>=1.0.12, <1.4 -coloredlogs==5.0 certifi>=2017.11.5 +coloredlogs==5.0 +heif-image-plugin==0.3.2 numpy>=1.8.2 -pillow>=4.3.0 -pynacl>=1.2.1 -pytz>=2018.3 -pyRFC3339>=1.0 pillow-avif-plugin>=1.1.0 -pyheif-pillow-opener>=0.1.0 +pillow>=4.3.0 +psycopg2-binary>=2.6.1 +pyheif==0.6.1 +pynacl>=1.2.1 +pyRFC3339>=1.0 +pytz>=2018.3 +pyyaml>=3.11 +SQLAlchemy>=1.0.12, <1.4 youtube_dl diff --git a/server/szuru-admin b/server/szuru-admin index 004a7517..08ba1827 100755 --- a/server/szuru-admin +++ b/server/szuru-admin @@ -91,6 +91,15 @@ def reset_filenames() -> None: rename_in_dir("posts/custom-thumbnails/") +def regenerate_thumbnails() -> None: + for post in db.session.query(model.Post).all(): + print("Generating tumbnail for post %d ..." % post.post_id, end="\r") + try: + postfuncs.generate_post_thumbnail(post) + except Exception: + pass + + def main() -> None: parser_top = ArgumentParser( description="Collection of CLI commands for an administrator to use", @@ -114,6 +123,12 @@ def main() -> None: help="reset and rename the content and thumbnail " "filenames in case of a lost/changed secret key", ) + parser.add_argument( + "--regenerate-thumbnails", + action="store_true", + help="regenerate the thumbnails for posts if the " + "thumbnail files are missing", + ) command = parser_top.parse_args() try: @@ -123,6 +138,8 @@ def main() -> None: check_audio() elif command.reset_filenames: reset_filenames() + elif command.regenerate_thumbnails: + regenerate_thumbnails() except errors.BaseError as e: print(e, file=stderr) diff --git a/server/szurubooru/config.py b/server/szurubooru/config.py index 1515a54f..8f87642f 100644 --- a/server/szurubooru/config.py +++ b/server/szurubooru/config.py @@ -33,7 +33,7 @@ def _docker_config() -> Dict: "show_sql": int(os.getenv("LOG_SQL", 0)), "data_url": os.getenv("DATA_URL", "data/"), "data_dir": "/data/", - "database": "postgres://%(user)s:%(pass)s@%(host)s:%(port)d/%(db)s" + "database": "postgresql://%(user)s:%(pass)s@%(host)s:%(port)d/%(db)s" % { "user": os.getenv("POSTGRES_USER"), "pass": os.getenv("POSTGRES_PASSWORD"), diff --git a/server/szurubooru/facade.py b/server/szurubooru/facade.py index a7e48449..4c8084f6 100644 --- a/server/szurubooru/facade.py +++ b/server/szurubooru/facade.py @@ -135,7 +135,7 @@ _live_migrations = ( def create_app() -> Callable[[Any, Any], Any]: - """ Create a WSGI compatible App object. """ + """Create a WSGI compatible App object.""" validate_config() coloredlogs.install(fmt="[%(asctime)-15s] %(name)s %(message)s") if config.config["debug"]: diff --git a/server/szurubooru/func/auth.py b/server/szurubooru/func/auth.py index d0137756..17d25f70 100644 --- a/server/szurubooru/func/auth.py +++ b/server/szurubooru/func/auth.py @@ -25,7 +25,7 @@ RANK_MAP = OrderedDict( def get_password_hash(salt: str, password: str) -> Tuple[str, int]: - """ Retrieve argon2id password hash. """ + """Retrieve argon2id password hash.""" return ( pwhash.argon2id.str( (config.config["secret"] + salt + password).encode("utf8") @@ -37,7 +37,7 @@ def get_password_hash(salt: str, password: str) -> Tuple[str, int]: def get_sha256_legacy_password_hash( salt: str, password: str ) -> Tuple[str, int]: - """ Retrieve old-style sha256 password hash. """ + """Retrieve old-style sha256 password hash.""" digest = hashlib.sha256() digest.update(config.config["secret"].encode("utf8")) digest.update(salt.encode("utf8")) @@ -46,7 +46,7 @@ def get_sha256_legacy_password_hash( def get_sha1_legacy_password_hash(salt: str, password: str) -> Tuple[str, int]: - """ Retrieve old-style sha1 password hash. """ + """Retrieve old-style sha1 password hash.""" digest = hashlib.sha1() digest.update(b"1A2/$_4xVa") digest.update(salt.encode("utf8")) @@ -125,7 +125,7 @@ def verify_privilege(user: model.User, privilege_name: str) -> None: def generate_authentication_token(user: model.User) -> str: - """ Generate nonguessable challenge (e.g. links in password reminder). """ + """Generate nonguessable challenge (e.g. links in password reminder).""" assert user digest = hashlib.md5() digest.update(config.config["secret"].encode("utf8")) diff --git a/server/szurubooru/func/image_hash.py b/server/szurubooru/func/image_hash.py index 05b27a42..76d5a846 100644 --- a/server/szurubooru/func/image_hash.py +++ b/server/szurubooru/func/image_hash.py @@ -4,16 +4,13 @@ from datetime import datetime from io import BytesIO from typing import Any, Callable, List, Optional, Set, Tuple +import HeifImagePlugin import numpy as np import pillow_avif -import pyheif from PIL import Image -from pyheif_pillow_opener import register_heif_opener from szurubooru import config, errors -register_heif_opener() - logger = logging.getLogger(__name__) # Math based on paper from H. Chi Wong, Marshall Bern and David Goldberg diff --git a/server/szurubooru/func/images.py b/server/szurubooru/func/images.py index de41222f..e135d182 100644 --- a/server/szurubooru/func/images.py +++ b/server/szurubooru/func/images.py @@ -7,6 +7,8 @@ import subprocess from io import BytesIO from typing import List +import HeifImagePlugin +import pillow_avif from PIL import Image as PILImage from szurubooru import errors @@ -277,10 +279,10 @@ class Image: proc = subprocess.Popen( cli, stdout=subprocess.PIPE, - stdin=subprocess.PIPE, + stdin=subprocess.DEVNULL, stderr=subprocess.PIPE, ) - out, err = proc.communicate(input=self.content) + out, err = proc.communicate() if proc.returncode != 0: logger.warning( "Failed to execute ffmpeg command (cli=%r, err=%r)", diff --git a/server/szurubooru/func/mime.py b/server/szurubooru/func/mime.py index 3be43f77..8fae5679 100644 --- a/server/szurubooru/func/mime.py +++ b/server/szurubooru/func/mime.py @@ -36,9 +36,12 @@ def get_mime_type(content: bytes) -> str: if content[0:4] == b"\x1A\x45\xDF\xA3": return "video/webm" - if content[4:12] in (b"ftypisom", b"ftypiso5", b"ftypmp42", b"ftypM4V "): + if content[4:12] in (b"ftypisom", b"ftypiso5", b"ftypiso6", b"ftypmp42", b"ftypM4V "): return "video/mp4" + if content[4:12] == b"ftypqt ": + return "video/quicktime" + return "application/octet-stream" @@ -54,6 +57,7 @@ def get_extension(mime_type: str) -> Optional[str]: "image/heif": "heif", "image/heic": "heic", "video/mp4": "mp4", + "video/quicktime": "mov", "video/webm": "webm", "application/octet-stream": "dat", } @@ -65,7 +69,12 @@ def is_flash(mime_type: str) -> bool: def is_video(mime_type: str) -> bool: - return mime_type.lower() in ("application/ogg", "video/mp4", "video/webm") + return mime_type.lower() in ( + "application/ogg", + "video/mp4", + "video/quicktime", + "video/webm", + ) def is_image(mime_type: str) -> bool: diff --git a/server/szurubooru/func/net.py b/server/szurubooru/func/net.py index 9dff3c45..c53a62eb 100644 --- a/server/szurubooru/func/net.py +++ b/server/szurubooru/func/net.py @@ -39,13 +39,20 @@ def download(url: str, use_video_downloader: bool = False) -> bytes: length_tally = 0 try: with urllib.request.urlopen(request) as handle: - while (chunk := handle.read(_dl_chunk_size)) : + while chunk := handle.read(_dl_chunk_size): length_tally += len(chunk) if length_tally > config.config["max_dl_filesize"]: - raise DownloadTooLargeError(url) + raise DownloadTooLargeError( + "Download target exceeds maximum. (%d)" + % (config.config["max_dl_filesize"]), + extra_fields={"URL": url}, + ) content_buffer += chunk except urllib.error.HTTPError as ex: - raise DownloadError(url) from ex + raise DownloadError( + "Download target returned HTTP %d. (%s)" % (ex.code, ex.reason), + extra_fields={"URL": url}, + ) from ex if ( youtube_dl_error @@ -69,7 +76,8 @@ def _get_youtube_dl_content_url(url: str) -> str: ) except subprocess.CalledProcessError: raise errors.ThirdPartyError( - "Could not extract content location from %s" % (url) + "Could not extract content location from URL.", + extra_fields={"URL": url}, ) from None diff --git a/server/szurubooru/func/util.py b/server/szurubooru/func/util.py index f8391365..453e1213 100644 --- a/server/szurubooru/func/util.py +++ b/server/szurubooru/func/util.py @@ -83,12 +83,12 @@ def flip(source: Dict[Any, Any]) -> Dict[Any, Any]: def is_valid_email(email: Optional[str]) -> bool: - """ Return whether given email address is valid or empty. """ + """Return whether given email address is valid or empty.""" return not email or re.match(r"^[^@]*@[^@]*\.[^@]*$", email) is not None class dotdict(dict): - """ dot.notation access to dictionary attributes. """ + """dot.notation access to dictionary attributes.""" def __getattr__(self, attr: str) -> Any: return self.get(attr) @@ -98,7 +98,7 @@ class dotdict(dict): def parse_time_range(value: str) -> Tuple[datetime, datetime]: - """ Return tuple containing min/max time for given text representation. """ + """Return tuple containing min/max time for given text representation.""" one_day = timedelta(days=1) one_second = timedelta(seconds=1) almost_one_day = one_day - one_second diff --git a/server/szurubooru/middleware/authenticator.py b/server/szurubooru/middleware/authenticator.py index e73b235e..436543b1 100644 --- a/server/szurubooru/middleware/authenticator.py +++ b/server/szurubooru/middleware/authenticator.py @@ -7,7 +7,7 @@ from szurubooru.rest.errors import HttpBadRequest def _authenticate_basic_auth(username: str, password: str) -> model.User: - """ Try to authenticate user. Throw AuthError for invalid users. """ + """Try to authenticate user. Throw AuthError for invalid users.""" user = users.get_user_by_name(username) if not auth.is_valid_password(user, password): raise errors.AuthError("Invalid password.") @@ -17,7 +17,7 @@ def _authenticate_basic_auth(username: str, password: str) -> model.User: def _authenticate_token( username: str, token: str ) -> Tuple[model.User, model.UserToken]: - """ Try to authenticate user. Throw AuthError for invalid users. """ + """Try to authenticate user. Throw AuthError for invalid users.""" user = users.get_user_by_name(username) user_token = user_tokens.get_by_user_and_token(user, token) if not auth.is_valid_token(user_token): @@ -72,7 +72,7 @@ def _get_user(ctx: rest.Context, bump_login: bool) -> Optional[model.User]: def process_request(ctx: rest.Context) -> None: - """ Bind the user to request. Update last login time if needed. """ + """Bind the user to request. Update last login time if needed.""" bump_login = ctx.get_param_as_bool("bump-login", default=False) auth_user = _get_user(ctx, bump_login) if auth_user: diff --git a/server/szurubooru/rest/app.py b/server/szurubooru/rest/app.py index a6f10fbc..c098bd04 100644 --- a/server/szurubooru/rest/app.py +++ b/server/szurubooru/rest/app.py @@ -11,7 +11,7 @@ from szurubooru.rest import context, errors, middleware, routes def _json_serializer(obj: Any) -> str: - """ JSON serializer for objects not serializable by default JSON code """ + """JSON serializer for objects not serializable by default JSON code""" if isinstance(obj, datetime): serial = obj.isoformat("T") + "Z" return serial diff --git a/server/szurubooru/search/configs/post_search_config.py b/server/szurubooru/search/configs/post_search_config.py index ddc003b7..8d4672d4 100644 --- a/server/szurubooru/search/configs/post_search_config.py +++ b/server/szurubooru/search/configs/post_search_config.py @@ -122,6 +122,34 @@ def _pool_filter( )(query, criterion, negated) +def _category_filter( + query: SaQuery, criterion: Optional[criteria.BaseCriterion], negated: bool +) -> SaQuery: + assert criterion + + # Step 1. find the id for the category + q1 = db.session.query(model.TagCategory.tag_category_id).filter( + model.TagCategory.name == criterion.value + ) + + # Step 2. find the tags with that category + q2 = db.session.query(model.Tag.tag_id).filter( + model.Tag.category_id.in_(q1) + ) + + # Step 3. find all posts that have at least one of those tags + q3 = db.session.query(model.PostTag.post_id).filter( + model.PostTag.tag_id.in_(q2) + ) + + # Step 4. profit + expr = model.Post.post_id.in_(q3) + if negated: + expr = ~expr + + return query.filter(expr) + + class PostSearchConfig(BaseSearchConfig): def __init__(self) -> None: self.user = None # type: Optional[model.User] @@ -349,6 +377,7 @@ class PostSearchConfig(BaseSearchConfig): ), ), (["pool"], _pool_filter), + (["category"], _category_filter), ] ) diff --git a/server/szurubooru/tests/api/test_tag_updating.py b/server/szurubooru/tests/api/test_tag_updating.py index 729734d9..be5f4858 100644 --- a/server/szurubooru/tests/api/test_tag_updating.py +++ b/server/szurubooru/tests/api/test_tag_updating.py @@ -145,8 +145,9 @@ def test_trying_to_update_without_privileges( ) +@pytest.mark.parametrize("type", ["suggestions", "implications"]) def test_trying_to_create_tags_without_privileges( - config_injector, context_factory, tag_factory, user_factory + config_injector, context_factory, tag_factory, user_factory, type ): tag = tag_factory(names=["tag"]) db.session.add(tag) @@ -165,16 +166,7 @@ def test_trying_to_create_tags_without_privileges( with pytest.raises(errors.AuthError): api.tag_api.update_tag( context_factory( - params={"suggestions": ["tag1", "tag2"], "version": 1}, - user=user_factory(rank=model.User.RANK_REGULAR), - ), - {"tag_name": "tag"}, - ) - db.session.rollback() - with pytest.raises(errors.AuthError): - api.tag_api.update_tag( - context_factory( - params={"implications": ["tag1", "tag2"], "version": 1}, + params={type: ["tag1", "tag2"], "version": 1}, user=user_factory(rank=model.User.RANK_REGULAR), ), {"tag_name": "tag"}, diff --git a/server/szurubooru/tests/assets/mov.mov b/server/szurubooru/tests/assets/mov.mov new file mode 100644 index 00000000..911ee852 Binary files /dev/null and b/server/szurubooru/tests/assets/mov.mov differ diff --git a/server/szurubooru/tests/conftest.py b/server/szurubooru/tests/conftest.py index e7811fe1..280987ca 100644 --- a/server/szurubooru/tests/conftest.py +++ b/server/szurubooru/tests/conftest.py @@ -43,14 +43,26 @@ def query_logger(pytestconfig): logging.getLogger("sqlalchemy.engine").setLevel(logging.INFO) -@pytest.yield_fixture(scope="function", autouse=True) -def session(query_logger, postgresql_db): +@pytest.fixture(scope="function", autouse=True) +def session(query_logger, transacted_postgresql_db): + db.session = transacted_postgresql_db.session + transacted_postgresql_db.create_table(*model.Base.metadata.sorted_tables) + try: + yield transacted_postgresql_db.session + finally: + transacted_postgresql_db.reset_db() + + +@pytest.fixture(scope="function") +def nontransacted_session(query_logger, postgresql_db): + old_db_session = db.session db.session = postgresql_db.session postgresql_db.create_table(*model.Base.metadata.sorted_tables) try: yield postgresql_db.session finally: postgresql_db.reset_db() + db.session = old_db_session @pytest.fixture diff --git a/server/szurubooru/tests/func/test_mime.py b/server/szurubooru/tests/func/test_mime.py index b33746b4..551ba7c3 100644 --- a/server/szurubooru/tests/func/test_mime.py +++ b/server/szurubooru/tests/func/test_mime.py @@ -7,6 +7,7 @@ from szurubooru.func import mime "input_path,expected_mime_type", [ ("mp4.mp4", "video/mp4"), + ("mov.mov", "video/quicktime"), ("webm.webm", "video/webm"), ("flash.swf", "application/x-shockwave-flash"), ("png.png", "image/png"), @@ -35,6 +36,7 @@ def test_get_mime_type_for_empty_file(): [ ("video/mp4", "mp4"), ("video/webm", "webm"), + ("video/quicktime", "mov"), ("application/x-shockwave-flash", "swf"), ("image/png", "png"), ("image/jpeg", "jpg"), @@ -70,6 +72,8 @@ def test_is_flash(input_mime_type, expected_state): ("VIDEO/WEBM", True), ("video/mp4", True), ("VIDEO/MP4", True), + ("video/quicktime", True), + ("VIDEO/QUICKTIME", True), ("video/anything_else", False), ("application/ogg", True), ("not a video", False), diff --git a/server/szurubooru/tests/func/test_net.py b/server/szurubooru/tests/func/test_net.py index c5b4c73e..be2f3c93 100644 --- a/server/szurubooru/tests/func/test_net.py +++ b/server/szurubooru/tests/func/test_net.py @@ -1,3 +1,5 @@ +import os + import pytest from szurubooru import errors @@ -16,6 +18,9 @@ def inject_config(tmpdir, config_injector): ) +@pytest.mark.skipif( + "TEST_NET" not in os.environ, reason="Network tests skipped by default." +) def test_download(): url = "http://info.cern.ch/hypertext/WWW/TheProject.html" @@ -62,6 +67,9 @@ def test_download(): assert actual_content == expected_content +@pytest.mark.skipif( + "TEST_NET" not in os.environ, reason="Network tests skipped by default." +) @pytest.mark.parametrize( "url", [ @@ -74,6 +82,9 @@ def test_too_large_download(url): net.download(url, use_video_downloader=True) +@pytest.mark.skipif( + "TEST_NET" not in os.environ, reason="Network tests skipped by default." +) @pytest.mark.parametrize( "url,expected_sha1", [ @@ -96,6 +107,9 @@ def test_content_download(url, expected_sha1): assert get_sha1(actual_content) == expected_sha1 +@pytest.mark.skipif( + "TEST_NET" not in os.environ, reason="Network tests skipped by default." +) def test_bad_content_downlaod(): url = "http://info.cern.ch/hypertext/WWW/TheProject.html" with pytest.raises(errors.ThirdPartyError): @@ -108,11 +122,13 @@ def test_no_webhooks(config_injector): assert len(res) == 0 +@pytest.mark.skipif( + "TEST_NET" not in os.environ, reason="Network tests skipped by default." +) @pytest.mark.parametrize( "webhook,status_code", [ ("https://postman-echo.com/post", 200), - ("http://localhost/", 400), ("https://postman-echo.com/get", 400), ], ) @@ -121,6 +137,9 @@ def test_single_webhook(config_injector, webhook, status_code): assert ret == status_code +@pytest.mark.skipif( + "TEST_NET" not in os.environ, reason="Network tests skipped by default." +) def test_multiple_webhooks(config_injector): config_injector( { diff --git a/server/szurubooru/tests/func/test_snapshots.py b/server/szurubooru/tests/func/test_snapshots.py index da935307..dc68ff05 100644 --- a/server/szurubooru/tests/func/test_snapshots.py +++ b/server/szurubooru/tests/func/test_snapshots.py @@ -1,7 +1,7 @@ from datetime import datetime from unittest.mock import patch -import pytest +import pytest # noqa: F401 from szurubooru import db, model from szurubooru.func import snapshots, users @@ -144,46 +144,6 @@ def test_create(tag_factory, user_factory): assert results[0].data == "mocked" -def test_modify_saves_non_empty_diffs(post_factory, user_factory): - if "sqlite" in db.session.get_bind().driver: - pytest.xfail( - "SQLite doesn't support transaction isolation, " - "which is required to retrieve original entity" - ) - post = post_factory() - post.notes = [model.PostNote(polygon=[(0, 0), (0, 1), (1, 1)], text="old")] - user = user_factory() - db.session.add_all([post, user]) - db.session.commit() - post.source = "new source" - post.notes = [model.PostNote(polygon=[(0, 0), (0, 1), (1, 1)], text="new")] - db.session.flush() - with patch("szurubooru.func.snapshots._post_to_webhooks"): - snapshots.modify(post, user) - db.session.flush() - results = db.session.query(model.Snapshot).all() - assert len(results) == 1 - assert results[0].data == { - "type": "object change", - "value": { - "source": { - "type": "primitive change", - "old-value": None, - "new-value": "new source", - }, - "notes": { - "type": "list change", - "removed": [ - {"polygon": [[0, 0], [0, 1], [1, 1]], "text": "old"} - ], - "added": [ - {"polygon": [[0, 0], [0, 1], [1, 1]], "text": "new"} - ], - }, - }, - } - - def test_modify_doesnt_save_empty_diffs(tag_factory, user_factory): tag = tag_factory(names=["dummy"]) user = user_factory() diff --git a/server/szurubooru/tests/func/test_snapshots_transactional_isolation.py b/server/szurubooru/tests/func/test_snapshots_transactional_isolation.py new file mode 100644 index 00000000..b98cea7a --- /dev/null +++ b/server/szurubooru/tests/func/test_snapshots_transactional_isolation.py @@ -0,0 +1,59 @@ +from unittest.mock import patch + +import pytest + +from szurubooru import db, model +from szurubooru.func import snapshots + + +@pytest.fixture(autouse=True) +def session(query_logger, postgresql_db): + """ + Override db session for this specific test section only + """ + db.session = postgresql_db.session + postgresql_db.create_table(*model.Base.metadata.sorted_tables) + try: + yield postgresql_db.session + finally: + postgresql_db.reset_db() + + +def test_modify_saves_non_empty_diffs(post_factory, user_factory): + if "sqlite" in db.session.get_bind().driver: + pytest.xfail( + "SQLite doesn't support transaction isolation, " + "which is required to retrieve original entity" + ) + post = post_factory() + post.notes = [model.PostNote(polygon=[(0, 0), (0, 1), (1, 1)], text="old")] + user = user_factory() + db.session.add_all([post, user]) + db.session.commit() + post.source = "new source" + post.notes = [model.PostNote(polygon=[(0, 0), (0, 1), (1, 1)], text="new")] + db.session.flush() + with patch("szurubooru.func.snapshots._post_to_webhooks"): + snapshots.modify(post, user) + db.session.flush() + results = db.session.query(model.Snapshot).all() + assert len(results) == 1 + assert results[0].data == { + "type": "object change", + "value": { + "source": { + "type": "primitive change", + "old-value": None, + "new-value": "new source", + }, + "notes": { + "type": "list change", + "removed": [ + {"polygon": [[0, 0], [0, 1], [1, 1]], "text": "old"} + ], + "added": [ + {"polygon": [[0, 0], [0, 1], [1, 1]], "text": "new"} + ], + }, + }, + } diff --git a/server/szurubooru/tests/func/test_tag_categories.py b/server/szurubooru/tests/func/test_tag_categories.py index 11300cf4..9e649a34 100644 --- a/server/szurubooru/tests/func/test_tag_categories.py +++ b/server/szurubooru/tests/func/test_tag_categories.py @@ -107,17 +107,16 @@ def test_update_category_name_reusing_other_name( tag_categories.update_category_name(category, "NAME") +@pytest.mark.parametrize("name", ["name", "NAME"]) def test_update_category_name_reusing_own_name( - config_injector, tag_category_factory + config_injector, tag_category_factory, name ): config_injector({"tag_category_name_regex": ".*"}) - for name in ["name", "NAME"]: - category = tag_category_factory(name="name") - db.session.add(category) - db.session.flush() - tag_categories.update_category_name(category, name) - assert category.name == name - db.session.rollback() + category = tag_category_factory(name="name") + db.session.add(category) + db.session.flush() + tag_categories.update_category_name(category, name) + assert category.name == name def test_update_category_color_with_empty_string(tag_category_factory): diff --git a/server/szurubooru/tests/func/test_tags.py b/server/szurubooru/tests/func/test_tags.py index ac8963c7..60df1220 100644 --- a/server/szurubooru/tests/func/test_tags.py +++ b/server/szurubooru/tests/func/test_tags.py @@ -513,15 +513,14 @@ def test_update_tag_names_trying_to_use_taken_name( tags.update_tag_names(tag, ["A"]) -def test_update_tag_names_reusing_own_name(config_injector, tag_factory): +@pytest.mark.parametrize("name", list("aA")) +def test_update_tag_names_reusing_own_name(config_injector, tag_factory, name): config_injector({"tag_name_regex": "^[a-zA-Z]*$"}) - for name in list("aA"): - tag = tag_factory(names=["a"]) - db.session.add(tag) - db.session.flush() - tags.update_tag_names(tag, [name]) - assert [tag_name.name for tag_name in tag.names] == [name] - db.session.rollback() + tag = tag_factory(names=["a"]) + db.session.add(tag) + db.session.flush() + tags.update_tag_names(tag, [name]) + assert [tag_name.name for tag_name in tag.names] == [name] def test_update_tag_names_changing_primary_name(config_injector, tag_factory): @@ -533,7 +532,6 @@ def test_update_tag_names_changing_primary_name(config_injector, tag_factory): db.session.flush() db.session.refresh(tag) assert [tag_name.name for tag_name in tag.names] == ["b", "a"] - db.session.rollback() @pytest.mark.parametrize("attempt", ["name", "NAME", "alias", "ALIAS"]) diff --git a/server/szurubooru/tests/search/configs/test_pool_search_config.py b/server/szurubooru/tests/search/configs/test_pool_search_config.py index 202635c6..1103ec40 100644 --- a/server/szurubooru/tests/search/configs/test_pool_search_config.py +++ b/server/szurubooru/tests/search/configs/test_pool_search_config.py @@ -136,8 +136,6 @@ def test_escaping( ) db.session.flush() - if db_driver and db.session.get_bind().driver != db_driver: - pytest.xfail() if expected_pool_names is None: with pytest.raises(errors.SearchError): executor.execute(input, offset=0, limit=100) diff --git a/server/szurubooru/tests/search/configs/test_post_search_config.py b/server/szurubooru/tests/search/configs/test_post_search_config.py index 4fb8191a..b86fa273 100644 --- a/server/szurubooru/tests/search/configs/test_post_search_config.py +++ b/server/szurubooru/tests/search/configs/test_post_search_config.py @@ -863,3 +863,55 @@ def test_tumbleweed( db.session.flush() verify_unpaged("special:tumbleweed", [4]) verify_unpaged("-special:tumbleweed", [1, 2, 3]) + + +@pytest.mark.parametrize( + "input,expected_post_ids", + [ + ("category:cat1", [1, 2, 3]), + ("category:cat2", [3, 4]), + ], +) +def test_search_by_tag_category( + verify_unpaged, + post_factory, + tag_factory, + tag_category_factory, + input, + expected_post_ids, +): + cat1 = tag_category_factory(name="cat1") + cat2 = tag_category_factory(name="cat2") + tag1 = tag_factory(names=["t1"], category=cat1) + tag2 = tag_factory(names=["t2"], category=cat1) + tag3 = tag_factory(names=["t3"], category=cat2) + + post1 = post_factory(id=1) + post1.tags.append(tag1) + + post2 = post_factory(id=2) + post2.tags.append(tag2) + + post3 = post_factory(id=3) + post3.tags.append(tag1) + post3.tags.append(tag3) + + post4 = post_factory(id=4) + post4.tags.append(tag3) + + post5 = post_factory(id=5) + + db.session.add_all( + [ + tag1, + tag2, + tag3, + post1, + post2, + post3, + post4, + post5, + ] + ) + db.session.flush() + verify_unpaged(input, expected_post_ids) diff --git a/server/szurubooru/tests/search/configs/test_tag_search_config.py b/server/szurubooru/tests/search/configs/test_tag_search_config.py index 8175b73c..9fe9a80e 100644 --- a/server/szurubooru/tests/search/configs/test_tag_search_config.py +++ b/server/szurubooru/tests/search/configs/test_tag_search_config.py @@ -134,8 +134,6 @@ def test_escaping(executor, tag_factory, input, expected_tag_names, db_driver): ) db.session.flush() - if db_driver and db.session.get_bind().driver != db_driver: - pytest.xfail() if expected_tag_names is None: with pytest.raises(errors.SearchError): executor.execute(input, offset=0, limit=100)