diff options
390 files changed, 22489 insertions, 12693 deletions
@@ -1,6 +1,15 @@ +# User-specific configuration config.yaml .env + +# Client Development Artifacts */*_modules/ +client/public + +# Server Development Artifacts .coverage .cache -client/public +server/**/lib/ +server/**/bin/ +server/**/pyvenv.cfg +__pycache__/ diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..5053b6e --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,100 @@ +repos: + +- repo: https://github.com/pre-commit/pre-commit-hooks + rev: v3.2.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 + hooks: + - id: remove-tabs + +- repo: https://github.com/psf/black + rev: 20.8b1 + hooks: + - id: black + files: 'server/' + types: [python] + language_version: python3.8 + +- repo: https://github.com/timothycrosley/isort + rev: '5.4.2' + hooks: + - id: isort + files: 'server/' + types: [python] + exclude: server/szurubooru/migrations/env.py + additional_dependencies: + - toml + +- repo: https://github.com/prettier/prettier + rev: '2.1.1' + hooks: + - id: prettier + files: client/js/ + exclude: client/js/.gitignore + args: ['--config', 'client/.prettierrc.yml'] + +- repo: https://github.com/pre-commit/mirrors-eslint + rev: v7.8.0 + hooks: + - id: eslint + files: client/js/ + args: ['--fix'] + additional_dependencies: + - eslint-config-prettier + +- repo: https://gitlab.com/pycqa/flake8 + rev: '3.8.3' + hooks: + - id: flake8 + files: server/szurubooru/ + additional_dependencies: + - 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/INSTALL-OLD.md b/INSTALL-OLD.md deleted file mode 100644 index 1cb2429..0000000 --- a/INSTALL-OLD.md +++ /dev/null @@ -1,218 +0,0 @@ -**This installation guide is deprecated and might be out -of date! It is recommended that you deploy using -[Docker](https://github.com/rr-/szurubooru/blob/master/INSTALL.md) -instead.** - -This guide assumes Arch Linux. Although exact instructions for other -distributions are different, the steps stay roughly the same. - -### Installing hard dependencies - -Szurubooru requires the following dependencies: -- Python (3.5 or later) -- Postgres -- FFmpeg -- node.js - -```console -user@host:~$ sudo pacman -S postgresql -user@host:~$ sudo pacman -S python -user@host:~$ sudo pacman -S python-pip -user@host:~$ sudo pacman -S ffmpeg -user@host:~$ sudo pacman -S npm -user@host:~$ sudo pacman -S elasticsearch -user@host:~$ sudo pip install virtualenv -user@host:~$ python --version -Python 3.5.1 -``` - -The reason `ffmpeg` is used over, say, `ImageMagick` or even `PIL` is because of -Flash and video posts. - - - -### Setting up a database - -First, basic `postgres` configuration: - -```console -user@host:~$ sudo -i -u postgres initdb --locale en_US.UTF-8 -E UTF8 -D /var/lib/postgres/data -user@host:~$ sudo systemctl start postgresql -user@host:~$ sudo systemctl enable postgresql -``` - -Then creating a database: - -```console -user@host:~$ sudo -i -u postgres createuser --interactive -Enter name of role to add: szuru -Shall the new role be a superuser? (y/n) n -Shall the new role be allowed to create databases? (y/n) n -Shall the new role be allowed to create more new roles? (y/n) n -user@host:~$ sudo -i -u postgres createdb szuru -user@host:~$ sudo -i -u postgres psql -c "ALTER USER szuru PASSWORD 'dog';" -``` - - - -### Setting up elasticsearch - -```console -user@host:~$ sudo systemctl start elasticsearch -user@host:~$ sudo systemctl enable elasticsearch -``` - -### Preparing environment - -Getting `szurubooru`: - -```console -user@host:~$ git clone https://github.com/rr-/szurubooru.git szuru -user@host:~$ cd szuru -``` - -Installing frontend dependencies: - -```console -user@host:szuru$ cd client -user@host:szuru/client$ npm install -``` - -`npm` sandboxes dependencies by default, i.e. installs them to -`./node_modules`. This is good, because it avoids polluting the system with the -project's dependencies. To make Python work the same way, we'll use -`virtualenv`. Installing backend dependencies with `virtualenv` looks like -this: - -```console -user@host:szuru/client$ cd ../server -user@host:szuru/server$ virtualenv python_modules # consistent with node_modules -user@host:szuru/server$ source python_modules/bin/activate # enters the sandbox -(python_modules) user@host:szuru/server$ pip install -r requirements.txt # installs the dependencies -``` - - - -### Preparing `szurubooru` for first run - -1. Compile the frontend: - - ```console - user@host:szuru$ cd client - user@host:szuru/client$ node build.js - ``` - - You can include the flags `--no-transpile` to disable the JavaScript - transpiler, which provides compatibility with older browsers, and - `--debug` to generate JS source mappings. - -2. Configure things: - - ```console - user@host:szuru/client$ cd .. - user@host:szuru$ mv server/config.yaml.dist . - user@host:szuru$ cp config.yaml.dist config.yaml - user@host:szuru$ vim config.yaml - ``` - - Pay extra attention to these fields: - - - data directory, - - data URL, - - database, - - the `smtp` section. - -3. Upgrade the database: - - ```console - user@host:szuru/client$ cd ../server - user@host:szuru/server$ source python_modules/bin/activate - (python_modules) user@host:szuru/server$ alembic upgrade head - ``` - - `alembic` should have been installed during installation of `szurubooru`'s - dependencies. - -4. Run the tests: - - ```console - (python_modules) user@host:szuru/server$ pytest - ``` - -It is recommended to rebuild the frontend after each change to configuration. - - - -### Wiring `szurubooru` to the web server - -`szurubooru` is divided into two parts: public static files, and the API. It -tries not to impose any networking configurations on the user, so it is the -user's responsibility to wire these to their web server. - -The static files are located in the `client/public/data` directory and are -meant to be exposed directly to the end users. - -The API should be exposed using WSGI server such as `waitress`, `gunicorn` or -similar. Other configurations might be possible but I didn't pursue them. - -API calls are made to the relative URL `/api/`. Your HTTP server should be -configured to proxy this URL format to the WSGI server. Some users may prefer -to use a dedicated reverse proxy for this, to incorporate additional features -such as load balancing and SSL. - -Note that the API URL in the virtual host configuration needs to be the same as -the one in the `config.yaml`, so that client knows how to access the backend! - -#### Example - -In this example: - -- The booru is accessed from `http://example.com/` -- The API is accessed from `http://example.com/api` -- The API server listens locally on port 6666, and is proxied by nginx -- The static files are served from `/srv/www/booru/client/public/data` - -**nginx configuration**: - -```nginx -server { - listen 80; - server_name example.com; - - location ~ ^/api$ { - return 302 /api/; - } - location ~ ^/api/(.*)$ { - if ($request_uri ~* "/api/(.*)") { # preserve PATH_INFO as-is - proxy_pass http://127.0.0.1:6666/$1; - } - } - location / { - root /srv/www/booru/client/public; - try_files $uri /index.htm; - } -} -``` - -**`config.yaml`**: - -```yaml -data_url: 'http://example.com/data/' -data_dir: '/srv/www/booru/client/public/data' -``` - -To run the server using `waitress`: - -```console -user@host:szuru/server$ source python_modules/bin/activate -(python_modules) user@host:szuru/server$ pip install waitress -(python_modules) user@host:szuru/server$ waitress-serve --port 6666 szurubooru.facade:app -``` - -or `gunicorn`: - -```console -user@host:szuru/server$ source python_modules/bin/activate -(python_modules) user@host:szuru/server$ pip install gunicorn -(python_modules) user@host:szuru/server$ gunicorn szurubooru.facade:app -b 127.0.0.1:6666 -``` diff --git a/INSTALL.md b/INSTALL.md deleted file mode 100644 index 72fe4b1..0000000 --- a/INSTALL.md +++ /dev/null @@ -1,114 +0,0 @@ -This assumes that you have Docker (version 17.05 or greater) -and Docker Compose (version 1.6.0 or greater) already installed. - -### Prepare things - -1. Getting `szurubooru`: - - ```console - user@host:~$ git clone https://github.com/rr-/szurubooru.git szuru - user@host:~$ cd szuru - ``` -2. Configure the application: - - ```console - user@host:szuru$ cp server/config.yaml.dist server/config.yaml - user@host:szuru$ edit server/config.yaml - ``` - - Pay extra attention to these fields: - - - secret - - the `smtp` section. - - You can omit lines when you want to use the defaults of that field. - -3. Configure Docker Compose: - - ```console - user@host:szuru$ cp example.env .env - user@host:szuru$ edit .env - ``` - - Change the values of the variables in `.env` as needed. - Read the comments to guide you. - -### Running the Application - -1. Configurations for ElasticSearch: - - You may need to raise the `vm.max_map_count` - parameter to at least `262144` in order for the - ElasticSearch container to function. Instructions - on how to do so are provided - [here](https://www.elastic.co/guide/en/elasticsearch/reference/current/docker.html#docker-cli-run-prod-mode). - -2. Build or update the containers: - - ```console - user@host:szuru$ docker-compose pull - user@host:szuru$ docker-compose build --pull - ``` - - This will build both the frontend and backend containers, and may take - some time. - -3. Start and stop the the application - - ```console - # To start: - user@host:szuru$ docker-compose up -d - # To monitor (CTRL+C to exit): - user@host:szuru$ docker-compose logs -f - # To stop - user@host:szuru$ docker-compose down - ``` - -### Additional Features - -1. **Using a seperate domain to host static files (image content)** - - If you want to host your website on, (`http://example.com/`) but want - to serve the images on a different domain, (`http://static.example.com/`) - then you can run the backend container with an additional environment - variable `DATA_URL=http://static.example.com/`. Make sure that this - additional host has access contents to the `/data` volume mounted in the - backend. - -2. **Setting a specific base URI for proxying** - - Some users may wish to access the service at a different base URI, such - as `http://example.com/szuru/`, commonly when sharing multiple HTTP - services on one domain using a reverse proxy. In this case, simply set - `BASE_URL="/szuru/"` in the frontend container (unless you are hosting your - data on a different domain). - - You should set your reverse proxy to proxy `http(s)://example.com/szuru` to - `http://<internal IP or hostname of frontend container>/`. For an NGINX - reverse proxy, that will appear as: - - ```nginx - location /szuru { - proxy_http_version 1.1; - proxy_pass http://<internal IP or hostname of frontend container>/; - - proxy_set_header Host $http_host; - proxy_set_header Upgrade $http_upgrade; - proxy_set_header Connection "upgrade"; - proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - proxy_set_header X-Scheme $scheme; - proxy_set_header X-Real-IP $remote_addr; - proxy_set_header X-Forwarded-Proto $scheme; - proxy_set_header X-Script-Name /szuru; - } - ``` - -3. **Preparing for production** - - If you plan on using szurubooru in a production setting, you may opt to - use a reverse proxy for added security and caching capabilities. Start - by having the client docker listen only on localhost by changing `PORT` - in your `.env` file to `127.0.0.1:8080` instead of simply `:8080`. Then - configure NGINX (or your caching/reverse proxy server of your choice) - to proxy_pass `http://127.0.0.1:8080`. We've included an example config - located in the `nginx-vhost.production` file. @@ -8,9 +8,10 @@ scrubbing](http://sjp.pwn.pl/sjp/;2527372). It is pronounced as *shoorubooru*. ## Features - Post content: images (JPG, PNG, GIF, animated GIF), videos (MP4, WEBM), Flash animations +- Ability to retrieve web video content using [youtube-dl](https://github.com/ytdl-org/youtube-dl) - Post comments - Post notes / annotations, including arbitrary polygons -- Rich JSON REST API ([see documentation](https://github.com/rr-/szurubooru/blob/master/API.md)) +- Rich JSON REST API ([see documentation](doc/API.md)) - Token based authentication for clients - Rich search system - Rich privilege system @@ -19,6 +20,7 @@ scrubbing](http://sjp.pwn.pl/sjp/;2527372). It is pronounced as *shoorubooru*. - Tag suggestions - Tag implications (adding a tag automatically adds another) - Tag aliases +- Pools and pool categories - Duplicate detection - Post rating and favoriting; comment rating - Polished UI @@ -28,9 +30,10 @@ scrubbing](http://sjp.pwn.pl/sjp/;2527372). It is pronounced as *shoorubooru*. ## Installation It is recommended that you use Docker for deployment. -[See installation instructions.](https://github.com/rr-/szurubooru/blob/master/INSTALL.md) +[See installation instructions.](doc/INSTALL.md) -Users who wish to avoid using Docker may find the [old installation instructions](https://github.com/rr-/szurubooru/blob/master/INSTALL-OLD.md) helpful. +More installation resources, as well as related projects can be found on the +[GitHub project Wiki](https://github.com/rr-/szurubooru/wiki) ## Screenshots @@ -44,4 +47,4 @@ Post view: ## License -[GPLv3](https://github.com/rr-/szurubooru/blob/master/LICENSE.md). +[GPLv3](LICENSE.md). diff --git a/client/.dockerignore b/client/.dockerignore index 1313ed6..cc2d280 100644 --- a/client/.dockerignore +++ b/client/.dockerignore @@ -1,6 +1,4 @@ node_modules/* -package-lock.json - Dockerfile .dockerignore **/.gitignore diff --git a/client/.eslintrc.yml b/client/.eslintrc.yml new file mode 100644 index 0000000..a9d703f --- /dev/null +++ b/client/.eslintrc.yml @@ -0,0 +1,12 @@ +env: + browser: true + commonjs: true + es6: true +extends: 'prettier' +globals: + Atomics: readonly + SharedArrayBuffer: readonly +ignorePatterns: + - build.js +parserOptions: + ecmaVersion: 11 diff --git a/client/.prettierrc.yml b/client/.prettierrc.yml new file mode 100644 index 0000000..925bde8 --- /dev/null +++ b/client/.prettierrc.yml @@ -0,0 +1,4 @@ +parser: babel +printWidth: 79 +tabWidth: 4 +quoteProps: consistent diff --git a/client/Dockerfile b/client/Dockerfile index 94ab800..f3b9955 100644 --- a/client/Dockerfile +++ b/client/Dockerfile @@ -1,7 +1,7 @@ FROM node:9 as builder WORKDIR /opt/app -COPY package.json ./ +COPY package.json package-lock.json ./ RUN npm install COPY . ./ @@ -11,19 +11,34 @@ ARG CLIENT_BUILD_ARGS="" RUN BASE_URL="__BASEURL__" node build.js --gzip ${CLIENT_BUILD_ARGS} -FROM nginx:alpine +FROM scratch as approot + +COPY docker-start.sh / + +WORKDIR /etc/nginx +COPY nginx.conf.docker ./nginx.conf + WORKDIR /var/www +COPY --from=builder /opt/app/public/ . -RUN \ - # Create init file - echo "#!/bin/sh" >> /init && \ - echo 'sed -i "s|__BACKEND__|${BACKEND_HOST}|" /etc/nginx/nginx.conf' >> /init && \ - echo 'sed -i "s|__BASEURL__|${BASE_URL:-/}|g" /var/www/index.htm /var/www/manifest.json' >> /init && \ - echo 'exec nginx' >> /init && \ - chmod a+x /init -CMD ["/init"] +FROM nginx:alpine as release + +RUN apk --no-cache add dumb-init +COPY --from=approot / / + +CMD ["/docker-start.sh"] VOLUME ["/data"] -COPY nginx.conf.docker /etc/nginx/nginx.conf -COPY --from=builder /opt/app/public/ . +ARG DOCKER_REPO +ARG BUILD_DATE +ARG SOURCE_COMMIT +LABEL \ + maintainer="" \ + org.opencontainers.image.title="${DOCKER_REPO}" \ + org.opencontainers.image.url="https://github.com/rr-/szurubooru" \ + org.opencontainers.image.documentation="https://github.com/rr-/szurubooru/blob/${SOURCE_COMMIT}/doc/INSTALL.md" \ + org.opencontainers.image.created="${BUILD_DATE}" \ + org.opencontainers.image.source="https://github.com/rr-/szurubooru" \ + org.opencontainers.image.revision="${SOURCE_COMMIT}" \ + org.opencontainers.image.licenses="GPL-3.0" diff --git a/client/build.js b/client/build.js index a6af310..0f577e9 100755 --- a/client/build.js +++ b/client/build.js @@ -21,11 +21,13 @@ const webapp_splash_screens = [ ]; const external_js = [ - 'underscore', - 'superagent', - 'mousetrap', + 'dompurify', 'js-cookie', + 'marked', + 'mousetrap', 'nprogress', + 'superagent', + 'underscore', ]; const app_manifest = { @@ -35,7 +37,7 @@ const app_manifest = { src: baseUrl() + 'img/android-chrome-192x192.png', type: 'image/png', sizes: '192x192' - }, + }, { src: baseUrl() + 'img/android-chrome-512x512.png', type: 'image/png', @@ -225,7 +227,6 @@ function bundleConfig() { function bundleBinaryAssets() { fs.copyFileSync('./img/favicon.png', './public/img/favicon.png'); - fs.copyFileSync('./img/transparency_grid.png', './public/img/transparency_grid.png'); console.info('Copied images'); fs.copyFileSync('./fonts/open_sans.woff2', './public/webfonts/open_sans.woff2') @@ -301,8 +302,12 @@ function makeOutputDirs() { makeOutputDirs(); bundleConfig(); -bundleBinaryAssets(); -bundleWebAppFiles(); +if (!process.argv.includes('--no-binary-assets')) { + bundleBinaryAssets(); +} +if (!process.argv.includes('--no-web-app-files')) { + bundleWebAppFiles(); +} if (!process.argv.includes('--no-html')) { bundleHtml(); } diff --git a/client/css/colors.styl b/client/css/colors.styl index 3be05fe..cf7e7ca 100644 --- a/client/css/colors.styl +++ b/client/css/colors.styl @@ -1,13 +1,17 @@ $main-color = #24AADD $window-color = white +$window-color-darktheme = #1a1a1a $top-navigation-color = #F5F5F5 +$top-navigation-color-darktheme = #333333 $text-color = #111 +$text-color-darktheme = #e6e6e6 $inactive-link-color = #888 +$inactive-link-color-darktheme = #cccccc $line-color = #DDD -$inactive-tab-text-color = $inactive-link-color -$active-tab-text-color = $text-color $active-tab-background-color = rgba(0, 0, 0, 0.06) $focused-tab-background-color = rgba(0, 0, 0, 0.03) +$active-tab-background-color-darktheme = rgba(255, 255, 255, 0.06) +$focused-tab-background-color-darktheme = rgba(255, 255, 255, 0.03) $message-info-border-color = #BDF $message-info-background-color = #E3EFF9 $message-error-border-color = #FCC @@ -21,6 +25,7 @@ $input-good-background-color = #F5FFF5 $input-enabled-background-color = #FAFAFA $input-enabled-border-color = #EEE $input-enabled-text-color = $text-color +$input-enabled-text-color-darktheme = $text-color-darktheme $input-disabled-background-color = #FAFAFA $input-disabled-border-color = #EEE $input-disabled-text-color = #888 @@ -35,7 +40,6 @@ $new-tag-background-color = #DFC $new-tag-text-color = black $implied-tag-background-color = #FFC $implied-tag-text-color = black -$tag-suggestions-background-color = $window-color $tag-suggestions-header-color = #EEE $tag-suggestions-border-color = #AAA $duplicate-tag-background-color = #FDC @@ -57,3 +61,4 @@ $safety-sketchy = #F3D75F $safety-unsafe = #F3985F $scrollbar-thumb-color = $main-color $scrollbar-bg-color = $input-enabled-background-color +$transparency-grid-square-color = #F0F0F0 diff --git a/client/css/comment-control.styl b/client/css/comment-control.styl index 7e98171..0d5959b 100644 --- a/client/css/comment-control.styl +++ b/client/css/comment-control.styl @@ -1,5 +1,7 @@ @import colors $comment-header-background-color = $top-navigation-color +$comment-header-background-color-darktheme = $top-navigation-color-darktheme + $comment-border-color = #DDD .comment-container @@ -81,7 +83,7 @@ $comment-border-color = #DDD .edit, .delete, .score-container a, .nickname a &:not(.inactive) - color: mix($main-color, $inactive-tab-text-color) + color: mix($main-color, $inactive-link-color) i margin-right: 0.3em @@ -112,6 +114,17 @@ $comment-border-color = #DDD .messages margin: 1em 0 +.darktheme .comment-container .comment header + background: $comment-header-background-color-darktheme + nav.edit + ul + li + &.active + background: $window-color-darktheme + border-bottom: 1px solid $window-color-darktheme + .edit, .delete, .score-container a, .nickname a + &:not(.inactive) + color: mix($main-color, $inactive-link-color-darktheme) .comment-content ul, ol diff --git a/client/css/comment-list-view.styl b/client/css/comment-list-view.styl index bd50beb..642ba1c 100644 --- a/client/css/comment-list-view.styl +++ b/client/css/comment-list-view.styl @@ -1,5 +1,6 @@ @import colors $comment-border-color = $top-navigation-color +$comment-border-color-darktheme = $top-navigation-color-darktheme .global-comment-list text-align: left @@ -46,3 +47,8 @@ $comment-border-color = $top-navigation-color .comments-container width: 100% + +.darktheme .global-comment-list + &>ul + &>li + border-top: 3px solid $comment-border-color-darktheme diff --git a/client/css/core-forms.styl b/client/css/core-forms.styl index bed63e3..ee21ed1 100644 --- a/client/css/core-forms.styl +++ b/client/css/core-forms.styl @@ -26,6 +26,10 @@ form:not(.horizontal) font-size: 80% line-height: 120% +.darktheme form:not(.horizontal) + .hint + color: $inactive-link-color-darktheme + form.horizontal display: inline-block margin-bottom: 1em @@ -167,6 +171,16 @@ input[type=time] background: $input-disabled-background-color color: $input-disabled-text-color +.darktheme + input[type=date], + input[type=time] + border: 2px solid darken($input-enabled-border-color, 75%) + background: darken($input-enabled-background-color, 75%) + color: $input-enabled-text-color-darktheme + &:disabled + background: darken($input-disabled-background-color, 75%) + &[readonly] + background: darken($input-disabled-background-color, 75%) /* @@ -204,6 +218,21 @@ input[type=number] background: $input-disabled-background-color color: $input-disabled-text-color +.darktheme + select, + textarea, + input[type=text], + input[type=email], + input[type=password], + input[type=number] + border: 2px solid darken($input-enabled-border-color, 75%) + background: darken($input-enabled-background-color, 75%) + color: $input-enabled-text-color-darktheme + &:disabled + background: darken($input-disabled-background-color, 75%) + &[readonly] + background: darken($input-disabled-background-color, 75%) + input[readonly], input[readonly]+.radio, input[readonly]+.checkbox, @@ -242,8 +271,9 @@ form.show-validation .input outline: 0 border: 2px solid $input-good-border-color background: $input-good-background-color - - +.darktheme form.show-validation .input + input:valid + background: darken($input-good-background-color, 75%) /* * Buttons @@ -310,6 +340,10 @@ input::-moz-focus-inner button margin-left: 0.5em +.darktheme .file-dropper-holder + .file-dropper + background: $window-color-darktheme + input[type=file]:disabled+.file-dropper cursor: default opacity: .5 @@ -319,8 +353,6 @@ input[type=file]:focus+.file-dropper, .file-dropper.active border-color: $main-color - - .autocomplete position: absolute z-index: 10 @@ -345,6 +377,10 @@ input[type=file]:focus+.file-dropper, .disabled color: $inactive-link-color +.darktheme .autocomplete + background: $window-color-darktheme + ul li .disabled + color: $inactive-link-color-darktheme .anticomplete display: none diff --git a/client/css/core-general.styl b/client/css/core-general.styl index 95fa38c..6b481b3 100644 --- a/client/css/core-general.styl +++ b/client/css/core-general.styl @@ -1,6 +1,11 @@ @import colors @import mixins +$active-tab-text-color = $text-color +$active-tab-text-color-darktheme = $text-color-darktheme +$inactive-tab-text-color = $inactive-link-color +$inactive-tab-text-color-darktheme = $inactive-link-color-darktheme + /* latin */ @font-face font-family: 'Open Sans'; @@ -28,6 +33,10 @@ body @media (max-width: 1200px) font-size: 0.95em +body.darktheme + color: $text-color-darktheme + background: $window-color-darktheme + h1, h2, h3 font-weight: normal margin-bottom: 1em @@ -62,6 +71,11 @@ a .vim-nav-hint position: absolute visibility: hidden +.darktheme a + &.inactive + color: $inactive-link-color-darktheme + &.icon + color: $inactive-link-color-darktheme a.append, span.append margin-left: 1em @@ -102,12 +116,19 @@ form .fa-question-circle-o >*:last-child margin-bottom: 0 +.darktheme #content-holder + >.content-wrapper:not(.transparent) + background: $top-navigation-color-darktheme + hr border: 0 border-top: 1px solid $line-color margin: 1em 0 padding: 0 +.darktheme hr + border-top: 1px solid darken($line-color, 25%) + nav ul list-style-type: none @@ -205,6 +226,21 @@ nav @media (max-width: 1000px) display: none +.darktheme nav + &.buttons + ul + li:not(.active) a + color: $inactive-tab-text-color-darktheme + li:hover:not(.active) a + color: $active-tab-text-color-darktheme + li.active a + background: $active-tab-background-color-darktheme + color: $active-tab-text-color-darktheme + :focus + background: $focused-tab-background-color-darktheme + &#top-navigation + background: $top-navigation-color-darktheme + a .access-key text-decoration: underline @@ -229,6 +265,18 @@ a .access-key border: 1px solid $message-success-border-color background: $message-success-background-color +.darktheme .messages + .message + &.info + border: 1px solid darken($message-info-border-color, 30%) + background: darken($message-info-background-color, 60%) + &.error + border: 1px solid darken($message-error-border-color, 30%) + background: darken($message-error-background-color, 60%) + &.success + border: 1px solid darken($message-success-border-color, 30%) + background: darken($message-success-background-color, 60%) + .thumbnail /*background-image: attr(data-src url)*/ /* not available yet */ vertical-align: middle @@ -239,9 +287,14 @@ a .access-key width: 20px height: 20px &.empty - background-image: url('../img/transparency_grid.png') + background-image: + linear-gradient(45deg, $transparency-grid-square-color 25%, transparent 25%), + linear-gradient(-45deg, $transparency-grid-square-color 25%, transparent 25%), + linear-gradient(45deg, transparent 75%, $transparency-grid-square-color 75%), + linear-gradient(-45deg, transparent 75%, $transparency-grid-square-color 75%) + background-position: 0 0, 0 10px, 10px -10px, -10px 0px background-repeat: repeat - background-size: initial + background-size: 20px 20px img opacity: 0 width: 100% diff --git a/client/css/expander-control.styl b/client/css/expander-control.styl index ab5060d..928f5e4 100644 --- a/client/css/expander-control.styl +++ b/client/css/expander-control.styl @@ -23,3 +23,11 @@ line-height: 2em .expander-content padding: 0.5em 0.5em 2em 0.5em + +.darktheme .expander + header + background: $active-tab-background-color-darktheme + a + color: mix($text-color-darktheme, $inactive-link-color-darktheme) + i + color: $inactive-link-color-darktheme diff --git a/client/css/pager.styl b/client/css/pager.styl index 3976404..547b719 100644 --- a/client/css/pager.styl +++ b/client/css/pager.styl @@ -25,3 +25,9 @@ background: white padding: 0 1em z-index: 2 + +.darktheme .pager + .page + .page-header + &:before + background: $top-navigation-color-darktheme diff --git a/client/css/pool-categories-view.styl b/client/css/pool-categories-view.styl new file mode 100644 index 0000000..0a51c9a --- /dev/null +++ b/client/css/pool-categories-view.styl @@ -0,0 +1,29 @@ +@import colors + +.content-wrapper.pool-categories + width: 100% + max-width: 45em + table + border-spacing: 0 + width: 100% + tr.default td + background: $default-pool-category-background-color + td, th + padding: .4em + &.color + input[type=text] + width: 8em + &.usages + text-align: center + &.remove, &.set-default + white-space: pre + th + white-space: nowrap + &:first-child + padding-left: 0 + &:last-child + padding-right: 0 + tfoot + display: none + form + width: auto diff --git a/client/css/pool-input-control.styl b/client/css/pool-input-control.styl new file mode 100644 index 0000000..ef28e72 --- /dev/null +++ b/client/css/pool-input-control.styl @@ -0,0 +1,58 @@ +@import colors + +div.pool-input + position: relative + + .main-control + display: flex + input + flex: 5 + button + flex: 1 + margin: 0 0 0 0.5em + + +ul.compact-pools + width: 100% + margin: 0.5em 0 0 0 + padding: 0 + li + margin: 0 + width: 100% + line-height: 140% + white-space: nowrap + overflow: hidden + text-overflow: ellipsis + transition: background-color 0.5s linear + a + display: inline + a:focus + outline: 0 + box-shadow: inset 0 0 0 2px $main-color + &.implication + background: $implied-pool-background-color + color: $implied-pool-text-color + &.new + background: $new-pool-background-color + color: $new-pool-text-color + &.duplicate + background: $duplicate-pool-background-color + color: $duplicate-pool-text-color + i + padding-right: 0.4em + +div.pool-input, ul.compact-pools + .pool-usages, .pool-weight, .remove-pool + color: $inactive-link-color + unselectable() + .pool-usages, .pool-weight + font-size: 90% + .pool-usages, .pool-weight + margin-left: 0.7em + .remove-pool + margin-right: 0.5em + +.darktheme + div.pool-input, ul.compact-pools + .pool-usages, .pool-weight, .remove-pool + color: $inactive-link-color-darktheme diff --git a/client/css/pool-list-view.styl b/client/css/pool-list-view.styl new file mode 100644 index 0000000..b7ac15e --- /dev/null +++ b/client/css/pool-list-view.styl @@ -0,0 +1,63 @@ +@import colors + +.pool-list + table + width: 100% + border-spacing: 0 + text-align: left + line-height: 1.3em + tr:hover td + background: $top-navigation-color + th, td + padding: 0.1em 0.5em + th + white-space: nowrap + background: $top-navigation-color + .names + width: 84% + .post-count + text-align: center + width: 8% + .creation-time + text-align: center + width: 8% + white-space: pre + ul + list-style-type: none + margin: 0 + padding: 0 + display: inline + li + padding: 0 + display: inline + &:not(:last-child):after + content: ', ' + @media (max-width: 800px) + .posts + display: none + +.darktheme .pool-list + table + tr:hover td + background: $top-navigation-color-darktheme + th + background: $top-navigation-color-darktheme + +.pool-list-header + label + display: none !important + text-align: left + form + width: auto + input[name=search-text] + width: 25em + @media (max-width: 1000px) + width: 100% + .append + vertical-align: middle + font-size: 0.95em + color: $inactive-link-color + +.darktheme .pool-list-header + .append + color: $inactive-link-color-darktheme diff --git a/client/css/pool-view.styl b/client/css/pool-view.styl new file mode 100644 index 0000000..bf16b79 --- /dev/null +++ b/client/css/pool-view.styl @@ -0,0 +1,33 @@ +#pool + width: 100% + max-width: 40em + h1 + word-break: break-all + line-height: 130% + margin-top: 0 + form + width: 100% + .pool-edit + textarea + height: 10em + .pool-summary + section + &.description + margin: 1.5em 0 0 0 + &.details + vertical-align: top + padding-right: 0.5em + ul + margin: 0 + padding: 0 + list-style-type: none + li + display: inline + margin: 0 + padding: 0 + li:not(:last-of-type):after + content: ', ' + ul:empty:after + content: '(none)' + section + margin-bottom: 1em diff --git a/client/css/post-content-control.styl b/client/css/post-content-control.styl index 6882dd9..5c4a18d 100644 --- a/client/css/post-content-control.styl +++ b/client/css/post-content-control.styl @@ -1,6 +1,14 @@ +@import colors + .post-container .post-content.transparency-grid img - background: url('../img/transparency_grid.png') + background-image: + linear-gradient(45deg, $transparency-grid-square-color 25%, transparent 25%), + linear-gradient(-45deg, $transparency-grid-square-color 25%, transparent 25%), + linear-gradient(45deg, transparent 75%, $transparency-grid-square-color 75%), + linear-gradient(-45deg, transparent 75%, $transparency-grid-square-color 75%) + background-size: 20px 20px + background-position: 0 0, 0 10px, 10px -10px, -10px 0px text-align: center .post-content @@ -17,3 +25,6 @@ height: 100% img position: relative !important // to center image on small screens + + img + image-orientation: from-image diff --git a/client/css/post-list-view.styl b/client/css/post-list-view.styl index 318b0c6..a9f14f9 100644 --- a/client/css/post-list-view.styl +++ b/client/css/post-list-view.styl @@ -70,7 +70,7 @@ height: 1em text-align: center line-height: 1em - font-size: 1.6em + font-size: 2.2em &.tagged background: rgba(0, 230, 0, 0.7) &:after @@ -141,10 +141,11 @@ .thumbnail - background-position: 50% 30% width: 100% height: 100% outline-offset: -3px + &:not(.empty) + background-position: 50% 30% .thumbnail-wrapper.no-tags .thumbnail @@ -159,6 +160,22 @@ .thumbnail outline: 4px solid $main-color !important +.post-flow + ul + li + min-width: inherit + width: inherit + &:not(.flexbox-dummy) + height: 14vw + .thumbnail + outline-offset: -1px + .thumbnail-wrapper.no-tags + .thumbnail + outline: 2px solid $post-thumbnail-no-tags-border-color + &:hover a, a:active, a:focus + .thumbnail + outline: 2px solid $main-color !important + .post-list-header white-space: nowrap text-align: left diff --git a/client/css/post-main-view.styl b/client/css/post-main-view.styl index a3e970b..6078617 100644 --- a/client/css/post-main-view.styl +++ b/client/css/post-main-view.styl @@ -161,6 +161,12 @@ margin: 0 padding: 0 + .post-source + textarea + white-space: pre + overflow-wrap: normal + overflow-x: scroll + form width: auto diff --git a/client/css/post-upload.styl b/client/css/post-upload.styl index 57c5509..6dca386 100644 --- a/client/css/post-upload.styl +++ b/client/css/post-upload.styl @@ -1,5 +1,6 @@ @import colors $upload-header-background-color = $top-navigation-color +$upload-header-background-color-darktheme = $top-navigation-color-darktheme $upload-border-color = #DDD $cancel-button-color = tomato @@ -164,3 +165,15 @@ $cancel-button-color = tomato color: $inactive-link-color &:last-child .move-down color: $inactive-link-color + .darktheme &:first-child .move-up + color: $inactive-link-color-darktheme + .darktheme &:last-child .move-down + color: $inactive-link-color-darktheme + +.darktheme #post-upload .uploadables-container .uploadable-container + .uploadable header + background: $upload-header-background-color-darktheme + &:first-child .move-up + color: $inactive-link-color-darktheme + &:last-child .move-down + color: $inactive-link-color-darktheme diff --git a/client/css/snapshots-list-view.styl b/client/css/snapshots-list-view.styl index b059a64..153631d 100644 --- a/client/css/snapshots-list-view.styl +++ b/client/css/snapshots-list-view.styl @@ -31,16 +31,34 @@ $snapshot-merged-background-color = #FEC div.operation-created background: $snapshot-created-background-color &+.details - background: lighten($snapshot-created-background-color, 50%) + background: alpha(@background, 50%) div.operation-modified background: $snapshot-modified-background-color &+.details - background: lighten($snapshot-modified-background-color, 50%) + background: alpha(@background, 50%) div.operation-deleted background: $snapshot-deleted-background-color &+.details - background: lighten($snapshot-deleted-background-color, 50%) + background: alpha(@background, 50%) div.operation-merged background: $snapshot-merged-background-color &+.details - background: lighten($snapshot-merged-background-color, 50%) + background: alpha(@background, 50%) + +.darktheme .snapshot-list ul li + div.operation-created + background: darken($snapshot-created-background-color, 80%) + &+.details + background: alpha(@background, 50%) + div.operation-modified + background: darken($snapshot-modified-background-color, 80%) + &+.details + background: alpha(@background, 50%) + div.operation-deleted + background: darken($snapshot-deleted-background-color, 80%) + &+.details + background: alpha(@background, 50%) + div.operation-merged + background: darken($snapshot-merged-background-color, 80%) + &+.details + background: alpha(@background, 50%) diff --git a/client/css/tag-categories-view.styl b/client/css/tag-categories-view.styl index b8a9180..55aee75 100644 --- a/client/css/tag-categories-view.styl +++ b/client/css/tag-categories-view.styl @@ -27,4 +27,3 @@ display: none form width: auto - diff --git a/client/css/tag-input-control.styl b/client/css/tag-input-control.styl index e14e596..bc34cb9 100644 --- a/client/css/tag-input-control.styl +++ b/client/css/tag-input-control.styl @@ -46,7 +46,7 @@ div.tag-input .wrapper margin-left: 0.5em - background: $tag-suggestions-background-color + background: $window-color border: 1px solid $tag-suggestions-border-color width: 15em word-break: break-all @@ -62,7 +62,7 @@ div.tag-input max-height: 20em padding: 0.5em 1em 0 1em li:last-child - border-bottom: 0.5em solid alpha($tag-suggestions-background-color, 0) + border-bottom: 0.5em solid alpha($window-color, 0) li margin: 0 font-size: 90% @@ -125,3 +125,17 @@ div.tag-input, ul.compact-tags margin-left: 0.7em .remove-tag margin-right: 0.5em + +.darktheme + div.tag-input .tag-suggestions + .buttons a + color: $inactive-link-color-darktheme + .wrapper + background: $window-color-darktheme + ul li:last-child + border-bottom: 0.5em solid alpha($window-color-darktheme, 0) + .append + color: $inactive-link-color-darktheme + div.tag-input, ul.compact-tags + .tag-usages, .tag-weight, .remove-tag + color: $inactive-link-color-darktheme diff --git a/client/css/tag-list-view.styl b/client/css/tag-list-view.styl index 4fd167f..465773d 100644 --- a/client/css/tag-list-view.styl +++ b/client/css/tag-list-view.styl @@ -40,6 +40,13 @@ .implications, .suggestions display: none +.darktheme .tag-list + table + tr:hover td + background: $top-navigation-color-darktheme + th + background: $top-navigation-color-darktheme + .tag-list-header label display: none !important @@ -54,3 +61,7 @@ vertical-align: middle font-size: 0.95em color: $inactive-link-color + +.darktheme .tag-list-header + .append + color: $inactive-link-color-darktheme diff --git a/client/css/user-list-view.styl b/client/css/user-list-view.styl index 1fba50b..f1569f8 100644 --- a/client/css/user-list-view.styl +++ b/client/css/user-list-view.styl @@ -26,6 +26,10 @@ height: 3em margin: 0.25em 0.6em 0 0 +.darktheme .user-list + ul li + background: $top-navigation-color-darktheme + .user-list-header label display: none !important @@ -40,3 +44,7 @@ vertical-align: middle font-size: 0.95em color: $inactive-link-color + +.darktheme .user-list-header + .append + color: $inactive-link-color-darktheme diff --git a/client/docker-start.sh b/client/docker-start.sh new file mode 100755 index 0000000..0b2bec8 --- /dev/null +++ b/client/docker-start.sh @@ -0,0 +1,11 @@ +#!/usr/bin/dumb-init /bin/sh + +# Integrate environment variables +sed -i "s|__BACKEND__|${BACKEND_HOST}|" \ + /etc/nginx/nginx.conf +sed -i "s|__BASEURL__|${BASE_URL:-/}|g" \ + /var/www/index.htm \ + /var/www/manifest.json + +# Start server +exec nginx diff --git a/client/hooks/build b/client/hooks/build index 8434e52..46443f4 100755 --- a/client/hooks/build +++ b/client/hooks/build @@ -1,4 +1,16 @@ -#!/bin/bash -BUILD_INFO=docker-$(echo $SOURCE_COMMIT | cut -c1-7)-auto -echo "Using BUILD_INFO=$BUILD_INFO" -docker build --build-arg BUILD_INFO=$BUILD_INFO -f $DOCKERFILE_PATH -t $IMAGE_NAME . +#!/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 new file mode 100755 index 0000000..1b1e0ad --- /dev/null +++ b/client/hooks/post_push @@ -0,0 +1,19 @@ +#!/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/help_search.tpl b/client/html/help_search.tpl index 7073789..e382f2e 100644 --- a/client/html/help_search.tpl +++ b/client/html/help_search.tpl @@ -4,6 +4,7 @@ --><li data-name='posts'><a href='<%- ctx.formatClientLink('help', 'search', 'posts') %>'>Posts</a></li><!-- --><li data-name='users'><a href='<%- ctx.formatClientLink('help', 'search', 'users') %>'>Users</a></li><!-- --><li data-name='tags'><a href='<%- ctx.formatClientLink('help', 'search', 'tags') %>'>Tags</a></li><!-- + --><li data-name='pools'><a href='<%- ctx.formatClientLink('help', 'search', 'pools') %>'>Pools</li><!-- --></ul><!-- --></nav> diff --git a/client/html/help_search_pools.tpl b/client/html/help_search_pools.tpl new file mode 100644 index 0000000..a1f0c80 --- /dev/null +++ b/client/html/help_search_pools.tpl @@ -0,0 +1,97 @@ +<p><strong>Anonymous tokens</strong></p> + +<p>Same as <code>name</code> token.</p> + +<p><strong>Named tokens</strong></p> + +<table> + <tbody> + <tr> + <td><code>name</code></td> + <td>having given name (accepts wildcards)</td> + </tr> + <tr> + <td><code>category</code></td> + <td>having given category (accepts wildcards)</td> + </tr> + <tr> + <td><code>creation-date</code></td> + <td>created at given date</td> + </tr> + <tr> + <td><code>creation-time</code></td> + <td>alias of <code>creation-date</code></td> + </tr> + <tr> + <td><code>last-edit-date</code></td> + <td>edited at given date</td> + </tr> + <tr> + <td><code>last-edit-time</code></td> + <td>alias of <code>last-edit-date</code></td> + </tr> + <tr> + <td><code>edit-date</code></td> + <td>alias of <code>last-edit-date</code></td> + </tr> + <tr> + <td><code>edit-time</code></td> + <td>alias of <code>last-edit-date</code></td> + </tr> + <tr> + <td><code>post-count</code></td> + <td>alias of <code>usages</code></td> + </tr> + </tbody> +</table> + +<p><strong>Sort style tokens</strong></p> + +<table> + <tbody> + <tr> + <td><code>random</code></td> + <td>as random as it can get</td> + </tr> + <tr> + <td><code>name</code></td> + <td>A to Z</td> + </tr> + <tr> + <td><code>category</code></td> + <td>category (A to Z)</td> + </tr> + <tr> + <td><code>creation-date</code></td> + <td>recently created first</td> + </tr> + <tr> + <td><code>creation-time</code></td> + <td>alias of <code>creation-date</code></td> + </tr> + <tr> + <td><code>last-edit-date</code></td> + <td>recently edited first</td> + </tr> + <tr> + <td><code>last-edit-time</code></td> + <td>alias of <code>creation-time</code></td> + </tr> + <tr> + <td><code>edit-date</code></td> + <td>alias of <code>creation-time</code></td> + </tr> + <tr> + <td><code>edit-time</code></td> + <td>alias of <code>creation-time</code></td> + </tr> + <tr> + <td><code>post-count</code></td> + <td>number of posts</td> + </tr> + </tbody> +</table> + +<p><strong>Special tokens</strong></p> + +<p>None.</p> diff --git a/client/html/help_search_posts.tpl b/client/html/help_search_posts.tpl index d1097b7..efcb7c1 100644 --- a/client/html/help_search_posts.tpl +++ b/client/html/help_search_posts.tpl @@ -20,15 +20,15 @@ </tr> <tr> <td><code>uploader</code></td> - <td>uploaded by given use (accepts wildcards)r</td> + <td>uploaded by given user (accepts wildcards)</td> </tr> <tr> <td><code>upload</code></td> - <td>alias of <code>upload</code></td> + <td>alias of <code>uploader</code></td> </tr> <tr> <td><code>submit</code></td> - <td>alias of <code>upload</code></td> + <td>alias of <code>uploader</code></td> </tr> <tr> <td><code>comment</code></td> @@ -43,6 +43,10 @@ <td>having given source URL (accepts wildcards)</td> </tr> <tr> + <td><code>pool</code></td> + <td>belonging to the pool with the given ID</td> + </tr> + <tr> <td><code>tag-count</code></td> <td>having given number of tags</td> </tr> diff --git a/client/html/pool.tpl b/client/html/pool.tpl new file mode 100644 index 0000000..831d07c --- /dev/null +++ b/client/html/pool.tpl @@ -0,0 +1,18 @@ +<div class='content-wrapper' id='pool'> + <h1><%- ctx.getPrettyName(ctx.pool.names[0]) %></h1> + <nav class='buttons'><!-- + --><ul><!-- + --><li data-name='summary'><a href='<%- ctx.formatClientLink('pool', ctx.pool.id) %>'>Summary</a></li><!-- + --><% if (ctx.canEditAnything) { %><!-- + --><li data-name='edit'><a href='<%- ctx.formatClientLink('pool', ctx.pool.id, 'edit') %>'>Edit</a></li><!-- + --><% } %><!-- + --><% if (ctx.canMerge) { %><!-- + --><li data-name='merge'><a href='<%- ctx.formatClientLink('pool', ctx.pool.id, 'merge') %>'>Merge with…</a></li><!-- + --><% } %><!-- + --><% if (ctx.canDelete) { %><!-- + --><li data-name='delete'><a href='<%- ctx.formatClientLink('pool', ctx.pool.id, 'delete') %>'>Delete</a></li><!-- + --><% } %><!-- + --></ul><!-- + --></nav> + <div class='pool-content-holder'></div> +</div> diff --git a/client/html/pool_categories.tpl b/client/html/pool_categories.tpl new file mode 100644 index 0000000..b045038 --- /dev/null +++ b/client/html/pool_categories.tpl @@ -0,0 +1,30 @@ +<div class='content-wrapper pool-categories'> + <form> + <h1>Pool categories</h1> + <div class="table-wrap"> + <table> + <thead> + <tr> + <th class='name'>Category name</th> + <th class='color'>CSS color</th> + <th class='usages'>Usages</th> + </tr> + </thead> + <tbody> + </tbody> + </table> + </div> + + <% if (ctx.canCreate) { %> + <p><a href class='add'>Add new category</a></p> + <% } %> + + <div class='messages'></div> + + <% if (ctx.canCreate || ctx.canEditName || ctx.canEditColor || ctx.canDelete) { %> + <div class='buttons'> + <input type='submit' class='save' value='Save changes'> + </div> + <% } %> + </form> +</div> diff --git a/client/html/pool_category_row.tpl b/client/html/pool_category_row.tpl new file mode 100644 index 0000000..5ff4456 --- /dev/null +++ b/client/html/pool_category_row.tpl @@ -0,0 +1,43 @@ +<% if (ctx.poolCategory.isDefault) { %><% + %><tr data-category='<%- ctx.poolCategory.name %>' class='default'><% +%><% } else { %><% + %><tr data-category='<%- ctx.poolCategory.name %>'><% +%><% } %> + <td class='name'> + <% if (ctx.canEditName) { %> + <%= ctx.makeTextInput({value: ctx.poolCategory.name, required: true}) %> + <% } else { %> + <%- ctx.poolCategory.name %> + <% } %> + </td> + <td class='color'> + <% if (ctx.canEditColor) { %> + <%= ctx.makeColorInput({value: ctx.poolCategory.color}) %> + <% } else { %> + <%- ctx.poolCategory.color %> + <% } %> + </td> + <td class='usages'> + <% if (ctx.poolCategory.name) { %> + <a href='<%- ctx.formatClientLink('pools', {query: 'category:' + ctx.poolCategory.name}) %>'> + <%- ctx.poolCategory.poolCount %> + </a> + <% } else { %> + <%- ctx.poolCategory.poolCount %> + <% } %> + </td> + <% if (ctx.canDelete) { %> + <td class='remove'> + <% if (ctx.poolCategory.poolCount) { %> + <a class='inactive' title="Can't delete category in use">Remove</a> + <% } else { %> + <a href>Remove</a> + <% } %> + </td> + <% } %> + <% if (ctx.canSetDefault) { %> + <td class='set-default'> + <a href>Make default</a> + </td> + <% } %> +</tr> diff --git a/client/html/pool_create.tpl b/client/html/pool_create.tpl new file mode 100644 index 0000000..3c04545 --- /dev/null +++ b/client/html/pool_create.tpl @@ -0,0 +1,42 @@ +<div class='content-wrapper pool-create'> + <form> + <ul class='input'> + <li class='names'> + <%= ctx.makeTextInput({ + text: 'Names', + value: '', + required: true, + }) %> + </li> + <li class='category'> + <%= ctx.makeSelect({ + text: 'Category', + keyValues: ctx.categories, + selectedKey: 'default', + required: true, + }) %> + </li> + <li class='description'> + <%= ctx.makeTextarea({ + text: 'Description', + value: '', + }) %> + </li> + <li class='posts'> + <%= ctx.makeTextInput({ + text: 'Posts', + value: '', + placeholder: 'space-separated post IDs', + }) %> + </li> + </ul> + + <% if (ctx.canCreate) { %> + <div class='messages'></div> + + <div class='buttons'> + <input type='submit' class='save' value='Create pool'> + </div> + <% } %> + </form> +</div> diff --git a/client/html/pool_delete.tpl b/client/html/pool_delete.tpl new file mode 100644 index 0000000..1ef7fb5 --- /dev/null +++ b/client/html/pool_delete.tpl @@ -0,0 +1,21 @@ +<div class='pool-delete'> + <form> + <p>This pool has <a href='<%- ctx.formatClientLink('posts', {query: 'pool:' + ctx.pool.id}) %>'><%- ctx.pool.postCount %> post(s)</a>.</p> + + <ul class='input'> + <li> + <%= ctx.makeCheckbox({ + name: 'confirm-deletion', + text: 'I confirm that I want to delete this pool.', + required: true, + }) %> + </li> + </ul> + + <div class='messages'></div> + + <div class='buttons'> + <input type='submit' value='Delete pool'/> + </div> + </form> +</div> diff --git a/client/html/pool_edit.tpl b/client/html/pool_edit.tpl new file mode 100644 index 0000000..c9f2adb --- /dev/null +++ b/client/html/pool_edit.tpl @@ -0,0 +1,50 @@ +<div class='content-wrapper pool-edit'> + <form> + <ul class='input'> + <li class='names'> + <% if (ctx.canEditNames) { %> + <%= ctx.makeTextInput({ + text: 'Names', + value: ctx.pool.names.join(' '), + required: true, + }) %> + <% } %> + </li> + <li class='category'> + <% if (ctx.canEditCategory) { %> + <%= ctx.makeSelect({ + text: 'Category', + keyValues: ctx.categories, + selectedKey: ctx.pool.category, + required: true, + }) %> + <% } %> + </li> + <li class='description'> + <% if (ctx.canEditDescription) { %> + <%= ctx.makeTextarea({ + text: 'Description', + value: ctx.pool.description, + }) %> + <% } %> + </li> + <li class='posts'> + <% if (ctx.canEditPosts) { %> + <%= ctx.makeTextInput({ + text: 'Posts', + placeholder: 'space-separated post IDs', + value: ctx.pool.posts.map(post => post.id).join(' ') + }) %> + <% } %> + </li> + </ul> + + <% if (ctx.canEditAnything) { %> + <div class='messages'></div> + + <div class='buttons'> + <input type='submit' class='save' value='Save changes'> + </div> + <% } %> + </form> +</div> diff --git a/client/html/pool_input.tpl b/client/html/pool_input.tpl new file mode 100644 index 0000000..483e790 --- /dev/null +++ b/client/html/pool_input.tpl @@ -0,0 +1,7 @@ +<div class='pool-input'> + <div class='main-control'> + <input type='text' placeholder='type to add…'/> + </div> + + <ul class='compact-pools'></ul> +</div> diff --git a/client/html/pool_merge.tpl b/client/html/pool_merge.tpl new file mode 100644 index 0000000..ea5776a --- /dev/null +++ b/client/html/pool_merge.tpl @@ -0,0 +1,22 @@ +<div class='pool-merge'> + <form> + <ul class='input'> + <li class='target'> + <%= ctx.makeTextInput({name: 'target-pool', required: true, text: 'Target pool', pattern: ctx.poolNamePattern}) %> + </li> + + <li> + <p>Posts in the two pools will be combined. + Category needs to be handled manually.</p> + + <%= ctx.makeCheckbox({required: true, text: 'I confirm that I want to merge this pool.'}) %> + </li> + </ul> + + <div class='messages'></div> + + <div class='buttons'> + <input type='submit' value='Merge pool'/> + </div> + </form> +</div> diff --git a/client/html/pool_summary.tpl b/client/html/pool_summary.tpl new file mode 100644 index 0000000..8f4e27d --- /dev/null +++ b/client/html/pool_summary.tpl @@ -0,0 +1,23 @@ +<div class='content-wrapper pool-summary'> + <section class='details'> + <section> + Category: + <span class='<%= ctx.makeCssName(ctx.pool.category, 'pool') %>'><%- ctx.pool.category %></span> + </section> + + <section> + Aliases:<br/> + <ul><!-- + --><% for (let name of ctx.pool.names.slice(1)) { %><!-- + --><li><%= ctx.makePoolLink(ctx.pool.id, false, false, ctx.pool, name) %></li><!-- + --><% } %><!-- + --></ul> + </section> + </section> + + <section class='description'> + <hr/> + <%= ctx.makeMarkdown(ctx.pool.description || 'This pool has no description yet.') %> + <p>This pool has <a href='<%- ctx.formatClientLink('posts', {query: 'pool:' + ctx.pool.id}) %>'><%- ctx.pool.postCount %> post(s)</a>.</p> + </section> +</div> diff --git a/client/html/pools_header.tpl b/client/html/pools_header.tpl new file mode 100644 index 0000000..c54959f --- /dev/null +++ b/client/html/pools_header.tpl @@ -0,0 +1,22 @@ +<div class='pool-list-header'> + <form class='horizontal'> + <ul class='input'> + <li> + <%= ctx.makeTextInput({text: 'Search query', id: 'search-text', name: 'search-text', value: ctx.parameters.query}) %> + </li> + </ul> + + <div class='buttons'> + <input type='submit' value='Search'/> + <a class='button append' href='<%- ctx.formatClientLink('help', 'search', 'pools') %>'>Syntax help</a> + + <% if (ctx.canCreate) { %> + <a class='append' href='<%- ctx.formatClientLink('pool', 'create') %>'>Add new pool</a> + <% } %> + + <% if (ctx.canEditPoolCategories) { %> + <a class='append' href='<%- ctx.formatClientLink('pool-categories') %>'>Pool categories</a> + <% } %> + </div> + </form> +</div> diff --git a/client/html/pools_page.tpl b/client/html/pools_page.tpl new file mode 100644 index 0000000..0d81180 --- /dev/null +++ b/client/html/pools_page.tpl @@ -0,0 +1,48 @@ +<div class='pool-list table-wrap'> + <% if (ctx.response.results.length) { %> + <table> + <thead> + <th class='names'> + <% if (ctx.parameters.query == 'sort:name' || !ctx.parameters.query) { %> + <a href='<%- ctx.formatClientLink('pools', {query: '-sort:name'}) %>'>Pool name(s)</a> + <% } else { %> + <a href='<%- ctx.formatClientLink('pools', {query: 'sort:name'}) %>'>Pool name(s)</a> + <% } %> + </th> + <th class='post-count'> + <% if (ctx.parameters.query == 'sort:post-count') { %> + <a href='<%- ctx.formatClientLink('pools', {query: '-sort:post-count'}) %>'>Post count</a> + <% } else { %> + <a href='<%- ctx.formatClientLink('pools', {query: 'sort:post-count'}) %>'>Post count</a> + <% } %> + </th> + <th class='creation-time'> + <% if (ctx.parameters.query == 'sort:creation-time') { %> + <a href='<%- ctx.formatClientLink('pools', {query: '-sort:creation-time'}) %>'>Created on</a> + <% } else { %> + <a href='<%- ctx.formatClientLink('pools', {query: 'sort:creation-time'}) %>'>Created on</a> + <% } %> + </th> + </thead> + <tbody> + <% for (let pool of ctx.response.results) { %> + <tr> + <td class='names'> + <ul> + <% for (let name of pool.names) { %> + <li><%= ctx.makePoolLink(pool.id, false, false, pool, name) %></li> + <% } %> + </ul> + </td> + <td class='post-count'> + <a href='<%- ctx.formatClientLink('posts', {query: 'pool:' + pool.id}) %>'><%- pool.postCount %></a> + </td> + <td class='creation-time'> + <%= ctx.makeRelativeTime(pool.creationTime) %> + </td> + </tr> + <% } %> + </tbody> + </table> + <% } %> +</div> diff --git a/client/html/post_content.tpl b/client/html/post_content.tpl index 10bfb15..fd5b094 100644 --- a/client/html/post_content.tpl +++ b/client/html/post_content.tpl @@ -17,6 +17,7 @@ class: 'resize-listener', controls: true, loop: (ctx.post.flags || []).includes('loop'), + playsinline: true, autoplay: ctx.autoplay, }, ctx.makeElement('source', { diff --git a/client/html/post_edit_sidebar.tpl b/client/html/post_edit_sidebar.tpl index 85c039f..9408606 100644 --- a/client/html/post_edit_sidebar.tpl +++ b/client/html/post_edit_sidebar.tpl @@ -60,9 +60,8 @@ <% if (ctx.canEditPostSource) { %> <section class='post-source'> - <%= ctx.makeTextInput({ + <%= ctx.makeTextarea({ text: 'Source', - name: 'source', value: ctx.post.source, }) %> </section> @@ -74,6 +73,12 @@ </section> <% } %> + <% if (ctx.canEditPoolPosts) { %> + <section class='pools'> + <%= ctx.makeTextInput({}) %> + </section> + <% } %> + <% if (ctx.canEditPostMetrics) { %> <section class='metrics'> <%= ctx.makeTextInput({}) %> diff --git a/client/html/post_merge_side.tpl b/client/html/post_merge_side.tpl index b2c1fc6..53dd31f 100644 --- a/client/html/post_merge_side.tpl +++ b/client/html/post_merge_side.tpl @@ -35,6 +35,7 @@ 'image/gif': 'GIF', 'image/jpeg': 'JPEG', 'image/png': 'PNG', + 'image/webp': 'WEBP', 'video/webm': 'WEBM', 'video/mp4': 'MPEG-4', 'application/x-shockwave-flash': 'SWF', diff --git a/client/html/post_readonly_sidebar.tpl b/client/html/post_readonly_sidebar.tpl index 9f6c530..8dfafdc 100644 --- a/client/html/post_readonly_sidebar.tpl +++ b/client/html/post_readonly_sidebar.tpl @@ -8,6 +8,7 @@ 'image/gif': 'GIF', 'image/jpeg': 'JPEG', 'image/png': 'PNG', + 'image/webp': 'WEBP', 'video/webm': 'WEBM', 'video/mp4': 'MPEG-4', 'application/x-shockwave-flash': 'SWF', @@ -41,9 +42,10 @@ <% if (ctx.post.source) { %> <section class='source'> - Source: <a href='<%- ctx.post.source %>' title='<%- ctx.post.source %>'> - <%- ctx.post.prettyPrintSource() %> - </a> + Source: <% for (let i = 0; i < ctx.post.sourceSplit.length; i++) { %> + <% if (i != 0) { %>·<% } %> + <a href='<%- ctx.post.sourceSplit[i] %>' title='<%- ctx.post.sourceSplit[i] %>'><%- ctx.extractRootDomain(ctx.post.sourceSplit[i]) %></a> + <% } %> </section> <% } %> @@ -91,7 +93,7 @@ --><% if (ctx.canListPosts) { %><!-- --><a href='<%- ctx.formatClientLink('posts', {query: ctx.escapeColons(tag.names[0])}) %>' class='<%= ctx.makeCssName(tag.category, 'tag') %>'><!-- --><% } %><!-- - --><%- tag.names[0] %> <!-- + --><%- ctx.getPrettyName(tag.names[0]) %> <!-- --><% if (ctx.canListPosts) { %><!-- --></a><!-- --><% } %><!-- diff --git a/client/html/post_upload_row.tpl b/client/html/post_upload_row.tpl index d5875f2..559e935 100644 --- a/client/html/post_upload_row.tpl +++ b/client/html/post_upload_row.tpl @@ -64,16 +64,6 @@ }) %> </div> <% } %> - - <% if (['video'].includes(ctx.uploadable.type)) { %> - <div class='loop-video'> - <%= ctx.makeCheckbox({ - text: 'Loop video', - name: 'loop-video', - checked: ctx.uploadable.flags.includes('loop'), - }) %> - </div> - <% } %> </div> <div class='messages'></div> diff --git a/client/html/posts_page.tpl b/client/html/posts_page.tpl index ea1bbfb..5336fe8 100644 --- a/client/html/posts_page.tpl +++ b/client/html/posts_page.tpl @@ -1,4 +1,4 @@ -<div class='post-list'> +<% if (ctx.postFlow) { %><div class='post-list post-flow'><% } else { %><div class='post-list'><% } %> <% if (ctx.response.results.length) { %> <ul> <% for (let post of ctx.response.results) { %> diff --git a/client/html/settings.tpl b/client/html/settings.tpl index f85b012..25ba63a 100644 --- a/client/html/settings.tpl +++ b/client/html/settings.tpl @@ -48,6 +48,15 @@ <li> <%= ctx.makeCheckbox({ + text: 'Use dark theme', + name: 'dark-theme', + checked: ctx.browsingSettings.darkTheme, + }) %> + <p class='hint'>Changing this setting will require you to refresh the page for it to apply.</p> + </li> + + <li> + <%= ctx.makeCheckbox({ text: 'Upscale small posts', name: 'upscale-small-posts', checked: ctx.browsingSettings.upscaleSmallPosts}) %> @@ -64,6 +73,15 @@ <li> <%= ctx.makeCheckbox({ + text: 'Use post flow', + name: 'post-flow', + checked: ctx.browsingSettings.postFlow, + }) %> + <p class='hint'>Use a content-aware flow for thumbnails on the post search page.</p> + </li> + + <li> + <%= ctx.makeCheckbox({ text: 'Enable transparency grid', name: 'transparency-grid', checked: ctx.browsingSettings.transparencyGrid, @@ -87,6 +105,15 @@ checked: ctx.browsingSettings.autoplayVideos, }) %> </li> + + <li> + <%= ctx.makeCheckbox({ + text: 'Display underscores as spaces', + name: 'underscores-as-spaces', + checked: ctx.browsingSettings.tagUnderscoresAsSpaces, + }) %> + <p class='hint'>Display all underscores as if they were spaces. This is only a visual change, which means that you'll still have to use underscores when searching or editing tags.</p> + </li> </ul> <div class='messages'></div> diff --git a/client/html/tag.tpl b/client/html/tag.tpl index 29c5ec5..56255e8 100644 --- a/client/html/tag.tpl +++ b/client/html/tag.tpl @@ -1,5 +1,5 @@ <div class='content-wrapper' id='tag'> - <h1><%- ctx.tag.names[0] %></h1> + <h1><%- ctx.getPrettyName(ctx.tag.names[0]) %></h1> <nav class='buttons'><!-- --><ul><!-- --><li data-name='summary'><a href='<%- ctx.formatClientLink('tag', ctx.tag.names[0]) %>'>Summary</a></li><!-- diff --git a/client/html/tag_categories.tpl b/client/html/tag_categories.tpl index fe6b898..f9d28e1 100644 --- a/client/html/tag_categories.tpl +++ b/client/html/tag_categories.tpl @@ -7,6 +7,7 @@ <tr> <th class='name'>Category name</th> <th class='color'>CSS color</th> + <th class='order'>Order</th> <th class='usages'>Usages</th> </tr> </thead> @@ -21,7 +22,7 @@ <div class='messages'></div> - <% if (ctx.canCreate || ctx.canEditName || ctx.canEditColor || ctx.canDelete) { %> + <% if (ctx.canCreate || ctx.canEditName || ctx.canEditColor || ctx.canEditOrder || ctx.canDelete) { %> <div class='buttons'> <input type='submit' class='save' value='Save changes'> </div> diff --git a/client/html/tag_category_row.tpl b/client/html/tag_category_row.tpl index dcc8c16..ce7aa59 100644 --- a/client/html/tag_category_row.tpl +++ b/client/html/tag_category_row.tpl @@ -17,6 +17,13 @@ <%- ctx.tagCategory.color %> <% } %> </td> + <td class='order'> + <% if (ctx.canEditOrder) { %> + <%= ctx.makeNumericInput({value: ctx.tagCategory.order}) %> + <% } else { %> + <%- ctx.tagCategory.order %> + <% } %> + </td> <td class='usages'> <% if (ctx.tagCategory.name) { %> <a href='<%- ctx.formatClientLink('tags', {query: 'category:' + ctx.tagCategory.name}) %>'> diff --git a/client/html/tags_page.tpl b/client/html/tags_page.tpl index 8c97398..2e08239 100644 --- a/client/html/tags_page.tpl +++ b/client/html/tags_page.tpl @@ -3,35 +3,35 @@ <table> <thead> <th class='names'> - <% if (ctx.query == 'sort:name' || !ctx.query) { %> + <% if (ctx.parameters.query == 'sort:name' || !ctx.parameters.query) { %> <a href='<%- ctx.formatClientLink('tags', {query: '-sort:name'}) %>'>Tag name(s)</a> <% } else { %> <a href='<%- ctx.formatClientLink('tags', {query: 'sort:name'}) %>'>Tag name(s)</a> <% } %> </th> <th class='implications'> - <% if (ctx.query == 'sort:implication-count') { %> + <% if (ctx.parameters.query == 'sort:implication-count') { %> <a href='<%- ctx.formatClientLink('tags', {query: '-sort:implication-count'}) %>'>Implications</a> <% } else { %> <a href='<%- ctx.formatClientLink('tags', {query: 'sort:implication-count'}) %>'>Implications</a> <% } %> </th> <th class='suggestions'> - <% if (ctx.query == 'sort:suggestion-count') { %> + <% if (ctx.parameters.query == 'sort:suggestion-count') { %> <a href='<%- ctx.formatClientLink('tags', {query: '-sort:suggestion-count'}) %>'>Suggestions</a> <% } else { %> <a href='<%- ctx.formatClientLink('tags', {query: 'sort:suggestion-count'}) %>'>Suggestions</a> <% } %> </th> <th class='usages'> - <% if (ctx.query == 'sort:usages') { %> + <% if (ctx.parameters.query == 'sort:usages') { %> <a href='<%- ctx.formatClientLink('tags', {query: '-sort:usages'}) %>'>Usages</a> <% } else { %> <a href='<%- ctx.formatClientLink('tags', {query: 'sort:usages'}) %>'>Usages</a> <% } %> </th> <th class='creation-time'> - <% if (ctx.query == 'sort:creation-time') { %> + <% if (ctx.parameters.query == 'sort:creation-time') { %> <a href='<%- ctx.formatClientLink('tags', {query: '-sort:creation-time'}) %>'>Created on</a> <% } else { %> <a href='<%- ctx.formatClientLink('tags', {query: 'sort:creation-time'}) %>'>Created on</a> diff --git a/client/img/transparency_grid.png b/client/img/transparency_grid.png Binary files differdeleted file mode 100644 index 7ae8113..0000000 --- a/client/img/transparency_grid.png +++ /dev/null diff --git a/client/js/api.js b/client/js/api.js index c8e571c..5bde6d8 100644 --- a/client/js/api.js +++ b/client/js/api.js @@ -1,26 +1,14 @@ -'use strict'; +"use strict"; -const cookies = require('js-cookie'); -const request = require('superagent'); -const events = require('./events.js'); -const progress = require('./util/progress.js'); -const uri = require('./util/uri.js'); +const cookies = require("js-cookie"); +const request = require("superagent"); +const events = require("./events.js"); +const progress = require("./util/progress.js"); +const uri = require("./util/uri.js"); let fileTokens = {}; let remoteConfig = null; -function getCookieName() { - const bases = document.getElementsByTagName('base'); - if (bases.length) { - let baseHref = bases[0].href; - baseHref = baseHref.replace('/', ''); - return 'szuru-' + baseHref; - } else { - return 'szuru'; - } -} -const cookieName = getCookieName(); - class Api extends events.EventTarget { constructor() { super(); @@ -30,22 +18,22 @@ class Api extends events.EventTarget { this.token = null; this.cache = {}; this.allRanks = [ - 'anonymous', - 'restricted', - 'regular', - 'power', - 'moderator', - 'administrator', - 'nobody', + "anonymous", + "restricted", + "regular", + "power", + "moderator", + "administrator", + "nobody", ]; this.rankNames = new Map([ - ['anonymous', 'Anonymous'], - ['restricted', 'Restricted user'], - ['regular', 'Regular user'], - ['power', 'Power user'], - ['moderator', 'Moderator'], - ['administrator', 'Administrator'], - ['nobody', 'Nobody'], + ["anonymous", "Anonymous"], + ["restricted", "Restricted user"], + ["regular", "Regular user"], + ["power", "Power user"], + ["moderator", "Moderator"], + ["administrator", "Administrator"], + ["nobody", "Nobody"], ]); } @@ -55,11 +43,12 @@ class Api extends events.EventTarget { resolve(this.cache[url]); }); } - return this._wrappedRequest(url, request.get, {}, {}, options) - .then(response => { + return this._wrappedRequest(url, request.get, {}, {}, options).then( + (response) => { this.cache[url] = response; return Promise.resolve(response); - }); + } + ); } post(url, data, files, options) { @@ -79,10 +68,9 @@ class Api extends events.EventTarget { fetchConfig() { if (remoteConfig === null) { - return this.get(uri.formatApiLink('info')) - .then(response => { - remoteConfig = response.config; - }); + return this.get(uri.formatApiLink("info")).then((response) => { + remoteConfig = response.config; + }); } else { return Promise.resolve(); } @@ -96,6 +84,10 @@ class Api extends events.EventTarget { return remoteConfig.tagNameRegex; } + getPoolNameRegex() { + return remoteConfig.poolNameRegex; + } + getPasswordRegex() { return remoteConfig.passwordRegex; } @@ -123,7 +115,8 @@ class Api extends events.EventTarget { continue; } const rankIndex = this.allRanks.indexOf( - remoteConfig.privileges[p]); + remoteConfig.privileges[p] + ); if (minViableRank === null || rankIndex < minViableRank) { minViableRank = rankIndex; } @@ -131,17 +124,16 @@ class Api extends events.EventTarget { if (minViableRank === null) { throw `Bad privilege name: ${lookup}`; } - let myRank = this.user !== null ? - this.allRanks.indexOf(this.user.rank) : - 0; + let myRank = + this.user !== null ? this.allRanks.indexOf(this.user.rank) : 0; return myRank >= minViableRank; } loginFromCookies() { - const auth = cookies.getJSON(cookieName); - return auth && auth.user && auth.token ? - this.loginWithToken(auth.user, auth.token, true) : - Promise.resolve(); + const auth = cookies.getJSON("auth"); + return auth && auth.user && auth.token + ? this.loginWithToken(auth.user, auth.token, true) + : Promise.resolve(); } loginWithToken(userName, token, doRemember) { @@ -149,63 +141,74 @@ class Api extends events.EventTarget { return new Promise((resolve, reject) => { this.userName = userName; this.token = token; - this.get('/user/' + userName + '?bump-login=true') - .then(response => { + this.get("/user/" + userName + "?bump-login=true").then( + (response) => { const options = {}; if (doRemember) { options.expires = 365; } cookies.set( - 'auth', - {'user': userName, 'token': token}, - options); + "auth", + { user: userName, token: token }, + options + ); this.user = response; resolve(); - this.dispatchEvent(new CustomEvent('login')); - }, error => { + this.dispatchEvent(new CustomEvent("login")); + }, + (error) => { reject(error); this.logout(); - }); + } + ); }); } createToken(userName, options) { let userTokenRequest = { enabled: true, - note: 'Web Login Token' + note: "Web Login Token", }; - if (typeof options.expires !== 'undefined') { - userTokenRequest.expirationTime = new Date().addDays(options.expires).toISOString() + if (typeof options.expires !== "undefined") { + userTokenRequest.expirationTime = new Date() + .addDays(options.expires) + .toISOString(); } return new Promise((resolve, reject) => { - this.post('/user-token/' + userName, userTokenRequest) - .then(response => { + this.post("/user-token/" + userName, userTokenRequest).then( + (response) => { cookies.set( - cookieName, - {'user': userName, 'token': response.token}, - options); + "auth", + { user: userName, token: response.token }, + options + ); this.userName = userName; this.token = response.token; this.userPassword = null; - }, error => { + }, + (error) => { reject(error); - }); + } + ); }); } deleteToken(userName, userToken) { return new Promise((resolve, reject) => { - this.delete('/user-token/' + userName + '/' + userToken, {}) - .then(response => { + this.delete("/user-token/" + userName + "/" + userToken, {}).then( + (response) => { const options = {}; cookies.set( - 'auth', - {'user': userName, 'token': null}, - options); + "auth", + { user: userName, token: null }, + options + ); resolve(); - }, error => { + }, + (error) => { reject(error); - }); + } + ); }); } @@ -214,8 +217,8 @@ class Api extends events.EventTarget { return new Promise((resolve, reject) => { this.userName = userName; this.userPassword = userPassword; - this.get('/user/' + userName + '?bump-login=true') - .then(response => { + this.get("/user/" + userName + "?bump-login=true").then( + (response) => { const options = {}; if (doRemember) { options.expires = 365; @@ -223,22 +226,26 @@ class Api extends events.EventTarget { this.createToken(this.userName, options); this.user = response; resolve(); - this.dispatchEvent(new CustomEvent('login')); - }, error => { + this.dispatchEvent(new CustomEvent("login")); + }, + (error) => { reject(error); this.logout(); - }); + } + ); }); } logout() { let self = this; - this.deleteToken(this.userName, this.token) - .then(response => { + this.deleteToken(this.userName, this.token).then( + (response) => { self._logout(); - }, error => { + }, + (error) => { self._logout(); - }); + } + ); } _logout() { @@ -246,17 +253,19 @@ class Api extends events.EventTarget { this.userName = null; this.userPassword = null; this.token = null; - this.dispatchEvent(new CustomEvent('logout')); + this.dispatchEvent(new CustomEvent("logout")); } forget() { - cookies.remove('auth'); + cookies.remove("auth"); } isLoggedIn(user) { if (user) { - return this.userName !== null && - this.userName.toLowerCase() === user.name.toLowerCase(); + return ( + this.userName !== null && + this.userName.toLowerCase() === user.name.toLowerCase() + ); } else { return this.userName !== null; } @@ -267,8 +276,7 @@ class Api extends events.EventTarget { } _getFullUrl(url) { - const fullUrl = - ('api/' + url).replace(/([^:])\/+/g, '$1/'); + const fullUrl = ("api/" + url).replace(/([^:])\/+/g, "$1/"); const matches = fullUrl.match(/^([^?]*)\??(.*)$/); const baseUrl = matches[1]; const request = matches[2]; @@ -293,7 +301,7 @@ class Api extends events.EventTarget { const file = files[key]; const fileId = this._getFileId(file); if (fileTokens[fileId]) { - data[key + 'Token'] = fileTokens[fileId]; + data[key + "Token"] = fileTokens[fileId]; } else { promise = promise .then(() => { @@ -301,33 +309,40 @@ class Api extends events.EventTarget { abortFunction = () => uploadPromise.abort(); return uploadPromise; }) - .then(token => { + .then((token) => { abortFunction = () => {}; fileTokens[fileId] = token; - data[key + 'Token'] = token; + data[key + "Token"] = token; return Promise.resolve(); }); } } } - promise = promise.then( - () => { + promise = promise + .then(() => { let requestPromise = this._rawRequest( - url, requestFactory, data, {}, options); + url, + requestFactory, + data, + {}, + options + ); abortFunction = () => requestPromise.abort(); return requestPromise; }) - .catch(error => { - if (error.response && error.response.name === - 'MissingOrExpiredRequiredFileError') { + .catch((error) => { + if ( + error.response && + error.response.name === "MissingOrExpiredRequiredFileError" + ) { for (let key of Object.keys(files)) { const file = files[key]; const fileId = this._getFileId(file); fileTokens[fileId] = null; } error.message = - 'The uploaded file has expired; ' + - 'please resend the form to reupload.'; + "The uploaded file has expired; " + + "please resend the form to reupload."; } return Promise.reject(error); }); @@ -339,13 +354,17 @@ class Api extends events.EventTarget { let abortFunction = () => {}; let returnedPromise = new Promise((resolve, reject) => { let uploadPromise = this._rawRequest( - 'uploads', request.post, {}, {content: file}, options); + "uploads", + request.post, + {}, + { content: file }, + options + ); abortFunction = () => uploadPromise.abort(); - return uploadPromise.then( - response => { - abortFunction = () => {}; - return resolve(response.token); - }, reject); + return uploadPromise.then((response) => { + abortFunction = () => {}; + return resolve(response.token); + }, reject); }); returnedPromise.abort = () => abortFunction(); return returnedPromise; @@ -360,7 +379,7 @@ class Api extends events.EventTarget { let returnedPromise = new Promise((resolve, reject) => { let req = requestFactory(fullUrl); - req.set('Accept', 'application/json'); + req.set("Accept", "application/json"); if (query) { req.query(query); @@ -370,7 +389,7 @@ class Api extends events.EventTarget { for (let key of Object.keys(files)) { const value = files[key]; if (value.constructor === String) { - data[key + 'Url'] = value; + data[key + "Url"] = value; } else { req.attach(key, value || new Blob()); } @@ -379,9 +398,9 @@ class Api extends events.EventTarget { if (data) { if (files && Object.keys(files).length) { - req.attach('metadata', new Blob([JSON.stringify(data)])); + req.attach("metadata", new Blob([JSON.stringify(data)])); } else { - req.set('Content-Type', 'application/json'); + req.set("Content-Type", "application/json"); req.send(data); } } @@ -389,19 +408,29 @@ class Api extends events.EventTarget { try { if (this.userName && this.token) { req.auth = null; - req.set('Authorization', 'Token ' - + new Buffer(this.userName + ":" + this.token).toString('base64')) + // eslint-disable-next-line no-undef + req.set( + "Authorization", + "Token " + + new Buffer( + this.userName + ":" + this.token + ).toString("base64") + ); } else if (this.userName && this.userPassword) { req.auth( this.userName, - encodeURIComponent(this.userPassword) - .replace(/%([0-9A-F]{2})/g, (match, p1) => { - return String.fromCharCode('0x' + p1); - })); + encodeURIComponent(this.userPassword).replace( + /%([0-9A-F]{2})/g, + (match, p1) => { + return String.fromCharCode("0x" + p1); + } + ) + ); } } catch (e) { reject( - new Error('Authentication error (malformed credentials)')); + new Error("Authentication error (malformed credentials)") + ); } if (!options.noProgress) { @@ -409,10 +438,11 @@ class Api extends events.EventTarget { } abortFunction = () => { - req.abort(); // does *NOT* call the callback passed in .end() + req.abort(); // does *NOT* call the callback passed in .end() progress.done(); reject( - new Error('The request was aborted due to user cancel.')); + new Error("The request was aborted due to user cancel.") + ); }; req.end((error, response) => { @@ -421,7 +451,8 @@ class Api extends events.EventTarget { if (error) { if (response && response.body) { error = new Error( - response.body.description || 'Unknown error'); + response.body.description || "Unknown error" + ); error.response = response.body; } reject(error); diff --git a/client/js/config.js b/client/js/config.js index 4524be6..da8fb8f 100644 --- a/client/js/config.js +++ b/client/js/config.js @@ -1,4 +1,4 @@ -'use strict'; +"use strict"; -const config = require('./.config.autogen.json'); +const config = require("./.config.autogen.json"); module.exports = config; diff --git a/client/js/controllers/auth_controller.js b/client/js/controllers/auth_controller.js index 2838e53..e01fb9b 100644 --- a/client/js/controllers/auth_controller.js +++ b/client/js/controllers/auth_controller.js @@ -1,33 +1,40 @@ -'use strict'; +"use strict"; -const router = require('../router.js'); -const api = require('../api.js'); -const uri = require('../util/uri.js'); -const topNavigation = require('../models/top_navigation.js'); -const LoginView = require('../views/login_view.js'); +const router = require("../router.js"); +const api = require("../api.js"); +const tags = require("../tags.js"); +const pools = require("../pools.js"); +const uri = require("../util/uri.js"); +const topNavigation = require("../models/top_navigation.js"); +const LoginView = require("../views/login_view.js"); class LoginController { constructor() { api.forget(); - topNavigation.activate('login'); - topNavigation.setTitle('Login'); + topNavigation.activate("login"); + topNavigation.setTitle("Login"); this._loginView = new LoginView(); - this._loginView.addEventListener('submit', e => this._evtLogin(e)); + this._loginView.addEventListener("submit", (e) => this._evtLogin(e)); } _evtLogin(e) { this._loginView.clearMessages(); this._loginView.disableForm(); api.forget(); - api.login(e.detail.name, e.detail.password, e.detail.remember) - .then(() => { + api.login(e.detail.name, e.detail.password, e.detail.remember).then( + () => { const ctx = router.show(uri.formatClientLink()); - ctx.controller.showSuccess('Logged in'); - }, error => { + ctx.controller.showSuccess("Logged in"); + // reload tag category color map, this is required when `tag_categories:list` has a permission other than anonymous + tags.refreshCategoryColorMap(); + pools.refreshCategoryColorMap(); + }, + (error) => { this._loginView.showError(error.message); this._loginView.enableForm(); - }); + } + ); } } @@ -36,15 +43,15 @@ class LogoutController { api.forget(); api.logout(); const ctx = router.show(uri.formatClientLink()); - ctx.controller.showSuccess('Logged out'); + ctx.controller.showSuccess("Logged out"); } } -module.exports = router => { - router.enter(['login'], (ctx, next) => { +module.exports = (router) => { + router.enter(["login"], (ctx, next) => { ctx.controller = new LoginController(); }); - router.enter(['logout'], (ctx, next) => { + router.enter(["logout"], (ctx, next) => { ctx.controller = new LogoutController(); }); }; diff --git a/client/js/controllers/base_post_controller.js b/client/js/controllers/base_post_controller.js index b86aea7..7a21449 100644 --- a/client/js/controllers/base_post_controller.js +++ b/client/js/controllers/base_post_controller.js @@ -1,19 +1,19 @@ -'use strict'; +"use strict"; -const api = require('../api.js'); -const topNavigation = require('../models/top_navigation.js'); -const EmptyView = require('../views/empty_view.js'); +const api = require("../api.js"); +const topNavigation = require("../models/top_navigation.js"); +const EmptyView = require("../views/empty_view.js"); class BasePostController { constructor(ctx) { - if (!api.hasPrivilege('posts:view')) { + if (!api.hasPrivilege("posts:view")) { this._view = new EmptyView(); - this._view.showError('You don\'t have privileges to view posts.'); + this._view.showError("You don't have privileges to view posts."); return; } - topNavigation.activate('posts'); - topNavigation.setTitle('Post #' + ctx.parameters.id.toString()); + topNavigation.activate("posts"); + topNavigation.setTitle("Post #" + ctx.parameters.id.toString()); } } diff --git a/client/js/controllers/comments_controller.js b/client/js/controllers/comments_controller.js index 2de638c..d54059e 100644 --- a/client/js/controllers/comments_controller.js +++ b/client/js/controllers/comments_controller.js @@ -1,49 +1,55 @@ -'use strict'; +"use strict"; -const api = require('../api.js'); -const uri = require('../util/uri.js'); -const PostList = require('../models/post_list.js'); -const topNavigation = require('../models/top_navigation.js'); -const PageController = require('../controllers/page_controller.js'); -const CommentsPageView = require('../views/comments_page_view.js'); -const EmptyView = require('../views/empty_view.js'); +const api = require("../api.js"); +const uri = require("../util/uri.js"); +const PostList = require("../models/post_list.js"); +const topNavigation = require("../models/top_navigation.js"); +const PageController = require("../controllers/page_controller.js"); +const CommentsPageView = require("../views/comments_page_view.js"); +const EmptyView = require("../views/empty_view.js"); -const fields = ['id', 'comments', 'commentCount', 'thumbnailUrl']; +const fields = ["id", "comments", "commentCount", "thumbnailUrl"]; class CommentsController { constructor(ctx) { - if (!api.hasPrivilege('comments:list')) { + if (!api.hasPrivilege("comments:list")) { this._view = new EmptyView(); this._view.showError( - 'You don\'t have privileges to view comments.'); + "You don't have privileges to view comments." + ); return; } - topNavigation.activate('comments'); - topNavigation.setTitle('Listing comments'); + topNavigation.activate("comments"); + topNavigation.setTitle("Listing comments"); this._pageController = new PageController(); this._pageController.run({ parameters: ctx.parameters, defaultLimit: 10, getClientUrlForPage: (offset, limit) => { - const parameters = Object.assign( - {}, ctx.parameters, {offset: offset, limit: limit}); - return uri.formatClientLink('comments', parameters); + const parameters = Object.assign({}, ctx.parameters, { + offset: offset, + limit: limit, + }); + return uri.formatClientLink("comments", parameters); }, requestPage: (offset, limit) => { return PostList.search( - 'sort:comment-date comment-count-min:1', - offset, limit, fields); + "sort:comment-date comment-count-min:1", + offset, + limit, + fields + ); }, - pageRenderer: pageCtx => { + pageRenderer: (pageCtx) => { Object.assign(pageCtx, { - canViewPosts: api.hasPrivilege('posts:view'), + canViewPosts: api.hasPrivilege("posts:view"), }); const view = new CommentsPageView(pageCtx); - view.addEventListener('submit', e => this._evtUpdate(e)); - view.addEventListener('score', e => this._evtScore(e)); - view.addEventListener('delete', e => this._evtDelete(e)); + view.addEventListener("submit", (e) => this._evtUpdate(e)); + view.addEventListener("score", (e) => this._evtScore(e)); + view.addEventListener("delete", (e) => this._evtDelete(e)); return view; }, }); @@ -52,25 +58,27 @@ class CommentsController { _evtUpdate(e) { // TODO: disable form e.detail.comment.text = e.detail.text; - e.detail.comment.save() - .catch(error => { - e.detail.target.showError(error.message); - // TODO: enable form - }); + e.detail.comment.save().catch((error) => { + e.detail.target.showError(error.message); + // TODO: enable form + }); } _evtScore(e) { - e.detail.comment.setScore(e.detail.score) - .catch(error => window.alert(error.message)); + e.detail.comment + .setScore(e.detail.score) + .catch((error) => window.alert(error.message)); } _evtDelete(e) { - e.detail.comment.delete() - .catch(error => window.alert(error.message)); + e.detail.comment + .delete() + .catch((error) => window.alert(error.message)); } -}; +} -module.exports = router => { - router.enter(['comments'], - (ctx, next) => { new CommentsController(ctx); }); +module.exports = (router) => { + router.enter(["comments"], (ctx, next) => { + new CommentsController(ctx); + }); }; diff --git a/client/js/controllers/help_controller.js b/client/js/controllers/help_controller.js index 8e65346..c290a6b 100644 --- a/client/js/controllers/help_controller.js +++ b/client/js/controllers/help_controller.js @@ -1,24 +1,24 @@ -'use strict'; +"use strict"; -const topNavigation = require('../models/top_navigation.js'); -const HelpView = require('../views/help_view.js'); +const topNavigation = require("../models/top_navigation.js"); +const HelpView = require("../views/help_view.js"); class HelpController { constructor(section, subsection) { - topNavigation.activate('help'); - topNavigation.setTitle('Help'); + topNavigation.activate("help"); + topNavigation.setTitle("Help"); this._helpView = new HelpView(section, subsection); } } -module.exports = router => { - router.enter(['help'], (ctx, next) => { +module.exports = (router) => { + router.enter(["help"], (ctx, next) => { new HelpController(); }); - router.enter(['help', ':section'], (ctx, next) => { + router.enter(["help", ":section"], (ctx, next) => { new HelpController(ctx.parameters.section); }); - router.enter(['help', ':section', ':subsection'], (ctx, next) => { + router.enter(["help", ":section", ":subsection"], (ctx, next) => { new HelpController(ctx.parameters.section, ctx.parameters.subsection); }); }; diff --git a/client/js/controllers/home_controller.js b/client/js/controllers/home_controller.js index cc22ca9..cf56e27 100644 --- a/client/js/controllers/home_controller.js +++ b/client/js/controllers/home_controller.js @@ -1,26 +1,26 @@ -'use strict'; +"use strict"; -const api = require('../api.js'); -const config = require('../config.js'); -const Info = require('../models/info.js'); -const topNavigation = require('../models/top_navigation.js'); -const HomeView = require('../views/home_view.js'); +const api = require("../api.js"); +const config = require("../config.js"); +const Info = require("../models/info.js"); +const topNavigation = require("../models/top_navigation.js"); +const HomeView = require("../views/home_view.js"); class HomeController { constructor() { - topNavigation.activate('home'); - topNavigation.setTitle('Home'); + topNavigation.activate("home"); + topNavigation.setTitle("Home"); this._homeView = new HomeView({ name: api.getName(), version: config.meta.version, buildDate: config.meta.buildDate, - canListSnapshots: api.hasPrivilege('snapshots:list'), - canListPosts: api.hasPrivilege('posts:list'), + canListSnapshots: api.hasPrivilege("snapshots:list"), + canListPosts: api.hasPrivilege("posts:list"), }); - Info.get() - .then(info => { + Info.get().then( + (info) => { this._homeView.setStats({ diskUsage: info.diskUsage, postCount: info.postCount, @@ -31,7 +31,8 @@ class HomeController { featuringTime: info.featuringTime, }); }, - error => this._homeView.showError(error.message)); + (error) => this._homeView.showError(error.message) + ); } showSuccess(message) { @@ -41,9 +42,9 @@ class HomeController { showError(message) { this._homeView.showError(message); } -}; +} -module.exports = router => { +module.exports = (router) => { router.enter([], (ctx, next) => { ctx.controller = new HomeController(); }); diff --git a/client/js/controllers/not_found_controller.js b/client/js/controllers/not_found_controller.js index 66f52e9..c99e308 100644 --- a/client/js/controllers/not_found_controller.js +++ b/client/js/controllers/not_found_controller.js @@ -1,17 +1,17 @@ -'use strict'; +"use strict"; -const topNavigation = require('../models/top_navigation.js'); -const NotFoundView = require('../views/not_found_view.js'); +const topNavigation = require("../models/top_navigation.js"); +const NotFoundView = require("../views/not_found_view.js"); class NotFoundController { constructor(path) { - topNavigation.activate(''); - topNavigation.setTitle('Not found'); + topNavigation.activate(""); + topNavigation.setTitle("Not found"); this._notFoundView = new NotFoundView(path); } -}; +} -module.exports = router => { +module.exports = (router) => { router.enter(null, (ctx, next) => { ctx.controller = new NotFoundController(ctx.canonicalPath); }); diff --git a/client/js/controllers/page_controller.js b/client/js/controllers/page_controller.js index ae55804..d3ee7e9 100644 --- a/client/js/controllers/page_controller.js +++ b/client/js/controllers/page_controller.js @@ -1,8 +1,8 @@ -'use strict'; +"use strict"; -const settings = require('../models/settings.js'); -const EndlessPageView = require('../views/endless_page_view.js'); -const ManualPageView = require('../views/manual_page_view.js'); +const settings = require("../models/settings.js"); +const EndlessPageView = require("../views/endless_page_view.js"); +const ManualPageView = require("../views/manual_page_view.js"); class PageController { constructor(ctx) { diff --git a/client/js/controllers/password_reset_controller.js b/client/js/controllers/password_reset_controller.js index e0a9801..aa66c03 100644 --- a/client/js/controllers/password_reset_controller.js +++ b/client/js/controllers/password_reset_controller.js @@ -1,19 +1,20 @@ -'use strict'; +"use strict"; -const router = require('../router.js'); -const api = require('../api.js'); -const uri = require('../util/uri.js'); -const topNavigation = require('../models/top_navigation.js'); -const PasswordResetView = require('../views/password_reset_view.js'); +const router = require("../router.js"); +const api = require("../api.js"); +const uri = require("../util/uri.js"); +const topNavigation = require("../models/top_navigation.js"); +const PasswordResetView = require("../views/password_reset_view.js"); class PasswordResetController { constructor() { - topNavigation.activate('login'); - topNavigation.setTitle('Password reminder'); + topNavigation.activate("login"); + topNavigation.setTitle("Password reminder"); this._passwordResetView = new PasswordResetView(); - this._passwordResetView.addEventListener( - 'submit', e => this._evtReset(e)); + this._passwordResetView.addEventListener("submit", (e) => + this._evtReset(e) + ); } _evtReset(e) { @@ -21,15 +22,20 @@ class PasswordResetController { this._passwordResetView.disableForm(); api.forget(); api.logout(); - api.get(uri.formatApiLink('password-reset', e.detail.userNameOrEmail)) - .then(() => { + api.get( + uri.formatApiLink("password-reset", e.detail.userNameOrEmail) + ).then( + () => { this._passwordResetView.showSuccess( - 'E-mail has been sent. To finish the procedure, ' + - 'please click the link it contains.'); - }, error => { + "E-mail has been sent. To finish the procedure, " + + "please click the link it contains." + ); + }, + (error) => { this._passwordResetView.showError(error.message); this._passwordResetView.enableForm(); - }); + } + ); } } @@ -38,26 +44,30 @@ class PasswordResetFinishController { api.forget(); api.logout(); let password = null; - api.post(uri.formatApiLink('password-reset', name), {token: token}) - .then(response => { + api.post(uri.formatApiLink("password-reset", name), { token: token }) + .then((response) => { password = response.password; return api.login(name, password, false); - }).then(() => { - const ctx = router.show(uri.formatClientLink()); - ctx.controller.showSuccess('New password: ' + password); - }, error => { - const ctx = router.show(uri.formatClientLink()); - ctx.controller.showError(error.message); - }); + }) + .then( + () => { + const ctx = router.show(uri.formatClientLink()); + ctx.controller.showSuccess("New password: " + password); + }, + (error) => { + const ctx = router.show(uri.formatClientLink()); + ctx.controller.showError(error.message); + } + ); } } -module.exports = router => { - router.enter(['password-reset'], (ctx, next) => { +module.exports = (router) => { + router.enter(["password-reset"], (ctx, next) => { ctx.controller = new PasswordResetController(); }); - router.enter(['password-reset', ':descriptor'], (ctx, next) => { - const [name, token] = ctx.parameters.descriptor.split(':', 2); + router.enter(["password-reset", ":descriptor"], (ctx, next) => { + const [name, token] = ctx.parameters.descriptor.split(":", 2); ctx.controller = new PasswordResetFinishController(name, token); }); }; diff --git a/client/js/controllers/pool_categories_controller.js b/client/js/controllers/pool_categories_controller.js new file mode 100644 index 0000000..4db725c --- /dev/null +++ b/client/js/controllers/pool_categories_controller.js @@ -0,0 +1,69 @@ +"use strict"; + +const api = require("../api.js"); +const pools = require("../pools.js"); +const PoolCategoryList = require("../models/pool_category_list.js"); +const topNavigation = require("../models/top_navigation.js"); +const PoolCategoriesView = require("../views/pool_categories_view.js"); +const EmptyView = require("../views/empty_view.js"); + +class PoolCategoriesController { + constructor() { + if (!api.hasPrivilege("poolCategories:list")) { + this._view = new EmptyView(); + this._view.showError( + "You don't have privileges to view pool categories." + ); + return; + } + + topNavigation.activate("pools"); + topNavigation.setTitle("Listing pools"); + PoolCategoryList.get().then( + (response) => { + this._poolCategories = response.results; + this._view = new PoolCategoriesView({ + poolCategories: this._poolCategories, + canEditName: api.hasPrivilege("poolCategories:edit:name"), + canEditColor: api.hasPrivilege( + "poolCategories:edit:color" + ), + canDelete: api.hasPrivilege("poolCategories:delete"), + canCreate: api.hasPrivilege("poolCategories:create"), + canSetDefault: api.hasPrivilege( + "poolCategories:setDefault" + ), + }); + this._view.addEventListener("submit", (e) => + this._evtSubmit(e) + ); + }, + (error) => { + this._view = new EmptyView(); + this._view.showError(error.message); + } + ); + } + + _evtSubmit(e) { + this._view.clearMessages(); + this._view.disableForm(); + this._poolCategories.save().then( + () => { + pools.refreshCategoryColorMap(); + this._view.enableForm(); + this._view.showSuccess("Changes saved."); + }, + (error) => { + this._view.enableForm(); + this._view.showError(error.message); + } + ); + } +} + +module.exports = (router) => { + router.enter(["pool-categories"], (ctx, next) => { + ctx.controller = new PoolCategoriesController(ctx, next); + }); +}; diff --git a/client/js/controllers/pool_controller.js b/client/js/controllers/pool_controller.js new file mode 100644 index 0000000..6844171 --- /dev/null +++ b/client/js/controllers/pool_controller.js @@ -0,0 +1,176 @@ +"use strict"; + +const router = require("../router.js"); +const api = require("../api.js"); +const misc = require("../util/misc.js"); +const uri = require("../util/uri.js"); +const Pool = require("../models/pool.js"); +const Post = require("../models/post.js"); +const PoolCategoryList = require("../models/pool_category_list.js"); +const topNavigation = require("../models/top_navigation.js"); +const PoolView = require("../views/pool_view.js"); +const EmptyView = require("../views/empty_view.js"); + +class PoolController { + constructor(ctx, section) { + if (!api.hasPrivilege("pools:view")) { + this._view = new EmptyView(); + this._view.showError("You don't have privileges to view pools."); + return; + } + + Promise.all([ + PoolCategoryList.get(), + Pool.get(ctx.parameters.id), + ]).then( + (responses) => { + const [poolCategoriesResponse, pool] = responses; + + topNavigation.activate("pools"); + topNavigation.setTitle("Pool #" + pool.names[0]); + + this._name = ctx.parameters.name; + pool.addEventListener("change", (e) => + this._evtSaved(e, section) + ); + + const categories = {}; + for (let category of poolCategoriesResponse.results) { + categories[category.name] = category.name; + } + + this._view = new PoolView({ + pool: pool, + section: section, + canEditAnything: api.hasPrivilege("pools:edit"), + canEditNames: api.hasPrivilege("pools:edit:names"), + canEditCategory: api.hasPrivilege("pools:edit:category"), + canEditDescription: api.hasPrivilege( + "pools:edit:description" + ), + canEditPosts: api.hasPrivilege("pools:edit:posts"), + canMerge: api.hasPrivilege("pools:merge"), + canDelete: api.hasPrivilege("pools:delete"), + categories: categories, + escapeColons: uri.escapeColons, + }); + + this._view.addEventListener("change", (e) => + this._evtChange(e) + ); + this._view.addEventListener("submit", (e) => + this._evtUpdate(e) + ); + this._view.addEventListener("merge", (e) => this._evtMerge(e)); + this._view.addEventListener("delete", (e) => + this._evtDelete(e) + ); + }, + (error) => { + this._view = new EmptyView(); + this._view.showError(error.message); + } + ); + } + + _evtChange(e) { + misc.enableExitConfirmation(); + } + + _evtSaved(e, section) { + misc.disableExitConfirmation(); + if (this._name !== e.detail.pool.names[0]) { + router.replace( + uri.formatClientLink("pool", e.detail.pool.id, section), + null, + false + ); + } + } + + _evtUpdate(e) { + this._view.clearMessages(); + this._view.disableForm(); + if (e.detail.names !== undefined) { + e.detail.pool.names = e.detail.names; + } + if (e.detail.category !== undefined) { + e.detail.pool.category = e.detail.category; + } + if (e.detail.description !== undefined) { + e.detail.pool.description = e.detail.description; + } + if (e.detail.posts !== undefined) { + e.detail.pool.posts.clear(); + for (let postId of e.detail.posts) { + e.detail.pool.posts.add( + Post.fromResponse({ id: parseInt(postId) }) + ); + } + } + e.detail.pool.save().then( + () => { + this._view.showSuccess("Pool saved."); + this._view.enableForm(); + }, + (error) => { + this._view.showError(error.message); + this._view.enableForm(); + } + ); + } + + _evtMerge(e) { + this._view.clearMessages(); + this._view.disableForm(); + e.detail.pool.merge(e.detail.targetPoolId, e.detail.addAlias).then( + () => { + this._view.showSuccess("Pool merged."); + this._view.enableForm(); + router.replace( + uri.formatClientLink( + "pool", + e.detail.targetPoolId, + "merge" + ), + null, + false + ); + }, + (error) => { + this._view.showError(error.message); + this._view.enableForm(); + } + ); + } + + _evtDelete(e) { + this._view.clearMessages(); + this._view.disableForm(); + e.detail.pool.delete().then( + () => { + const ctx = router.show(uri.formatClientLink("pools")); + ctx.controller.showSuccess("Pool deleted."); + }, + (error) => { + this._view.showError(error.message); + this._view.enableForm(); + } + ); + } +} + +module.exports = (router) => { + router.enter(["pool", ":id", "edit"], (ctx, next) => { + ctx.controller = new PoolController(ctx, "edit"); + }); + router.enter(["pool", ":id", "merge"], (ctx, next) => { + ctx.controller = new PoolController(ctx, "merge"); + }); + router.enter(["pool", ":id", "delete"], (ctx, next) => { + ctx.controller = new PoolController(ctx, "delete"); + }); + router.enter(["pool", ":id"], (ctx, next) => { + ctx.controller = new PoolController(ctx, "summary"); + }); +}; diff --git a/client/js/controllers/pool_create_controller.js b/client/js/controllers/pool_create_controller.js new file mode 100644 index 0000000..7140aac --- /dev/null +++ b/client/js/controllers/pool_create_controller.js @@ -0,0 +1,65 @@ +"use strict"; + +const router = require("../router.js"); +const api = require("../api.js"); +const misc = require("../util/misc.js"); +const uri = require("../util/uri.js"); +const PoolCategoryList = require("../models/pool_category_list.js"); +const PoolCreateView = require("../views/pool_create_view.js"); +const EmptyView = require("../views/empty_view.js"); + +class PoolCreateController { + constructor(ctx) { + if (!api.hasPrivilege("pools:create")) { + this._view = new EmptyView(); + this._view.showError("You don't have privileges to create pools."); + return; + } + + PoolCategoryList.get().then( + (poolCategoriesResponse) => { + const categories = {}; + for (let category of poolCategoriesResponse.results) { + categories[category.name] = category.name; + } + + this._view = new PoolCreateView({ + canCreate: api.hasPrivilege("pools:create"), + categories: categories, + escapeColons: uri.escapeColons, + }); + + this._view.addEventListener("submit", (e) => + this._evtCreate(e) + ); + }, + (error) => { + this._view = new EmptyView(); + this._view.showError(error.message); + } + ); + } + + _evtCreate(e) { + this._view.clearMessages(); + this._view.disableForm(); + api.post(uri.formatApiLink("pool"), e.detail).then( + () => { + this._view.clearMessages(); + misc.disableExitConfirmation(); + const ctx = router.show(uri.formatClientLink("pools")); + ctx.controller.showSuccess("Pool created."); + }, + (error) => { + this._view.showError(error.message); + this._view.enableForm(); + } + ); + } +} + +module.exports = (router) => { + router.enter(["pool", "create"], (ctx, next) => { + ctx.controller = new PoolCreateController(ctx, "create"); + }); +}; diff --git a/client/js/controllers/pool_list_controller.js b/client/js/controllers/pool_list_controller.js new file mode 100644 index 0000000..91d655c --- /dev/null +++ b/client/js/controllers/pool_list_controller.js @@ -0,0 +1,119 @@ +"use strict"; + +const router = require("../router.js"); +const api = require("../api.js"); +const uri = require("../util/uri.js"); +const PoolList = require("../models/pool_list.js"); +const topNavigation = require("../models/top_navigation.js"); +const PageController = require("../controllers/page_controller.js"); +const PoolsHeaderView = require("../views/pools_header_view.js"); +const PoolsPageView = require("../views/pools_page_view.js"); +const EmptyView = require("../views/empty_view.js"); + +const fields = [ + "id", + "names", + "posts", + "creationTime", + "postCount", + "category", +]; + +class PoolListController { + constructor(ctx) { + this._pageController = new PageController(); + + if (!api.hasPrivilege("pools:list")) { + this._view = new EmptyView(); + this._view.showError("You don't have privileges to view pools."); + return; + } + + this._ctx = ctx; + + topNavigation.activate("pools"); + topNavigation.setTitle("Listing pools"); + + this._headerView = new PoolsHeaderView({ + hostNode: this._pageController.view.pageHeaderHolderNode, + parameters: ctx.parameters, + canCreate: api.hasPrivilege("pools:create"), + canEditPoolCategories: api.hasPrivilege("poolCategories:edit"), + }); + this._headerView.addEventListener( + "submit", + (e) => this._evtSubmit(e), + "navigate", + (e) => this._evtNavigate(e) + ); + + this._syncPageController(); + } + + showSuccess(message) { + this._pageController.showSuccess(message); + } + + showError(message) { + this._pageController.showError(message); + } + + _evtSubmit(e) { + this._view.clearMessages(); + this._view.disableForm(); + e.detail.pool.save().then( + () => { + this._installView(e.detail.pool, "edit"); + this._view.showSuccess("Pool created."); + router.replace( + uri.formatClientLink("pool", e.detail.pool.id, "edit"), + null, + false + ); + }, + (error) => { + this._view.showError(error.message); + this._view.enableForm(); + } + ); + } + + _evtNavigate(e) { + router.showNoDispatch( + uri.formatClientLink("pools", e.detail.parameters) + ); + Object.assign(this._ctx.parameters, e.detail.parameters); + this._syncPageController(); + } + + _syncPageController() { + this._pageController.run({ + parameters: this._ctx.parameters, + defaultLimit: 50, + getClientUrlForPage: (offset, limit) => { + const parameters = Object.assign({}, this._ctx.parameters, { + offset: offset, + limit: limit, + }); + return uri.formatClientLink("pools", parameters); + }, + requestPage: (offset, limit) => { + return PoolList.search( + this._ctx.parameters.query, + offset, + limit, + fields + ); + }, + pageRenderer: (pageCtx) => { + return new PoolsPageView(pageCtx); + }, + }); + } +} + +module.exports = (router) => { + router.enter(["pools"], (ctx, next) => { + ctx.controller = new PoolListController(ctx); + }); +}; diff --git a/client/js/controllers/post_detail_controller.js b/client/js/controllers/post_detail_controller.js index 47d5430..9bab1ce 100644 --- a/client/js/controllers/post_detail_controller.js +++ b/client/js/controllers/post_detail_controller.js @@ -1,28 +1,33 @@ -'use strict'; +"use strict"; -const router = require('../router.js'); -const api = require('../api.js'); -const misc = require('../util/misc.js'); -const uri = require('../util/uri.js'); -const settings = require('../models/settings.js'); -const Post = require('../models/post.js'); -const PostList = require('../models/post_list.js'); -const PostDetailView = require('../views/post_detail_view.js'); -const BasePostController = require('./base_post_controller.js'); -const EmptyView = require('../views/empty_view.js'); +const router = require("../router.js"); +const api = require("../api.js"); +const misc = require("../util/misc.js"); +const uri = require("../util/uri.js"); +const settings = require("../models/settings.js"); +const Post = require("../models/post.js"); +const PostList = require("../models/post_list.js"); +const PostDetailView = require("../views/post_detail_view.js"); +const BasePostController = require("./base_post_controller.js"); +const EmptyView = require("../views/empty_view.js"); class PostDetailController extends BasePostController { constructor(ctx, section) { super(ctx); - Post.get(ctx.parameters.id).then(post => { - this._id = ctx.parameters.id; - post.addEventListener('change', e => this._evtSaved(e, section)); - this._installView(post, section); - }, error => { - this._view = new EmptyView(); - this._view.showError(error.message); - }); + Post.get(ctx.parameters.id).then( + (post) => { + this._id = ctx.parameters.id; + post.addEventListener("change", (e) => + this._evtSaved(e, section) + ); + this._installView(post, section); + }, + (error) => { + this._view = new EmptyView(); + this._view.showError(error.message); + } + ); } showSuccess(message) { @@ -33,56 +38,68 @@ class PostDetailController extends BasePostController { this._view = new PostDetailView({ post: post, section: section, - canMerge: api.hasPrivilege('posts:merge'), + canMerge: api.hasPrivilege("posts:merge"), }); - this._view.addEventListener('select', e => this._evtSelect(e)); - this._view.addEventListener('merge', e => this._evtMerge(e)); + this._view.addEventListener("select", (e) => this._evtSelect(e)); + this._view.addEventListener("merge", (e) => this._evtMerge(e)); } _evtSelect(e) { this._view.clearMessages(); this._view.disableForm(); - Post.get(e.detail.postId).then(post => { - this._view.selectPost(post); - this._view.enableForm(); - }, error => { - this._view.showError(error.message); - this._view.enableForm(); - }); + Post.get(e.detail.postId).then( + (post) => { + this._view.selectPost(post); + this._view.enableForm(); + }, + (error) => { + this._view.showError(error.message); + this._view.enableForm(); + } + ); } _evtSaved(e, section) { misc.disableExitConfirmation(); if (this._id !== e.detail.post.id) { router.replace( - uri.formatClientLink('post', e.detail.post.id, section), - null, false); + uri.formatClientLink("post", e.detail.post.id, section), + null, + false + ); } } _evtMerge(e) { this._view.clearMessages(); this._view.disableForm(); - e.detail.post.merge(e.detail.targetPost.id, e.detail.useOldContent) - .then(() => { - this._installView(e.detail.post, 'merge'); - this._view.showSuccess('Post merged.'); - router.replace( - uri.formatClientLink( - 'post', e.detail.targetPost.id, 'merge'), - null, false); - }, error => { - this._view.showError(error.message); - this._view.enableForm(); - }); + e.detail.post + .merge(e.detail.targetPost.id, e.detail.useOldContent) + .then( + () => { + this._installView(e.detail.post, "merge"); + this._view.showSuccess("Post merged."); + router.replace( + uri.formatClientLink( + "post", + e.detail.targetPost.id, + "merge" + ), + null, + false + ); + }, + (error) => { + this._view.showError(error.message); + this._view.enableForm(); + } + ); } } -module.exports = router => { - router.enter( - ['post', ':id', 'merge'], - (ctx, next) => { - ctx.controller = new PostDetailController(ctx, 'merge'); - }); +module.exports = (router) => { + router.enter(["post", ":id", "merge"], (ctx, next) => { + ctx.controller = new PostDetailController(ctx, "merge"); + }); }; diff --git a/client/js/controllers/post_list_controller.js b/client/js/controllers/post_list_controller.js index 9dffbf2..af4bd8e 100644 --- a/client/js/controllers/post_list_controller.js +++ b/client/js/controllers/post_list_controller.js @@ -1,48 +1,58 @@ -'use strict'; +"use strict"; -const router = require('../router.js'); -const api = require('../api.js'); -const settings = require('../models/settings.js'); -const uri = require('../util/uri.js'); -const tags = require('../tags.js'); -const PostList = require('../models/post_list.js'); -const topNavigation = require('../models/top_navigation.js'); -const PageController = require('../controllers/page_controller.js'); -const PostsHeaderView = require('../views/posts_header_view.js'); -const PostsPageView = require('../views/posts_page_view.js'); -const EmptyView = require('../views/empty_view.js'); +const router = require("../router.js"); +const api = require("../api.js"); +const settings = require("../models/settings.js"); +const tags = require("../tags.js"); +const uri = require("../util/uri.js"); +const PostList = require("../models/post_list.js"); +const topNavigation = require("../models/top_navigation.js"); +const PageController = require("../controllers/page_controller.js"); +const PostsHeaderView = require("../views/posts_header_view.js"); +const PostsPageView = require("../views/posts_page_view.js"); +const EmptyView = require("../views/empty_view.js"); const fields = [ - 'id', 'thumbnailUrl', 'type', 'safety', - 'score', 'favoriteCount', 'commentCount', 'tags', 'version']; + "id", + "thumbnailUrl", + "type", + "safety", + "score", + "favoriteCount", + "commentCount", + "tags", + "version", +]; class PostListController { constructor(ctx) { - if (!api.hasPrivilege('posts:list')) { + this._pageController = new PageController(); + + if (!api.hasPrivilege("posts:list")) { this._view = new EmptyView(); - this._view.showError('You don\'t have privileges to view posts.'); + this._view.showError("You don't have privileges to view posts."); return; } - topNavigation.activate('posts'); - topNavigation.setTitle('Listing posts'); - this._ctx = ctx; - this._pageController = new PageController(); + + topNavigation.activate("posts"); + topNavigation.setTitle("Listing posts"); this._headerView = new PostsHeaderView({ hostNode: this._pageController.view.pageHeaderHolderNode, parameters: ctx.parameters, enableSafety: api.safetyEnabled(), - canBulkEditTags: api.hasPrivilege('posts:bulk-edit:tags'), - canBulkEditSafety: api.hasPrivilege('posts:bulk-edit:safety'), - canViewMetrics: api.hasPrivilege('metrics:list'), + canBulkEditTags: api.hasPrivilege("posts:bulk-edit:tags"), + canBulkEditSafety: api.hasPrivilege("posts:bulk-edit:safety"), + canViewMetrics: api.hasPrivilege("metrics:list"), bulkEdit: { tags: this._bulkEditTags, }, }); - this._headerView.addEventListener( - 'navigate', e => this._evtNavigate(e)); + this._headerView.addEventListener("navigate", (e) => + this._evtNavigate(e) + ); this._syncPageController(); } @@ -56,32 +66,35 @@ class PostListController { } get _bulkEditTags() { - return (this._ctx.parameters.tag || '').split(/\s+/).filter(s => s); + return (this._ctx.parameters.tag || "").split(/\s+/).filter((s) => s); } get _bulkEditRelationsIds() { - return (this._ctx.parameters.relations || '').split(/\s+/).filter(s => s).map(id => parseInt(id)); + return (this._ctx.parameters.relations || "").split(/\s+/).filter(s => s) + .map(id => parseInt(id)); } _evtNavigate(e) { router.showNoDispatch( - uri.formatClientLink('posts', e.detail.parameters)); + uri.formatClientLink("posts", e.detail.parameters) + ); Object.assign(this._ctx.parameters, e.detail.parameters); - this._bulkEditTags.map(tagName => + this._bulkEditTags.map((tagName) => tags.resolveTagAndCategory(tagName) - .catch(error => window.alert(error.message)) + .catch((error) => window.alert(error.message)) ); this._syncPageController(); } _evtTag(e) { Promise.all( - this._bulkEditTags.map(tag => { + this._bulkEditTags.map((tag) => { let tagData = tags.parseTagAndCategory(tag); return e.detail.post.tags.addByName(tagData.name); - })) - .then(() => e.detail.post.save()) - .catch(error => window.alert(error.message)); + }) + ) + .then(e.detail.post.save()) + .catch((error) => window.alert(error.message)); } _evtUntag(e) { @@ -89,23 +102,25 @@ class PostListController { let tagData = tags.parseTagAndCategory(tag); e.detail.post.tags.removeByName(tagData.name); } - e.detail.post.save().catch(error => window.alert(error.message)); + e.detail.post.save().catch((error) => window.alert(error.message)); } _evtChangeSafety(e) { e.detail.post.safety = e.detail.safety; - e.detail.post.save().catch(error => window.alert(error.message)); + e.detail.post.save().catch((error) => window.alert(error.message)); } _evtAddRelation(e) { let addedPost = e.detail.post; - // If we're just starting to build this instance of relations, use the first post's list: + // If we're just starting to build this instance of relations, + // use the first post's list: let relations = this._bulkEditRelationsIds || addedPost.relations; for (let relationId of relations) { addedPost.relations.push(relationId); } - // Only save the updated post, the relationship will propagate to others automatically - addedPost.save().catch(error => window.alert(error.message)); + // Only save the updated post, the relationship will propagate to + // others automatically + addedPost.save().catch((error) => window.alert(error.message)); relations.push(addedPost.id); this._updateRelationsForBulkEdit(relations); @@ -115,16 +130,16 @@ class PostListController { let removedPost = e.detail.post; let relations = this._bulkEditRelationsIds; removedPost.relations = removedPost.relations - .filter(id => !relations.some(relationId => relationId == id)); + .filter((id)=> !relations.some((relationId) => relationId == id)); // Only save the updated post, the relationship will propagate to others automatically - removedPost.save().catch(error => window.alert(error.message)); - relations = relations.filter(id => id != removedPost.id); + removedPost.save().catch((error) => window.alert(error.message)); + relations = relations.filter((id) => id != removedPost.id); this._updateRelationsForBulkEdit(relations); } _updateRelationsForBulkEdit(relations) { //Whitespace instead of empty string so that it stays part of the query: - this._ctx.parameters.relations = relations.join(' ') || ' '; + this._ctx.parameters.relations = relations.join(" ") || " "; } _syncPageController() { @@ -132,42 +147,56 @@ class PostListController { parameters: this._ctx.parameters, defaultLimit: parseInt(settings.get().postsPerPage), getClientUrlForPage: (offset, limit) => { - const parameters = Object.assign( - {}, this._ctx.parameters, {offset: offset, limit: limit}); - return uri.formatClientLink('posts', parameters); + const parameters = Object.assign({}, this._ctx.parameters, { + offset: offset, + limit: limit, + }); + return uri.formatClientLink("posts", parameters); }, requestPage: (offset, limit) => { let query = uri.getPostsQuery(this._ctx.parameters); - return PostList.search(query, offset, limit, fields, this._ctx.parameters.cachenumber); + return PostList.search( + query, + offset, + limit, + fields, + this._ctx.parameters.cachenumber + ); }, - pageRenderer: pageCtx => { + pageRenderer: (pageCtx) => { Object.assign(pageCtx, { - canViewPosts: api.hasPrivilege('posts:view'), - canBulkEditTags: api.hasPrivilege('posts:bulk-edit:tags'), - canBulkEditSafety: api.hasPrivilege('posts:bulk-edit:safety'), - canViewMetrics: api.hasPrivilege('metrics:list'), + canViewPosts: api.hasPrivilege("posts:view"), + canBulkEditTags: api.hasPrivilege("posts:bulk-edit:tags"), + canBulkEditSafety: api.hasPrivilege( + "posts:bulk-edit:safety" + ), + canViewMetrics: api.hasPrivilege("metrics:list"), bulkEdit: { tags: this._bulkEditTags, relations: this._ctx.parameters.relations, }, + postFlow: settings.get().postFlow, }); const view = new PostsPageView(pageCtx); - view.addEventListener('tag', e => this._evtTag(e)); - view.addEventListener('untag', e => this._evtUntag(e)); - view.addEventListener( - 'changeSafety', e => this._evtChangeSafety(e)); - view.addEventListener( - 'addRelation', e => this._evtAddRelation(e)); - view.addEventListener( - 'removeRelation', e => this._evtRemoveRelation(e)); + view.addEventListener("tag", (e) => this._evtTag(e)); + view.addEventListener("untag", (e) => this._evtUntag(e)); + view.addEventListener("changeSafety", (e) => + this._evtChangeSafety(e) + ); + view.addEventListener("addRelation", (e) => + this._evtAddRelation(e) + ); + view.addEventListener("removeRelation", (e) => + this._evtRemoveRelation(e) + ); return view; }, }); } } -module.exports = router => { - router.enter( - ['posts'], - (ctx, next) => { ctx.controller = new PostListController(ctx); }); +module.exports = (router) => { + router.enter(["posts"], (ctx, next) => { + ctx.controller = new PostListController(ctx); + }); }; diff --git a/client/js/controllers/post_main_controller.js b/client/js/controllers/post_main_controller.js index 046d5d8..d473788 100644 --- a/client/js/controllers/post_main_controller.js +++ b/client/js/controllers/post_main_controller.js @@ -1,17 +1,17 @@ -'use strict'; +"use strict"; -const router = require('../router.js'); -const api = require('../api.js'); -const uri = require('../util/uri.js'); -const misc = require('../util/misc.js'); -const settings = require('../models/settings.js'); -const Comment = require('../models/comment.js'); -const Tag = require('../models/tag.js'); -const Post = require('../models/post.js'); -const PostList = require('../models/post_list.js'); -const PostMainView = require('../views/post_main_view.js'); -const BasePostController = require('./base_post_controller.js'); -const EmptyView = require('../views/empty_view.js'); +const router = require("../router.js"); +const api = require("../api.js"); +const uri = require("../util/uri.js"); +const misc = require("../util/misc.js"); +const settings = require("../models/settings.js"); +const Comment = require("../models/comment.js"); +const Post = require("../models/post.js"); +const PostList = require("../models/post_list.js"); +const Tag = require("../models/tag.js"); +const PostMainView = require("../views/post_main_view.js"); +const BasePostController = require("./base_post_controller.js"); +const EmptyView = require("../views/empty_view.js"); class PostMainController extends BasePostController { constructor(ctx, editMode) { @@ -20,65 +20,99 @@ class PostMainController extends BasePostController { let parameters = ctx.parameters; let query = uri.getPostsQuery(parameters); Promise.all([ - Post.get(ctx.parameters.id), - PostList.getAround(ctx.parameters.id, query, ctx.parameters.cachenumber), - ]).then(responses => { - const [post, aroundResponse] = responses; - this._post = post; - this._view = new PostMainView({ - post: post, - editMode: editMode, - prevPostId: aroundResponse.prev ? aroundResponse.prev.id : null, - nextPostId: aroundResponse.next ? aroundResponse.next.id : null, - randomPostId: aroundResponse.random ? aroundResponse.random.id : null, - canEditPosts: api.hasPrivilege('posts:edit'), - canDeletePosts: api.hasPrivilege('posts:delete'), - canFeaturePosts: api.hasPrivilege('posts:feature'), - canListComments: api.hasPrivilege('comments:list'), - canCreateComments: api.hasPrivilege('comments:create'), - parameters: parameters, - }); + Post.get(ctx.parameters.id), + PostList.getAround( + ctx.parameters.id, + query, + ctx.parameters.cachenumber + ), + ]).then( + (responses) => { + const [post, aroundResponse] = responses; + this._post = post; + this._view = new PostMainView({ + post: post, + editMode: editMode, + prevPostId: aroundResponse.prev + ? aroundResponse.prev.id + : null, + nextPostId: aroundResponse.next + ? aroundResponse.next.id + : null, + randomPostId: aroundResponse.random + ? aroundResponse.random.id + : null, + canEditPosts: api.hasPrivilege("posts:edit"), + canDeletePosts: api.hasPrivilege("posts:delete"), + canFeaturePosts: api.hasPrivilege("posts:feature"), + canListComments: api.hasPrivilege("comments:list"), + canCreateComments: api.hasPrivilege("comments:create"), + parameters: parameters, + }); - if (this._view.sidebarControl) { - this._view.sidebarControl.addEventListener( - 'favorite', e => this._evtFavoritePost(e)); - this._view.sidebarControl.addEventListener( - 'unfavorite', e => this._evtUnfavoritePost(e)); - this._view.sidebarControl.addEventListener( - 'score', e => this._evtScorePost(e)); - this._view.sidebarControl.addEventListener( - 'fitModeChange', e => this._evtFitModeChange(e)); - this._view.sidebarControl.addEventListener( - 'change', e => this._evtPostChange(e)); - this._view.sidebarControl.addEventListener( - 'submit', e => this._evtUpdatePost(e)); - this._view.sidebarControl.addEventListener( - 'feature', e => this._evtFeaturePost(e)); - this._view.sidebarControl.addEventListener( - 'delete', e => this._evtDeletePost(e)); - this._view.sidebarControl.addEventListener( - 'merge', e => this._evtMergePost(e)); - } + if (this._view.sidebarControl) { + this._view.sidebarControl.addEventListener( + "favorite", + (e) => this._evtFavoritePost(e) + ); + this._view.sidebarControl.addEventListener( + "unfavorite", + (e) => this._evtUnfavoritePost(e) + ); + this._view.sidebarControl.addEventListener("score", (e) => + this._evtScorePost(e) + ); + this._view.sidebarControl.addEventListener( + "fitModeChange", + (e) => this._evtFitModeChange(e) + ); + this._view.sidebarControl.addEventListener("change", (e) => + this._evtPostChange(e) + ); + this._view.sidebarControl.addEventListener("submit", (e) => + this._evtUpdatePost(e) + ); + this._view.sidebarControl.addEventListener( + "feature", + (e) => this._evtFeaturePost(e) + ); + this._view.sidebarControl.addEventListener("delete", (e) => + this._evtDeletePost(e) + ); + this._view.sidebarControl.addEventListener("merge", (e) => + this._evtMergePost(e) + ); + } - if (this._view.commentControl) { - this._view.commentControl.addEventListener( - 'change', e => this._evtCommentChange(e)); - this._view.commentControl.addEventListener( - 'submit', e => this._evtCreateComment(e)); - } + if (this._view.commentControl) { + this._view.commentControl.addEventListener("change", (e) => + this._evtCommentChange(e) + ); + this._view.commentControl.addEventListener("submit", (e) => + this._evtCreateComment(e) + ); + } - if (this._view.commentListControl) { - this._view.commentListControl.addEventListener( - 'submit', e => this._evtUpdateComment(e)); - this._view.commentListControl.addEventListener( - 'score', e => this._evtScoreComment(e)); - this._view.commentListControl.addEventListener( - 'delete', e => this._evtDeleteComment(e)); + if (this._view.commentListControl) { + this._view.commentListControl.addEventListener( + "submit", + (e) => this._evtUpdateComment(e) + ); + this._view.commentListControl.addEventListener( + "score", + (e) => this._evtScoreComment(e) + ); + this._view.commentListControl.addEventListener( + "delete", + (e) => this._evtDeleteComment(e) + ); + } + }, + (error) => { + this._view = new EmptyView(); + this._view.showError(error.message); } - }, error => { - this._view = new EmptyView(); - this._view.showError(error.message); - }); + ); } _evtFitModeChange(e) { @@ -90,32 +124,36 @@ class PostMainController extends BasePostController { _evtFeaturePost(e) { this._view.sidebarControl.disableForm(); this._view.sidebarControl.clearMessages(); - e.detail.post.feature() - .then(() => { - this._view.sidebarControl.showSuccess('Post featured.'); + e.detail.post.feature().then( + () => { + this._view.sidebarControl.showSuccess("Post featured."); this._view.sidebarControl.enableForm(); - }, error => { + }, + (error) => { this._view.sidebarControl.showError(error.message); this._view.sidebarControl.enableForm(); - }); + } + ); } _evtMergePost(e) { - router.show(uri.formatClientLink('post', e.detail.post.id, 'merge')); + router.show(uri.formatClientLink("post", e.detail.post.id, "merge")); } _evtDeletePost(e) { this._view.sidebarControl.disableForm(); this._view.sidebarControl.clearMessages(); - e.detail.post.delete() - .then(() => { + e.detail.post.delete().then( + () => { misc.disableExitConfirmation(); - const ctx = router.show(uri.formatClientLink('posts')); - ctx.controller.showSuccess('Post deleted.'); - }, error => { + const ctx = router.show(uri.formatClientLink("posts")); + ctx.controller.showSuccess("Post deleted."); + }, + (error) => { this._view.sidebarControl.showError(error.message); this._view.sidebarControl.enableForm(); - }); + } + ); } _evtUpdatePost(e) { @@ -140,14 +178,17 @@ class PostMainController extends BasePostController { if (e.detail.source !== undefined) { post.source = e.detail.source; } - post.save() - .then(() => { + post.save().then( + () => { + this._view.sidebarControl.showSuccess("Post saved."); this._view.sidebarControl.enableForm(); misc.disableExitConfirmation(); - }, error => { + }, + (error) => { this._view.sidebarControl.showError(error.message); this._view.sidebarControl.enableForm(); - }); + } + ); } _evtPostChange(e) { @@ -162,79 +203,82 @@ class PostMainController extends BasePostController { this._view.commentControl.disableForm(); const comment = Comment.create(this._post.id); comment.text = e.detail.text; - comment.save() - .then(() => { + comment.save().then( + () => { this._post.comments.add(comment); this._view.commentControl.exitEditMode(); this._view.commentControl.enableForm(); misc.disableExitConfirmation(); - }, error => { + }, + (error) => { this._view.commentControl.showError(error.message); this._view.commentControl.enableForm(); - }); + } + ); } _evtUpdateComment(e) { // TODO: disable form e.detail.comment.text = e.detail.text; - e.detail.comment.save() - .catch(error => { - e.detail.target.showError(error.message); - // TODO: enable form - }); + e.detail.comment.save().catch((error) => { + e.detail.target.showError(error.message); + // TODO: enable form + }); } _evtScoreComment(e) { - e.detail.comment.setScore(e.detail.score) - .catch(error => window.alert(error.message)); + e.detail.comment + .setScore(e.detail.score) + .catch((error) => window.alert(error.message)); } _evtDeleteComment(e) { - e.detail.comment.delete() - .catch(error => window.alert(error.message)); + e.detail.comment + .delete() + .catch((error) => window.alert(error.message)); } _evtScorePost(e) { - if (!api.hasPrivilege('posts:score')) { + if (!api.hasPrivilege("posts:score")) { return; } - e.detail.post.setScore(e.detail.score) - .catch(error => window.alert(error.message)); + e.detail.post + .setScore(e.detail.score) + .catch((error) => window.alert(error.message)); } _evtFavoritePost(e) { - if (!api.hasPrivilege('posts:favorite')) { + if (!api.hasPrivilege("posts:favorite")) { return; } - e.detail.post.addToFavorites() - .catch(error => window.alert(error.message)); + e.detail.post + .addToFavorites() + .catch((error) => window.alert(error.message)); } _evtUnfavoritePost(e) { - if (!api.hasPrivilege('posts:favorite')) { + if (!api.hasPrivilege("posts:favorite")) { return; } - e.detail.post.removeFromFavorites() - .catch(error => window.alert(error.message)); + e.detail.post + .removeFromFavorites() + .catch((error) => window.alert(error.message)); } } -module.exports = router => { - router.enter(['post', ':id', 'edit'], - (ctx, next) => { - // restore parameters from history state - if (ctx.state.parameters) { - Object.assign(ctx.parameters, ctx.state.parameters); - } - ctx.controller = new PostMainController(ctx, true); - }); - router.enter( - ['post', ':id'], - (ctx, next) => { - // restore parameters from history state - if (ctx.state.parameters) { - Object.assign(ctx.parameters, ctx.state.parameters); - } - ctx.controller = new PostMainController(ctx, false); - }); +module.exports = (router) => { + router.enter(["post", ":id", "edit"], (ctx, next) => { + // restore parameters from history state + if (ctx.state.parameters) { + Object.assign(ctx.parameters, ctx.state.parameters); + } + ctx.controller = new PostMainController(ctx, true); + }); + router.enter(["post", ":id"], (ctx, next) => { + // restore parameters from history state + if (ctx.state.parameters) { + Object.assign(ctx.parameters, ctx.state.parameters); + } + ctx.controller = new PostMainController(ctx, false); + }); }; diff --git a/client/js/controllers/post_upload_controller.js b/client/js/controllers/post_upload_controller.js index f176803..360dd4f 100644 --- a/client/js/controllers/post_upload_controller.js +++ b/client/js/controllers/post_upload_controller.js @@ -1,42 +1,42 @@ -'use strict'; +"use strict"; -const api = require('../api.js'); -const router = require('../router.js'); -const uri = require('../util/uri.js'); -const misc = require('../util/misc.js'); -const progress = require('../util/progress.js'); -const topNavigation = require('../models/top_navigation.js'); -const settings = require('../models/settings.js'); -const Post = require('../models/post.js'); -const Tag = require('../models/tag.js'); -const PostUploadView = require('../views/post_upload_view.js'); -const EmptyView = require('../views/empty_view.js'); +const api = require("../api.js"); +const router = require("../router.js"); +const uri = require("../util/uri.js"); +const misc = require("../util/misc.js"); +const progress = require("../util/progress.js"); +const settings = require("../models/settings.js"); +const topNavigation = require("../models/top_navigation.js"); +const Post = require("../models/post.js"); +const Tag = require("../models/tag.js"); +const PostUploadView = require("../views/post_upload_view.js"); +const EmptyView = require("../views/empty_view.js"); const genericErrorMessage = - 'One of the posts needs your attention; ' + + "One of the posts needs your attention; " + 'click "resume upload" when you\'re ready.'; class PostUploadController { constructor() { this._lastCancellablePromise = null; - if (!api.hasPrivilege('posts:create')) { + if (!api.hasPrivilege("posts:create")) { this._view = new EmptyView(); - this._view.showError('You don\'t have privileges to upload posts.'); + this._view.showError("You don't have privileges to upload posts."); return; } - topNavigation.activate('upload'); - topNavigation.setTitle('Upload'); + topNavigation.activate("upload"); + topNavigation.setTitle("Upload"); this._view = new PostUploadView({ - canUploadAnonymously: api.hasPrivilege('posts:create:anonymous'), - canViewPosts: api.hasPrivilege('posts:view'), + canUploadAnonymously: api.hasPrivilege("posts:create:anonymous"), + canViewPosts: api.hasPrivilege("posts:view"), enableSafety: api.safetyEnabled(), defaultSafety: settings.get().uploadSafety }); - this._view.addEventListener('change', e => this._evtChange(e)); - this._view.addEventListener('submit', e => this._evtSubmit(e)); - this._view.addEventListener('cancel', e => this._evtCancel(e)); + this._view.addEventListener("change", (e) => this._evtChange(e)); + this._view.addEventListener("submit", (e) => this._evtSubmit(e)); + this._view.addEventListener("cancel", (e) => this._evtCancel(e)); } _evtChange(e) { @@ -59,98 +59,127 @@ class PostUploadController { this._view.clearMessages(); const tagErrors = []; // to be displayed after all uploads - e.detail.uploadables.reduce( - (promise, uploadable) => - promise.then(() => this._uploadSinglePost( - uploadable, e.detail.skipDuplicates, e.detail.copyTagsToOriginals)), - Promise.resolve()) - .then(() => { + e.detail.uploadables + .reduce( + (promise, uploadable) => + promise.then(() => + this._uploadSinglePost( + uploadable, + e.detail.skipDuplicates, + e.detail.copyTagsToOriginals + ) + ), + Promise.resolve() + ) + .then( + () => { this._view.clearMessages(); misc.disableExitConfirmation(); - const ctx = router.show(uri.formatClientLink('posts')); - ctx.controller.showSuccess('Posts uploaded.'); + const ctx = router.show(uri.formatClientLink("posts")); + ctx.controller.showSuccess("Posts uploaded."); for (let tagError of tagErrors) { ctx.controller.showError(tagError); } - }, error => { + }, + (error) => { if (error.uploadable) { if (error.similarPosts) { error.uploadable.lookalikes = error.similarPosts; this._view.updateUploadable(error.uploadable); this._view.showInfo(genericErrorMessage); this._view.showInfo( - error.message, error.uploadable); + error.message, + error.uploadable + ); } else { this._view.showError(genericErrorMessage); this._view.showError( - error.message, error.uploadable); + error.message, + error.uploadable + ); } } else { this._view.showError(error.message); } this._view.enableForm(); - }); + } + ); } _uploadSinglePost(uploadable, skipDuplicates, copyTagsToOriginals) { progress.start(); let reverseSearchPromise = Promise.resolve(); if (!uploadable.lookalikesConfirmed) { - reverseSearchPromise = - Post.reverseSearch(uploadable.url || uploadable.file); + reverseSearchPromise = Post.reverseSearch( + uploadable.url || uploadable.file + ); } this._lastCancellablePromise = reverseSearchPromise; - return reverseSearchPromise.then(searchResult => { - if (searchResult) { - // notify about exact duplicate - if (searchResult.exactPost) { - if (copyTagsToOriginals) { - return this._copyTagsToOriginalAndSave(uploadable, searchResult.exactPost); - } else if (skipDuplicates) { - this._view.removeUploadable(uploadable); - return Promise.resolve(); - } else { - let error = new Error('Post already uploaded ' + - `(@${searchResult.exactPost.id})`); + return reverseSearchPromise + .then((searchResult) => { + if (searchResult) { + // notify about exact duplicate + if (searchResult.exactPost) { + if (copyTagsToOriginals) { + return this._copyTagsToOriginalAndSave( + uploadable, searchResult.exactPost + ); + } else if (skipDuplicates) { + this._view.removeUploadable(uploadable); + return Promise.resolve(); + } else { + let error = new Error( + "Post already uploaded " + + `(@${searchResult.exactPost.id})` + ); + error.uploadable = uploadable; + error.similarPosts = [ + { + distance: 0, + post: searchResult.exactPost + } + ]; + return Promise.reject(error); + } + } + + // notify about similar posts + if (searchResult.similarPosts.length) { + let error = new Error( + `Found ${searchResult.similarPosts.length} similar ` + + "posts.\nYou can resume or discard this upload." + ); error.uploadable = uploadable; - error.similarPosts = [ - {distance: 0, post: searchResult.exactPost} - ]; + error.similarPosts = searchResult.similarPosts; return Promise.reject(error); + } else if (uploadable.foundOriginal) { + return this._copyTagsToOriginalAndSave( + uploadable, uploadable.foundOriginal + ); } } - // notify about similar posts - if (searchResult.similarPosts.length) { - let error = new Error( - `Found ${searchResult.similarPosts.length} similar ` + - 'posts.\nYou can resume or discard this upload.'); - error.uploadable = uploadable; - error.similarPosts = searchResult.similarPosts; - return Promise.reject(error); - } - } else if (uploadable.foundOriginal) { - return this._copyTagsToOriginalAndSave(uploadable, uploadable.foundOriginal); - } - - // no duplicates, proceed with saving - let post = this._uploadableToPost(uploadable); - let savePromise = post.save(uploadable.anonymous) - .then(() => { + // no duplicates, proceed with saving + let post = this._uploadableToPost(uploadable); + let savePromise = post.save(uploadable.anonymous).then(() => { this._view.removeUploadable(uploadable); return Promise.resolve(); }); - this._lastCancellablePromise = savePromise; - return savePromise; - }).then(result => { - progress.done(); - return Promise.resolve(result); - }, error => { - error.uploadable = uploadable; - progress.done(); - return Promise.reject(error); - }); + this._lastCancellablePromise = savePromise; + return savePromise; + }) + .then( + (result) => { + progress.done(); + return Promise.resolve(result); + }, + (error) => { + error.uploadable = uploadable; + progress.done(); + return Promise.reject(error); + } + ); } _uploadableToPost(uploadable) { @@ -164,23 +193,30 @@ class PostUploadController { } post.relations = uploadable.relations; post.newContent = uploadable.url || uploadable.file; + // if uploadable.source is ever going to be a valid field (e.g when setting source directly in the upload window) + // you'll need to change the line below to `post.source = uploadable.source || uploadable.url;` + if (uploadable.url) { + post.source = uploadable.url; + } return post; } _copyTagsToOriginalAndSave(uploadable, original) { uploadable.tags.map(tag => original.tags.addByName(tag)); let savePromise = original.save() - .then(() => { - this._view.removeUploadable(uploadable); - return Promise.resolve(); - }); + .then( + () => { + this._view.removeUploadable(uploadable); + return Promise.resolve(); + } + ); this._lastCancellablePromise = savePromise; return savePromise; } } -module.exports = router => { - router.enter(['upload'], (ctx, next) => { +module.exports = (router) => { + router.enter(["upload"], (ctx, next) => { ctx.controller = new PostUploadController(); }); }; diff --git a/client/js/controllers/settings_controller.js b/client/js/controllers/settings_controller.js index 224b205..8cf2c1c 100644 --- a/client/js/controllers/settings_controller.js +++ b/client/js/controllers/settings_controller.js @@ -1,28 +1,28 @@ -'use strict'; +"use strict"; -const settings = require('../models/settings.js'); -const topNavigation = require('../models/top_navigation.js'); -const SettingsView = require('../views/settings_view.js'); +const settings = require("../models/settings.js"); +const topNavigation = require("../models/top_navigation.js"); +const SettingsView = require("../views/settings_view.js"); class SettingsController { constructor() { - topNavigation.activate('settings'); - topNavigation.setTitle('Browsing settings'); + topNavigation.activate("settings"); + topNavigation.setTitle("Browsing settings"); this._view = new SettingsView({ settings: settings.get(), }); - this._view.addEventListener('submit', e => this._evtSubmit(e)); + this._view.addEventListener("submit", (e) => this._evtSubmit(e)); } _evtSubmit(e) { this._view.clearMessages(); settings.save(e.detail); - this._view.showSuccess('Settings saved.'); + this._view.showSuccess("Settings saved."); } -}; +} -module.exports = router => { - router.enter(['settings'], (ctx, next) => { +module.exports = (router) => { + router.enter(["settings"], (ctx, next) => { ctx.controller = new SettingsController(); }); }; diff --git a/client/js/controllers/snapshots_controller.js b/client/js/controllers/snapshots_controller.js index e64b9db..120b6cb 100644 --- a/client/js/controllers/snapshots_controller.js +++ b/client/js/controllers/snapshots_controller.js @@ -1,41 +1,43 @@ -'use strict'; +"use strict"; -const api = require('../api.js'); -const uri = require('../util/uri.js'); -const SnapshotList = require('../models/snapshot_list.js'); -const PageController = require('../controllers/page_controller.js'); -const topNavigation = require('../models/top_navigation.js'); -const SnapshotsPageView = require('../views/snapshots_page_view.js'); -const EmptyView = require('../views/empty_view.js'); +const api = require("../api.js"); +const uri = require("../util/uri.js"); +const SnapshotList = require("../models/snapshot_list.js"); +const PageController = require("../controllers/page_controller.js"); +const topNavigation = require("../models/top_navigation.js"); +const SnapshotsPageView = require("../views/snapshots_page_view.js"); +const EmptyView = require("../views/empty_view.js"); class SnapshotsController { constructor(ctx) { - if (!api.hasPrivilege('snapshots:list')) { + if (!api.hasPrivilege("snapshots:list")) { this._view = new EmptyView(); - this._view.showError('You don\'t have privileges to view history.'); + this._view.showError("You don't have privileges to view history."); return; } - topNavigation.activate(''); - topNavigation.setTitle('History'); + topNavigation.activate(""); + topNavigation.setTitle("History"); this._pageController = new PageController(); this._pageController.run({ parameters: ctx.parameters, defaultLimit: 25, getClientUrlForPage: (offset, limit) => { - const parameters = Object.assign( - {}, ctx.parameters, {offset: offset, limit: limit}); - return uri.formatClientLink('history', parameters); + const parameters = Object.assign({}, ctx.parameters, { + offset: offset, + limit: limit, + }); + return uri.formatClientLink("history", parameters); }, requestPage: (offset, limit) => { - return SnapshotList.search('', offset, limit); + return SnapshotList.search("", offset, limit); }, - pageRenderer: pageCtx => { + pageRenderer: (pageCtx) => { Object.assign(pageCtx, { - canViewPosts: api.hasPrivilege('posts:view'), - canViewUsers: api.hasPrivilege('users:view'), - canViewTags: api.hasPrivilege('tags:view'), + canViewPosts: api.hasPrivilege("posts:view"), + canViewUsers: api.hasPrivilege("users:view"), + canViewTags: api.hasPrivilege("tags:view"), }); return new SnapshotsPageView(pageCtx); }, @@ -43,7 +45,8 @@ class SnapshotsController { } } -module.exports = router => { - router.enter(['history'], - (ctx, next) => { ctx.controller = new SnapshotsController(ctx); }); +module.exports = (router) => { + router.enter(["history"], (ctx, next) => { + ctx.controller = new SnapshotsController(ctx); + }); }; diff --git a/client/js/controllers/tag_categories_controller.js b/client/js/controllers/tag_categories_controller.js index 49600cf..1ca743f 100644 --- a/client/js/controllers/tag_categories_controller.js +++ b/client/js/controllers/tag_categories_controller.js @@ -1,57 +1,68 @@ -'use strict'; +"use strict"; -const api = require('../api.js'); -const tags = require('../tags.js'); -const TagCategoryList = require('../models/tag_category_list.js'); -const topNavigation = require('../models/top_navigation.js'); -const TagCategoriesView = require('../views/tag_categories_view.js'); -const EmptyView = require('../views/empty_view.js'); +const api = require("../api.js"); +const tags = require("../tags.js"); +const TagCategoryList = require("../models/tag_category_list.js"); +const topNavigation = require("../models/top_navigation.js"); +const TagCategoriesView = require("../views/tag_categories_view.js"); +const EmptyView = require("../views/empty_view.js"); class TagCategoriesController { constructor() { - if (!api.hasPrivilege('tagCategories:list')) { + if (!api.hasPrivilege("tagCategories:list")) { this._view = new EmptyView(); this._view.showError( - 'You don\'t have privileges to view tag categories.'); + "You don't have privileges to view tag categories." + ); return; } - topNavigation.activate('tags'); - topNavigation.setTitle('Listing tags'); - TagCategoryList.get().then(response => { - this._tagCategories = response.results; - this._view = new TagCategoriesView({ - tagCategories: this._tagCategories, - canEditName: api.hasPrivilege('tagCategories:edit:name'), - canEditColor: api.hasPrivilege('tagCategories:edit:color'), - canDelete: api.hasPrivilege('tagCategories:delete'), - canCreate: api.hasPrivilege('tagCategories:create'), - canSetDefault: api.hasPrivilege('tagCategories:setDefault'), - }); - this._view.addEventListener('submit', e => this._evtSubmit(e)); - }, error => { - this._view = new EmptyView(); - this._view.showError(error.message); - }); + topNavigation.activate("tags"); + topNavigation.setTitle("Listing tags"); + TagCategoryList.get().then( + (response) => { + this._tagCategories = response.results; + this._view = new TagCategoriesView({ + tagCategories: this._tagCategories, + canEditName: api.hasPrivilege("tagCategories:edit:name"), + canEditColor: api.hasPrivilege("tagCategories:edit:color"), + canEditOrder: api.hasPrivilege("tagCategories:edit:order"), + canDelete: api.hasPrivilege("tagCategories:delete"), + canCreate: api.hasPrivilege("tagCategories:create"), + canSetDefault: api.hasPrivilege( + "tagCategories:setDefault" + ), + }); + this._view.addEventListener("submit", (e) => + this._evtSubmit(e) + ); + }, + (error) => { + this._view = new EmptyView(); + this._view.showError(error.message); + } + ); } _evtSubmit(e) { this._view.clearMessages(); this._view.disableForm(); - this._tagCategories.save() - .then(() => { + this._tagCategories.save().then( + () => { tags.refreshCategoryColorMap(); this._view.enableForm(); - this._view.showSuccess('Changes saved.'); - }, error => { + this._view.showSuccess("Changes saved."); + }, + (error) => { this._view.enableForm(); this._view.showError(error.message); - }); + } + ); } } -module.exports = router => { - router.enter(['tag-categories'], (ctx, next) => { +module.exports = (router) => { + router.enter(["tag-categories"], (ctx, next) => { ctx.controller = new TagCategoriesController(ctx, next); }); }; diff --git a/client/js/controllers/tag_controller.js b/client/js/controllers/tag_controller.js index b1d7ca3..494137c 100644 --- a/client/js/controllers/tag_controller.js +++ b/client/js/controllers/tag_controller.js @@ -1,69 +1,90 @@ -'use strict'; +"use strict"; -const router = require('../router.js'); -const api = require('../api.js'); -const misc = require('../util/misc.js'); -const uri = require('../util/uri.js'); -const Tag = require('../models/tag.js'); -const TagCategoryList = require('../models/tag_category_list.js'); -const Metric = require('../models/metric.js'); -const topNavigation = require('../models/top_navigation.js'); -const TagView = require('../views/tag_view.js'); -const EmptyView = require('../views/empty_view.js'); +const router = require("../router.js"); +const api = require("../api.js"); +const Metric = require("../models/metric.js"); +const misc = require("../util/misc.js"); +const uri = require("../util/uri.js"); +const Tag = require("../models/tag.js"); +const TagCategoryList = require("../models/tag_category_list.js"); +const topNavigation = require("../models/top_navigation.js"); +const TagView = require("../views/tag_view.js"); +const EmptyView = require("../views/empty_view.js"); class TagController { constructor(ctx, section) { - if (!api.hasPrivilege('tags:view')) { + if (!api.hasPrivilege("tags:view")) { this._view = new EmptyView(); - this._view.showError('You don\'t have privileges to view tags.'); + this._view.showError("You don't have privileges to view tags."); return; } Promise.all([ TagCategoryList.get(), Tag.get(ctx.parameters.name), - ]).then(responses => { - const [tagCategoriesResponse, tag] = responses; + ]).then( + (responses) => { + const [tagCategoriesResponse, tag] = responses; - topNavigation.activate('tags'); - topNavigation.setTitle('Tag #' + tag.names[0]); + topNavigation.activate("tags"); + topNavigation.setTitle("Tag #" + tag.names[0]); - this._name = ctx.parameters.name; - tag.addEventListener('change', e => this._evtSaved(e, section)); + this._name = ctx.parameters.name; + tag.addEventListener("change", (e) => + this._evtSaved(e, section) + ); - const categories = {}; - for (let category of tagCategoriesResponse.results) { - categories[category.name] = category.name; - } + const categories = {}; + for (let category of tagCategoriesResponse.results) { + categories[category.name] = category.name; + } - this._view = new TagView({ - tag: tag, - section: section, - canEditAnything: api.hasPrivilege('tags:edit'), - canEditNames: api.hasPrivilege('tags:edit:names'), - canEditCategory: api.hasPrivilege('tags:edit:category'), - canEditImplications: api.hasPrivilege('tags:edit:implications'), - canEditSuggestions: api.hasPrivilege('tags:edit:suggestions'), - canEditDescription: api.hasPrivilege('tags:edit:description'), - canCreateMetric: api.hasPrivilege('metrics:create'), - canDeleteMetric: api.hasPrivilege('metrics:delete'), - canEditMetricBounds: api.hasPrivilege('metrics:edit:bounds'), - canMerge: api.hasPrivilege('tags:merge'), - canDelete: api.hasPrivilege('tags:delete'), - categories: categories, - escapeColons: uri.escapeColons, - }); + this._view = new TagView({ + tag: tag, + section: section, + canEditAnything: api.hasPrivilege("tags:edit"), + canEditNames: api.hasPrivilege("tags:edit:names"), + canEditCategory: api.hasPrivilege("tags:edit:category"), + canEditImplications: api.hasPrivilege( + "tags:edit:implications" + ), + canEditSuggestions: api.hasPrivilege( + "tags:edit:suggestions" + ), + canEditDescription: api.hasPrivilege( + "tags:edit:description" + ), + canCreateMetric: api.hasPrivilege("metrics:create"), + canDeleteMetric: api.hasPrivilege("metrics:delete"), + canEditMetricBounds: api.hasPrivilege("metrics:edit:bounds"), + canMerge: api.hasPrivilege("tags:merge"), + canDelete: api.hasPrivilege("tags:delete"), + categories: categories, + escapeColons: uri.escapeColons, + }); - this._view.addEventListener('change', e => this._evtChange(e)); - this._view.addEventListener('submit', e => this._evtUpdate(e)); - this._view.addEventListener('merge', e => this._evtMerge(e)); - this._view.addEventListener('delete', e => this._evtDelete(e)); - this._view.addEventListener('metricUpdate', e => this._evtMetricUpdate(e)); - this._view.addEventListener('metricDelete', e => this._evtMetricDelete(e)); - }, error => { - this._view = new EmptyView(); - this._view.showError(error.message); - }); + this._view.addEventListener("change", (e) => + this._evtChange(e) + ); + this._view.addEventListener("submit", (e) => + this._evtUpdate(e) + ); + this._view.addEventListener("merge", (e) => this._evtMerge(e)); + this._view.addEventListener("delete", (e) => + this._evtDelete(e) + ); + this._view.addEventListener("metricUpdate", (e) => + this._evtMetricUpdate(e) + ); + this._view.addEventListener("metricDelete", (e) => + this._evtMetricDelete(e) + ); + }, + (error) => { + this._view = new EmptyView(); + this._view.showError(error.message); + } + ); } _evtChange(e) { @@ -74,8 +95,10 @@ class TagController { misc.disableExitConfirmation(); if (this._name !== e.detail.tag.names[0]) { router.replace( - uri.formatClientLink('tag', e.detail.tag.names[0], section), - null, false); + uri.formatClientLink("tag", e.detail.tag.names[0], section), + null, + false + ); } } @@ -91,13 +114,16 @@ class TagController { if (e.detail.description !== undefined) { e.detail.tag.description = e.detail.description; } - e.detail.tag.save().then(() => { - this._view.showSuccess('Tag saved.'); - this._view.enableForm(); - }, error => { - this._view.showError(error.message); - this._view.enableForm(); - }); + e.detail.tag.save().then( + () => { + this._view.showSuccess("Tag saved."); + this._view.enableForm(); + }, + (error) => { + this._view.showError(error.message); + this._view.enableForm(); + } + ); } _evtMetricUpdate(e) { @@ -109,7 +135,7 @@ class TagController { e.detail.tag.metric = metric; e.detail.tag.save().then(() => { - this._view.showSuccess('Metric updated.'); + this._view.showSuccess("Metric updated."); this._view.enableForm(); }, error => { this._view.showError(error.message); @@ -121,7 +147,7 @@ class TagController { this._view.clearMessages(); this._view.disableForm(); e.detail.tag.deleteMetric().then(() => { - this._view.showSuccess('Metric deleted.'); + this._view.showSuccess("Metric deleted."); this._view.enableForm(); }, error => { this._view.showError(error.message); @@ -132,49 +158,57 @@ class TagController { _evtMerge(e) { this._view.clearMessages(); this._view.disableForm(); - e.detail.tag - .merge(e.detail.targetTagName, e.detail.addAlias) - .then(() => { - this._view.showSuccess('Tag merged.'); + e.detail.tag.merge(e.detail.targetTagName, e.detail.addAlias).then( + () => { + this._view.showSuccess("Tag merged."); this._view.enableForm(); router.replace( uri.formatClientLink( - 'tag', e.detail.targetTagName, 'merge'), - null, false); - }, error => { + "tag", + e.detail.targetTagName, + "merge" + ), + null, + false + ); + }, + (error) => { this._view.showError(error.message); this._view.enableForm(); - }); + } + ); } _evtDelete(e) { this._view.clearMessages(); this._view.disableForm(); - e.detail.tag.delete() - .then(() => { - const ctx = router.show(uri.formatClientLink('tags')); - ctx.controller.showSuccess('Tag deleted.'); - }, error => { + e.detail.tag.delete().then( + () => { + const ctx = router.show(uri.formatClientLink("tags")); + ctx.controller.showSuccess("Tag deleted."); + }, + (error) => { this._view.showError(error.message); this._view.enableForm(); - }); + } + ); } } -module.exports = router => { - router.enter(['tag', ':name', 'edit'], (ctx, next) => { - ctx.controller = new TagController(ctx, 'edit'); +module.exports = (router) => { + router.enter(["tag", ":name", "edit"], (ctx, next) => { + ctx.controller = new TagController(ctx, "edit"); }); - router.enter(['tag', ':name', 'metric'], (ctx, next) => { - ctx.controller = new TagController(ctx, 'metric'); + router.enter(["tag", ":name", "metric"], (ctx, next) => { + ctx.controller = new TagController(ctx, "metric"); }); - router.enter(['tag', ':name', 'merge'], (ctx, next) => { - ctx.controller = new TagController(ctx, 'merge'); + router.enter(["tag", ":name", "merge"], (ctx, next) => { + ctx.controller = new TagController(ctx, "merge"); }); - router.enter(['tag', ':name', 'delete'], (ctx, next) => { - ctx.controller = new TagController(ctx, 'delete'); + router.enter(["tag", ":name", "delete"], (ctx, next) => { + ctx.controller = new TagController(ctx, "delete"); }); - router.enter(['tag', ':name'], (ctx, next) => { - ctx.controller = new TagController(ctx, 'summary'); + router.enter(["tag", ":name"], (ctx, next) => { + ctx.controller = new TagController(ctx, "summary"); }); }; diff --git a/client/js/controllers/tag_list_controller.js b/client/js/controllers/tag_list_controller.js index 8bc7dbb..010c556 100644 --- a/client/js/controllers/tag_list_controller.js +++ b/client/js/controllers/tag_list_controller.js @@ -1,44 +1,47 @@ -'use strict'; +"use strict"; -const router = require('../router.js'); -const api = require('../api.js'); -const uri = require('../util/uri.js'); -const TagList = require('../models/tag_list.js'); -const topNavigation = require('../models/top_navigation.js'); -const PageController = require('../controllers/page_controller.js'); -const TagsHeaderView = require('../views/tags_header_view.js'); -const TagsPageView = require('../views/tags_page_view.js'); -const EmptyView = require('../views/empty_view.js'); +const router = require("../router.js"); +const api = require("../api.js"); +const uri = require("../util/uri.js"); +const TagList = require("../models/tag_list.js"); +const topNavigation = require("../models/top_navigation.js"); +const PageController = require("../controllers/page_controller.js"); +const TagsHeaderView = require("../views/tags_header_view.js"); +const TagsPageView = require("../views/tags_page_view.js"); +const EmptyView = require("../views/empty_view.js"); const fields = [ - 'names', - 'suggestions', - 'implications', - 'creationTime', - 'usages', - 'category']; + "names", + "suggestions", + "implications", + "creationTime", + "usages", + "category", +]; class TagListController { constructor(ctx) { - if (!api.hasPrivilege('tags:list')) { + this._pageController = new PageController(); + + if (!api.hasPrivilege("tags:list")) { this._view = new EmptyView(); - this._view.showError('You don\'t have privileges to view tags.'); + this._view.showError("You don't have privileges to view tags."); return; } - topNavigation.activate('tags'); - topNavigation.setTitle('Listing tags'); - this._ctx = ctx; - this._pageController = new PageController(); + + topNavigation.activate("tags"); + topNavigation.setTitle("Listing tags"); this._headerView = new TagsHeaderView({ hostNode: this._pageController.view.pageHeaderHolderNode, parameters: ctx.parameters, - canEditTagCategories: api.hasPrivilege('tagCategories:edit'), + canEditTagCategories: api.hasPrivilege("tagCategories:edit"), }); - this._headerView.addEventListener( - 'navigate', e => this._evtNavigate(e)); + this._headerView.addEventListener("navigate", (e) => + this._evtNavigate(e) + ); this._syncPageController(); } @@ -53,7 +56,8 @@ class TagListController { _evtNavigate(e) { router.showNoDispatch( - uri.formatClientLink('tags', e.detail.parameters)); + uri.formatClientLink("tags", e.detail.parameters) + ); Object.assign(this._ctx.parameters, e.detail.parameters); this._syncPageController(); } @@ -63,23 +67,29 @@ class TagListController { parameters: this._ctx.parameters, defaultLimit: 50, getClientUrlForPage: (offset, limit) => { - const parameters = Object.assign( - {}, this._ctx.parameters, {offset: offset, limit: limit}); - return uri.formatClientLink('tags', parameters); + const parameters = Object.assign({}, this._ctx.parameters, { + offset: offset, + limit: limit, + }); + return uri.formatClientLink("tags", parameters); }, requestPage: (offset, limit) => { return TagList.search( - this._ctx.parameters.query, offset, limit, fields); + this._ctx.parameters.query, + offset, + limit, + fields + ); }, - pageRenderer: pageCtx => { + pageRenderer: (pageCtx) => { return new TagsPageView(pageCtx); }, }); } } -module.exports = router => { - router.enter( - ['tags'], - (ctx, next) => { ctx.controller = new TagListController(ctx); }); +module.exports = (router) => { + router.enter(["tags"], (ctx, next) => { + ctx.controller = new TagListController(ctx); + }); }; diff --git a/client/js/controllers/top_navigation_controller.js b/client/js/controllers/top_navigation_controller.js index 9cd70c1..fbc5399 100644 --- a/client/js/controllers/top_navigation_controller.js +++ b/client/js/controllers/top_navigation_controller.js @@ -1,19 +1,20 @@ -'use strict'; +"use strict"; -const api = require('../api.js'); -const topNavigation = require('../models/top_navigation.js'); -const TopNavigationView = require('../views/top_navigation_view.js'); +const api = require("../api.js"); +const topNavigation = require("../models/top_navigation.js"); +const TopNavigationView = require("../views/top_navigation_view.js"); class TopNavigationController { constructor() { api.fetchConfig().then(() => { this._topNavigationView = new TopNavigationView(); - topNavigation.addEventListener( - 'activate', e => this._evtActivate(e)); + topNavigation.addEventListener("activate", (e) => + this._evtActivate(e) + ); - api.addEventListener('login', e => this._evtAuthChange(e)); - api.addEventListener('logout', e => this._evtAuthChange(e)); + api.addEventListener("login", (e) => this._evtAuthChange(e)); + api.addEventListener("logout", (e) => this._evtAuthChange(e)); this._render(); }); @@ -28,37 +29,41 @@ class TopNavigationController { } _updateNavigationFromPrivileges() { - topNavigation.get('account').url = 'user/' + api.userName; - topNavigation.get('account').imageUrl = - api.user ? api.user.avatarUrl : null; + topNavigation.get("account").url = "user/" + api.userName; + topNavigation.get("account").imageUrl = api.user + ? api.user.avatarUrl + : null; topNavigation.showAll(); - if (!api.hasPrivilege('posts:list')) { - topNavigation.hide('posts'); + if (!api.hasPrivilege("posts:list")) { + topNavigation.hide("posts"); } - if (!api.hasPrivilege('posts:create')) { - topNavigation.hide('upload'); + if (!api.hasPrivilege("posts:create")) { + topNavigation.hide("upload"); } - if (!api.hasPrivilege('comments:list')) { - topNavigation.hide('comments'); + if (!api.hasPrivilege("comments:list")) { + topNavigation.hide("comments"); } - if (!api.hasPrivilege('tags:list')) { - topNavigation.hide('tags'); + if (!api.hasPrivilege("tags:list")) { + topNavigation.hide("tags"); } - if (!api.hasPrivilege('users:list')) { - topNavigation.hide('users'); + if (!api.hasPrivilege("users:list")) { + topNavigation.hide("users"); + } + if (!api.hasPrivilege("pools:list")) { + topNavigation.hide("pools"); } if (api.isLoggedIn()) { - if (!api.hasPrivilege('users:create:any')) { - topNavigation.hide('register'); + if (!api.hasPrivilege("users:create:any")) { + topNavigation.hide("register"); } - topNavigation.hide('login'); + topNavigation.hide("login"); } else { - if (!api.hasPrivilege('users:create:self')) { - topNavigation.hide('register'); + if (!api.hasPrivilege("users:create:self")) { + topNavigation.hide("register"); } - topNavigation.hide('account'); - topNavigation.hide('logout'); + topNavigation.hide("account"); + topNavigation.hide("logout"); } } @@ -66,10 +71,11 @@ class TopNavigationController { this._updateNavigationFromPrivileges(); this._topNavigationView.render({ items: topNavigation.getAll(), - name: api.getName() + name: api.getName(), }); this._topNavigationView.activate( - topNavigation.activeItem ? topNavigation.activeItem.key : ''); + topNavigation.activeItem ? topNavigation.activeItem.key : "" + ); } } diff --git a/client/js/controllers/user_controller.js b/client/js/controllers/user_controller.js index 48989af..326736b 100644 --- a/client/js/controllers/user_controller.js +++ b/client/js/controllers/user_controller.js @@ -1,23 +1,25 @@ -'use strict'; +"use strict"; -const router = require('../router.js'); -const api = require('../api.js'); -const uri = require('../util/uri.js'); -const misc = require('../util/misc.js'); -const views = require('../util/views.js'); -const User = require('../models/user.js'); -const UserToken = require('../models/user_token.js'); -const topNavigation = require('../models/top_navigation.js'); -const UserView = require('../views/user_view.js'); -const EmptyView = require('../views/empty_view.js'); +const router = require("../router.js"); +const api = require("../api.js"); +const uri = require("../util/uri.js"); +const misc = require("../util/misc.js"); +const views = require("../util/views.js"); +const User = require("../models/user.js"); +const UserToken = require("../models/user_token.js"); +const topNavigation = require("../models/top_navigation.js"); +const UserView = require("../views/user_view.js"); +const EmptyView = require("../views/empty_view.js"); class UserController { constructor(ctx, section) { const userName = ctx.parameters.name; - if (!api.hasPrivilege('users:view') && - !api.isLoggedIn({name: userName})) { + if ( + !api.hasPrivilege("users:view") && + !api.isLoggedIn({ name: userName }) + ) { this._view = new EmptyView(); - this._view.showError('You don\'t have privileges to view users.'); + this._view.showError("You don't have privileges to view users."); return; } @@ -25,100 +27,129 @@ class UserController { this._errorMessages = []; let userTokenPromise = Promise.resolve([]); - if (section === 'list-tokens') { - userTokenPromise = UserToken.get(userName) - .then(userTokens => { - return userTokens.map(token => { - token.isCurrentAuthToken = api.isCurrentAuthToken(token); + if (section === "list-tokens") { + userTokenPromise = UserToken.get(userName).then( + (userTokens) => { + return userTokens.map((token) => { + token.isCurrentAuthToken = api.isCurrentAuthToken( + token + ); return token; }); - }, error => { + }, + (error) => { return []; - }); + } + ); } - topNavigation.setTitle('User ' + userName); - Promise.all([ - userTokenPromise, - User.get(userName) - ]).then(responses => { - const [userTokens, user] = responses; - const isLoggedIn = api.isLoggedIn(user); - const infix = isLoggedIn ? 'self' : 'any'; + topNavigation.setTitle("User " + userName); + Promise.all([userTokenPromise, User.get(userName)]).then( + (responses) => { + const [userTokens, user] = responses; + const isLoggedIn = api.isLoggedIn(user); + const infix = isLoggedIn ? "self" : "any"; - this._name = userName; - user.addEventListener('change', e => this._evtSaved(e, section)); + this._name = userName; + user.addEventListener("change", (e) => + this._evtSaved(e, section) + ); - const myRankIndex = api.user ? - api.allRanks.indexOf(api.user.rank) : - 0; - let ranks = {}; - for (let [rankIdx, rankIdentifier] of api.allRanks.entries()) { - if (rankIdentifier === 'anonymous') { - continue; + const myRankIndex = api.user + ? api.allRanks.indexOf(api.user.rank) + : 0; + let ranks = {}; + for (let [rankIdx, rankIdentifier] of api.allRanks.entries()) { + if (rankIdentifier === "anonymous") { + continue; + } + if (rankIdx > myRankIndex) { + continue; + } + ranks[rankIdentifier] = api.rankNames.get(rankIdentifier); } - if (rankIdx > myRankIndex) { - continue; - } - ranks[rankIdentifier] = api.rankNames.get(rankIdentifier); - } - if (isLoggedIn) { - topNavigation.activate('account'); - } else { - topNavigation.activate('users'); - } + if (isLoggedIn) { + topNavigation.activate("account"); + } else { + topNavigation.activate("users"); + } - this._view = new UserView({ - user: user, - section: section, - isLoggedIn: isLoggedIn, - canEditName: api.hasPrivilege(`users:edit:${infix}:name`), - canEditPassword: api.hasPrivilege(`users:edit:${infix}:pass`), - canEditEmail: api.hasPrivilege(`users:edit:${infix}:email`), - canEditRank: api.hasPrivilege(`users:edit:${infix}:rank`), - canEditAvatar: api.hasPrivilege(`users:edit:${infix}:avatar`), - canEditAnything: api.hasPrivilege(`users:edit:${infix}`), - canListTokens: api.hasPrivilege(`userTokens:list:${infix}`), - canCreateToken: api.hasPrivilege(`userTokens:create:${infix}`), - canEditToken: api.hasPrivilege(`userTokens:edit:${infix}`), - canDeleteToken: api.hasPrivilege(`userTokens:delete:${infix}`), - canDelete: api.hasPrivilege(`users:delete:${infix}`), - ranks: ranks, - tokens: userTokens, - }); - this._view.addEventListener('change', e => this._evtChange(e)); - this._view.addEventListener('submit', e => this._evtUpdate(e)); - this._view.addEventListener('delete', e => this._evtDelete(e)); - this._view.addEventListener('create-token', e => this._evtCreateToken(e)); - this._view.addEventListener('delete-token', e => this._evtDeleteToken(e)); - this._view.addEventListener('update-token', e => this._evtUpdateToken(e)); + this._view = new UserView({ + user: user, + section: section, + isLoggedIn: isLoggedIn, + canEditName: api.hasPrivilege(`users:edit:${infix}:name`), + canEditPassword: api.hasPrivilege( + `users:edit:${infix}:pass` + ), + canEditEmail: api.hasPrivilege( + `users:edit:${infix}:email` + ), + canEditRank: api.hasPrivilege(`users:edit:${infix}:rank`), + canEditAvatar: api.hasPrivilege( + `users:edit:${infix}:avatar` + ), + canEditAnything: api.hasPrivilege(`users:edit:${infix}`), + canListTokens: api.hasPrivilege( + `userTokens:list:${infix}` + ), + canCreateToken: api.hasPrivilege( + `userTokens:create:${infix}` + ), + canEditToken: api.hasPrivilege(`userTokens:edit:${infix}`), + canDeleteToken: api.hasPrivilege( + `userTokens:delete:${infix}` + ), + canDelete: api.hasPrivilege(`users:delete:${infix}`), + ranks: ranks, + tokens: userTokens, + }); + this._view.addEventListener("change", (e) => + this._evtChange(e) + ); + this._view.addEventListener("submit", (e) => + this._evtUpdate(e) + ); + this._view.addEventListener("delete", (e) => + this._evtDelete(e) + ); + this._view.addEventListener("create-token", (e) => + this._evtCreateToken(e) + ); + this._view.addEventListener("delete-token", (e) => + this._evtDeleteToken(e) + ); + this._view.addEventListener("update-token", (e) => + this._evtUpdateToken(e) + ); - for (let message of this._successMessages) { - this.showSuccess(message); - } + for (let message of this._successMessages) { + this.showSuccess(message); + } - for (let message of this._errorMessages) { - this.showError(message); + for (let message of this._errorMessages) { + this.showError(message); + } + }, + (error) => { + this._view = new EmptyView(); + this._view.showError(error.message); } - - }, error => { - this._view = new EmptyView(); - this._view.showError(error.message); - }); + ); } showSuccess(message) { - if (typeof this._view === 'undefined') { - this._successMessages.push(message) + if (typeof this._view === "undefined") { + this._successMessages.push(message); } else { this._view.showSuccess(message); } } showError(message) { - if (typeof this._view === 'undefined') { - this._errorMessages.push(message) + if (typeof this._view === "undefined") { + this._errorMessages.push(message); } else { this._view.showError(message); } @@ -132,8 +163,10 @@ class UserController { misc.disableExitConfirmation(); if (this._name !== e.detail.user.name) { router.replace( - uri.formatClientLink('user', e.detail.user.name, section), - null, false); + uri.formatClientLink("user", e.detail.user.name, section), + null, + false + ); } } @@ -141,7 +174,7 @@ class UserController { this._view.clearMessages(); this._view.disableForm(); const isLoggedIn = api.isLoggedIn(e.detail.user); - const infix = isLoggedIn ? 'self' : 'any'; + const infix = isLoggedIn ? "self" : "any"; if (e.detail.name !== undefined) { e.detail.user.name = e.detail.name; @@ -164,72 +197,105 @@ class UserController { } } - e.detail.user.save().then(() => { - return isLoggedIn ? - api.login( - e.detail.name || api.userName, - e.detail.password || api.userPassword, - false) : - Promise.resolve(); - }).then(() => { - this._view.showSuccess('Settings updated.'); - this._view.enableForm(); - }, error => { - this._view.showError(error.message); - this._view.enableForm(); - }); + e.detail.user + .save() + .then(() => { + return isLoggedIn + ? api.login( + e.detail.name || api.userName, + e.detail.password || api.userPassword, + false + ) + : Promise.resolve(); + }) + .then( + () => { + this._view.showSuccess("Settings updated."); + this._view.enableForm(); + }, + (error) => { + this._view.showError(error.message); + this._view.enableForm(); + } + ); } _evtDelete(e) { this._view.clearMessages(); this._view.disableForm(); const isLoggedIn = api.isLoggedIn(e.detail.user); - e.detail.user.delete() - .then(() => { + e.detail.user.delete().then( + () => { if (isLoggedIn) { api.forget(); api.logout(); } - if (api.hasPrivilege('users:list')) { - const ctx = router.show(uri.formatClientLink('users')); - ctx.controller.showSuccess('Account deleted.'); + if (api.hasPrivilege("users:list")) { + const ctx = router.show(uri.formatClientLink("users")); + ctx.controller.showSuccess("Account deleted."); } else { const ctx = router.show(uri.formatClientLink()); - ctx.controller.showSuccess('Account deleted.'); + ctx.controller.showSuccess("Account deleted."); } - }, error => { + }, + (error) => { this._view.showError(error.message); this._view.enableForm(); - }); + } + ); } _evtCreateToken(e) { this._view.clearMessages(); this._view.disableForm(); - UserToken.create(e.detail.user.name, e.detail.note, e.detail.expirationTime) - .then(response => { - const ctx = router.show(uri.formatClientLink('user', e.detail.user.name, 'list-tokens')); - ctx.controller.showSuccess('Token ' + response.token + ' created.'); - }, error => { + UserToken.create( + e.detail.user.name, + e.detail.note, + e.detail.expirationTime + ).then( + (response) => { + const ctx = router.show( + uri.formatClientLink( + "user", + e.detail.user.name, + "list-tokens" + ) + ); + ctx.controller.showSuccess( + "Token " + response.token + " created." + ); + }, + (error) => { this._view.showError(error.message); this._view.enableForm(); - }); + } + ); } _evtDeleteToken(e) { this._view.clearMessages(); this._view.disableForm(); if (api.isCurrentAuthToken(e.detail.userToken)) { - router.show(uri.formatClientLink('logout')); + router.show(uri.formatClientLink("logout")); } else { - e.detail.userToken.delete(e.detail.user.name) - .then(() => { - const ctx = router.show(uri.formatClientLink('user', e.detail.user.name, 'list-tokens')); - ctx.controller.showSuccess('Token ' + e.detail.userToken.token + ' deleted.'); - }, error => { + e.detail.userToken.delete(e.detail.user.name).then( + () => { + const ctx = router.show( + uri.formatClientLink( + "user", + e.detail.user.name, + "list-tokens" + ) + ); + ctx.controller.showSuccess( + "Token " + e.detail.userToken.token + " deleted." + ); + }, + (error) => { this._view.showError(error.message); this._view.enableForm(); - }); + } + ); } } @@ -241,27 +307,38 @@ class UserController { e.detail.userToken.note = e.detail.note; } - e.detail.userToken.save(e.detail.user.name).then(response => { - const ctx = router.show(uri.formatClientLink('user', e.detail.user.name, 'list-tokens')); - ctx.controller.showSuccess('Token ' + response.token + ' updated.'); - }, error => { - this._view.showError(error.message); - this._view.enableForm(); - }); + e.detail.userToken.save(e.detail.user.name).then( + (response) => { + const ctx = router.show( + uri.formatClientLink( + "user", + e.detail.user.name, + "list-tokens" + ) + ); + ctx.controller.showSuccess( + "Token " + response.token + " updated." + ); + }, + (error) => { + this._view.showError(error.message); + this._view.enableForm(); + } + ); } } -module.exports = router => { - router.enter(['user', ':name'], (ctx, next) => { - ctx.controller = new UserController(ctx, 'summary'); +module.exports = (router) => { + router.enter(["user", ":name"], (ctx, next) => { + ctx.controller = new UserController(ctx, "summary"); }); - router.enter(['user', ':name', 'edit'], (ctx, next) => { - ctx.controller = new UserController(ctx, 'edit'); + router.enter(["user", ":name", "edit"], (ctx, next) => { + ctx.controller = new UserController(ctx, "edit"); }); - router.enter(['user', ':name', 'list-tokens'], (ctx, next) => { - ctx.controller = new UserController(ctx, 'list-tokens'); + router.enter(["user", ":name", "list-tokens"], (ctx, next) => { + ctx.controller = new UserController(ctx, "list-tokens"); }); - router.enter(['user', ':name', 'delete'], (ctx, next) => { - ctx.controller = new UserController(ctx, 'delete'); + router.enter(["user", ":name", "delete"], (ctx, next) => { + ctx.controller = new UserController(ctx, "delete"); }); }; diff --git a/client/js/controllers/user_list_controller.js b/client/js/controllers/user_list_controller.js index 8451ada..d6c4fe5 100644 --- a/client/js/controllers/user_list_controller.js +++ b/client/js/controllers/user_list_controller.js @@ -1,35 +1,37 @@ -'use strict'; +"use strict"; -const api = require('../api.js'); -const router = require('../router.js'); -const uri = require('../util/uri.js'); -const UserList = require('../models/user_list.js'); -const topNavigation = require('../models/top_navigation.js'); -const PageController = require('../controllers/page_controller.js'); -const UsersHeaderView = require('../views/users_header_view.js'); -const UsersPageView = require('../views/users_page_view.js'); -const EmptyView = require('../views/empty_view.js'); +const api = require("../api.js"); +const router = require("../router.js"); +const uri = require("../util/uri.js"); +const UserList = require("../models/user_list.js"); +const topNavigation = require("../models/top_navigation.js"); +const PageController = require("../controllers/page_controller.js"); +const UsersHeaderView = require("../views/users_header_view.js"); +const UsersPageView = require("../views/users_page_view.js"); +const EmptyView = require("../views/empty_view.js"); class UserListController { constructor(ctx) { - if (!api.hasPrivilege('users:list')) { + this._pageController = new PageController(); + + if (!api.hasPrivilege("users:list")) { this._view = new EmptyView(); - this._view.showError('You don\'t have privileges to view users.'); + this._view.showError("You don't have privileges to view users."); return; } - topNavigation.activate('users'); - topNavigation.setTitle('Listing users'); + topNavigation.activate("users"); + topNavigation.setTitle("Listing users"); this._ctx = ctx; - this._pageController = new PageController(); this._headerView = new UsersHeaderView({ hostNode: this._pageController.view.pageHeaderHolderNode, parameters: ctx.parameters, }); - this._headerView.addEventListener( - 'navigate', e => this._evtNavigate(e)); + this._headerView.addEventListener("navigate", (e) => + this._evtNavigate(e) + ); this._syncPageController(); } @@ -40,7 +42,8 @@ class UserListController { _evtNavigate(e) { router.showNoDispatch( - uri.formatClientLink('users', e.detail.parameters)); + uri.formatClientLink("users", e.detail.parameters) + ); Object.assign(this._ctx.parameters, e.detail.parameters); this._syncPageController(); } @@ -50,17 +53,22 @@ class UserListController { parameters: this._ctx.parameters, defaultLimit: 30, getClientUrlForPage: (offset, limit) => { - const parameters = Object.assign( - {}, this._ctx.parameters, {offset: offset, limit: limit}); - return uri.formatClientLink('users', parameters); + const parameters = Object.assign({}, this._ctx.parameters, { + offset: offset, + limit: limit, + }); + return uri.formatClientLink("users", parameters); }, requestPage: (offset, limit) => { return UserList.search( - this._ctx.parameters.query, offset, limit); + this._ctx.parameters.query, + offset, + limit + ); }, - pageRenderer: pageCtx => { + pageRenderer: (pageCtx) => { Object.assign(pageCtx, { - canViewUsers: api.hasPrivilege('users:view'), + canViewUsers: api.hasPrivilege("users:view"), }); return new UsersPageView(pageCtx); }, @@ -68,8 +76,8 @@ class UserListController { } } -module.exports = router => { - router.enter( - ['users'], - (ctx, next) => { ctx.controller = new UserListController(ctx); }); +module.exports = (router) => { + router.enter(["users"], (ctx, next) => { + ctx.controller = new UserListController(ctx); + }); }; diff --git a/client/js/controllers/user_registration_controller.js b/client/js/controllers/user_registration_controller.js index 78b9402..89cfd8c 100644 --- a/client/js/controllers/user_registration_controller.js +++ b/client/js/controllers/user_registration_controller.js @@ -1,25 +1,25 @@ -'use strict'; +"use strict"; -const router = require('../router.js'); -const api = require('../api.js'); -const uri = require('../util/uri.js'); -const User = require('../models/user.js'); -const topNavigation = require('../models/top_navigation.js'); -const RegistrationView = require('../views/registration_view.js'); -const EmptyView = require('../views/empty_view.js'); +const router = require("../router.js"); +const api = require("../api.js"); +const uri = require("../util/uri.js"); +const User = require("../models/user.js"); +const topNavigation = require("../models/top_navigation.js"); +const RegistrationView = require("../views/registration_view.js"); +const EmptyView = require("../views/empty_view.js"); class UserRegistrationController { constructor() { - if (!api.hasPrivilege('users:create:self')) { + if (!api.hasPrivilege("users:create:self")) { this._view = new EmptyView(); - this._view.showError('Registration is closed.'); + this._view.showError("Registration is closed."); return; } - topNavigation.activate('register'); - topNavigation.setTitle('Registration'); + topNavigation.activate("register"); + topNavigation.setTitle("Registration"); this._view = new RegistrationView(); - this._view.addEventListener('submit', e => this._evtRegister(e)); + this._view.addEventListener("submit", (e) => this._evtRegister(e)); } _evtRegister(e) { @@ -30,30 +30,35 @@ class UserRegistrationController { user.email = e.detail.email; user.password = e.detail.password; const isLoggedIn = api.isLoggedIn(); - user.save().then(() => { - if (isLoggedIn) { - return Promise.resolve(); - } else { - api.forget(); - return api.login(e.detail.name, e.detail.password, false); - } - }).then(() => { - if (isLoggedIn) { - const ctx = router.show(uri.formatClientLink('users')); - ctx.controller.showSuccess('User added!'); - } else { - const ctx = router.show(uri.formatClientLink()); - ctx.controller.showSuccess('Welcome aboard!'); - } - }, error => { - this._view.showError(error.message); - this._view.enableForm(); - }); + user.save() + .then(() => { + if (isLoggedIn) { + return Promise.resolve(); + } else { + api.forget(); + return api.login(e.detail.name, e.detail.password, false); + } + }) + .then( + () => { + if (isLoggedIn) { + const ctx = router.show(uri.formatClientLink("users")); + ctx.controller.showSuccess("User added!"); + } else { + const ctx = router.show(uri.formatClientLink()); + ctx.controller.showSuccess("Welcome aboard!"); + } + }, + (error) => { + this._view.showError(error.message); + this._view.enableForm(); + } + ); } } -module.exports = router => { - router.enter(['register'], (ctx, next) => { +module.exports = (router) => { + router.enter(["register"], (ctx, next) => { new UserRegistrationController(); }); }; diff --git a/client/js/controls/auto_complete_control.js b/client/js/controls/auto_complete_control.js index 5ce3bb2..fb3792f 100644 --- a/client/js/controls/auto_complete_control.js +++ b/client/js/controls/auto_complete_control.js @@ -1,6 +1,6 @@ -'use strict'; +"use strict"; -const views = require('../util/views.js'); +const views = require("../util/views.js"); const KEY_TAB = 9; const KEY_RETURN = 13; @@ -10,14 +10,14 @@ const KEY_UP = 38; const KEY_DOWN = 40; function _getSelectionStart(input) { - if ('selectionStart' in input) { + if ("selectionStart" in input) { return input.selectionStart; } if (document.selection) { input.focus(); const sel = document.selection.createRange(); const selLen = document.selection.createRange().text.length; - sel.moveStart('character', -input.value.length); + sel.moveStart("character", -input.value.length); return sel.text.length - selLen; } return 0; @@ -27,18 +27,22 @@ class AutoCompleteControl { constructor(sourceInputNode, options) { this._sourceInputNode = sourceInputNode; this._options = {}; - Object.assign(this._options, { - verticalShift: 2, - maxResults: 15, - getTextToFind: () => { - const value = sourceInputNode.value; - const start = _getSelectionStart(sourceInputNode); - return value.substring(0, start).replace(/.*\s+/, ''); + Object.assign( + this._options, + { + verticalShift: 2, + maxResults: 15, + getTextToFind: () => { + const value = sourceInputNode.value; + const start = _getSelectionStart(sourceInputNode); + return value.substring(0, start).replace(/.*\s+/, ""); + }, + confirm: null, + delete: null, + getMatches: null, }, - confirm: null, - delete: null, - getMatches: null, - }, options); + options + ); this._showTimeout = null; this._results = []; @@ -49,22 +53,25 @@ class AutoCompleteControl { hide() { window.clearTimeout(this._showTimeout); - this._suggestionDiv.style.display = 'none'; + this._suggestionDiv.style.display = "none"; this._isVisible = false; } replaceSelectedText(result, addSpace) { const start = _getSelectionStart(this._sourceInputNode); - let prefix = ''; + let prefix = ""; let suffix = this._sourceInputNode.value.substring(start); let middle = this._sourceInputNode.value.substring(0, start); - const index = middle.lastIndexOf(' '); + const spaceIndex = middle.lastIndexOf(" "); + const commaIndex = middle.lastIndexOf(","); + const index = spaceIndex < commaIndex ? commaIndex : spaceIndex; + const delimiter = spaceIndex < commaIndex ? "" : " "; if (index !== -1) { prefix = this._sourceInputNode.value.substring(0, index + 1); middle = this._sourceInputNode.value.substring(index + 1); } - this._sourceInputNode.value = ( - prefix + result.toString() + ' ' + suffix.trimLeft()); + this._sourceInputNode.value = + prefix + result.toString() + delimiter + suffix.trimLeft(); if (!addSpace) { this._sourceInputNode.value = this._sourceInputNode.value.trim(); } @@ -86,7 +93,7 @@ class AutoCompleteControl { } _show() { - this._suggestionDiv.style.display = 'block'; + this._suggestionDiv.style.display = "block"; this._isVisible = true; } @@ -101,27 +108,32 @@ class AutoCompleteControl { _install() { if (!this._sourceInputNode) { - throw new Error('Input element was not found'); + throw new Error("Input element was not found"); } - if (this._sourceInputNode.getAttribute('data-autocomplete')) { + if (this._sourceInputNode.getAttribute("data-autocomplete")) { throw new Error( - 'Autocompletion was already added for this element'); + "Autocompletion was already added for this element" + ); } - this._sourceInputNode.setAttribute('data-autocomplete', true); - this._sourceInputNode.setAttribute('autocomplete', 'off'); + this._sourceInputNode.setAttribute("data-autocomplete", true); + this._sourceInputNode.setAttribute("autocomplete", "off"); - this._sourceInputNode.addEventListener( - 'keydown', e => this._evtKeyDown(e)); - this._sourceInputNode.addEventListener( - 'blur', e => this._evtBlur(e)); + this._sourceInputNode.addEventListener("keydown", (e) => + this._evtKeyDown(e) + ); + this._sourceInputNode.addEventListener("blur", (e) => + this._evtBlur(e) + ); this._suggestionDiv = views.htmlToDom( - '<div class="autocomplete"><ul></ul></div>'); - this._suggestionList = this._suggestionDiv.querySelector('ul'); + '<div class="autocomplete"><ul></ul></div>' + ); + this._suggestionList = this._suggestionDiv.querySelector("ul"); document.body.appendChild(this._suggestionDiv); - views.monitorNodeRemoval( - this._sourceInputNode, () => { this._uninstall(); }); + views.monitorNodeRemoval(this._sourceInputNode, () => { + this._uninstall(); + }); } _uninstall() { @@ -137,13 +149,21 @@ class AutoCompleteControl { if (key === KEY_ESCAPE) { func = this.hide; } else if (key === KEY_TAB && shift) { - func = () => { this._selectPrevious(); }; + func = () => { + this._selectPrevious(); + }; } else if (key === KEY_TAB && !shift) { - func = () => { this._selectNext(); }; + func = () => { + this._selectNext(); + }; } else if (key === KEY_UP) { - func = () => { this._selectPrevious(); }; + func = () => { + this._selectPrevious(); + }; } else if (key === KEY_DOWN) { - func = () => { this._selectNext(); }; + func = () => { + this._selectNext(); + }; } else if (key === KEY_RETURN && this._activeResult >= 0) { func = () => { this._confirm(this._getActiveSuggestion()); @@ -164,14 +184,17 @@ class AutoCompleteControl { func(); } else { window.clearTimeout(this._showTimeout); - this._showTimeout = window.setTimeout( - () => { this._showOrHide(); }, 250); + this._showTimeout = window.setTimeout(() => { + this._showOrHide(); + }, 250); } } _evtBlur(e) { window.clearTimeout(this._showTimeout); - window.setTimeout(() => { this.hide(); }, 50); + window.setTimeout(() => { + this.hide(); + }, 50); } _getActiveSuggestion() { @@ -182,9 +205,11 @@ class AutoCompleteControl { } _selectPrevious() { - this._select(this._activeResult === -1 ? - this._results.length - 1 : - this._activeResult - 1); + this._select( + this._activeResult === -1 + ? this._results.length - 1 + : this._activeResult - 1 + ); } _selectNext() { @@ -192,15 +217,18 @@ class AutoCompleteControl { } _select(newActiveResult) { - this._activeResult = - newActiveResult.between(0, this._results.length - 1, true) ? - newActiveResult : - -1; + this._activeResult = newActiveResult.between( + 0, + this._results.length - 1, + true + ) + ? newActiveResult + : -1; this._refreshActiveResult(); } _updateResults(textToFind) { - this._options.getMatches(textToFind).then(matches => { + this._options.getMatches(textToFind).then((matches) => { const oldResults = this._results.slice(); this._results = matches.slice(0, this._options.maxResults); const oldResultsHash = JSON.stringify(oldResults); @@ -223,34 +251,30 @@ class AutoCompleteControl { } for (let [resultIndex, resultItem] of this._results.entries()) { let resultIndexWorkaround = resultIndex; - const listItem = document.createElement('li'); - const link = document.createElement('a'); + const listItem = document.createElement("li"); + const link = document.createElement("a"); link.innerHTML = resultItem.caption; - link.setAttribute('href', ''); - link.setAttribute('data-key', resultItem.value); - link.addEventListener( - 'mouseenter', - e => { - e.preventDefault(); - this._activeResult = resultIndexWorkaround; - this._refreshActiveResult(); - }); - link.addEventListener( - 'mousedown', - e => { - e.preventDefault(); - this._activeResult = resultIndexWorkaround; - this._confirm(this._getActiveSuggestion()); - this.hide(); - }); + link.setAttribute("href", ""); + link.setAttribute("data-key", resultItem.value); + link.addEventListener("mouseenter", (e) => { + e.preventDefault(); + this._activeResult = resultIndexWorkaround; + this._refreshActiveResult(); + }); + link.addEventListener("mousedown", (e) => { + e.preventDefault(); + this._activeResult = resultIndexWorkaround; + this._confirm(this._getActiveSuggestion()); + this.hide(); + }); listItem.appendChild(link); this._suggestionList.appendChild(listItem); } this._refreshActiveResult(); // display the suggestions offscreen to get the height - this._suggestionDiv.style.left = '-9999px'; - this._suggestionDiv.style.top = '-9999px'; + this._suggestionDiv.style.left = "-9999px"; + this._suggestionDiv.style.top = "-9999px"; this._show(); const verticalShift = this._options.verticalShift; const inputRect = this._sourceInputNode.getBoundingClientRect(); @@ -264,38 +288,44 @@ class AutoCompleteControl { inputRect.top + inputRect.height / 2 < viewPortHeight / 2 ? 1 : -1; let x = inputRect.left - bodyRect.left; - let y = direction == 1 ? - inputRect.bottom - bodyRect.top - verticalShift : - inputRect.top - bodyRect.top - listRect.height + verticalShift; + let y = + direction === 1 + ? inputRect.bottom - bodyRect.top - verticalShift + : inputRect.top - + bodyRect.top - + listRect.height + + verticalShift; // remove offscreen items until whole suggestion list can fit on the // screen - while ((y < 0 || y + listRect.height > viewPortHeight) && - this._suggestionList.childNodes.length) { + while ( + (y < 0 || y + listRect.height > viewPortHeight) && + this._suggestionList.childNodes.length + ) { this._suggestionList.removeChild(this._suggestionList.lastChild); const prevHeight = listRect.height; listRect = this._suggestionDiv.getBoundingClientRect(); const heightDelta = prevHeight - listRect.height; - if (direction == -1) { + if (direction === -1) { y += heightDelta; } } - this._suggestionDiv.style.left = x + 'px'; - this._suggestionDiv.style.top = y + 'px'; + this._suggestionDiv.style.left = x + "px"; + this._suggestionDiv.style.top = y + "px"; } _refreshActiveResult() { - let activeItem = this._suggestionList.querySelector('li.active'); + let activeItem = this._suggestionList.querySelector("li.active"); if (activeItem) { - activeItem.classList.remove('active'); + activeItem.classList.remove("active"); } if (this._activeResult >= 0) { - const allItems = this._suggestionList.querySelectorAll('li'); + const allItems = this._suggestionList.querySelectorAll("li"); activeItem = allItems[this._activeResult]; - activeItem.classList.add('active'); + activeItem.classList.add("active"); } } -}; +} module.exports = AutoCompleteControl; diff --git a/client/js/controls/comment_control.js b/client/js/controls/comment_control.js index 849dd4d..cdcfd53 100644 --- a/client/js/controls/comment_control.js +++ b/client/js/controls/comment_control.js @@ -1,12 +1,12 @@ -'use strict'; +"use strict"; -const api = require('../api.js'); -const misc = require('../util/misc.js'); -const events = require('../events.js'); -const views = require('../util/views.js'); +const api = require("../api.js"); +const misc = require("../util/misc.js"); +const events = require("../events.js"); +const views = require("../util/views.js"); -const template = views.getTemplate('comment'); -const scoreTemplate = views.getTemplate('score'); +const template = views.getTemplate("comment"); +const scoreTemplate = views.getTemplate("score"); class CommentControl extends events.EventTarget { constructor(hostNode, comment, onlyEditing) { @@ -16,104 +16,111 @@ class CommentControl extends events.EventTarget { this._onlyEditing = onlyEditing; if (comment) { - comment.addEventListener( - 'change', e => this._evtChange(e)); - comment.addEventListener( - 'changeScore', e => this._evtChangeScore(e)); + comment.addEventListener("change", (e) => this._evtChange(e)); + comment.addEventListener("changeScore", (e) => + this._evtChangeScore(e) + ); } const isLoggedIn = comment && api.isLoggedIn(comment.user); - const infix = isLoggedIn ? 'own' : 'any'; - views.replaceContent(this._hostNode, template({ - comment: comment, - user: comment ? comment.user : api.user, - canViewUsers: api.hasPrivilege('users:view'), - canEditComment: api.hasPrivilege(`comments:edit:${infix}`), - canDeleteComment: api.hasPrivilege(`comments:delete:${infix}`), - onlyEditing: onlyEditing, - })); + const infix = isLoggedIn ? "own" : "any"; + views.replaceContent( + this._hostNode, + template({ + comment: comment, + user: comment ? comment.user : api.user, + canViewUsers: api.hasPrivilege("users:view"), + canEditComment: api.hasPrivilege(`comments:edit:${infix}`), + canDeleteComment: api.hasPrivilege(`comments:delete:${infix}`), + onlyEditing: onlyEditing, + }) + ); if (this._editButtonNodes) { for (let node of this._editButtonNodes) { - node.addEventListener('click', e => this._evtEditClick(e)); + node.addEventListener("click", (e) => this._evtEditClick(e)); } } if (this._deleteButtonNode) { - this._deleteButtonNode.addEventListener( - 'click', e => this._evtDeleteClick(e)); + this._deleteButtonNode.addEventListener("click", (e) => + this._evtDeleteClick(e) + ); } if (this._previewEditingButtonNode) { - this._previewEditingButtonNode.addEventListener( - 'click', e => this._evtPreviewEditingClick(e)); + this._previewEditingButtonNode.addEventListener("click", (e) => + this._evtPreviewEditingClick(e) + ); } if (this._saveChangesButtonNode) { - this._saveChangesButtonNode.addEventListener( - 'click', e => this._evtSaveChangesClick(e)); + this._saveChangesButtonNode.addEventListener("click", (e) => + this._evtSaveChangesClick(e) + ); } if (this._cancelEditingButtonNode) { - this._cancelEditingButtonNode.addEventListener( - 'click', e => this._evtCancelEditingClick(e)); + this._cancelEditingButtonNode.addEventListener("click", (e) => + this._evtCancelEditingClick(e) + ); } this._installScore(); if (onlyEditing) { - this._selectNav('edit'); - this._selectTab('edit'); + this._selectNav("edit"); + this._selectTab("edit"); } else { - this._selectNav('readonly'); - this._selectTab('preview'); + this._selectNav("readonly"); + this._selectTab("preview"); } } get _formNode() { - return this._hostNode.querySelector('form'); + return this._hostNode.querySelector("form"); } get _scoreContainerNode() { - return this._hostNode.querySelector('.score-container'); + return this._hostNode.querySelector(".score-container"); } get _editButtonNodes() { - return this._hostNode.querySelectorAll('li.edit>a, a.edit'); + return this._hostNode.querySelectorAll("li.edit>a, a.edit"); } get _previewEditingButtonNode() { - return this._hostNode.querySelector('li.preview>a'); + return this._hostNode.querySelector("li.preview>a"); } get _deleteButtonNode() { - return this._hostNode.querySelector('.delete'); + return this._hostNode.querySelector(".delete"); } get _upvoteButtonNode() { - return this._hostNode.querySelector('.upvote'); + return this._hostNode.querySelector(".upvote"); } get _downvoteButtonNode() { - return this._hostNode.querySelector('.downvote'); + return this._hostNode.querySelector(".downvote"); } get _saveChangesButtonNode() { - return this._hostNode.querySelector('.save-changes'); + return this._hostNode.querySelector(".save-changes"); } get _cancelEditingButtonNode() { - return this._hostNode.querySelector('.cancel-editing'); + return this._hostNode.querySelector(".cancel-editing"); } get _textareaNode() { - return this._hostNode.querySelector('.tab.edit textarea'); + return this._hostNode.querySelector(".tab.edit textarea"); } get _contentNode() { - return this._hostNode.querySelector('.tab.preview .comment-content'); + return this._hostNode.querySelector(".tab.preview .comment-content"); } get _heightKeeperNode() { - return this._hostNode.querySelector('.keep-height'); + return this._hostNode.querySelector(".keep-height"); } _installScore() { @@ -122,32 +129,35 @@ class CommentControl extends events.EventTarget { scoreTemplate({ score: this._comment ? this._comment.score : 0, ownScore: this._comment ? this._comment.ownScore : 0, - canScore: api.hasPrivilege('comments:score'), - })); + canScore: api.hasPrivilege("comments:score"), + }) + ); if (this._upvoteButtonNode) { - this._upvoteButtonNode.addEventListener( - 'click', e => this._evtScoreClick(e, 1)); + this._upvoteButtonNode.addEventListener("click", (e) => + this._evtScoreClick(e, 1) + ); } if (this._downvoteButtonNode) { - this._downvoteButtonNode.addEventListener( - 'click', e => this._evtScoreClick(e, -1)); + this._downvoteButtonNode.addEventListener("click", (e) => + this._evtScoreClick(e, -1) + ); } } enterEditMode() { - this._selectNav('edit'); - this._selectTab('edit'); + this._selectNav("edit"); + this._selectTab("edit"); } exitEditMode() { if (this._onlyEditing) { - this._selectNav('edit'); - this._selectTab('edit'); - this._setText(''); + this._selectNav("edit"); + this._selectTab("edit"); + this._setText(""); } else { - this._selectNav('readonly'); - this._selectTab('preview'); + this._selectNav("readonly"); + this._selectTab("preview"); this._setText(this._comment.text); } this._forgetHeight(); @@ -173,27 +183,31 @@ class CommentControl extends events.EventTarget { _evtScoreClick(e, score) { e.preventDefault(); - if (!api.hasPrivilege('comments:score')) { + if (!api.hasPrivilege("comments:score")) { return; } - this.dispatchEvent(new CustomEvent('score', { - detail: { - comment: this._comment, - score: this._comment.ownScore === score ? 0 : score, - }, - })); + this.dispatchEvent( + new CustomEvent("score", { + detail: { + comment: this._comment, + score: this._comment.ownScore === score ? 0 : score, + }, + }) + ); } _evtDeleteClick(e) { e.preventDefault(); - if (!window.confirm('Are you sure you want to delete this comment?')) { + if (!window.confirm("Are you sure you want to delete this comment?")) { return; } - this.dispatchEvent(new CustomEvent('delete', { - detail: { - comment: this._comment, - }, - })); + this.dispatchEvent( + new CustomEvent("delete", { + detail: { + comment: this._comment, + }, + }) + ); } _evtChange(e) { @@ -206,26 +220,24 @@ class CommentControl extends events.EventTarget { _evtPreviewEditingClick(e) { e.preventDefault(); - this._contentNode.innerHTML = - misc.formatMarkdown(this._textareaNode.value); - this._selectTab('edit'); - this._selectTab('preview'); - } - - _evtEditClick(e) { - e.preventDefault(); - this.enterEditMode(); + this._contentNode.innerHTML = misc.formatMarkdown( + this._textareaNode.value + ); + this._selectTab("edit"); + this._selectTab("preview"); } _evtSaveChangesClick(e) { e.preventDefault(); - this.dispatchEvent(new CustomEvent('submit', { - detail: { - target: this, - comment: this._comment, - text: this._textareaNode.value, - }, - })); + this.dispatchEvent( + new CustomEvent("submit", { + detail: { + target: this, + comment: this._comment, + text: this._textareaNode.value, + }, + }) + ); } _evtCancelEditingClick(e) { @@ -239,27 +251,27 @@ class CommentControl extends events.EventTarget { } _selectNav(modeName) { - for (let node of this._hostNode.querySelectorAll('nav')) { - node.classList.toggle('active', node.classList.contains(modeName)); + for (let node of this._hostNode.querySelectorAll("nav")) { + node.classList.toggle("active", node.classList.contains(modeName)); } } _selectTab(tabName) { this._ensureHeight(); - for (let node of this._hostNode.querySelectorAll('.tab, .tabs li')) { - node.classList.toggle('active', node.classList.contains(tabName)); + for (let node of this._hostNode.querySelectorAll(".tab, .tabs li")) { + node.classList.toggle("active", node.classList.contains(tabName)); } } _ensureHeight() { this._heightKeeperNode.style.minHeight = - this._heightKeeperNode.getBoundingClientRect().height + 'px'; + this._heightKeeperNode.getBoundingClientRect().height + "px"; } _forgetHeight() { this._heightKeeperNode.style.minHeight = null; } -}; +} module.exports = CommentControl; diff --git a/client/js/controls/comment_list_control.js b/client/js/controls/comment_list_control.js index ddef71f..7717ee5 100644 --- a/client/js/controls/comment_list_control.js +++ b/client/js/controls/comment_list_control.js @@ -1,10 +1,10 @@ -'use strict'; +"use strict"; -const events = require('../events.js'); -const views = require('../util/views.js'); -const CommentControl = require('../controls/comment_control.js'); +const events = require("../events.js"); +const views = require("../util/views.js"); +const CommentControl = require("../controls/comment_control.js"); -const template = views.getTemplate('comment-list'); +const template = views.getTemplate("comment-list"); class CommentListControl extends events.EventTarget { constructor(hostNode, comments, reversed) { @@ -13,8 +13,8 @@ class CommentListControl extends events.EventTarget { this._comments = comments; this._commentIdToNode = {}; - comments.addEventListener('add', e => this._evtAdd(e)); - comments.addEventListener('remove', e => this._evtRemove(e)); + comments.addEventListener("add", (e) => this._evtAdd(e)); + comments.addEventListener("remove", (e) => this._evtRemove(e)); views.replaceContent(this._hostNode, template()); @@ -28,16 +28,19 @@ class CommentListControl extends events.EventTarget { } get _commentListNode() { - return this._hostNode.querySelector('ul'); + return this._hostNode.querySelector("ul"); } _installCommentNode(comment) { - const commentListItemNode = document.createElement('li'); + const commentListItemNode = document.createElement("li"); const commentControl = new CommentControl( - commentListItemNode, comment, false); - events.proxyEvent(commentControl, this, 'submit'); - events.proxyEvent(commentControl, this, 'score'); - events.proxyEvent(commentControl, this, 'delete'); + commentListItemNode, + comment, + false + ); + events.proxyEvent(commentControl, this, "submit"); + events.proxyEvent(commentControl, this, "score"); + events.proxyEvent(commentControl, this, "delete"); this._commentIdToNode[comment.id] = commentListItemNode; this._commentListNode.appendChild(commentListItemNode); } @@ -54,6 +57,6 @@ class CommentListControl extends events.EventTarget { _evtRemove(e) { this._uninstallCommentNode(e.detail.comment); } -}; +} module.exports = CommentListControl; diff --git a/client/js/controls/expander_control.js b/client/js/controls/expander_control.js index 590a84f..c26bd03 100644 --- a/client/js/controls/expander_control.js +++ b/client/js/controls/expander_control.js @@ -1,26 +1,28 @@ -'use strict'; +"use strict"; -const ICON_CLASS_OPENED = 'fa-chevron-down'; -const ICON_CLASS_CLOSED = 'fa-chevron-up'; +const ICON_CLASS_OPENED = "fa-chevron-down"; +const ICON_CLASS_CLOSED = "fa-chevron-up"; -const views = require('../util/views.js'); +const views = require("../util/views.js"); -const template = views.getTemplate('expander'); +const template = views.getTemplate("expander"); class ExpanderControl { constructor(name, title, nodes) { this._name = name; - nodes = Array.from(nodes).filter(n => n); + nodes = Array.from(nodes).filter((n) => n); if (!nodes.length) { return; } - const expanderNode = template({title: title}); - const toggleLinkNode = expanderNode.querySelector('a'); - const toggleIconNode = expanderNode.querySelector('i'); - const expanderContentNode = expanderNode.querySelector('div'); - toggleLinkNode.addEventListener('click', e => this._evtToggleClick(e)); + const expanderNode = template({ title: title }); + const toggleLinkNode = expanderNode.querySelector("a"); + const toggleIconNode = expanderNode.querySelector("i"); + const expanderContentNode = expanderNode.querySelector("div"); + toggleLinkNode.addEventListener("click", (e) => + this._evtToggleClick(e) + ); nodes[0].parentNode.insertBefore(expanderNode, nodes[0]); @@ -32,10 +34,11 @@ class ExpanderControl { this._toggleIconNode = toggleIconNode; expanderNode.classList.toggle( - 'collapsed', - this._allStates[this._name] === undefined ? - false : - !this._allStates[this._name]); + "collapsed", + this._allStates[this._name] === undefined + ? false + : !this._allStates[this._name] + ); this._syncIcon(); } @@ -43,21 +46,22 @@ class ExpanderControl { return this._expanderNode; } + // 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; } } get _isOpened() { - return !this._expanderNode.classList.contains('collapsed'); + return !this._expanderNode.classList.contains("collapsed"); } get _allStates() { try { - return JSON.parse(localStorage.getItem('expander')) || {}; + return JSON.parse(localStorage.getItem("expander")) || {}; } catch (e) { return {}; } @@ -66,12 +70,12 @@ class ExpanderControl { _save() { const newStates = Object.assign({}, this._allStates); newStates[this._name] = this._isOpened; - localStorage.setItem('expander', JSON.stringify(newStates)); + localStorage.setItem("expander", JSON.stringify(newStates)); } _evtToggleClick(e) { e.preventDefault(); - this._expanderNode.classList.toggle('collapsed'); + this._expanderNode.classList.toggle("collapsed"); this._save(); this._syncIcon(); } diff --git a/client/js/controls/file_dropper_control.js b/client/js/controls/file_dropper_control.js index f725ee0..2dfb492 100644 --- a/client/js/controls/file_dropper_control.js +++ b/client/js/controls/file_dropper_control.js @@ -1,9 +1,9 @@ -'use strict'; +"use strict"; -const events = require('../events.js'); -const views = require('../util/views.js'); +const events = require("../events.js"); +const views = require("../util/views.js"); -const template = views.getTemplate('file-dropper'); +const template = views.getTemplate("file-dropper"); const KEY_RETURN = 13; @@ -17,37 +17,42 @@ class FileDropperControl extends events.EventTarget { allowMultiple: options.allowMultiple, allowUrls: options.allowUrls, lock: options.lock, - id: 'file-' + Math.random().toString(36).substring(7), + id: "file-" + Math.random().toString(36).substring(7), urlPlaceholder: - options.urlPlaceholder || 'Alternatively, paste an URL here.', + options.urlPlaceholder || "Alternatively, paste an URL here.", }); - this._dropperNode = source.querySelector('.file-dropper'); - this._urlInputNode = source.querySelector('input[type=text]'); - this._urlConfirmButtonNode = source.querySelector('button'); - this._fileInputNode = source.querySelector('input[type=file]'); - this._fileInputNode.style.display = 'none'; + this._dropperNode = source.querySelector(".file-dropper"); + this._urlInputNode = source.querySelector("input[type=text]"); + this._urlConfirmButtonNode = source.querySelector("button"); + this._fileInputNode = source.querySelector("input[type=file]"); + this._fileInputNode.style.display = "none"; this._fileInputNode.multiple = options.allowMultiple || false; this._counter = 0; - this._dropperNode.addEventListener( - 'dragenter', e => this._evtDragEnter(e)); - this._dropperNode.addEventListener( - 'dragleave', e => this._evtDragLeave(e)); - this._dropperNode.addEventListener( - 'dragover', e => this._evtDragOver(e)); - this._dropperNode.addEventListener( - 'drop', e => this._evtDrop(e)); - this._fileInputNode.addEventListener( - 'change', e => this._evtFileChange(e)); + this._dropperNode.addEventListener("dragenter", (e) => + this._evtDragEnter(e) + ); + this._dropperNode.addEventListener("dragleave", (e) => + this._evtDragLeave(e) + ); + this._dropperNode.addEventListener("dragover", (e) => + this._evtDragOver(e) + ); + this._dropperNode.addEventListener("drop", (e) => this._evtDrop(e)); + this._fileInputNode.addEventListener("change", (e) => + this._evtFileChange(e) + ); if (this._urlInputNode) { - this._urlInputNode.addEventListener( - 'keydown', e => this._evtUrlInputKeyDown(e)); + this._urlInputNode.addEventListener("keydown", (e) => + this._evtUrlInputKeyDown(e) + ); } if (this._urlConfirmButtonNode) { - this._urlConfirmButtonNode.addEventListener( - 'click', e => this._evtUrlConfirmButtonClick(e)); + this._urlConfirmButtonNode.addEventListener("click", (e) => + this._evtUrlConfirmButtonClick(e) + ); } this._originalHtml = this._dropperNode.innerHTML; @@ -56,24 +61,27 @@ class FileDropperControl extends events.EventTarget { reset() { this._dropperNode.innerHTML = this._originalHtml; - this.dispatchEvent(new CustomEvent('reset')); + this.dispatchEvent(new CustomEvent("reset")); } _emitFiles(files) { files = Array.from(files); if (this._options.lock) { - this._dropperNode.innerText = - files.map(file => file.name).join(', '); + this._dropperNode.innerText = files + .map((file) => file.name) + .join(", "); } this.dispatchEvent( - new CustomEvent('fileadd', {detail: {files: files}})); + new CustomEvent("fileadd", { detail: { files: files } }) + ); } _emitUrls(urls) { - urls = Array.from(urls).map(url => url.trim()); + urls = Array.from(urls).map((url) => url.trim()); if (this._options.lock) { - this._dropperNode.innerText = - urls.map(url => url.split(/\//).reverse()[0]).join(', '); + this._dropperNode.innerText = urls + .map((url) => url.split(/\//).reverse()[0]) + .join(", "); } for (let url of urls) { if (!url) { @@ -84,18 +92,20 @@ class FileDropperControl extends events.EventTarget { return; } } - this.dispatchEvent(new CustomEvent('urladd', {detail: {urls: urls}})); + this.dispatchEvent( + new CustomEvent("urladd", { detail: { urls: urls } }) + ); } _evtDragEnter(e) { - this._dropperNode.classList.add('active'); - counter++; + this._dropperNode.classList.add("active"); + this._counter++; } _evtDragLeave(e) { this._counter--; if (this._counter === 0) { - this._dropperNode.classList.remove('active'); + this._dropperNode.classList.remove("active"); } } @@ -109,12 +119,12 @@ class FileDropperControl extends events.EventTarget { _evtDrop(e) { e.preventDefault(); - this._dropperNode.classList.remove('active'); + this._dropperNode.classList.remove("active"); if (!e.dataTransfer.files.length) { - window.alert('Only files are supported.'); + window.alert("Only files are supported."); } if (!this._options.allowMultiple && e.dataTransfer.files.length > 1) { - window.alert('Cannot select multiple files.'); + window.alert("Cannot select multiple files."); } this._emitFiles(e.dataTransfer.files); } @@ -124,16 +134,16 @@ class FileDropperControl extends events.EventTarget { return; } e.preventDefault(); - this._dropperNode.classList.remove('active'); + this._dropperNode.classList.remove("active"); this._emitUrls(this._urlInputNode.value.split(/[\r\n]/)); - this._urlInputNode.value = ''; + this._urlInputNode.value = ""; } _evtUrlConfirmButtonClick(e) { e.preventDefault(); - this._dropperNode.classList.remove('active'); + this._dropperNode.classList.remove("active"); this._emitUrls(this._urlInputNode.value.split(/[\r\n]/)); - this._urlInputNode.value = ''; + this._urlInputNode.value = ""; } } diff --git a/client/js/controls/pool_auto_complete_control.js b/client/js/controls/pool_auto_complete_control.js new file mode 100644 index 0000000..97794a8 --- /dev/null +++ b/client/js/controls/pool_auto_complete_control.js @@ -0,0 +1,59 @@ +"use strict"; + +const misc = require("../util/misc.js"); +const PoolList = require("../models/pool_list.js"); +const AutoCompleteControl = require("./auto_complete_control.js"); + +function _poolListToMatches(pools, options) { + return [...pools] + .sort((pool1, pool2) => { + return pool2.postCount - pool1.postCount; + }) + .map((pool) => { + let cssName = misc.makeCssName(pool.category, "pool"); + const caption = + '<span class="' + + cssName + + '">' + + misc.escapeHtml(pool.names[0] + " (" + pool.postCount + ")") + + "</span>"; + return { + caption: caption, + value: pool, + }; + }); +} + +class PoolAutoCompleteControl extends AutoCompleteControl { + constructor(input, options) { + const minLengthForPartialSearch = 3; + + options.getMatches = (text) => { + const term = misc.escapeSearchTerm(text); + const query = + (text.length < minLengthForPartialSearch + ? term + "*" + : "*" + term + "*") + " sort:post-count"; + + return new Promise((resolve, reject) => { + PoolList.search(query, 0, this._options.maxResults, [ + "id", + "names", + "category", + "postCount", + "version", + ]).then( + (response) => + resolve( + _poolListToMatches(response.results, this._options) + ), + reject + ); + }); + }; + + super(input, options); + } +} + +module.exports = PoolAutoCompleteControl; diff --git a/client/js/controls/pool_input_control.js b/client/js/controls/pool_input_control.js new file mode 100644 index 0000000..c8995da --- /dev/null +++ b/client/js/controls/pool_input_control.js @@ -0,0 +1,195 @@ +"use strict"; + +const api = require("../api.js"); +const pools = require("../pools.js"); +const misc = require("../util/misc.js"); +const uri = require("../util/uri.js"); +const Pool = require("../models/pool.js"); +const settings = require("../models/settings.js"); +const events = require("../events.js"); +const views = require("../util/views.js"); +const PoolAutoCompleteControl = require("./pool_auto_complete_control.js"); + +const KEY_SPACE = 32; +const KEY_RETURN = 13; + +const SOURCE_INIT = "init"; +const SOURCE_IMPLICATION = "implication"; +const SOURCE_USER_INPUT = "user-input"; +const SOURCE_CLIPBOARD = "clipboard"; + +const template = views.getTemplate("pool-input"); + +function _fadeOutListItemNodeStatus(listItemNode) { + if (listItemNode.classList.length) { + if (listItemNode.fadeTimeout) { + window.clearTimeout(listItemNode.fadeTimeout); + } + listItemNode.fadeTimeout = window.setTimeout(() => { + while (listItemNode.classList.length) { + listItemNode.classList.remove(listItemNode.classList.item(0)); + } + listItemNode.fadeTimeout = null; + }, 2500); + } +} + +class PoolInputControl extends events.EventTarget { + constructor(hostNode, poolList) { + super(); + this.pools = poolList; + this._hostNode = hostNode; + this._poolToListItemNode = new Map(); + + // dom + const editAreaNode = template(); + this._editAreaNode = editAreaNode; + this._poolInputNode = editAreaNode.querySelector("input"); + this._poolListNode = editAreaNode.querySelector("ul.compact-pools"); + + this._autoCompleteControl = new PoolAutoCompleteControl( + this._poolInputNode, + { + getTextToFind: () => { + return this._poolInputNode.value; + }, + confirm: (pool) => { + this._poolInputNode.value = ""; + this.addPool(pool, SOURCE_USER_INPUT); + }, + delete: (pool) => { + this._poolInputNode.value = ""; + this.deletePool(pool); + }, + verticalShift: -2, + } + ); + + // show + this._hostNode.style.display = "none"; + this._hostNode.parentNode.insertBefore( + this._editAreaNode, + hostNode.nextSibling + ); + + // add existing pools + for (let pool of [...this.pools]) { + const listItemNode = this._createListItemNode(pool); + this._poolListNode.appendChild(listItemNode); + } + } + + addPool(pool, source) { + if (source !== SOURCE_INIT && this.pools.hasPoolId(pool.id)) { + return Promise.resolve(); + } + + this.pools.add(pool, false); + + const listItemNode = this._createListItemNode(pool); + if (!pool.category) { + listItemNode.classList.add("new"); + } + this._poolListNode.prependChild(listItemNode); + _fadeOutListItemNodeStatus(listItemNode); + + this.dispatchEvent( + new CustomEvent("add", { + detail: { pool: pool, source: source }, + }) + ); + this.dispatchEvent(new CustomEvent("change")); + + return Promise.resolve(); + } + + deletePool(pool) { + if (!this.pools.hasPoolId(pool.id)) { + return; + } + this.pools.removeById(pool.id); + this._hideAutoComplete(); + + this._deleteListItemNode(pool); + + this.dispatchEvent( + new CustomEvent("remove", { + detail: { pool: pool }, + }) + ); + this.dispatchEvent(new CustomEvent("change")); + } + + _createListItemNode(pool) { + const className = pool.category + ? misc.makeCssName(pool.category, "pool") + : null; + + const poolLinkNode = document.createElement("a"); + if (className) { + poolLinkNode.classList.add(className); + } + poolLinkNode.setAttribute( + "href", + uri.formatClientLink("pool", pool.names[0]) + ); + + const poolIconNode = document.createElement("i"); + poolIconNode.classList.add("fa"); + poolIconNode.classList.add("fa-pool"); + poolLinkNode.appendChild(poolIconNode); + + const searchLinkNode = document.createElement("a"); + if (className) { + searchLinkNode.classList.add(className); + } + searchLinkNode.setAttribute( + "href", + uri.formatClientLink("posts", { query: "pool:" + pool.id }) + ); + searchLinkNode.textContent = pool.names[0] + " "; + + const usagesNode = document.createElement("span"); + usagesNode.classList.add("pool-usages"); + usagesNode.setAttribute("data-pseudo-content", pool.postCount); + + const removalLinkNode = document.createElement("a"); + removalLinkNode.classList.add("remove-pool"); + removalLinkNode.setAttribute("href", ""); + removalLinkNode.setAttribute("data-pseudo-content", "×"); + removalLinkNode.addEventListener("click", (e) => { + e.preventDefault(); + this.deletePool(pool); + }); + + const listItemNode = document.createElement("li"); + listItemNode.appendChild(removalLinkNode); + listItemNode.appendChild(poolLinkNode); + listItemNode.appendChild(searchLinkNode); + listItemNode.appendChild(usagesNode); + for (let name of pool.names) { + this._poolToListItemNode.set(name, listItemNode); + } + return listItemNode; + } + + _deleteListItemNode(pool) { + const listItemNode = this._getListItemNode(pool); + if (listItemNode) { + listItemNode.parentNode.removeChild(listItemNode); + } + for (let name of pool.names) { + this._poolToListItemNode.delete(name); + } + } + + _getListItemNode(pool) { + return this._poolToListItemNode.get(pool.names[0]); + } + + _hideAutoComplete() { + this._autoCompleteControl.hide(); + } +} + +module.exports = PoolInputControl; diff --git a/client/js/controls/post_content_control.js b/client/js/controls/post_content_control.js index 202a5b1..0023854 100644 --- a/client/js/controls/post_content_control.js +++ b/client/js/controls/post_content_control.js @@ -1,39 +1,41 @@ -'use strict'; +"use strict"; -const settings = require('../models/settings.js'); -const views = require('../util/views.js'); -const optimizedResize = require('../util/optimized_resize.js'); +const settings = require("../models/settings.js"); +const views = require("../util/views.js"); +const optimizedResize = require("../util/optimized_resize.js"); class PostContentControl { constructor(hostNode, post, viewportSizeCalculator, fitFunctionOverride) { this._post = post; this._viewportSizeCalculator = viewportSizeCalculator; this._hostNode = hostNode; - this._template = views.getTemplate('post-content'); + this._template = views.getTemplate("post-content"); let fitMode = settings.get().fitMode; - if (typeof fitFunctionOverride !== 'undefined') { + if (typeof fitFunctionOverride !== "undefined") { fitMode = fitFunctionOverride; } - this._currentFitFunction = { - 'fit-both': this.fitBoth, - 'fit-original': this.fitOriginal, - 'fit-width': this.fitWidth, - 'fit-height': this.fitHeight, - }[fitMode] || this.fitBoth; + this._currentFitFunction = + { + "fit-both": this.fitBoth, + "fit-original": this.fitOriginal, + "fit-width": this.fitWidth, + "fit-height": this.fitHeight, + }[fitMode] || this.fitBoth; this._install(); - this._post.addEventListener( - 'changeContent', e => this._evtPostContentChange(e)); + this._post.addEventListener("changeContent", (e) => + this._evtPostContentChange(e) + ); // Always disable overlay, because I'm not going to use notes this.disableOverlay(); } disableOverlay() { - this._hostNode.querySelector('.post-overlay').style.display = 'none'; + this._hostNode.querySelector(".post-overlay").style.display = "none"; } fitWidth() { @@ -95,10 +97,10 @@ class PostContentControl { _resize(width, height) { this._resizePostContent(width, height); - const resizeListenerNodes = this._postContentNode.querySelectorAll('.resize-listener'); + const resizeListenerNodes = this._postContentNode.querySelectorAll(".resize-listener"); for (let node of resizeListenerNodes) { - node.style.width = width + 'px'; - node.style.height = height + 'px'; + node.style.width = width + "px"; + node.style.height = height + "px"; } } @@ -108,8 +110,8 @@ class PostContentControl { if (window.innerWidth < 1000) { width = Math.max(window.innerWidth, width); } - this._postContentNode.style.width = width + 'px'; - this._postContentNode.style.height = height + 'px'; + this._postContentNode.style.width = width + "px"; + this._postContentNode.style.height = height + "px"; } _refreshSize() { @@ -122,8 +124,9 @@ class PostContentControl { if (window.innerWidth > 1000) { optimizedResize.add(() => this._refreshSize()); } - views.monitorNodeRemoval( - this._hostNode, () => { this._uninstall(); }); + views.monitorNodeRemoval(this._hostNode, () => { + this._uninstall(); + }); } _reinstall() { @@ -132,7 +135,7 @@ class PostContentControl { autoplay: settings.get().autoplayVideos, }); if (settings.get().transparencyGrid) { - newNode.classList.add('transparency-grid'); + newNode.classList.add("transparency-grid"); } if (this._postContentNode) { this._hostNode.replaceChild(newNode, this._postContentNode); diff --git a/client/js/controls/post_edit_sidebar_control.js b/client/js/controls/post_edit_sidebar_control.js index fb06406..1a95018 100644 --- a/client/js/controls/post_edit_sidebar_control.js +++ b/client/js/controls/post_edit_sidebar_control.js @@ -1,18 +1,19 @@ -'use strict'; +"use strict"; -const api = require('../api.js'); -const events = require('../events.js'); -const misc = require('../util/misc.js'); -const views = require('../util/views.js'); +const api = require("../api.js"); +const events = require("../events.js"); +const misc = require("../util/misc.js"); const keyboard = require('../util/keyboard.js'); -const Note = require('../models/note.js'); -const Point = require('../models/point.js'); -const TagInputControl = require('./tag_input_control.js'); -const PostMetricInputControl = require('./post_metric_input_control.js'); -const ExpanderControl = require('../controls/expander_control.js'); -const FileDropperControl = require('../controls/file_dropper_control.js'); +const views = require("../util/views.js"); +const Note = require("../models/note.js"); +const Point = require("../models/point.js"); +const TagInputControl = require("./tag_input_control.js"); +const PoolInputControl = require("./pool_input_control.js"); +const ExpanderControl = require("../controls/expander_control.js"); +const FileDropperControl = require("../controls/file_dropper_control.js"); +const PostMetricInputControl = require("./post_metric_input_control.js"); -const template = views.getTemplate('post-edit-sidebar'); +const template = views.getTemplate("post-edit-sidebar"); class PostEditSidebarControl extends events.EventTarget { constructor(hostNode, ctx, postContentControl, postNotesOverlayControl) { @@ -26,193 +27,255 @@ class PostEditSidebarControl extends events.EventTarget { this._postNotesOverlayControl.switchToPassiveEdit(); - views.replaceContent(this._hostNode, template({ - post: this._post, - enableSafety: api.safetyEnabled(), - hasClipboard: document.queryCommandSupported('copy'), - canEditPostSafety: api.hasPrivilege('posts:edit:safety'), - canEditPostSource: api.hasPrivilege('posts:edit:source'), - canEditPostTags: api.hasPrivilege('posts:edit:tags'), - canEditPostMetrics: api.hasPrivilege('metrics:edit:posts'), - canEditPostRelations: api.hasPrivilege('posts:edit:relations'), - canEditPostNotes: api.hasPrivilege('posts:edit:notes') && - this._post.type !== 'video' && - this._post.type !== 'flash', - canEditPostFlags: api.hasPrivilege('posts:edit:flags'), - canEditPostContent: api.hasPrivilege('posts:edit:content'), - canEditPostThumbnail: api.hasPrivilege('posts:edit:thumbnail'), - canCreateAnonymousPosts: api.hasPrivilege('posts:create:anonymous'), - canDeletePosts: api.hasPrivilege('posts:delete'), - canFeaturePosts: api.hasPrivilege('posts:feature'), - canMergePosts: api.hasPrivilege('posts:merge'), - })); + views.replaceContent( + this._hostNode, + template({ + post: this._post, + enableSafety: api.safetyEnabled(), + hasClipboard: document.queryCommandSupported("copy"), + canEditPostSafety: api.hasPrivilege("posts:edit:safety"), + canEditPostSource: api.hasPrivilege("posts:edit:source"), + canEditPostTags: api.hasPrivilege("posts:edit:tags"), + canEditPostMetrics: api.hasPrivilege("metrics:edit:posts"), + canEditPostRelations: api.hasPrivilege("posts:edit:relations"), + canEditPostNotes: + api.hasPrivilege("posts:edit:notes") && + post.type !== "video" && + post.type !== "flash", + canEditPostFlags: api.hasPrivilege("posts:edit:flags"), + canEditPostContent: api.hasPrivilege("posts:edit:content"), + canEditPostThumbnail: api.hasPrivilege("posts:edit:thumbnail"), + canEditPoolPosts: api.hasPrivilege("pools:edit:posts"), + canCreateAnonymousPosts: api.hasPrivilege( + "posts:create:anonymous" + ), + canDeletePosts: api.hasPrivilege("posts:delete"), + canFeaturePosts: api.hasPrivilege("posts:feature"), + canMergePosts: api.hasPrivilege("posts:merge"), + }) + ); new ExpanderControl( - 'post-info', - 'Basic info', - this._hostNode.querySelectorAll('.safety, .relations, .flags, .post-source')); + "post-info", + "Basic info", + this._hostNode.querySelectorAll( + ".safety, .relations, .flags, .post-source" + ) + ); this._tagsExpander = new ExpanderControl( - 'post-tags', + "post-tags", `Tags (${this._post.tags.length})`, - this._hostNode.querySelectorAll('.tags')); + this._hostNode.querySelectorAll(".tags") + ); this._metricsExpander = new ExpanderControl( - 'post-metrics', + "post-metrics", `Metrics (${this._post.tags.filterMetrics().length})`, - this._hostNode.querySelectorAll('.metrics')); + this._hostNode.querySelectorAll(".metrics") + ); this._notesExpander = new ExpanderControl( - 'post-notes', - 'Notes', - this._hostNode.querySelectorAll('.notes')); + "post-notes", + "Notes", + this._hostNode.querySelectorAll(".notes") + ); + this._poolsExpander = new ExpanderControl( + "post-pools", + `Pools (${this._post.pools.length})`, + this._hostNode.querySelectorAll(".pools") + ); new ExpanderControl( - 'post-content', - 'Content', - this._hostNode.querySelectorAll('.post-content, .post-thumbnail')); + "post-content", + "Content", + this._hostNode.querySelectorAll(".post-content, .post-thumbnail") + ); new ExpanderControl( - 'post-management', - 'Management', - this._hostNode.querySelectorAll('.management')); + "post-management", + "Management", + this._hostNode.querySelectorAll(".management") + ); this._syncExpanderTitles(); if (this._formNode) { - this._formNode.addEventListener('submit', e => this._evtSubmit(e)); + this._formNode.addEventListener("submit", (e) => + this._evtSubmit(e) + ); } if (this._tagInputNode) { this._tagControl = new TagInputControl( - this._tagInputNode, this._post.tags); + this._tagInputNode, + this._post.tags + ); + } + + if (this._poolInputNode) { + this._poolControl = new PoolInputControl( + this._poolInputNode, + this._post.pools + ); } if (this._metricInputNode) { this._metricControl = new PostMetricInputControl( - this._metricInputNode, this._ctx); + this._metricInputNode, + this._ctx + ); } if (this._contentInputNode) { this._contentFileDropper = new FileDropperControl( - this._contentInputNode, { + this._contentInputNode, + { allowUrls: true, lock: true, - urlPlaceholder: '...or paste an URL here.'}); - this._contentFileDropper.addEventListener('fileadd', e => { + urlPlaceholder: "...or paste an URL here.", + } + ); + this._contentFileDropper.addEventListener("fileadd", (e) => { this._newPostContent = e.detail.files[0]; }); - this._contentFileDropper.addEventListener('urladd', e => { + this._contentFileDropper.addEventListener("urladd", (e) => { this._newPostContent = e.detail.urls[0]; }); } if (this._thumbnailInputNode) { this._thumbnailFileDropper = new FileDropperControl( - this._thumbnailInputNode, {lock: true}); - this._thumbnailFileDropper.addEventListener('fileadd', e => { + this._thumbnailInputNode, + { lock: true } + ); + this._thumbnailFileDropper.addEventListener("fileadd", (e) => { this._newPostThumbnail = e.detail.files[0]; - this._thumbnailRemovalLinkNode.style.display = 'block'; + this._thumbnailRemovalLinkNode.style.display = "block"; }); } if (this._thumbnailRemovalLinkNode) { - this._thumbnailRemovalLinkNode.addEventListener( - 'click', e => this._evtRemoveThumbnailClick(e)); - this._thumbnailRemovalLinkNode.style.display = - this._post.hasCustomThumbnail ? 'block' : 'none'; + this._thumbnailRemovalLinkNode.addEventListener("click", (e) => + this._evtRemoveThumbnailClick(e) + ); + this._thumbnailRemovalLinkNode.style.display = this._post + .hasCustomThumbnail + ? "block" + : "none"; } if (this._addNoteLinkNode) { - this._addNoteLinkNode.addEventListener( - 'click', e => this._evtAddNoteClick(e)); + this._addNoteLinkNode.addEventListener("click", (e) => + this._evtAddNoteClick(e) + ); } if (this._copyNotesLinkNode) { - this._copyNotesLinkNode.addEventListener( - 'click', e => this._evtCopyNotesClick(e)); + this._copyNotesLinkNode.addEventListener("click", (e) => + this._evtCopyNotesClick(e) + ); } if (this._pasteNotesLinkNode) { - this._pasteNotesLinkNode.addEventListener( - 'click', e => this._evtPasteNotesClick(e)); + this._pasteNotesLinkNode.addEventListener("click", (e) => + this._evtPasteNotesClick(e) + ); } if (this._deleteNoteLinkNode) { - this._deleteNoteLinkNode.addEventListener( - 'click', e => this._evtDeleteNoteClick(e)); + this._deleteNoteLinkNode.addEventListener("click", (e) => + this._evtDeleteNoteClick(e) + ); } if (this._featureLinkNode) { - this._featureLinkNode.addEventListener( - 'click', e => this._evtFeatureClick(e)); + this._featureLinkNode.addEventListener("click", (e) => + this._evtFeatureClick(e) + ); } if (this._mergeLinkNode) { - this._mergeLinkNode.addEventListener( - 'click', e => this._evtMergeClick(e)); + this._mergeLinkNode.addEventListener("click", (e) => + this._evtMergeClick(e) + ); } if (this._deleteLinkNode) { - this._deleteLinkNode.addEventListener( - 'click', e => this._evtDeleteClick(e)); + this._deleteLinkNode.addEventListener("click", (e) => + this._evtDeleteClick(e) + ); } - this._postNotesOverlayControl.addEventListener( - 'blur', e => this._evtNoteBlur(e)); + this._postNotesOverlayControl.addEventListener("blur", (e) => + this._evtNoteBlur(e) + ); - this._postNotesOverlayControl.addEventListener( - 'focus', e => this._evtNoteFocus(e)); + this._postNotesOverlayControl.addEventListener("focus", (e) => + this._evtNoteFocus(e) + ); - this._post.addEventListener( - 'changeContent', e => this._evtPostContentChange(e)); + this._post.addEventListener("changeContent", (e) => + this._evtPostContentChange(e) + ); - this._post.addEventListener( - 'changeThumbnail', e => this._evtPostThumbnailChange(e)); + this._post.addEventListener("changeThumbnail", (e) => + this._evtPostThumbnailChange(e) + ); if (this._formNode) { const inputNodes = this._formNode.querySelectorAll( - 'input, textarea'); + "input, textarea" + ); for (let node of inputNodes) { - node.addEventListener( - 'change', - e => this.dispatchEvent(new CustomEvent('change'))); + node.addEventListener("change", (e) => + this.dispatchEvent(new CustomEvent("change")) + ); } - this._postNotesOverlayControl.addEventListener( - 'change', - e => this.dispatchEvent(new CustomEvent('change'))); + this._postNotesOverlayControl.addEventListener("change", (e) => + this.dispatchEvent(new CustomEvent("change")) + ); } - for (let eventType of ['add', 'remove']) { - this._post.notes.addEventListener(eventType, e => { + for (let eventType of ["add", "remove"]) { + this._post.notes.addEventListener(eventType, (e) => { + this._syncExpanderTitles(); + }); + this._post.pools.addEventListener(eventType, (e) => { this._syncExpanderTitles(); }); } if (this._tagControl) { - this._tagControl.addEventListener( - 'change', e => { - this.dispatchEvent(new CustomEvent('change')); - this._syncExpanderTitles(); - this._post.removeMetricsWithoutTag(); - if (this._metricControl) { - this._metricControl.refreshContent(); - } - }); + this._tagControl.addEventListener("change", (e) => { + this.dispatchEvent(new CustomEvent("change")); + this._syncExpanderTitles(); + this._post.removeMetricsWithoutTag(); + if (this._metricControl) { + this._metricControl.refreshContent(); + } + }); } if (this._metricControl) { - this._metricControl.addEventListener( - 'change', e => { - this.dispatchEvent(new CustomEvent('change')); - this._syncExpanderTitles(); - }); + this._metricControl.addEventListener("change", (e) => { + this.dispatchEvent(new CustomEvent("change")); + this._syncExpanderTitles(); + }); } if (this._noteTextareaNode) { - this._noteTextareaNode.addEventListener( - 'change', e => this._evtNoteTextChangeRequest(e)); + this._noteTextareaNode.addEventListener("change", (e) => + this._evtNoteTextChangeRequest(e) + ); } - keyboard.bind(['command+s', 'ctrl+s'], (e) => this._evtSubmit(e)); + if (this._poolControl) { + this._poolControl.addEventListener("change", (e) => { + this.dispatchEvent(new CustomEvent("change")); + this._syncExpanderTitles(); + }); + } + + keyboard.bind(["command+s", "ctrl+s"], (e) => this._evtSubmit(e)); if (this._tagInputNode) { - const realTagInput = this._formNode.querySelector('.tag-input input'); - keyboard.bindElement(realTagInput, ['command+s', 'ctrl+s'], (e) => this._evtSubmit(e)); + const realTagInput = this._formNode.querySelector(".tag-input input"); + keyboard.bindElement(realTagInput, ["command+s", "ctrl+s"], (e) => this._evtSubmit(e)); keyboard.bind('t', (e) => { e.preventDefault(); realTagInput.focus(); @@ -223,12 +286,13 @@ class PostEditSidebarControl extends events.EventTarget { _syncExpanderTitles() { this._notesExpander.title = `Notes (${this._post.notes.length})`; this._tagsExpander.title = `Tags (${this._post.tags.length})`; + this._poolsExpander.title = `Pools (${this._post.pools.length})`; let metricCount = this._post.tags.filterMetrics().length; if (metricCount > 0) { - this._metricsExpander.containerNode.style.display = 'block'; + this._metricsExpander.containerNode.style.display = "block"; this._metricsExpander.title = `Metrics (${metricCount})`; } else { - this._metricsExpander.containerNode.style.display = 'none'; + this._metricsExpander.containerNode.style.display = "none"; } } @@ -244,37 +308,43 @@ class PostEditSidebarControl extends events.EventTarget { e.preventDefault(); this._thumbnailFileDropper.reset(); this._newPostThumbnail = null; - this._thumbnailRemovalLinkNode.style.display = 'none'; + this._thumbnailRemovalLinkNode.style.display = "none"; } _evtFeatureClick(e) { e.preventDefault(); - if (confirm('Are you sure you want to feature this post?')) { - this.dispatchEvent(new CustomEvent('feature', { - detail: { - post: this._post, - }, - })); + if (confirm("Are you sure you want to feature this post?")) { + this.dispatchEvent( + new CustomEvent("feature", { + detail: { + post: this._post, + }, + }) + ); } } _evtMergeClick(e) { e.preventDefault(); - this.dispatchEvent(new CustomEvent('merge', { - detail: { - post: this._post, - }, - })); + this.dispatchEvent( + new CustomEvent("merge", { + detail: { + post: this._post, + }, + }) + ); } _evtDeleteClick(e) { e.preventDefault(); - if (confirm('Are you sure you want to delete this post?')) { - this.dispatchEvent(new CustomEvent('delete', { - detail: { - post: this._post, - }, - })); + if (confirm("Are you sure you want to delete this post?")) { + this.dispatchEvent( + new CustomEvent("delete", { + detail: { + post: this._post, + }, + }) + ); } } @@ -286,59 +356,64 @@ class PostEditSidebarControl extends events.EventTarget { _evtNoteFocus(e) { this._editedNote = e.detail.note; - this._addNoteLinkNode.classList.remove('inactive'); - this._deleteNoteLinkNode.classList.remove('inactive'); - this._noteTextareaNode.removeAttribute('disabled'); + this._addNoteLinkNode.classList.remove("inactive"); + this._deleteNoteLinkNode.classList.remove("inactive"); + this._noteTextareaNode.removeAttribute("disabled"); this._noteTextareaNode.value = e.detail.note.text; } _evtNoteBlur(e) { this._evtNoteTextChangeRequest(null); - this._addNoteLinkNode.classList.remove('inactive'); - this._deleteNoteLinkNode.classList.add('inactive'); + this._addNoteLinkNode.classList.remove("inactive"); + this._deleteNoteLinkNode.classList.add("inactive"); this._noteTextareaNode.blur(); - this._noteTextareaNode.setAttribute('disabled', 'disabled'); - this._noteTextareaNode.value = ''; + this._noteTextareaNode.setAttribute("disabled", "disabled"); + this._noteTextareaNode.value = ""; } _evtAddNoteClick(e) { e.preventDefault(); - if (e.target.classList.contains('inactive')) { + if (e.target.classList.contains("inactive")) { return; } - this._addNoteLinkNode.classList.add('inactive'); + this._addNoteLinkNode.classList.add("inactive"); this._postNotesOverlayControl.switchToDrawing(); } _evtCopyNotesClick(e) { e.preventDefault(); - let textarea = document.createElement('textarea'); - textarea.style.position = 'fixed'; - textarea.style.opacity = '0'; - textarea.value = JSON.stringify([...this._post.notes].map(note => ({ - polygon: [...note.polygon].map( - point => [point.x, point.y]), - text: note.text, - }))); + let textarea = document.createElement("textarea"); + textarea.style.position = "fixed"; + textarea.style.opacity = "0"; + textarea.value = JSON.stringify( + [...this._post.notes].map((note) => ({ + polygon: [...note.polygon].map((point) => [point.x, point.y]), + text: note.text, + })) + ); document.body.appendChild(textarea); textarea.select(); let success = false; try { - success = document.execCommand('copy'); + success = document.execCommand("copy"); } catch (err) { + // continue regardless of error } textarea.blur(); document.body.removeChild(textarea); - alert(success - ? 'Notes copied to clipboard.' - : 'Failed to copy the text to clipboard. Sorry.'); + alert( + success + ? "Notes copied to clipboard." + : "Failed to copy the text to clipboard. Sorry." + ); } _evtPasteNotesClick(e) { e.preventDefault(); const text = window.prompt( - 'Please enter the exported notes snapshot:'); + "Please enter the exported notes snapshot:" + ); if (!text) { return; } @@ -356,7 +431,7 @@ class PostEditSidebarControl extends events.EventTarget { _evtDeleteNoteClick(e) { e.preventDefault(); - if (e.target.classList.contains('inactive')) { + if (e.target.classList.contains("inactive")) { return; } this._post.notes.remove(this._editedNote); @@ -365,128 +440,152 @@ class PostEditSidebarControl extends events.EventTarget { _evtSubmit(e) { e.preventDefault(); - this.dispatchEvent(new CustomEvent('submit', { - detail: { - post: this._post, + this.dispatchEvent( + new CustomEvent("submit", { + detail: { + post: this._post, + + safety: this._safetyButtonNodes.length + ? Array.from(this._safetyButtonNodes) + .filter((node) => node.checked)[0] + .value.toLowerCase() + : undefined, - safety: this._safetyButtonNodes.length ? - Array.from(this._safetyButtonNodes) - .filter(node => node.checked)[0] - .value.toLowerCase() : - undefined, + flags: this._videoFlags, - flags: this._videoFlags, + tags: this._tagInputNode + ? misc.splitByWhitespace(this._tagInputNode.value) + : undefined, - tags: this._tagInputNode ? - misc.splitByWhitespace(this._tagInputNode.value) : - undefined, - relations: this._relationsInputNode ? - misc.splitByWhitespace(this._relationsInputNode.value) - .map(x => parseInt(x)) : - undefined, + pools: this._poolInputNode + ? misc.splitByWhitespace(this._poolInputNode.value) + : undefined, - content: this._newPostContent ? - this._newPostContent : - undefined, + relations: this._relationsInputNode + ? misc + .splitByWhitespace( + this._relationsInputNode.value + ) + .map((x) => parseInt(x)) + : undefined, - thumbnail: this._newPostThumbnail !== undefined ? - this._newPostThumbnail : - undefined, + content: this._newPostContent + ? this._newPostContent + : undefined, - source: this._sourceInputNode ? - this._sourceInputNode.value : - undefined, - }, - })); + thumbnail: + this._newPostThumbnail !== undefined + ? this._newPostThumbnail + : undefined, + + source: this._sourceInputNode + ? this._sourceInputNode.value + : undefined, + }, + }) + ); } get _formNode() { - return this._hostNode.querySelector('form'); + return this._hostNode.querySelector("form"); } get _submitButtonNode() { - return this._hostNode.querySelector('.submit'); + return this._hostNode.querySelector(".submit"); } get _safetyButtonNodes() { - return this._formNode.querySelectorAll('.safety input'); + return this._formNode.querySelectorAll(".safety input"); } get _tagInputNode() { - return this._formNode.querySelector('.tags input'); + return this._formNode.querySelector(".tags input"); + } + + get _poolInputNode() { + return this._formNode.querySelector(".pools input"); } get _loopVideoInputNode() { - return this._formNode.querySelector('.flags input[name=loop]'); + return this._formNode.querySelector(".flags input[name=loop]"); } get _soundVideoInputNode() { - return this._formNode.querySelector('.flags input[name=sound]'); + return this._formNode.querySelector(".flags input[name=sound]"); } get _videoFlags() { - if (!this._loopVideoInputNode) return undefined; + if (!this._loopVideoInputNode) { + return undefined; + } let ret = []; - if (this._loopVideoInputNode.checked) ret.push('loop'); - if (this._soundVideoInputNode.checked) ret.push('sound'); + if (this._loopVideoInputNode.checked) { + ret.push("loop"); + } + if (this._soundVideoInputNode.checked) { + ret.push("sound"); + } return ret; } get _relationsInputNode() { - return this._formNode.querySelector('.relations input'); + return this._formNode.querySelector(".relations input"); } get _contentInputNode() { - return this._formNode.querySelector('.post-content .dropper-container'); + return this._formNode.querySelector( + ".post-content .dropper-container" + ); } get _thumbnailInputNode() { return this._formNode.querySelector( - '.post-thumbnail .dropper-container'); + ".post-thumbnail .dropper-container" + ); } get _thumbnailRemovalLinkNode() { - return this._formNode.querySelector('.post-thumbnail a'); + return this._formNode.querySelector(".post-thumbnail a"); } get _sourceInputNode() { - return this._formNode.querySelector('.post-source input'); + return this._formNode.querySelector(".post-source textarea"); } get _featureLinkNode() { - return this._formNode.querySelector('.management .feature'); + return this._formNode.querySelector(".management .feature"); } get _mergeLinkNode() { - return this._formNode.querySelector('.management .merge'); + return this._formNode.querySelector(".management .merge"); } get _deleteLinkNode() { - return this._formNode.querySelector('.management .delete'); + return this._formNode.querySelector(".management .delete"); } get _addNoteLinkNode() { - return this._formNode.querySelector('.notes .add'); + return this._formNode.querySelector(".notes .add"); } get _copyNotesLinkNode() { - return this._formNode.querySelector('.notes .copy'); + return this._formNode.querySelector(".notes .copy"); } get _pasteNotesLinkNode() { - return this._formNode.querySelector('.notes .paste'); + return this._formNode.querySelector(".notes .paste"); } get _deleteNoteLinkNode() { - return this._formNode.querySelector('.notes .delete'); + return this._formNode.querySelector(".notes .delete"); } get _noteTextareaNode() { - return this._formNode.querySelector('.notes textarea'); + return this._formNode.querySelector(".notes textarea"); } get _metricInputNode() { - return this._formNode.querySelector('.metrics input'); + return this._formNode.querySelector(".metrics input"); } enableForm() { @@ -508,6 +607,6 @@ class PostEditSidebarControl extends events.EventTarget { showError(message) { views.showError(this._hostNode, message); } -}; +} module.exports = PostEditSidebarControl; diff --git a/client/js/controls/post_notes_overlay_control.js b/client/js/controls/post_notes_overlay_control.js index bbea095..030f7f2 100644 --- a/client/js/controls/post_notes_overlay_control.js +++ b/client/js/controls/post_notes_overlay_control.js @@ -1,13 +1,13 @@ -'use strict'; +"use strict"; -const keyboard = require('../util/keyboard.js'); -const views = require('../util/views.js'); -const events = require('../events.js'); -const misc = require('../util/misc.js'); -const Note = require('../models/note.js'); -const Point = require('../models/point.js'); +const keyboard = require("../util/keyboard.js"); +const views = require("../util/views.js"); +const events = require("../events.js"); +const misc = require("../util/misc.js"); +const Note = require("../models/note.js"); +const Point = require("../models/point.js"); -const svgNS = 'http://www.w3.org/2000/svg'; +const svgNS = "http://www.w3.org/2000/svg"; const snapThreshold = 10; const circleSize = 10; @@ -22,19 +22,19 @@ const KEY_RETURN = 13; function _getDistance(point1, point2) { return Math.sqrt( - Math.pow(point1.x - point2.x, 2) + - Math.pow(point1.y - point2.y, 2)); + Math.pow(point1.x - point2.x, 2) + Math.pow(point1.y - point2.y, 2) + ); } function _setNodeState(node, stateName) { if (node === null) { return; } - node.setAttribute('data-state', stateName); + node.setAttribute("data-state", stateName); } function _clearEditedNote(hostNode) { - const node = hostNode.querySelector('[data-state=\'editing\']'); + const node = hostNode.querySelector("[data-state='editing']"); _setNodeState(node, null); return node !== null; } @@ -82,32 +82,30 @@ class State { return false; } - evtCanvasKeyDown(e) { - } + evtCanvasKeyDown(e) {} - evtNoteMouseDown(e, hoveredNote) { - } + evtNoteMouseDown(e, hoveredNote) {} - evtCanvasMouseDown(e) { - } + evtCanvasMouseDown(e) {} - evtCanvasMouseMove(e) { - } + evtCanvasMouseMove(e) {} - evtCanvasMouseUp(e) { - } + evtCanvasMouseUp(e) {} _getScreenPoint(point) { return new Point( point.x * this._control.boundingBox.width, - point.y * this._control.boundingBox.height); + point.y * this._control.boundingBox.height + ); } _snapPoints(targetPoint, referencePoint) { const targetScreenPoint = this._getScreenPoint(targetPoint); const referenceScreenPoint = this._getScreenPoint(referencePoint); - if (_getDistance(targetScreenPoint, referenceScreenPoint) < - snapThreshold) { + if ( + _getDistance(targetScreenPoint, referenceScreenPoint) < + snapThreshold + ) { targetPoint.x = referencePoint.x; targetPoint.y = referencePoint.y; } @@ -124,15 +122,16 @@ class State { (e.clientX - this._control.boundingBox.left) / this._control.boundingBox.width, (e.clientY - this._control.boundingBox.top) / - this._control.boundingBox.height); + this._control.boundingBox.height + ); } } class ReadOnlyState extends State { constructor(control) { - super(control, 'read-only'); + super(control, "read-only"); if (_clearEditedNote(control._hostNode)) { - this._control.dispatchEvent(new CustomEvent('blur')); + this._control.dispatchEvent(new CustomEvent("blur")); } keyboard.unpause(); } @@ -144,9 +143,9 @@ class ReadOnlyState extends State { class PassiveState extends State { constructor(control) { - super(control, 'passive'); + super(control, "passive"); if (_clearEditedNote(control._hostNode)) { - this._control.dispatchEvent(new CustomEvent('blur')); + this._control.dispatchEvent(new CustomEvent("blur")); } keyboard.unpause(); } @@ -164,23 +163,24 @@ class ActiveState extends State { constructor(control, note, stateName) { super(control, stateName); if (_clearEditedNote(control._hostNode)) { - this._control.dispatchEvent(new CustomEvent('blur')); + this._control.dispatchEvent(new CustomEvent("blur")); } keyboard.pause(); if (note !== null) { this._note = note; this._control.dispatchEvent( - new CustomEvent('focus', { - detail: {note: note}, - })); - _setNodeState(this._note.groupNode, 'editing'); + new CustomEvent("focus", { + detail: { note: note }, + }) + ); + _setNodeState(this._note.groupNode, "editing"); } } } class SelectedState extends ActiveState { constructor(control, note) { - super(control, note, 'selected'); + super(control, note, "selected"); this._clickTimeout = null; this._control._hideNoteText(); } @@ -188,12 +188,12 @@ class SelectedState extends ActiveState { evtCanvasKeyDown(e) { const delta = e.ctrlKey ? 10 : 1; const offsetMap = { - [KEY_LEFT]: [-delta, 0], - [KEY_UP]: [0, -delta], - [KEY_DOWN]: [0, delta], + [KEY_LEFT]: [-delta, 0], + [KEY_UP]: [0, -delta], + [KEY_DOWN]: [0, delta], [KEY_RIGHT]: [delta, 0], }; - if (offsetMap.hasOwnProperty(e.which)) { + if (Object.prototype.hasOwnProperty.call(offsetMap, e.witch)) { e.stopPropagation(); e.stopImmediatePropagation(); e.preventDefault(); @@ -211,27 +211,40 @@ class SelectedState extends ActiveState { const mouseScreenPoint = this._getScreenPoint(mousePoint); if (e.shiftKey) { this._control._state = new ScalingNoteState( - this._control, this._note, mousePoint); + this._control, + this._note, + mousePoint + ); return; } if (this._note !== hoveredNote) { - this._control._state = - new SelectedState(this._control, hoveredNote); + this._control._state = new SelectedState( + this._control, + hoveredNote + ); return; } this._clickTimeout = window.setTimeout(() => { for (let polygonPoint of this._note.polygon) { const distance = _getDistance( mouseScreenPoint, - this._getScreenPoint(polygonPoint)); + this._getScreenPoint(polygonPoint) + ); if (distance < circleSize) { this._control._state = new MovingPointState( - this._control, this._note, polygonPoint, mousePoint); + this._control, + this._note, + polygonPoint, + mousePoint + ); return; } } this._control._state = new MovingNoteState( - this._control, this._note, mousePoint); + this._control, + this._note, + mousePoint + ); }, 100); } @@ -241,9 +254,12 @@ class SelectedState extends ActiveState { for (let polygonPoint of this._note.polygon) { const distance = _getDistance( mouseScreenPoint, - this._getScreenPoint(polygonPoint)); + this._getScreenPoint(polygonPoint) + ); polygonPoint.edgeNode.classList.toggle( - 'nearby', distance < circleSize); + "nearby", + distance < circleSize + ); } } @@ -252,16 +268,24 @@ class SelectedState extends ActiveState { const mouseScreenPoint = this._getScreenPoint(mousePoint); if (e.shiftKey) { this._control._state = new ScalingNoteState( - this._control, this._note, mousePoint); + this._control, + this._note, + mousePoint + ); return; } for (let polygonPoint of this._note.polygon) { const distance = _getDistance( mouseScreenPoint, - this._getScreenPoint(polygonPoint)); + this._getScreenPoint(polygonPoint) + ); if (distance < circleSize) { this._control._state = new MovingPointState( - this._control, this._note, polygonPoint, mousePoint); + this._control, + this._note, + polygonPoint, + mousePoint + ); return; } } @@ -284,31 +308,36 @@ class SelectedState extends ActiveState { const originalSize = _getNoteSize(this._note); const targetSize = new Point( originalSize.x + x / this._control.boundingBox.width, - originalSize.y + y / this._control.boundingBox.height); + originalSize.y + y / this._control.boundingBox.height + ); const scale = new Point( targetSize.x / originalSize.x, - targetSize.y / originalSize.y); + targetSize.y / originalSize.y + ); for (let point of this._note.polygon) { - point.x = origin.x + ((point.x - origin.x) * scale.x); - point.y = origin.y + ((point.y - origin.y) * scale.y); + point.x = origin.x + (point.x - origin.x) * scale.x; + point.y = origin.y + (point.y - origin.y) * scale.y; } } } class MovingPointState extends ActiveState { constructor(control, note, notePoint, mousePoint) { - super(control, note, 'moving-point'); + super(control, note, "moving-point"); this._notePoint = notePoint; - this._originalNotePoint = {x: notePoint.x, y: notePoint.y}; + this._originalNotePoint = { x: notePoint.x, y: notePoint.y }; this._originalPosition = mousePoint; - _setNodeState(this._note.groupNode, 'editing'); + _setNodeState(this._note.groupNode, "editing"); } evtCanvasKeyDown(e) { - if (e.which == KEY_ESCAPE) { + if (e.which === KEY_ESCAPE) { this._notePoint.x = this._originalNotePoint.x; this._notePoint.y = this._originalNotePoint.y; - this._control._state = new SelectedState(this._control, this._note); + this._control._state = new SelectedState( + this._control, + this._note + ); } } @@ -326,19 +355,24 @@ class MovingPointState extends ActiveState { class MovingNoteState extends ActiveState { constructor(control, note, mousePoint) { - super(control, note, 'moving-note'); - this._originalPolygon = [...note.polygon].map( - point => ({x: point.x, y: point.y})); + super(control, note, "moving-note"); + this._originalPolygon = [...note.polygon].map((point) => ({ + x: point.x, + y: point.y, + })); this._originalPosition = mousePoint; } evtCanvasKeyDown(e) { - if (e.which == KEY_ESCAPE) { + if (e.which === KEY_ESCAPE) { for (let i of misc.range(this._note.polygon.length)) { this._note.polygon.at(i).x = this._originalPolygon[i].x; this._note.polygon.at(i).y = this._originalPolygon[i].y; } - this._control._state = new SelectedState(this._control, this._note); + this._control._state = new SelectedState( + this._control, + this._note + ); } } @@ -358,20 +392,25 @@ class MovingNoteState extends ActiveState { class ScalingNoteState extends ActiveState { constructor(control, note, mousePoint) { - super(control, note, 'scaling-note'); - this._originalPolygon = [...note.polygon].map( - point => ({x: point.x, y: point.y})); + super(control, note, "scaling-note"); + this._originalPolygon = [...note.polygon].map((point) => ({ + x: point.x, + y: point.y, + })); this._originalMousePoint = mousePoint; this._originalSize = _getNoteSize(note); } evtCanvasKeyDown(e) { - if (e.which == KEY_ESCAPE) { + if (e.which === KEY_ESCAPE) { for (let i of misc.range(this._note.polygon.length)) { this._note.polygon.at(i).x = this._originalPolygon[i].x; this._note.polygon.at(i).y = this._originalPolygon[i].y; } - this._control._state = new SelectedState(this._control, this._note); + this._control._state = new SelectedState( + this._control, + this._note + ); } } @@ -385,11 +424,15 @@ class ScalingNoteState extends ActiveState { polygonPoint.x = originalMousePoint.x + (originalPolygonPoint.x - originalMousePoint.x) * - (1 + (mousePoint.x - originalMousePoint.x) / originalSize.x); + (1 + + (mousePoint.x - originalMousePoint.x) / + originalSize.x); polygonPoint.y = originalMousePoint.y + (originalPolygonPoint.y - originalMousePoint.y) * - (1 + (mousePoint.y - originalMousePoint.y) / originalSize.y); + (1 + + (mousePoint.y - originalMousePoint.y) / + originalSize.y); } } @@ -400,7 +443,7 @@ class ScalingNoteState extends ActiveState { class ReadyToDrawState extends ActiveState { constructor(control) { - super(control, null, 'ready-to-draw'); + super(control, null, "ready-to-draw"); } evtNoteMouseDown(e, hoveredNote) { @@ -411,23 +454,27 @@ class ReadyToDrawState extends ActiveState { const mousePoint = this._getPointFromEvent(e); if (e.shiftKey) { this._control._state = new DrawingRectangleState( - this._control, mousePoint); + this._control, + mousePoint + ); } else { this._control._state = new DrawingPolygonState( - this._control, mousePoint); + this._control, + mousePoint + ); } } } class DrawingRectangleState extends ActiveState { constructor(control, mousePoint) { - super(control, null, 'drawing-rectangle'); + super(control, null, "drawing-rectangle"); this._note = this._createNote(); this._note.polygon.add(new Point(mousePoint.x, mousePoint.y)); this._note.polygon.add(new Point(mousePoint.x, mousePoint.y)); this._note.polygon.add(new Point(mousePoint.x, mousePoint.y)); this._note.polygon.add(new Point(mousePoint.x, mousePoint.y)); - _setNodeState(this._note.groupNode, 'drawing'); + _setNodeState(this._note.groupNode, "drawing"); } evtCanvasMouseUp(e) { @@ -443,7 +490,10 @@ class DrawingRectangleState extends ActiveState { this._control._state = new ReadyToDrawState(this._control); } else { this._control._post.notes.add(this._note); - this._control._state = new SelectedState(this._control, this._note); + this._control._state = new SelectedState( + this._control, + this._note + ); } } @@ -458,20 +508,20 @@ class DrawingRectangleState extends ActiveState { class DrawingPolygonState extends ActiveState { constructor(control, mousePoint) { - super(control, null, 'drawing-polygon'); + super(control, null, "drawing-polygon"); this._note = this._createNote(); this._note.polygon.add(new Point(mousePoint.x, mousePoint.y)); this._note.polygon.add(new Point(mousePoint.x, mousePoint.y)); - _setNodeState(this._note.groupNode, 'drawing'); + _setNodeState(this._note.groupNode, "drawing"); } evtCanvasKeyDown(e) { - if (e.which == KEY_ESCAPE) { + if (e.which === KEY_ESCAPE) { this._note.polygon.remove(this._note.polygon.secondLastPoint); if (this._note.polygon.length === 1) { this._cancel(); } - } else if (e.which == KEY_RETURN) { + } else if (e.which === KEY_RETURN) { this._finish(); } } @@ -502,11 +552,16 @@ class DrawingPolygonState extends ActiveState { } if (e.shiftKey && secondLastPoint) { - const direction = (Math.round( - Math.atan2( - secondLastPoint.y - mousePoint.y, - secondLastPoint.x - mousePoint.x) / - (2 * Math.PI / 4)) + 4) % 4; + const direction = + (Math.round( + Math.atan2( + secondLastPoint.y - mousePoint.y, + secondLastPoint.x - mousePoint.x + ) / + ((2 * Math.PI) / 4) + ) + + 4) % + 4; if (direction === 0 || direction === 2) { lastPoint.x = mousePoint.x; lastPoint.y = secondLastPoint.y; @@ -533,7 +588,10 @@ class DrawingPolygonState extends ActiveState { } else { this._control._deleteDomNode(this._note); this._control._post.notes.add(this._note); - this._control._state = new SelectedState(this._control, this._note); + this._control._state = new SelectedState( + this._control, + this._note + ); } } } @@ -544,48 +602,51 @@ class PostNotesOverlayControl extends events.EventTarget { this._post = post; this._hostNode = hostNode; - this._svgNode = document.createElementNS(svgNS, 'svg'); - this._svgNode.classList.add('resize-listener'); - this._svgNode.classList.add('notes-overlay'); - this._svgNode.setAttribute('preserveAspectRatio', 'none'); - this._svgNode.setAttribute('viewBox', '0 0 1 1'); + this._svgNode = document.createElementNS(svgNS, "svg"); + this._svgNode.classList.add("resize-listener"); + this._svgNode.classList.add("notes-overlay"); + this._svgNode.setAttribute("preserveAspectRatio", "none"); + this._svgNode.setAttribute("viewBox", "0 0 1 1"); for (let note of this._post.notes) { this._createPolygonNode(note); } this._hostNode.appendChild(this._svgNode); - this._post.addEventListener('change', e => this._evtPostChange(e)); - this._post.notes.addEventListener('remove', e => { + this._post.addEventListener("change", (e) => this._evtPostChange(e)); + this._post.notes.addEventListener("remove", (e) => { this._deleteDomNode(e.detail.note); }); - this._post.notes.addEventListener('add', e => { + this._post.notes.addEventListener("add", (e) => { this._createPolygonNode(e.detail.note); }); - const keyHandler = e => this._evtCanvasKeyDown(e); - document.addEventListener('keydown', keyHandler); - this._svgNode.addEventListener( - 'mousedown', e => this._evtCanvasMouseDown(e)); - this._svgNode.addEventListener( - 'mouseup', e => this._evtCanvasMouseUp(e)); - this._svgNode.addEventListener( - 'mousemove', e => this._evtCanvasMouseMove(e)); + const keyHandler = (e) => this._evtCanvasKeyDown(e); + document.addEventListener("keydown", keyHandler); + this._svgNode.addEventListener("mousedown", (e) => + this._evtCanvasMouseDown(e) + ); + this._svgNode.addEventListener("mouseup", (e) => + this._evtCanvasMouseUp(e) + ); + this._svgNode.addEventListener("mousemove", (e) => + this._evtCanvasMouseMove(e) + ); - const wrapperNode = document.createElement('div'); - wrapperNode.classList.add('wrapper'); - this._textNode = document.createElement('div'); - this._textNode.classList.add('note-text'); + const wrapperNode = document.createElement("div"); + wrapperNode.classList.add("wrapper"); + this._textNode = document.createElement("div"); + this._textNode.classList.add("note-text"); this._textNode.appendChild(wrapperNode); - this._textNode.addEventListener( - 'mouseleave', e => this._evtNoteMouseLeave(e)); + this._textNode.addEventListener("mouseleave", (e) => + this._evtNoteMouseLeave(e) + ); document.body.appendChild(this._textNode); - views.monitorNodeRemoval( - this._hostNode, () => { - this._hostNode.removeChild(this._svgNode); - document.removeEventListener('keydown', keyHandler); - document.body.removeChild(this._textNode); - this._state = new ReadOnlyState(this); - }); + views.monitorNodeRemoval(this._hostNode, () => { + this._hostNode.removeChild(this._svgNode); + document.removeEventListener("keydown", keyHandler); + document.body.removeChild(this._textNode); + this._state = new ReadOnlyState(this); + }); this._state = new ReadOnlyState(this); } @@ -613,7 +674,7 @@ class PostNotesOverlayControl extends events.EventTarget { } _evtCanvasKeyDown(e) { - const illegalNodeNames = ['textarea', 'input', 'select']; + const illegalNodeNames = ["textarea", "input", "select"]; if (illegalNodeNames.includes(e.target.nodeName.toLowerCase())) { return; } @@ -655,53 +716,58 @@ class PostNotesOverlayControl extends events.EventTarget { _evtNoteMouseLeave(e) { const newElement = e.relatedTarget; - if (newElement === this._svgNode || - (!this._svgNode.contains(newElement) && + if ( + newElement === this._svgNode || + (!this._svgNode.contains(newElement) && !this._textNode.contains(newElement) && - newElement !== this._textNode)) { + newElement !== this._textNode) + ) { this._hideNoteText(); } } _showNoteText(note) { - this._textNode.querySelector('.wrapper').innerHTML = - misc.formatMarkdown(note.text); - this._textNode.style.display = 'block'; + this._textNode.querySelector( + ".wrapper" + ).innerHTML = misc.formatMarkdown(note.text); + this._textNode.style.display = "block"; const bodyRect = document.body.getBoundingClientRect(); const noteRect = this._textNode.getBoundingClientRect(); const svgRect = this.boundingBox; const centroid = _getNoteCentroid(note); - const x = ( + const x = -bodyRect.left + svgRect.left + svgRect.width * centroid.x - - noteRect.width / 2); - const y = ( + noteRect.width / 2; + const y = -bodyRect.top + svgRect.top + svgRect.height * centroid.y - - noteRect.height / 2); - this._textNode.style.left = x + 'px'; - this._textNode.style.top = y + 'px'; + noteRect.height / 2; + this._textNode.style.left = x + "px"; + this._textNode.style.top = y + "px"; } _hideNoteText() { - this._textNode.style.display = 'none'; + this._textNode.style.display = "none"; } _updatePolygonNotePoints(note) { note.polygonNode.setAttribute( - 'points', - [...note.polygon].map( - point => [point.x, point.y].join(',')).join(' ')); + "points", + [...note.polygon] + .map((point) => [point.x, point.y].join(",")) + .join(" ") + ); } _createEdgeNode(point, groupNode) { - const node = document.createElementNS(svgNS, 'ellipse'); - node.setAttribute('cx', point.x); - node.setAttribute('cy', point.y); - node.setAttribute('rx', circleSize / 2 / this.boundingBox.width); - node.setAttribute('ry', circleSize / 2 / this.boundingBox.height); + const node = document.createElementNS(svgNS, "ellipse"); + node.setAttribute("cx", point.x); + node.setAttribute("cy", point.y); + node.setAttribute("rx", circleSize / 2 / this.boundingBox.width); + node.setAttribute("ry", circleSize / 2 / this.boundingBox.height); point.edgeNode = node; groupNode.appendChild(node); } @@ -713,8 +779,8 @@ class PostNotesOverlayControl extends events.EventTarget { _updateEdgeNode(point, note) { this._updatePolygonNotePoints(note); - point.edgeNode.setAttribute('cx', point.x); - point.edgeNode.setAttribute('cy', point.y); + point.edgeNode.setAttribute("cx", point.x); + point.edgeNode.setAttribute("cy", point.y); } _deleteDomNode(note) { @@ -722,17 +788,19 @@ class PostNotesOverlayControl extends events.EventTarget { } _createPolygonNode(note) { - const groupNode = document.createElementNS(svgNS, 'g'); + const groupNode = document.createElementNS(svgNS, "g"); note.groupNode = groupNode; { - const node = document.createElementNS(svgNS, 'polygon'); + const node = document.createElementNS(svgNS, "polygon"); note.polygonNode = node; - node.setAttribute('vector-effect', 'non-scaling-stroke'); - node.setAttribute('stroke-alignment', 'inside'); - node.addEventListener( - 'mouseenter', e => this._evtNoteMouseEnter(e, note)); - node.addEventListener( - 'mouseleave', e => this._evtNoteMouseLeave(e)); + node.setAttribute("vector-effect", "non-scaling-stroke"); + node.setAttribute("stroke-alignment", "inside"); + node.addEventListener("mouseenter", (e) => + this._evtNoteMouseEnter(e, note) + ); + node.addEventListener("mouseleave", (e) => + this._evtNoteMouseLeave(e) + ); this._updatePolygonNotePoints(note); groupNode.appendChild(node); } @@ -740,17 +808,17 @@ class PostNotesOverlayControl extends events.EventTarget { this._createEdgeNode(point, groupNode); } - note.polygon.addEventListener('change', e => { + note.polygon.addEventListener("change", (e) => { this._updateEdgeNode(e.detail.point, note); - this.dispatchEvent(new CustomEvent('change')); + this.dispatchEvent(new CustomEvent("change")); }); - note.polygon.addEventListener('remove', e => { + note.polygon.addEventListener("remove", (e) => { this._deleteEdgeNode(e.detail.point, note); - this.dispatchEvent(new CustomEvent('change')); + this.dispatchEvent(new CustomEvent("change")); }); - note.polygon.addEventListener('add', e => { + note.polygon.addEventListener("add", (e) => { this._createEdgeNode(e.detail.point, groupNode); - this.dispatchEvent(new CustomEvent('change')); + this.dispatchEvent(new CustomEvent("change")); }); this._svgNode.appendChild(groupNode); diff --git a/client/js/controls/post_readonly_sidebar_control.js b/client/js/controls/post_readonly_sidebar_control.js index 62bfd79..d205d37 100644 --- a/client/js/controls/post_readonly_sidebar_control.js +++ b/client/js/controls/post_readonly_sidebar_control.js @@ -1,14 +1,15 @@ -'use strict'; +"use strict"; -const api = require('../api.js'); -const events = require('../events.js'); -const views = require('../util/views.js'); -const uri = require('../util/uri.js'); -const PostMetricListControl = require('./post_metric_list_control.js'); +const api = require("../api.js"); +const events = require("../events.js"); +const views = require("../util/views.js"); +const uri = require("../util/uri.js"); +const misc = require("../util/misc.js"); +const PostMetricListControl = require("./post_metric_list_control.js"); -const template = views.getTemplate('post-readonly-sidebar'); -const scoreTemplate = views.getTemplate('score'); -const favTemplate = views.getTemplate('fav'); +const template = views.getTemplate("post-readonly-sidebar"); +const scoreTemplate = views.getTemplate("score"); +const favTemplate = views.getTemplate("fav"); class PostReadonlySidebarControl extends events.EventTarget { constructor(hostNode, ctx, postContentControl) { @@ -18,84 +19,95 @@ class PostReadonlySidebarControl extends events.EventTarget { this._post = ctx.post; this._postContentControl = postContentControl; - this._post.addEventListener('changeFavorite', e => this._evtChangeFav(e)); - this._post.addEventListener('changeScore', e => this._evtChangeScore(e)); + this._post.addEventListener("changeFavorite", (e) => this._evtChangeFav(e)); + this._post.addEventListener("changeScore", (e) => this._evtChangeScore(e)); - views.replaceContent(this._hostNode, template({ - post: this._post, - enableSafety: api.safetyEnabled(), - canListPosts: api.hasPrivilege('posts:list'), - canEditPosts: api.hasPrivilege('posts:edit'), - canViewTags: api.hasPrivilege('tags:view'), - escapeColons: uri.escapeColons, - })); + views.replaceContent( + this._hostNode, + template({ + post: this._post, + enableSafety: api.safetyEnabled(), + canListPosts: api.hasPrivilege("posts:list"), + canEditPosts: api.hasPrivilege("posts:edit"), + canViewTags: api.hasPrivilege("tags:view"), + escapeColons: uri.escapeColons, + extractRootDomain: uri.extractRootDomain, + getPrettyName: misc.getPrettyName, + }) + ); this._installFav(); this._installScore(); this._installFitButtons(); this._syncFitButton(); if (this._metricsListNode) { - this._metricsControl = new PostMetricListControl(this._metricsListNode, this._post); + this._metricsControl = new PostMetricListControl( + this._metricsListNode, this._post + ); } } get _scoreContainerNode() { - return this._hostNode.querySelector('.score-container'); + return this._hostNode.querySelector(".score-container"); } get _favContainerNode() { - return this._hostNode.querySelector('.fav-container'); + return this._hostNode.querySelector(".fav-container"); } get _upvoteButtonNode() { - return this._hostNode.querySelector('.upvote'); + return this._hostNode.querySelector(".upvote"); } get _downvoteButtonNode() { - return this._hostNode.querySelector('.downvote'); + return this._hostNode.querySelector(".downvote"); } get _addFavButtonNode() { - return this._hostNode.querySelector('.add-favorite'); + return this._hostNode.querySelector(".add-favorite"); } get _remFavButtonNode() { - return this._hostNode.querySelector('.remove-favorite'); + return this._hostNode.querySelector(".remove-favorite"); } get _fitBothButtonNode() { - return this._hostNode.querySelector('.fit-both'); + return this._hostNode.querySelector(".fit-both"); } get _fitOriginalButtonNode() { - return this._hostNode.querySelector('.fit-original'); + return this._hostNode.querySelector(".fit-original"); } get _fitWidthButtonNode() { - return this._hostNode.querySelector('.fit-width'); + return this._hostNode.querySelector(".fit-width"); } get _fitHeightButtonNode() { - return this._hostNode.querySelector('.fit-height'); + return this._hostNode.querySelector(".fit-height"); } get _metricsListNode() { - return this._hostNode.querySelector('ul.compact-post-metrics'); + return this._hostNode.querySelector("ul.compact-post-metrics"); } _installFitButtons() { this._fitBothButtonNode.addEventListener( - 'click', this._eventZoomProxy( - () => this._postContentControl.fitBoth())); + "click", + this._eventZoomProxy(() => this._postContentControl.fitBoth()) + ); this._fitOriginalButtonNode.addEventListener( - 'click', this._eventZoomProxy( - () => this._postContentControl.fitOriginal())); + "click", + this._eventZoomProxy(() => this._postContentControl.fitOriginal()) + ); this._fitWidthButtonNode.addEventListener( - 'click', this._eventZoomProxy( - () => this._postContentControl.fitWidth())); + "click", + this._eventZoomProxy(() => this._postContentControl.fitWidth()) + ); this._fitHeightButtonNode.addEventListener( - 'click', this._eventZoomProxy( - () => this._postContentControl.fitHeight())); + "click", + this._eventZoomProxy(() => this._postContentControl.fitHeight()) + ); } _installFav() { @@ -104,16 +116,19 @@ class PostReadonlySidebarControl extends events.EventTarget { favTemplate({ favoriteCount: this._post.favoriteCount, ownFavorite: this._post.ownFavorite, - canFavorite: api.hasPrivilege('posts:favorite'), - })); + canFavorite: api.hasPrivilege("posts:favorite"), + }) + ); if (this._addFavButtonNode) { - this._addFavButtonNode.addEventListener( - 'click', e => this._evtAddToFavoritesClick(e)); + this._addFavButtonNode.addEventListener("click", (e) => + this._evtAddToFavoritesClick(e) + ); } if (this._remFavButtonNode) { - this._remFavButtonNode.addEventListener( - 'click', e => this._evtRemoveFromFavoritesClick(e)); + this._remFavButtonNode.addEventListener("click", (e) => + this._evtRemoveFromFavoritesClick(e) + ); } } @@ -123,77 +138,88 @@ class PostReadonlySidebarControl extends events.EventTarget { scoreTemplate({ score: this._post.score, ownScore: this._post.ownScore, - canScore: api.hasPrivilege('posts:score'), - })); + canScore: api.hasPrivilege("posts:score"), + }) + ); if (this._upvoteButtonNode) { - this._upvoteButtonNode.addEventListener( - 'click', e => this._evtScoreClick(e, 1)); + this._upvoteButtonNode.addEventListener("click", (e) => + this._evtScoreClick(e, 1) + ); } if (this._downvoteButtonNode) { - this._downvoteButtonNode.addEventListener( - 'click', e => this._evtScoreClick(e, -1)); + this._downvoteButtonNode.addEventListener("click", (e) => + this._evtScoreClick(e, -1) + ); } } _eventZoomProxy(func) { - return e => { + return (e) => { e.preventDefault(); e.target.blur(); func(); this._syncFitButton(); - this.dispatchEvent(new CustomEvent('fitModeChange', { - detail: { - mode: this._getFitMode(), - }, - })); + this.dispatchEvent( + new CustomEvent("fitModeChange", { + detail: { + mode: this._getFitMode(), + }, + }) + ); }; } _getFitMode() { const funcToName = {}; - funcToName[this._postContentControl.fitBoth] = 'fit-both'; - funcToName[this._postContentControl.fitOriginal] = 'fit-original'; - funcToName[this._postContentControl.fitWidth] = 'fit-width'; - funcToName[this._postContentControl.fitHeight] = 'fit-height'; + funcToName[this._postContentControl.fitBoth] = "fit-both"; + funcToName[this._postContentControl.fitOriginal] = "fit-original"; + funcToName[this._postContentControl.fitWidth] = "fit-width"; + funcToName[this._postContentControl.fitHeight] = "fit-height"; return funcToName[this._postContentControl._currentFitFunction]; } _syncFitButton() { const className = this._getFitMode(); - const oldNode = this._hostNode.querySelector('.zoom a.active'); + const oldNode = this._hostNode.querySelector(".zoom a.active"); const newNode = this._hostNode.querySelector(`.zoom a.${className}`); if (oldNode) { - oldNode.classList.remove('active'); + oldNode.classList.remove("active"); } - newNode.classList.add('active'); + newNode.classList.add("active"); } _evtAddToFavoritesClick(e) { e.preventDefault(); - this.dispatchEvent(new CustomEvent('favorite', { - detail: { - post: this._post, - }, - })); + this.dispatchEvent( + new CustomEvent("favorite", { + detail: { + post: this._post, + }, + }) + ); } _evtRemoveFromFavoritesClick(e) { e.preventDefault(); - this.dispatchEvent(new CustomEvent('unfavorite', { - detail: { - post: this._post, - }, - })); + this.dispatchEvent( + new CustomEvent("unfavorite", { + detail: { + post: this._post, + }, + }) + ); } _evtScoreClick(e, score) { e.preventDefault(); - this.dispatchEvent(new CustomEvent('score', { - detail: { - post: this._post, - score: this._post.ownScore === score ? 0 : score, - }, - })); + this.dispatchEvent( + new CustomEvent("score", { + detail: { + post: this._post, + score: this._post.ownScore === score ? 0 : score, + }, + }) + ); } _evtChangeFav(e) { @@ -203,6 +229,6 @@ class PostReadonlySidebarControl extends events.EventTarget { _evtChangeScore(e) { this._installScore(); } -}; +} module.exports = PostReadonlySidebarControl; diff --git a/client/js/controls/tag_auto_complete_control.js b/client/js/controls/tag_auto_complete_control.js index 3d9130e..8ee6aa8 100644 --- a/client/js/controls/tag_auto_complete_control.js +++ b/client/js/controls/tag_auto_complete_control.js @@ -1,57 +1,68 @@ -'use strict'; +"use strict"; -const misc = require('../util/misc.js'); -const views = require('../util/views.js'); -const TagList = require('../models/tag_list.js'); -const AutoCompleteControl = require('./auto_complete_control.js'); +const misc = require("../util/misc.js"); +const views = require("../util/views.js"); +const TagList = require("../models/tag_list.js"); +const AutoCompleteControl = require("./auto_complete_control.js"); function _tagListToMatches(tags, options) { - return [...tags].sort((tag1, tag2) => { - return tag2.usages - tag1.usages; - }).map(tag => { - let cssName = misc.makeCssName(tag.category, 'tag'); - if (options.isTaggedWith(tag.names[0])) { - cssName += ' disabled'; - } - const caption = ( - '<span class="' + cssName + '">' - + misc.escapeHtml(tag.names[0] + ' (' + tag.postCount + ')') - + '</span>'); - return { - caption: caption, - value: tag, - }; - }); + return [...tags] + .sort((tag1, tag2) => { + return tag2.usages - tag1.usages; + }) + .map((tag) => { + let cssName = misc.makeCssName(tag.category, "tag"); + if (options.isTaggedWith(tag.names[0])) { + cssName += " disabled"; + } + const caption = + '<span class="' + + cssName + + '">' + + misc.escapeHtml(tag.names[0] + " (" + tag.postCount + ")") + + "</span>"; + return { + caption: caption, + value: tag, + }; + }); } class TagAutoCompleteControl extends AutoCompleteControl { constructor(input, options) { const minLengthForPartialSearch = 3; - options = Object.assign({ - isTaggedWith: tag => false, - }, options); + options = Object.assign( + { + isTaggedWith: (tag) => false, + }, + options + ); - options.getMatches = text => { + options.getMatches = (text) => { const term = misc.escapeSearchTerm(text); - const query = ( - text.length < minLengthForPartialSearch - ? term + '*' - : '*' + term + '*') + ' sort:usages'; + const query = + (text.length < minLengthForPartialSearch + ? term + "*" + : "*" + term + "*") + " sort:usages"; return new Promise((resolve, reject) => { - TagList.search( - query, 0, this._options.maxResults, - ['names', 'category', 'usages']) - .then( - response => resolve( - _tagListToMatches(response.results, this._options)), - reject); + TagList.search(query, 0, this._options.maxResults, [ + "names", + "category", + "usages", + ]).then( + (response) => + resolve( + _tagListToMatches(response.results, this._options) + ), + reject + ); }); }; super(input, options); } -}; +} module.exports = TagAutoCompleteControl; diff --git a/client/js/controls/tag_input_control.js b/client/js/controls/tag_input_control.js index 133b582..0d3be97 100644 --- a/client/js/controls/tag_input_control.js +++ b/client/js/controls/tag_input_control.js @@ -1,26 +1,26 @@ -'use strict'; +"use strict"; -const api = require('../api.js'); -const tags = require('../tags.js'); -const misc = require('../util/misc.js'); -const uri = require('../util/uri.js'); -const Tag = require('../models/tag.js'); -const TagList = require('../models/tag_list.js'); -const settings = require('../models/settings.js'); -const events = require('../events.js'); -const views = require('../util/views.js'); -const TagAutoCompleteControl = require('./tag_auto_complete_control.js'); +const api = require("../api.js"); +const tags = require("../tags.js"); +const misc = require("../util/misc.js"); +const uri = require("../util/uri.js"); +const Tag = require("../models/tag.js"); +const TagList = require("../models/tag_list.js"); +const settings = require("../models/settings.js"); +const events = require("../events.js"); +const views = require("../util/views.js"); +const TagAutoCompleteControl = require("./tag_auto_complete_control.js"); const KEY_SPACE = 32; const KEY_RETURN = 13; -const SOURCE_INIT = 'init'; -const SOURCE_IMPLICATION = 'implication'; -const SOURCE_USER_INPUT = 'user-input'; -const SOURCE_SUGGESTION = 'suggestions'; -const SOURCE_CLIPBOARD = 'clipboard'; +const SOURCE_INIT = "init"; +const SOURCE_IMPLICATION = "implication"; +const SOURCE_USER_INPUT = "user-input"; +const SOURCE_SUGGESTION = "suggestions"; +const SOURCE_CLIPBOARD = "clipboard"; -const template = views.getTemplate('tag-input'); +const template = views.getTemplate("tag-input"); function _fadeOutListItemNodeStatus(listItemNode) { if (listItemNode.classList.length) { @@ -29,8 +29,7 @@ function _fadeOutListItemNodeStatus(listItemNode) { } listItemNode.fadeTimeout = window.setTimeout(() => { while (listItemNode.classList.length) { - listItemNode.classList.remove( - listItemNode.classList.item(0)); + listItemNode.classList.remove(listItemNode.classList.item(0)); } listItemNode.fadeTimeout = null; }, 2500); @@ -52,7 +51,9 @@ class SuggestionList { } set(suggestion, weight) { - if (this._suggestions.hasOwnProperty(suggestion)) { + if ( + Object.prototype.hasOwnProperty.call(this._suggestions, suggestion) + ) { weight = Math.max(weight, this._suggestions[suggestion]); } this._suggestions[suggestion] = weight; @@ -73,10 +74,10 @@ class SuggestionList { tuples.sort((a, b) => { let weightDiff = b[1] - a[1]; let nameDiff = a[0].localeCompare(b[0]); - return weightDiff == 0 ? nameDiff : weightDiff; + return weightDiff === 0 ? nameDiff : weightDiff; }); - return tuples.map(tuple => { - return {tagName: tuple[0], weight: tuple[1]}; + return tuples.map((tuple) => { + return { tagName: tuple[0], weight: tuple[1] }; }); } } @@ -91,48 +92,61 @@ class TagInputControl extends events.EventTarget { // dom const editAreaNode = template({ - tagsPlaceholder: placeholder || 'type to add…', + tagsPlaceholder: placeholder || "type to add...", }); this._editAreaNode = editAreaNode; - this._tagInputNode = editAreaNode.querySelector('input'); - this._suggestionsNode = editAreaNode.querySelector('.tag-suggestions'); - this._tagListNode = editAreaNode.querySelector('ul.compact-tags'); + this._tagInputNode = editAreaNode.querySelector("input"); + this._suggestionsNode = editAreaNode.querySelector(".tag-suggestions"); + this._tagListNode = editAreaNode.querySelector("ul.compact-tags"); this._autoCompleteControl = new TagAutoCompleteControl( - this._tagInputNode, { + this._tagInputNode, + { getTextToFind: () => { return this._tagInputNode.value; }, - confirm: tag => { - this._tagInputNode.value = ''; - // XXX: tags from autocomplete don't contain implications + confirm: (tag) => { + this._tagInputNode.value = ""; + // note: tags from autocomplete don't contain implications // so they need to be looked up in API this.addTagByName(tag.names[0], SOURCE_USER_INPUT); }, - delete: tag => { - this._tagInputNode.value = ''; + delete: (tag) => { + this._tagInputNode.value = ""; this.deleteTag(tag); }, verticalShift: -2, - isTaggedWith: tagName => this.tags.isTaggedWith(tagName), - }); + isTaggedWith: (tagName) => this.tags.isTaggedWith(tagName), + } + ); // dom events - this._tagInputNode.addEventListener( - 'keydown', e => this._evtInputKeyDown(e)); - this._tagInputNode.addEventListener( - 'paste', e => this._evtInputPaste(e)); - this._editAreaNode.querySelector('a.opacity').addEventListener( - 'click', e => this._evtToggleSuggestionsPopupOpacityClick(e)); - this._editAreaNode.querySelector('a.close').addEventListener( - 'click', e => this._evtCloseSuggestionsPopupClick(e)); - this._editAreaNode.querySelector('button').addEventListener( - 'click', e => this._evtAddTagButtonClick(e)); + this._tagInputNode.addEventListener("keydown", (e) => + this._evtInputKeyDown(e) + ); + this._tagInputNode.addEventListener("paste", (e) => + this._evtInputPaste(e) + ); + this._editAreaNode + .querySelector("a.opacity") + .addEventListener("click", (e) => + this._evtToggleSuggestionsPopupOpacityClick(e) + ); + this._editAreaNode + .querySelector("a.close") + .addEventListener("click", (e) => + this._evtCloseSuggestionsPopupClick(e) + ); + this._editAreaNode + .querySelector("button") + .addEventListener("click", (e) => this._evtAddTagButtonClick(e)); // show - this._hostNode.style.display = 'none'; + this._hostNode.style.display = "none"; this._hostNode.parentNode.insertBefore( - this._editAreaNode, hostNode.nextSibling); + this._editAreaNode, + hostNode.nextSibling + ); // add existing tags for (let tag of [...this.tags]) { @@ -152,48 +166,60 @@ class TagInputControl extends events.EventTarget { if (!name) { return; } - return Tag.get(name).then(tag => { - return this.addTag(tag, source); - }, () => { - const tag = new Tag(); - tag.names = [name]; - tag.category = null; - return this.addTag(tag, source); - }); + return Tag.get(name).then( + (tag) => { + return this.addTag(tag, source); + }, + () => { + const tag = new Tag(); + tag.names = [name]; + tag.category = null; + return this.addTag(tag, source); + } + ); } addTag(tag, source) { - if (source != SOURCE_INIT && this.tags.isTaggedWith(tag.names[0])) { + if (source !== SOURCE_INIT && this.tags.isTaggedWith(tag.names[0])) { const listItemNode = this._getListItemNode(tag); if (source !== SOURCE_IMPLICATION) { - listItemNode.classList.add('duplicate'); + listItemNode.classList.add("duplicate"); _fadeOutListItemNodeStatus(listItemNode); } return Promise.resolve(); } - return this.tags.addByTag(tag, false).then(() => { - const listItemNode = this._createListItemNode(tag); - if (!tag.category) { - listItemNode.classList.add('new'); - } - if (source === SOURCE_IMPLICATION) { - listItemNode.classList.add('implication'); - } - this._tagListNode.prependChild(listItemNode); - _fadeOutListItemNodeStatus(listItemNode); + return this.tags + .addByTag(tag, false) + .then(() => { + const listItemNode = this._createListItemNode(tag); + if (!tag.category) { + listItemNode.classList.add("new"); + } + if (source === SOURCE_IMPLICATION) { + listItemNode.classList.add("implication"); + } + this._tagListNode.prependChild(listItemNode); + _fadeOutListItemNodeStatus(listItemNode); - return Promise.all( - tag.implications.map( - implication => this.addTagByName( - implication.names[0], SOURCE_IMPLICATION))); - }).then(() => { - this.dispatchEvent(new CustomEvent('add', { - detail: {tag: tag, source: source}, - })); - this.dispatchEvent(new CustomEvent('change')); - return Promise.resolve(); - }); + return Promise.all( + tag.implications.map((implication) => + this.addTagByName( + implication.names[0], + SOURCE_IMPLICATION + ) + ) + ); + }) + .then(() => { + this.dispatchEvent( + new CustomEvent("add", { + detail: { tag: tag, source: source }, + }) + ); + this.dispatchEvent(new CustomEvent("change")); + return Promise.resolve(); + }); } deleteTag(tag) { @@ -205,25 +231,27 @@ class TagInputControl extends events.EventTarget { this._deleteListItemNode(tag); - this.dispatchEvent(new CustomEvent('remove', { - detail: {tag: tag}, - })); - this.dispatchEvent(new CustomEvent('change')); + this.dispatchEvent( + new CustomEvent("remove", { + detail: { tag: tag }, + }) + ); + this.dispatchEvent(new CustomEvent("change")); } _evtInputPaste(e) { e.preventDefault(); - const pastedText = window.clipboardData ? - window.clipboardData.getData('Text') : - (e.originalEvent || e).clipboardData.getData('text/plain'); + const pastedText = window.clipboardData + ? window.clipboardData.getData("Text") + : (e.originalEvent || e).clipboardData.getData("text/plain"); if (pastedText.length > 2000) { - window.alert('Pasted text is too long.'); + window.alert("Pasted text is too long."); return; } this._hideAutoComplete(); this.addTagByText(pastedText, SOURCE_CLIPBOARD); - this._tagInputNode.value = ''; + this._tagInputNode.value = ""; } _evtCloseSuggestionsPopupClick(e) { @@ -234,7 +262,7 @@ class TagInputControl extends events.EventTarget { _evtAddTagButtonClick(e) { e.preventDefault(); this.addTagByText(this._tagInputNode.value, SOURCE_USER_INPUT); - this._tagInputNode.value = ''; + this._tagInputNode.value = ""; } _evtToggleSuggestionsPopupOpacityClick(e) { @@ -243,41 +271,46 @@ class TagInputControl extends events.EventTarget { } _evtInputKeyDown(e) { - if (e.which == KEY_RETURN || e.which == KEY_SPACE) { + if (e.which === KEY_RETURN || e.which === KEY_SPACE) { e.preventDefault(); this._hideAutoComplete(); this.addTagByText(this._tagInputNode.value, SOURCE_USER_INPUT); - this._tagInputNode.value = ''; + this._tagInputNode.value = ""; } } _createListItemNode(tag) { - const className = tag.category ? - misc.makeCssName(tag.category, 'tag') : - null; + const className = tag.category + ? misc.makeCssName(tag.category, "tag") + : null; - const tagLinkNode = document.createElement('a'); + const tagLinkNode = document.createElement("a"); if (className) { tagLinkNode.classList.add(className); } tagLinkNode.setAttribute( - 'href', uri.formatClientLink('tag', tag.names[0])); + "href", + uri.formatClientLink("tag", tag.names[0]) + ); - const tagIconNode = document.createElement('i'); - tagIconNode.classList.add('fa'); - tagIconNode.classList.add('fa-tag'); - tagIconNode.classList.add('tag-icon'); + const tagIconNode = document.createElement("i"); + tagIconNode.classList.add("fa"); + tagIconNode.classList.add("fa-tag"); + tagIconNode.classList.add("tag-icon"); tagLinkNode.appendChild(tagIconNode); - const searchLinkNode = document.createElement('a'); + const searchLinkNode = document.createElement("a"); if (className) { searchLinkNode.classList.add(className); } searchLinkNode.setAttribute( - 'href', uri.formatClientLink( - 'posts', {query: uri.escapeColons(tag.names[0])})); - searchLinkNode.textContent = tag.names[0] + ' '; - searchLinkNode.addEventListener('click', e => { + "href", + uri.formatClientLink("posts", { + query: uri.escapeColons(tag.names[0]), + }) + ); + searchLinkNode.textContent = tag.names[0] + " "; + searchLinkNode.addEventListener("click", (e) => { e.preventDefault(); this._suggestions.clear(); if (tag.postCount > 0) { @@ -288,20 +321,20 @@ class TagInputControl extends events.EventTarget { } }); - const usagesNode = document.createElement('span'); - usagesNode.classList.add('tag-usages'); - usagesNode.setAttribute('data-pseudo-content', tag.postCount); + const usagesNode = document.createElement("span"); + usagesNode.classList.add("tag-usages"); + usagesNode.setAttribute("data-pseudo-content", tag.postCount); - const removalLinkNode = document.createElement('a'); - removalLinkNode.classList.add('remove-tag'); - removalLinkNode.setAttribute('href', ''); - removalLinkNode.setAttribute('data-pseudo-content', '×'); - removalLinkNode.addEventListener('click', e => { + const removalLinkNode = document.createElement("a"); + removalLinkNode.classList.add("remove-tag"); + removalLinkNode.setAttribute("href", ""); + removalLinkNode.setAttribute("data-pseudo-content", "×"); + removalLinkNode.addEventListener("click", (e) => { e.preventDefault(); this.deleteTag(tag); }); - const listItemNode = document.createElement('li'); + const listItemNode = document.createElement("li"); listItemNode.appendChild(removalLinkNode); listItemNode.appendChild(tagLinkNode); listItemNode.appendChild(searchLinkNode); @@ -331,20 +364,25 @@ class TagInputControl extends events.EventTarget { if (!browsingSettings.tagSuggestions) { return; } - api.get( - uri.formatApiLink('tag-siblings', tag.names[0]), - {noProgress: true}) - .then(response => { - return Promise.resolve(response.results); - }, response => { - return Promise.resolve([]); - }).then(siblings => { - const args = siblings.map(s => s.occurrences); + api.get(uri.formatApiLink("tag-siblings", tag.names[0]), { + noProgress: true, + }) + .then( + (response) => { + return Promise.resolve(response.results); + }, + (response) => { + return Promise.resolve([]); + } + ) + .then((siblings) => { + const args = siblings.map((s) => s.occurrences); let maxSiblingOccurrences = Math.max(1, ...args); for (let sibling of siblings) { this._suggestions.set( sibling.tag.names[0], - sibling.occurrences * 4.9 / maxSiblingOccurrences); + (sibling.occurrences * 4.9) / maxSiblingOccurrences + ); } for (let suggestion of tag.suggestions || []) { this._suggestions.set(suggestion, 5); @@ -358,10 +396,10 @@ class TagInputControl extends events.EventTarget { } _refreshSuggestionsPopup() { - if (!this._suggestionsNode.classList.contains('shown')) { + if (!this._suggestionsNode.classList.contains("shown")) { return; } - const listNode = this._suggestionsNode.querySelector('ul'); + const listNode = this._suggestionsNode.querySelector("ul"); listNode.scrollTop = 0; while (listNode.firstChild) { listNode.removeChild(listNode.firstChild); @@ -373,35 +411,36 @@ class TagInputControl extends events.EventTarget { continue; } - const addLinkNode = document.createElement('a'); + const addLinkNode = document.createElement("a"); addLinkNode.textContent = tagName; - addLinkNode.classList.add('add-tag'); - addLinkNode.setAttribute('href', ''); - Tag.get(tagName).then(tag => { + addLinkNode.classList.add("add-tag"); + addLinkNode.setAttribute("href", ""); + Tag.get(tagName).then((tag) => { addLinkNode.classList.add( - misc.makeCssName(tag.category, 'tag')); + misc.makeCssName(tag.category, "tag") + ); }); - addLinkNode.addEventListener('click', e => { + addLinkNode.addEventListener("click", (e) => { e.preventDefault(); listNode.removeChild(listItemNode); this.addTagByName(tagName, SOURCE_SUGGESTION); }); - const weightNode = document.createElement('span'); - weightNode.classList.add('tag-weight'); - weightNode.setAttribute('data-pseudo-content', weight); + const weightNode = document.createElement("span"); + weightNode.classList.add("tag-weight"); + weightNode.setAttribute("data-pseudo-content", weight); - const removalLinkNode = document.createElement('a'); - removalLinkNode.classList.add('remove-tag'); - removalLinkNode.setAttribute('href', ''); - removalLinkNode.setAttribute('data-pseudo-content', '×'); - removalLinkNode.addEventListener('click', e => { + const removalLinkNode = document.createElement("a"); + removalLinkNode.classList.add("remove-tag"); + removalLinkNode.setAttribute("href", ""); + removalLinkNode.setAttribute("data-pseudo-content", "×"); + removalLinkNode.addEventListener("click", (e) => { e.preventDefault(); listNode.removeChild(listItemNode); this._suggestions.ban(tagName); }); - const listItemNode = document.createElement('li'); + const listItemNode = document.createElement("li"); listItemNode.appendChild(removalLinkNode); listItemNode.appendChild(weightNode); listItemNode.appendChild(addLinkNode); @@ -411,19 +450,19 @@ class TagInputControl extends events.EventTarget { _closeSuggestionsPopup() { this._suggestions.clear(); - this._suggestionsNode.classList.remove('shown'); + this._suggestionsNode.classList.remove("shown"); } _removeSuggestionsPopupOpacity() { - this._suggestionsNode.classList.remove('translucent'); + this._suggestionsNode.classList.remove("translucent"); } _toggleSuggestionsPopupOpacity() { - this._suggestionsNode.classList.toggle('translucent'); + this._suggestionsNode.classList.toggle("translucent"); } _openSuggestionsPopup() { - this._suggestionsNode.classList.add('shown'); + this._suggestionsNode.classList.add("shown"); this._refreshSuggestionsPopup(); } diff --git a/client/js/events.js b/client/js/events.js index e395214..7d6ffa6 100644 --- a/client/js/events.js +++ b/client/js/events.js @@ -1,17 +1,17 @@ -'use strict'; +"use strict"; class EventTarget { constructor() { this.eventTarget = document.createDocumentFragment(); for (let method of [ - 'addEventListener', - 'dispatchEvent', - 'removeEventListener' + "addEventListener", + "dispatchEvent", + "removeEventListener", ]) { this[method] = this.eventTarget[method].bind(this.eventTarget); } } -}; +} function proxyEvent(source, target, sourceEventType, targetEventType) { if (!source.addEventListener) { @@ -20,17 +20,19 @@ function proxyEvent(source, target, sourceEventType, targetEventType) { if (!targetEventType) { targetEventType = sourceEventType; } - source.addEventListener(sourceEventType, e => { - target.dispatchEvent(new CustomEvent(targetEventType, { - detail: e.detail, - })); + source.addEventListener(sourceEventType, (e) => { + target.dispatchEvent( + new CustomEvent(targetEventType, { + detail: e.detail, + }) + ); }); } module.exports = { - Success: 'success', - Error: 'error', - Info: 'info', + Success: "success", + Error: "error", + Info: "info", proxyEvent: proxyEvent, EventTarget: EventTarget, diff --git a/client/js/main.js b/client/js/main.js index b89bf8e..7efaa41 100644 --- a/client/js/main.js +++ b/client/js/main.js @@ -1,77 +1,108 @@ -'use strict'; +"use strict"; -require('./util/polyfill.js'); -const misc = require('./util/misc.js'); -const views = require('./util/views.js'); -const router = require('./router.js'); +require("./util/polyfill.js"); +const misc = require("./util/misc.js"); +const views = require("./util/views.js"); +const router = require("./router.js"); -history.scrollRestoration = 'manual'; +history.scrollRestoration = "manual"; -router.exit( - null, - (ctx, next) => { - ctx.state.scrollX = window.scrollX; - ctx.state.scrollY = window.scrollY; - router.replace(router.url, ctx.state); - if (misc.confirmPageExit()) { - next(); - } - }); - -const mousetrap = require('mousetrap'); -router.enter( - null, - (ctx, next) => { - mousetrap.reset(); +router.exit(null, (ctx, next) => { + ctx.state.scrollX = window.scrollX; + ctx.state.scrollY = window.scrollY; + router.replace(router.url, ctx.state); + if (misc.confirmPageExit()) { next(); - }); + } +}); -const tags = require('./tags.js'); -const api = require('./api.js'); -tags.refreshCategoryColorMap(); // we don't care about errors +const mousetrap = require("mousetrap"); +router.enter(null, (ctx, next) => { + mousetrap.reset(); + next(); +}); -api.fetchConfig().then(() => { - // register controller routes - let controllers = []; - controllers.push(require('./controllers/home_controller.js')); - controllers.push(require('./controllers/help_controller.js')); - controllers.push(require('./controllers/auth_controller.js')); - controllers.push(require('./controllers/password_reset_controller.js')); - controllers.push(require('./controllers/comments_controller.js')); - controllers.push(require('./controllers/snapshots_controller.js')); - controllers.push(require('./controllers/post_detail_controller.js')); - controllers.push(require('./controllers/post_main_controller.js')); - controllers.push(require('./controllers/post_list_controller.js')); - controllers.push(require('./controllers/post_upload_controller.js')); - controllers.push(require('./controllers/tag_controller.js')); - controllers.push(require('./controllers/tag_list_controller.js')); - controllers.push(require('./controllers/tag_categories_controller.js')); - controllers.push(require('./controllers/settings_controller.js')); - controllers.push(require('./controllers/user_controller.js')); - controllers.push(require('./controllers/user_list_controller.js')); - controllers.push(require('./controllers/user_registration_controller.js')); - controllers.push(require('./controllers/metric_sorter_contoller.js')); +const tags = require("./tags.js"); +const pools = require("./pools.js"); +const api = require("./api.js"); +const settings = require("./models/settings.js"); - // 404 controller needs to be registered last - controllers.push(require('./controllers/not_found_controller.js')); +Promise.resolve() + .then(() => api.fetchConfig()) + .then( + () => { + // register controller routes + let controllers = []; + controllers.push(require("./controllers/home_controller.js")); + controllers.push(require("./controllers/help_controller.js")); + controllers.push(require("./controllers/auth_controller.js")); + controllers.push( + require("./controllers/password_reset_controller.js") + ); + controllers.push(require("./controllers/comments_controller.js")); + controllers.push(require("./controllers/snapshots_controller.js")); + controllers.push( + require("./controllers/post_detail_controller.js") + ); + controllers.push(require("./controllers/post_main_controller.js")); + controllers.push(require("./controllers/post_list_controller.js")); + controllers.push( + require("./controllers/post_upload_controller.js") + ); + controllers.push(require("./controllers/tag_controller.js")); + controllers.push(require("./controllers/tag_list_controller.js")); + controllers.push( + require("./controllers/tag_categories_controller.js") + ); + controllers.push( + require("./controllers/pool_create_controller.js") + ); + controllers.push(require("./controllers/pool_controller.js")); + controllers.push(require("./controllers/pool_list_controller.js")); + controllers.push( + require("./controllers/pool_categories_controller.js") + ); + controllers.push(require("./controllers/settings_controller.js")); + controllers.push(require("./controllers/user_controller.js")); + controllers.push(require("./controllers/user_list_controller.js")); + controllers.push( + require("./controllers/user_registration_controller.js") + ); + controllers.push(require('./controllers/metric_sorter_contoller.js')); - for (let controller of controllers) { - controller(router); - } -}, error => { - window.alert('Could not fetch basic configuration from server'); -}).then(() => { - api.loginFromCookies().then(() => { + // 404 controller needs to be registered last + controllers.push(require("./controllers/not_found_controller.js")); + + for (let controller of controllers) { + controller(router); + } + }, + (error) => { + window.alert("Could not fetch basic configuration from server"); + } + ) + .then(() => { + if (settings.get().darkTheme) { + document.body.classList.add("darktheme"); + } + }) + .then(() => api.loginFromCookies()) + .then( + () => { + tags.refreshCategoryColorMap(); + pools.refreshCategoryColorMap(); router.start(); - }, error => { - if (window.location.href.indexOf('login') !== -1) { + }, + (error) => { + if (window.location.href.indexOf("login") !== -1) { api.forget(); router.start(); } else { - const ctx = router.start('/'); + const ctx = router.start("/"); ctx.controller.showError( - 'An error happened while trying to log you in: ' + - error.message); + "An error happened while trying to log you in: " + + error.message + ); } - }); -}); + } + ); diff --git a/client/js/models/abstract_list.js b/client/js/models/abstract_list.js index 31a0505..7505905 100644 --- a/client/js/models/abstract_list.js +++ b/client/js/models/abstract_list.js @@ -1,6 +1,6 @@ -'use strict'; +"use strict"; -const events = require('../events.js'); +const events = require("../events.js"); class AbstractList extends events.EventTarget { constructor() { @@ -13,13 +13,15 @@ class AbstractList extends events.EventTarget { for (let item of response) { const addedItem = this._itemClass.fromResponse(item); if (addedItem.addEventListener) { - addedItem.addEventListener('delete', e => { + addedItem.addEventListener("delete", (e) => { ret.remove(addedItem); }); - addedItem.addEventListener('change', e => { - ret.dispatchEvent(new CustomEvent('change', { - detail: e.detail, - })); + addedItem.addEventListener("change", (e) => { + ret.dispatchEvent( + new CustomEvent("change", { + detail: e.detail, + }) + ); }); } ret._list.push(addedItem); @@ -29,28 +31,32 @@ class AbstractList extends events.EventTarget { sync(plainList) { this.clear(); - for (let item of (plainList || [])) { + for (let item of plainList || []) { this.add(this.constructor._itemClass.fromResponse(item)); } } add(item) { if (item.addEventListener) { - item.addEventListener('delete', e => { + item.addEventListener("delete", (e) => { this.remove(item); }); - item.addEventListener('change', e => { - this.dispatchEvent(new CustomEvent('change', { - detail: e.detail, - })); + item.addEventListener("change", (e) => { + this.dispatchEvent( + new CustomEvent("change", { + detail: e.detail, + }) + ); }); } this._list.push(item); const detail = {}; detail[this.constructor._itemName] = item; - this.dispatchEvent(new CustomEvent('add', { - detail: detail, - })); + this.dispatchEvent( + new CustomEvent("add", { + detail: detail, + }) + ); } clear() { @@ -67,9 +73,11 @@ class AbstractList extends events.EventTarget { this._list.splice(index, 1); const detail = {}; detail[this.constructor._itemName] = itemToRemove; - this.dispatchEvent(new CustomEvent('remove', { - detail: detail, - })); + this.dispatchEvent( + new CustomEvent("remove", { + detail: detail, + }) + ); return; } } diff --git a/client/js/models/comment.js b/client/js/models/comment.js index e10e83c..4707a3a 100644 --- a/client/js/models/comment.js +++ b/client/js/models/comment.js @@ -1,8 +1,8 @@ -'use strict'; +"use strict"; -const api = require('../api.js'); -const uri = require('../util/uri.js'); -const events = require('../events.js'); +const api = require("../api.js"); +const uri = require("../util/uri.js"); +const events = require("../events.js"); class Comment extends events.EventTarget { constructor() { @@ -22,77 +22,112 @@ class Comment extends events.EventTarget { return comment; } - get id() { return this._id; } - get postId() { return this._postId; } - get text() { return this._text || ''; } - get user() { return this._user; } - get creationTime() { return this._creationTime; } - get lastEditTime() { return this._lastEditTime; } - get score() { return this._score; } - get ownScore() { return this._ownScore; } + get id() { + return this._id; + } + + get postId() { + return this._postId; + } - set text(value) { this._text = value; } + get text() { + return this._text || ""; + } + + get user() { + return this._user; + } + + get creationTime() { + return this._creationTime; + } + + get lastEditTime() { + return this._lastEditTime; + } + + get score() { + return this._score; + } + + get ownScore() { + return this._ownScore; + } + + set text(value) { + this._text = value; + } save() { const detail = { version: this._version, text: this._text, }; - let promise = this._id ? - api.put(uri.formatApiLink('comment', this.id), detail) : - api.post(uri.formatApiLink('comments'), - Object.assign({postId: this._postId}, detail)); + let promise = this._id + ? api.put(uri.formatApiLink("comment", this.id), detail) + : api.post( + uri.formatApiLink("comments"), + Object.assign({ postId: this._postId }, detail) + ); - return promise.then(response => { + return promise.then((response) => { this._updateFromResponse(response); - this.dispatchEvent(new CustomEvent('change', { - detail: { - comment: this, - }, - })); + this.dispatchEvent( + new CustomEvent("change", { + detail: { + comment: this, + }, + }) + ); return Promise.resolve(); }); } delete() { - return api.delete( - uri.formatApiLink('comment', this.id), - {version: this._version}) - .then(response => { - this.dispatchEvent(new CustomEvent('delete', { - detail: { - comment: this, - }, - })); + return api + .delete(uri.formatApiLink("comment", this.id), { + version: this._version, + }) + .then((response) => { + this.dispatchEvent( + new CustomEvent("delete", { + detail: { + comment: this, + }, + }) + ); return Promise.resolve(); }); } setScore(score) { - return api.put( - uri.formatApiLink('comment', this.id, 'score'), - {score: score}) - .then(response => { + return api + .put(uri.formatApiLink("comment", this.id, "score"), { + score: score, + }) + .then((response) => { this._updateFromResponse(response); - this.dispatchEvent(new CustomEvent('changeScore', { - detail: { - comment: this, - }, - })); + this.dispatchEvent( + new CustomEvent("changeScore", { + detail: { + comment: this, + }, + }) + ); return Promise.resolve(); }); } _updateFromResponse(response) { - this._version = response.version; - this._id = response.id; - this._postId = response.postId; - this._text = response.text; - this._user = response.user; + this._version = response.version; + this._id = response.id; + this._postId = response.postId; + this._text = response.text; + this._user = response.user; this._creationTime = response.creationTime; this._lastEditTime = response.lastEditTime; - this._score = parseInt(response.score); - this._ownScore = parseInt(response.ownScore); + this._score = parseInt(response.score); + this._ownScore = parseInt(response.ownScore); } } diff --git a/client/js/models/comment_list.js b/client/js/models/comment_list.js index a8e1150..bae2d7a 100644 --- a/client/js/models/comment_list.js +++ b/client/js/models/comment_list.js @@ -1,12 +1,11 @@ -'use strict'; +"use strict"; -const AbstractList = require('./abstract_list.js'); -const Comment = require('./comment.js'); +const AbstractList = require("./abstract_list.js"); +const Comment = require("./comment.js"); -class CommentList extends AbstractList { -} +class CommentList extends AbstractList {} CommentList._itemClass = Comment; -CommentList._itemName = 'comment'; +CommentList._itemName = "comment"; module.exports = CommentList; diff --git a/client/js/models/info.js b/client/js/models/info.js index 35ba867..6b03b38 100644 --- a/client/js/models/info.js +++ b/client/js/models/info.js @@ -1,22 +1,20 @@ -'use strict'; +"use strict"; -const api = require('../api.js'); -const uri = require('../util/uri.js'); -const Post = require('./post.js'); +const api = require("../api.js"); +const uri = require("../util/uri.js"); +const Post = require("./post.js"); class Info { static get() { - return api.get(uri.formatApiLink('info')) - .then(response => { - return Promise.resolve(Object.assign( - {}, - response, - { - featuredPost: response.featuredPost ? - Post.fromResponse(response.featuredPost) : - undefined - })); - }); + return api.get(uri.formatApiLink("info")).then((response) => { + return Promise.resolve( + Object.assign({}, response, { + featuredPost: response.featuredPost + ? Post.fromResponse(response.featuredPost) + : undefined, + }) + ); + }); } } diff --git a/client/js/models/note.js b/client/js/models/note.js index 877db4e..87c8b3b 100644 --- a/client/js/models/note.js +++ b/client/js/models/note.js @@ -1,20 +1,27 @@ -'use strict'; +"use strict"; -const events = require('../events.js'); -const Point = require('./point.js'); -const PointList = require('./point_list.js'); +const events = require("../events.js"); +const Point = require("./point.js"); +const PointList = require("./point_list.js"); class Note extends events.EventTarget { constructor() { super(); - this._text = '…'; + this._text = "…"; this._polygon = new PointList(); } - get text() { return this._text; } - get polygon() { return this._polygon; } + get text() { + return this._text; + } + + get polygon() { + return this._polygon; + } - set text(value) { this._text = value; } + set text(value) { + this._text = value; + } static fromResponse(response) { const note = new Note(); diff --git a/client/js/models/note_list.js b/client/js/models/note_list.js index b54d7f3..10db435 100644 --- a/client/js/models/note_list.js +++ b/client/js/models/note_list.js @@ -1,12 +1,11 @@ -'use strict'; +"use strict"; -const AbstractList = require('./abstract_list.js'); -const Note = require('./note.js'); +const AbstractList = require("./abstract_list.js"); +const Note = require("./note.js"); -class NoteList extends AbstractList { -} +class NoteList extends AbstractList {} NoteList._itemClass = Note; -NoteList._itemName = 'note'; +NoteList._itemName = "note"; module.exports = NoteList; diff --git a/client/js/models/point.js b/client/js/models/point.js index f1c551e..70b4961 100644 --- a/client/js/models/point.js +++ b/client/js/models/point.js @@ -1,6 +1,6 @@ -'use strict'; +"use strict"; -const events = require('../events.js'); +const events = require("../events.js"); class Point extends events.EventTarget { constructor(x, y) { @@ -9,18 +9,27 @@ class Point extends events.EventTarget { this._y = y; } - get x() { return this._x; } - get y() { return this._y; } + get x() { + return this._x; + } + + get y() { + return this._y; + } set x(value) { this._x = value; - this.dispatchEvent(new CustomEvent('change', {detail: {point: this}})); + this.dispatchEvent( + new CustomEvent("change", { detail: { point: this } }) + ); } set y(value) { this._y = value; - this.dispatchEvent(new CustomEvent('change', {detail: {point: this}})); + this.dispatchEvent( + new CustomEvent("change", { detail: { point: this } }) + ); } -}; +} module.exports = Point; diff --git a/client/js/models/point_list.js b/client/js/models/point_list.js index 166e664..3ecd7d7 100644 --- a/client/js/models/point_list.js +++ b/client/js/models/point_list.js @@ -1,7 +1,7 @@ -'use strict'; +"use strict"; -const AbstractList = require('./abstract_list.js'); -const Point = require('./point.js'); +const AbstractList = require("./abstract_list.js"); +const Point = require("./point.js"); class PointList extends AbstractList { get firstPoint() { @@ -18,6 +18,6 @@ class PointList extends AbstractList { } PointList._itemClass = Point; -PointList._itemName = 'point'; +PointList._itemName = "point"; module.exports = PointList; diff --git a/client/js/models/pool.js b/client/js/models/pool.js new file mode 100644 index 0000000..51fa8a0 --- /dev/null +++ b/client/js/models/pool.js @@ -0,0 +1,183 @@ +"use strict"; + +const api = require("../api.js"); +const uri = require("../util/uri.js"); +const events = require("../events.js"); +const misc = require("../util/misc.js"); + +class Pool extends events.EventTarget { + constructor() { + const PostList = require("./post_list.js"); + + super(); + this._orig = {}; + + for (let obj of [this, this._orig]) { + obj._posts = new PostList(); + } + + this._updateFromResponse({}); + } + + get id() { + return this._id; + } + + get names() { + return this._names; + } + + get category() { + return this._category; + } + + get description() { + return this._description; + } + + get posts() { + return this._posts; + } + + get postCount() { + return this._postCount; + } + + get creationTime() { + return this._creationTime; + } + + get lastEditTime() { + return this._lastEditTime; + } + + set names(value) { + this._names = value; + } + + set category(value) { + this._category = value; + } + + set description(value) { + this._description = value; + } + + static fromResponse(response) { + const ret = new Pool(); + ret._updateFromResponse(response); + return ret; + } + + static get(id) { + return api.get(uri.formatApiLink("pool", id)).then((response) => { + return Promise.resolve(Pool.fromResponse(response)); + }); + } + + save() { + const detail = { version: this._version }; + + // send only changed fields to avoid user privilege violation + if (misc.arraysDiffer(this._names, this._orig._names, true)) { + detail.names = this._names; + } + if (this._category !== this._orig._category) { + detail.category = this._category; + } + if (this._description !== this._orig._description) { + detail.description = this._description; + } + if (misc.arraysDiffer(this._posts, this._orig._posts)) { + detail.posts = this._posts.map((post) => post.id); + } + + let promise = this._id + ? api.put(uri.formatApiLink("pool", this._id), detail) + : api.post(uri.formatApiLink("pools"), detail); + return promise.then((response) => { + this._updateFromResponse(response); + this.dispatchEvent( + new CustomEvent("change", { + detail: { + pool: this, + }, + }) + ); + return Promise.resolve(); + }); + } + + merge(targetId, addAlias) { + return api + .get(uri.formatApiLink("pool", targetId)) + .then((response) => { + return api.post(uri.formatApiLink("pool-merge"), { + removeVersion: this._version, + remove: this._id, + mergeToVersion: response.version, + mergeTo: targetId, + }); + }) + .then((response) => { + if (!addAlias) { + return Promise.resolve(response); + } + return api.put(uri.formatApiLink("pool", targetId), { + version: response.version, + names: response.names.concat(this._names), + }); + }) + .then((response) => { + this._updateFromResponse(response); + this.dispatchEvent( + new CustomEvent("change", { + detail: { + pool: this, + }, + }) + ); + return Promise.resolve(); + }); + } + + delete() { + return api + .delete(uri.formatApiLink("pool", this._id), { + version: this._version, + }) + .then((response) => { + this.dispatchEvent( + new CustomEvent("delete", { + detail: { + pool: this, + }, + }) + ); + return Promise.resolve(); + }); + } + + _updateFromResponse(response) { + const map = { + _id: response.id, + _version: response.version, + _origName: response.names ? response.names[0] : null, + _names: response.names, + _category: response.category, + _description: response.description, + _creationTime: response.creationTime, + _lastEditTime: response.lastEditTime, + _postCount: response.postCount || 0, + }; + + for (let obj of [this, this._orig]) { + obj._posts.sync(response.posts); + } + + Object.assign(this, map); + Object.assign(this._orig, map); + } +} + +module.exports = Pool; diff --git a/client/js/models/pool_category.js b/client/js/models/pool_category.js new file mode 100644 index 0000000..8c7df46 --- /dev/null +++ b/client/js/models/pool_category.js @@ -0,0 +1,114 @@ +"use strict"; + +const api = require("../api.js"); +const uri = require("../util/uri.js"); +const events = require("../events.js"); + +class PoolCategory extends events.EventTarget { + constructor() { + super(); + this._name = ""; + this._color = "#000000"; + this._poolCount = 0; + this._isDefault = false; + this._origName = null; + this._origColor = null; + } + + get name() { + return this._name; + } + + get color() { + return this._color; + } + + get poolCount() { + return this._poolCount; + } + + get isDefault() { + return this._isDefault; + } + + get isTransient() { + return !this._origName; + } + + set name(value) { + this._name = value; + } + + set color(value) { + this._color = value; + } + + static fromResponse(response) { + const ret = new PoolCategory(); + ret._updateFromResponse(response); + return ret; + } + + save() { + const detail = { version: this._version }; + + if (this.name !== this._origName) { + detail.name = this.name; + } + if (this.color !== this._origColor) { + detail.color = this.color; + } + + if (!Object.keys(detail).length) { + return Promise.resolve(); + } + + let promise = this._origName + ? api.put( + uri.formatApiLink("pool-category", this._origName), + detail + ) + : api.post(uri.formatApiLink("pool-categories"), detail); + + return promise.then((response) => { + this._updateFromResponse(response); + this.dispatchEvent( + new CustomEvent("change", { + detail: { + poolCategory: this, + }, + }) + ); + return Promise.resolve(); + }); + } + + delete() { + return api + .delete(uri.formatApiLink("pool-category", this._origName), { + version: this._version, + }) + .then((response) => { + this.dispatchEvent( + new CustomEvent("delete", { + detail: { + poolCategory: this, + }, + }) + ); + return Promise.resolve(); + }); + } + + _updateFromResponse(response) { + this._version = response.version; + this._name = response.name; + this._color = response.color; + this._isDefault = response.default; + this._poolCount = response.usages; + this._origName = this.name; + this._origColor = this.color; + } +} + +module.exports = PoolCategory; diff --git a/client/js/models/pool_category_list.js b/client/js/models/pool_category_list.js new file mode 100644 index 0000000..46b7838 --- /dev/null +++ b/client/js/models/pool_category_list.js @@ -0,0 +1,88 @@ +"use strict"; + +const api = require("../api.js"); +const uri = require("../util/uri.js"); +const AbstractList = require("./abstract_list.js"); +const PoolCategory = require("./pool_category.js"); + +class PoolCategoryList extends AbstractList { + constructor() { + super(); + this._defaultCategory = null; + this._origDefaultCategory = null; + this._deletedCategories = []; + this.addEventListener("remove", (e) => this._evtCategoryDeleted(e)); + } + + static fromResponse(response) { + const ret = super.fromResponse(response); + ret._defaultCategory = null; + for (let poolCategory of ret) { + if (poolCategory.isDefault) { + ret._defaultCategory = poolCategory; + } + } + ret._origDefaultCategory = ret._defaultCategory; + return ret; + } + + static get() { + return api + .get(uri.formatApiLink("pool-categories")) + .then((response) => { + return Promise.resolve( + Object.assign({}, response, { + results: PoolCategoryList.fromResponse( + response.results + ), + }) + ); + }); + } + + get defaultCategory() { + return this._defaultCategory; + } + + set defaultCategory(poolCategory) { + this._defaultCategory = poolCategory; + } + + save() { + let promises = []; + for (let poolCategory of this) { + promises.push(poolCategory.save()); + } + for (let poolCategory of this._deletedCategories) { + promises.push(poolCategory.delete()); + } + + if (this._defaultCategory !== this._origDefaultCategory) { + promises.push( + api.put( + uri.formatApiLink( + "pool-category", + this._defaultCategory.name, + "default" + ) + ) + ); + } + + return Promise.all(promises).then((response) => { + this._deletedCategories = []; + return Promise.resolve(); + }); + } + + _evtCategoryDeleted(e) { + if (!e.detail.poolCategory.isTransient) { + this._deletedCategories.push(e.detail.poolCategory); + } + } +} + +PoolCategoryList._itemClass = PoolCategory; +PoolCategoryList._itemName = "poolCategory"; + +module.exports = PoolCategoryList; diff --git a/client/js/models/pool_list.js b/client/js/models/pool_list.js new file mode 100644 index 0000000..a8839bb --- /dev/null +++ b/client/js/models/pool_list.js @@ -0,0 +1,49 @@ +"use strict"; + +const api = require("../api.js"); +const uri = require("../util/uri.js"); +const AbstractList = require("./abstract_list.js"); +const Pool = require("./pool.js"); + +class PoolList extends AbstractList { + static search(text, offset, limit, fields) { + return api + .get( + uri.formatApiLink("pools", { + query: text, + offset: offset, + limit: limit, + fields: fields.join(","), + }) + ) + .then((response) => { + return Promise.resolve( + Object.assign({}, response, { + results: PoolList.fromResponse(response.results), + }) + ); + }); + } + + hasPoolId(poolId) { + for (let pool of this._list) { + if (pool.id === poolId) { + return true; + } + } + return false; + } + + removeById(poolId) { + for (let pool of this._list) { + if (pool.id === poolId) { + this.remove(pool); + } + } + } +} + +PoolList._itemClass = Pool; +PoolList._itemName = "pool"; + +module.exports = PoolList; diff --git a/client/js/models/post.js b/client/js/models/post.js index 13e4c3d..a22f3b5 100644 --- a/client/js/models/post.js +++ b/client/js/models/post.js @@ -1,15 +1,17 @@ -'use strict'; +"use strict"; -const api = require('../api.js'); -const uri = require('../util/uri.js'); -const tags = require('../tags.js'); -const events = require('../events.js'); -const TagList = require('./tag_list.js'); -const NoteList = require('./note_list.js'); -const CommentList = require('./comment_list.js'); -const PostMetricList = require('./post_metric_list.js'); -const PostMetricRangeList = require('./post_metric_range_list.js'); -const misc = require('../util/misc.js'); +const api = require("../api.js"); +const uri = require("../util/uri.js"); +const tags = require("../tags.js"); +const events = require("../events.js"); +const TagList = require("./tag_list.js"); +const NoteList = require("./note_list.js"); +const CommentList = require("./comment_list.js"); +const PoolList = require("./pool_list.js"); +const Pool = require("./pool.js"); +const PostMetricList = require("./post_metric_list.js"); +const PostMetricRangeList = require("./post_metric_range_list.js"); +const misc = require("../util/misc.js"); class Post extends events.EventTarget { constructor() { @@ -20,6 +22,7 @@ class Post extends events.EventTarget { obj._tags = new TagList(); obj._notes = new NoteList(); obj._comments = new CommentList(); + obj._pools = new PoolList(); obj._metrics = new PostMetricList(); obj._metricRanges = new PostMetricRangeList(); } @@ -27,44 +30,153 @@ class Post extends events.EventTarget { this._updateFromResponse({}); } - get id() { return this._id; } - get type() { return this._type; } - get mimeType() { return this._mimeType; } - get creationTime() { return this._creationTime; } - get user() { return this._user; } - get safety() { return this._safety; } - get contentUrl() { return this._contentUrl; } - get fullContentUrl() { return this._fullContentUrl; } - get thumbnailUrl() { return this._thumbnailUrl; } - get source() { return this._source; } - get canvasWidth() { return this._canvasWidth || 800; } - get canvasHeight() { return this._canvasHeight || 450; } - get fileSize() { return this._fileSize || 0; } - get newContent() { throw 'Invalid operation'; } - get newThumbnail() { throw 'Invalid operation'; } + get id() { + return this._id; + } + + get type() { + return this._type; + } + + get mimeType() { + return this._mimeType; + } + + get creationTime() { + return this._creationTime; + } + + get user() { + return this._user; + } + + get safety() { + return this._safety; + } + + get contentUrl() { + return this._contentUrl; + } + + get fullContentUrl() { + return this._fullContentUrl; + } + + get thumbnailUrl() { + return this._thumbnailUrl; + } + + get source() { + return this._source; + } + + get sourceSplit() { + return this._source.split("\n"); + } + + get canvasWidth() { + return this._canvasWidth || 800; + } + + get canvasHeight() { + return this._canvasHeight || 450; + } + + get fileSize() { + return this._fileSize || 0; + } + + get newContent() { + throw "Invalid operation"; + } + + get newThumbnail() { + throw "Invalid operation"; + } + + get flags() { + return this._flags; + } + + get tags() { + return this._tags; + } + + get tagNames() { + return this._tags.map((tag) => tag.names[0]); + } + + get notes() { + return this._notes; + } + + get comments() { + return this._comments; + } + + get relations() { + return this._relations; + } + + get pools() { + return this._pools; + } - get flags() { return this._flags; } - get tags() { return this._tags; } - get tagNames() { return this._tags.map(tag => tag.names[0]); } - get notes() { return this._notes; } - get comments() { return this._comments; } - get relations() { return this._relations; } - get metrics() { return this._metrics; } - get metricRanges() { return this._metricRanges; } + get metrics() { + return this._metrics; + } + + get metricRanges() { + return this._metricRanges; + } + + get score() { + return this._score; + } + + get commentCount() { + return this._commentCount; + } - get score() { return this._score; } - get commentCount() { return this._commentCount; } - get favoriteCount() { return this._favoriteCount; } - get ownFavorite() { return this._ownFavorite; } - get ownScore() { return this._ownScore; } - get hasCustomThumbnail() { return this._hasCustomThumbnail; } + get favoriteCount() { + return this._favoriteCount; + } - set flags(value) { this._flags = value; } - set safety(value) { this._safety = value; } - set relations(value) { this._relations = value; } - set newContent(value) { this._newContent = value; } - set newThumbnail(value) { this._newThumbnail = value; } - set source(value) { this._source = value; } + get ownFavorite() { + return this._ownFavorite; + } + + get ownScore() { + return this._ownScore; + } + + get hasCustomThumbnail() { + return this._hasCustomThumbnail; + } + + set flags(value) { + this._flags = value; + } + + set safety(value) { + this._safety = value; + } + + set relations(value) { + this._relations = value; + } + + set newContent(value) { + this._newContent = value; + } + + set newThumbnail(value) { + this._newThumbnail = value; + } + + set source(value) { + this._source = value; + } static fromResponse(response) { const ret = new Post(); @@ -74,33 +186,69 @@ class Post extends events.EventTarget { static reverseSearch(content) { let apiPromise = api.post( - uri.formatApiLink('posts', 'reverse-search'), + uri.formatApiLink("posts", "reverse-search"), {}, - {content: content}); - let returnedPromise = apiPromise - .then(response => { - if (response.exactPost) { - response.exactPost = Post.fromResponse(response.exactPost); - } - for (let item of response.similarPosts) { - item.post = Post.fromResponse(item.post); - } - return Promise.resolve(response); - }); + { content: content } + ); + let returnedPromise = apiPromise.then((response) => { + if (response.exactPost) { + response.exactPost = Post.fromResponse(response.exactPost); + } + for (let item of response.similarPosts) { + item.post = Post.fromResponse(item.post); + } + return Promise.resolve(response); + }); returnedPromise.abort = () => apiPromise.abort(); return returnedPromise; } static get(id) { - return api.get(uri.formatApiLink('post', id)) - .then(response => { - return Promise.resolve(Post.fromResponse(response)); + return api.get(uri.formatApiLink("post", id)).then((response) => { + return Promise.resolve(Post.fromResponse(response)); + }); + } + + _savePoolPosts() { + const difference = (a, b) => a.filter((post) => !b.hasPoolId(post.id)); + + // find the pools where the post was added or removed + const added = difference(this.pools, this._orig._pools); + const removed = difference(this._orig._pools, this.pools); + + let ops = []; + + // update each pool's list of posts + for (let pool of added) { + let op = Pool.get(pool.id).then((response) => { + if (!response.posts.hasPostId(this._id)) { + response.posts.addById(this._id); + return response.save(); + } else { + return Promise.resolve(response); + } }); + ops.push(op); + } + + for (let pool of removed) { + let op = Pool.get(pool.id).then((response) => { + if (response.posts.hasPostId(this._id)) { + response.posts.removeById(this._id); + return response.save(); + } else { + return Promise.resolve(response); + } + }); + ops.push(op); + } + + return Promise.all(ops); } save(anonymous) { const files = {}; - const detail = {version: this._version}; + const detail = { version: this._version }; // send only changed fields to avoid user privilege violation if (anonymous === true) { @@ -113,14 +261,14 @@ class Post extends events.EventTarget { detail.flags = this._flags; } if (misc.arraysDiffer(this._tags, this._orig._tags)) { - detail.tags = this._tags.map(tag => tag.names[0]); + detail.tags = this._tags.map((tag) => tag.names[0]); } if (misc.arraysDiffer(this._relations, this._orig._relations)) { detail.relations = this._relations; } if (misc.arraysDiffer(this._notes, this._orig._notes)) { - detail.notes = this._notes.map(note => ({ - polygon: note.polygon.map(point => [point.x, point.y]), + detail.notes = this._notes.map((note) => ({ + polygon: note.polygon.map((point) => [point.x, point.y]), text: note.text, })); } @@ -147,138 +295,178 @@ class Post extends events.EventTarget { detail.source = this._source; } - let apiPromise = this._id ? - api.put(uri.formatApiLink('post', this.id), detail, files) : - api.post(uri.formatApiLink('posts'), detail, files); + let apiPromise = this._id + ? api.put(uri.formatApiLink("post", this.id), detail, files) + : api.post(uri.formatApiLink("posts"), detail, files); - return apiPromise.then(response => { - this._updateFromResponse(response); - this.dispatchEvent( - new CustomEvent('change', {detail: {post: this}})); - if (this._newContent) { - this.dispatchEvent( - new CustomEvent('changeContent', {detail: {post: this}})); - } - if (this._newThumbnail) { - this.dispatchEvent( - new CustomEvent('changeThumbnail', {detail: {post: this}})); - } - return Promise.resolve(); - }, error => { - if (error.response && - error.response.name === 'PostAlreadyUploadedError') { - error.message = - `Post already uploaded (@${error.response.otherPostId})`; - } - return Promise.reject(error); - }); + return apiPromise + .then((response) => { + if (misc.arraysDiffer(this._pools, this._orig._pools)) { + return this._savePoolPosts().then(() => + Promise.resolve(response) + ); + } + return Promise.resolve(response); + }) + .then( + (response) => { + this._updateFromResponse(response); + this.dispatchEvent( + new CustomEvent("change", { detail: { post: this } }) + ); + if (this._newContent) { + this.dispatchEvent( + new CustomEvent("changeContent", { + detail: { post: this }, + }) + ); + } + if (this._newThumbnail) { + this.dispatchEvent( + new CustomEvent("changeThumbnail", { + detail: { post: this }, + }) + ); + } + + return Promise.resolve(); + }, + (error) => { + if ( + error.response && + error.response.name === "PostAlreadyUploadedError" + ) { + error.message = `Post already uploaded (@${error.response.otherPostId})`; + } + return Promise.reject(error); + } + ); } feature() { - return api.post( - uri.formatApiLink('featured-post'), - {id: this._id}) - .then(response => { + return api + .post(uri.formatApiLink("featured-post"), { id: this._id }) + .then((response) => { return Promise.resolve(); }); } delete() { - return api.delete( - uri.formatApiLink('post', this.id), - {version: this._version}) - .then(response => { - this.dispatchEvent(new CustomEvent('delete', { - detail: { - post: this, - }, - })); + return api + .delete(uri.formatApiLink("post", this.id), { + version: this._version, + }) + .then((response) => { + this.dispatchEvent( + new CustomEvent("delete", { + detail: { + post: this, + }, + }) + ); return Promise.resolve(); }); } merge(targetId, useOldContent) { - return api.get(uri.formatApiLink('post', targetId)) - .then(response => { - return api.post(uri.formatApiLink('post-merge'), { + return api + .get(uri.formatApiLink("post", targetId)) + .then((response) => { + return api.post(uri.formatApiLink("post-merge"), { removeVersion: this._version, remove: this._id, mergeToVersion: response.version, mergeTo: targetId, replaceContent: useOldContent, }); - }).then(response => { + }) + .then((response) => { this._updateFromResponse(response); - this.dispatchEvent(new CustomEvent('change', { - detail: { - post: this, - }, - })); + this.dispatchEvent( + new CustomEvent("change", { + detail: { + post: this, + }, + }) + ); return Promise.resolve(); }); } setScore(score) { - return api.put( - uri.formatApiLink('post', this.id, 'score'), - {score: score}) - .then(response => { + return api + .put(uri.formatApiLink("post", this.id, "score"), { score: score }) + .then((response) => { const prevFavorite = this._ownFavorite; this._updateFromResponse(response); if (this._ownFavorite !== prevFavorite) { - this.dispatchEvent(new CustomEvent('changeFavorite', { + this.dispatchEvent( + new CustomEvent("changeFavorite", { + detail: { + post: this, + }, + }) + ); + } + this.dispatchEvent( + new CustomEvent("changeScore", { detail: { post: this, }, - })); - } - this.dispatchEvent(new CustomEvent('changeScore', { - detail: { - post: this, - }, - })); + }) + ); return Promise.resolve(); }); } addToFavorites() { - return api.post(uri.formatApiLink('post', this.id, 'favorite')) - .then(response => { + return api + .post(uri.formatApiLink("post", this.id, "favorite")) + .then((response) => { const prevScore = this._ownScore; this._updateFromResponse(response); if (this._ownScore !== prevScore) { - this.dispatchEvent(new CustomEvent('changeScore', { + this.dispatchEvent( + new CustomEvent("changeScore", { + detail: { + post: this, + }, + }) + ); + } + this.dispatchEvent( + new CustomEvent("changeFavorite", { detail: { post: this, }, - })); - } - this.dispatchEvent(new CustomEvent('changeFavorite', { - detail: { - post: this, - }, - })); + }) + ); return Promise.resolve(); }); } removeFromFavorites() { - return api.delete(uri.formatApiLink('post', this.id, 'favorite')) - .then(response => { + return api + .delete(uri.formatApiLink("post", this.id, "favorite")) + .then((response) => { const prevScore = this._ownScore; this._updateFromResponse(response); if (this._ownScore !== prevScore) { - this.dispatchEvent(new CustomEvent('changeScore', { + this.dispatchEvent( + new CustomEvent("changeScore", { + detail: { + post: this, + }, + }) + ); + } + this.dispatchEvent( + new CustomEvent("changeFavorite", { detail: { post: this, }, - })); - } - this.dispatchEvent(new CustomEvent('changeFavorite', { - detail: { - post: this, - }, - })); + }) + ); return Promise.resolve(); }); } @@ -293,39 +481,38 @@ class Post extends events.EventTarget { mutateContentUrl() { this._contentUrl = this._orig._contentUrl + - '?bypass-cache=' + + "?bypass-cache=" + Math.round(Math.random() * 1000); } - prettyPrintSource() { - return uri.extractRootDomain(this._source); - } - _updateFromResponse(response) { const map = () => ({ - _version: response.version, - _id: response.id, - _type: response.type, - _mimeType: response.mimeType, - _creationTime: response.creationTime, - _user: response.user, - _safety: response.safety, - _contentUrl: response.contentUrl, - _fullContentUrl: new URL(response.contentUrl, document.getElementsByTagName('base')[0].href).href, - _thumbnailUrl: response.thumbnailUrl, - _source: response.source, - _canvasWidth: response.canvasWidth, - _canvasHeight: response.canvasHeight, - _fileSize: response.fileSize, + _version: response.version, + _id: response.id, + _type: response.type, + _mimeType: response.mimeType, + _creationTime: response.creationTime, + _user: response.user, + _safety: response.safety, + _contentUrl: response.contentUrl, + _fullContentUrl: new URL( + response.contentUrl, + document.getElementsByTagName("base")[0].href + ).href, + _thumbnailUrl: response.thumbnailUrl, + _source: response.source, + _canvasWidth: response.canvasWidth, + _canvasHeight: response.canvasHeight, + _fileSize: response.fileSize, - _flags: [...response.flags || []], - _relations: [...response.relations || []], + _flags: [...(response.flags || [])], + _relations: [...(response.relations || [])], - _score: response.score, - _commentCount: response.commentCount, + _score: response.score, + _commentCount: response.commentCount, _favoriteCount: response.favoriteCount, - _ownScore: response.ownScore, - _ownFavorite: response.ownFavorite, + _ownScore: response.ownScore, + _ownFavorite: response.ownFavorite, _hasCustomThumbnail: response.hasCustomThumbnail, }); @@ -333,6 +520,7 @@ class Post extends events.EventTarget { obj._tags.sync(response.tags); obj._notes.sync(response.notes); obj._comments.sync(response.comments); + obj._pools.sync(response.pools); obj._metrics.sync(response.metrics); obj._metricRanges.sync(response.metricRanges); } @@ -340,6 +528,6 @@ class Post extends events.EventTarget { Object.assign(this, map()); Object.assign(this._orig, map()); } -}; +} module.exports = Post; diff --git a/client/js/models/post_list.js b/client/js/models/post_list.js index 8024ae4..3db7ade 100644 --- a/client/js/models/post_list.js +++ b/client/js/models/post_list.js @@ -1,52 +1,56 @@ -'use strict'; +"use strict"; -const settings = require('../models/settings.js'); -const api = require('../api.js'); -const uri = require('../util/uri.js'); -const AbstractList = require('./abstract_list.js'); -const Post = require('./post.js'); +const settings = require("../models/settings.js"); +const api = require("../api.js"); +const uri = require("../util/uri.js"); +const AbstractList = require("./abstract_list.js"); +const Post = require("./post.js"); class PostList extends AbstractList { static getAround(id, searchQuery, cachenumber) { return api.get( - uri.formatApiLink( - 'post', id, 'around', { - query: PostList._decorateSearchQuery(searchQuery || ''), - fields: 'id', - cachenumber: cachenumber, - })); + uri.formatApiLink("post", id, "around", { + query: PostList._decorateSearchQuery(searchQuery || ""), + fields: "id", + cachenumber: cachenumber, + }) + ); } static search(text, offset, limit, fields, cachenumber) { - return api.get( - uri.formatApiLink( - 'posts', { - query: PostList._decorateSearchQuery(text || ''), - offset: offset, - limit: limit, - fields: fields.join(','), - cachenumber: cachenumber, - })) - .then(response => { - return Promise.resolve(Object.assign( - {}, - response, - {results: PostList.fromResponse(response.results)})); + return api + .get( + uri.formatApiLink("posts", { + query: PostList._decorateSearchQuery(text || ""), + offset: offset, + limit: limit, + fields: fields.join(","), + cachenumber: cachenumber, + }) + ) + .then((response) => { + return Promise.resolve( + Object.assign({}, response, { + results: PostList.fromResponse(response.results), + }) + ); }); } static getMedian(text, fields) { - return api.get( - uri.formatApiLink( - 'posts', 'median', { - query: PostList._decorateSearchQuery(text || ''), - fields: fields.join(','), - })) - .then(response => { - return Promise.resolve(Object.assign( - {}, - response, - {results: PostList.fromResponse(response.results)})); + return api + .get( + uri.formatApiLink("posts", "median", { + query: PostList._decorateSearchQuery(text || ""), + fields: fields.join(","), + }) + ) + .then((response) => { + return Promise.resolve( + Object.assign({}, response, { + results: PostList.fromResponse(response.results) + }) + ); }); } @@ -60,15 +64,40 @@ class PostList extends AbstractList { } } if (disabledSafety.length) { - text = `-rating:${disabledSafety.join(',')} ${text}`; + text = `-rating:${disabledSafety.join(",")} ${text}`; } } return text.trim(); } + hasPostId(testId) { + for (let post of this._list) { + if (post.id === testId) { + return true; + } + } + return false; + } + + addById(id) { + if (this.hasPostId(id)) { + return; + } + + let post = Post.fromResponse({ id: id }); + this.add(post); + } + + removeById(testId) { + for (let post of this._list) { + if (post.id === testId) { + this.remove(post); + } + } + } } PostList._itemClass = Post; -PostList._itemName = 'post'; +PostList._itemName = "post"; module.exports = PostList; diff --git a/client/js/models/settings.js b/client/js/models/settings.js index 0001360..af937fa 100644 --- a/client/js/models/settings.js +++ b/client/js/models/settings.js @@ -1,7 +1,7 @@ -'use strict'; +"use strict"; -const events = require('../events.js'); -const api = require('../api.js'); +const api = require("../api.js"); +const events = require("../events.js"); const defaultSettings = { listPosts: { @@ -9,42 +9,58 @@ const defaultSettings = { sketchy: false, unsafe: false, }, - uploadSafety: 'safe', + uploadSafety: "safe", upscaleSmallPosts: false, endlessScroll: false, keyboardShortcuts: true, transparencyGrid: false, - fitMode: 'fit-both', + fitMode: "fit-both", tagSuggestions: true, autoplayVideos: false, postsPerPage: 40, + tagUnderscoresAsSpaces: false, + darkTheme: false, + postFlow: false, }; class Settings extends events.EventTarget { - save(newSettings, silent) { - newSettings = Object.assign(this.get(), newSettings); - localStorage.setItem(this._settingsKey, JSON.stringify(newSettings)); - if (silent !== true) { - this.dispatchEvent(new CustomEvent('change', { - detail: { - settings: this.get(), - }, - })); - } + constructor() { + super(); + this.cache = this._getFromLocalStorage(); } - get() { + _getFromLocalStorage() { let ret = Object.assign({}, defaultSettings); try { Object.assign(ret, JSON.parse(localStorage.getItem(this._settingsKey))); } catch (e) { + // continue regardless of error } return ret; } + save(newSettings, silent) { + newSettings = Object.assign(this.cache, newSettings); + localStorage.setItem(this._settingsKey, JSON.stringify(newSettings)); + this.cache = this._getFromLocalStorage(); + if (silent !== true) { + this.dispatchEvent( + new CustomEvent("change", { + detail: { + settings: this.cache, + }, + }) + ); + } + } + + get() { + return this.cache; + } + get _settingsKey() { - return 'settings-' + api.userName + return "settings-" + api.userName; } -}; +} module.exports = new Settings(); diff --git a/client/js/models/snapshot.js b/client/js/models/snapshot.js index 78b367c..5f8e2ae 100644 --- a/client/js/models/snapshot.js +++ b/client/js/models/snapshot.js @@ -1,7 +1,7 @@ -'use strict'; +"use strict"; -const api = require('../api.js'); -const events = require('../events.js'); +const api = require("../api.js"); +const events = require("../events.js"); class Snapshot extends events.EventTarget { constructor() { @@ -10,12 +10,29 @@ class Snapshot extends events.EventTarget { this._updateFromResponse({}); } - get operation() { return this._operation; } - get type() { return this._type; } - get id() { return this._id; } - get user() { return this._user; } - get data() { return this._data; } - get time() { return this._time; } + get operation() { + return this._operation; + } + + get type() { + return this._type; + } + + get id() { + return this._id; + } + + get user() { + return this._user; + } + + get data() { + return this._data; + } + + get time() { + return this._time; + } static fromResponse(response) { const ret = new Snapshot(); @@ -26,11 +43,11 @@ class Snapshot extends events.EventTarget { _updateFromResponse(response) { const map = { _operation: response.operation, - _type: response.type, - _id: response.id, - _user: response.user, - _data: response.data, - _time: response.time, + _type: response.type, + _id: response.id, + _user: response.user, + _data: response.data, + _time: response.time, }; Object.assign(this, map); diff --git a/client/js/models/snapshot_list.js b/client/js/models/snapshot_list.js index 3263a6a..9ea1bdd 100644 --- a/client/js/models/snapshot_list.js +++ b/client/js/models/snapshot_list.js @@ -1,24 +1,31 @@ -'use strict'; +"use strict"; -const api = require('../api.js'); -const uri = require('../util/uri.js'); -const AbstractList = require('./abstract_list.js'); -const Snapshot = require('./snapshot.js'); +const api = require("../api.js"); +const uri = require("../util/uri.js"); +const AbstractList = require("./abstract_list.js"); +const Snapshot = require("./snapshot.js"); class SnapshotList extends AbstractList { static search(text, offset, limit) { - return api.get(uri.formatApiLink( - 'snapshots', {query: text, offset: offset, limit: limit})) - .then(response => { - return Promise.resolve(Object.assign( - {}, - response, - {results: SnapshotList.fromResponse(response.results)})); + return api + .get( + uri.formatApiLink("snapshots", { + query: text, + offset: offset, + limit: limit, + }) + ) + .then((response) => { + return Promise.resolve( + Object.assign({}, response, { + results: SnapshotList.fromResponse(response.results), + }) + ); }); } } SnapshotList._itemClass = Snapshot; -SnapshotList._itemName = 'snapshot'; +SnapshotList._itemName = "snapshot"; module.exports = SnapshotList; diff --git a/client/js/models/tag.js b/client/js/models/tag.js index 1c04eb3..eb24ccd 100644 --- a/client/js/models/tag.js +++ b/client/js/models/tag.js @@ -1,13 +1,13 @@ -'use strict'; +"use strict"; -const api = require('../api.js'); -const uri = require('../util/uri.js'); -const events = require('../events.js'); -const misc = require('../util/misc.js'); +const api = require("../api.js"); +const uri = require("../util/uri.js"); +const events = require("../events.js"); +const misc = require("../util/misc.js"); class Tag extends events.EventTarget { constructor() { - const TagList = require('./tag_list.js'); + const TagList = require("./tag_list.js"); super(); this._orig = {}; @@ -20,20 +20,57 @@ class Tag extends events.EventTarget { this._updateFromResponse({}); } - get names() { return this._names; } - get category() { return this._category; } - get description() { return this._description; } - get suggestions() { return this._suggestions; } - get implications() { return this._implications; } - get postCount() { return this._postCount; } - get creationTime() { return this._creationTime; } - get lastEditTime() { return this._lastEditTime; } - get metric() { return this._metric; } + get names() { + return this._names; + } + + get category() { + return this._category; + } + + get description() { + return this._description; + } - set names(value) { this._names = value; } - set category(value) { this._category = value; } - set description(value) { this._description = value; } - set metric(value) { this._metric = value; } + get suggestions() { + return this._suggestions; + } + + get implications() { + return this._implications; + } + + get postCount() { + return this._postCount; + } + + get creationTime() { + return this._creationTime; + } + + get lastEditTime() { + return this._lastEditTime; + } + + set names(value) { + this._names = value; + } + + set category(value) { + this._category = value; + } + + set description(value) { + this._description = value; + } + + get metric() { + return this._metric; + } + + set metric(value) { + this._metric = value; + } static fromResponse(response) { const ret = new Tag(); @@ -42,14 +79,13 @@ class Tag extends events.EventTarget { } static get(name) { - return api.get(uri.formatApiLink('tag', name)) - .then(response => { - return Promise.resolve(Tag.fromResponse(response)); - }); + return api.get(uri.formatApiLink("tag", name)).then((response) => { + return Promise.resolve(Tag.fromResponse(response)); + }); } save() { - const detail = {version: this._version}; + const detail = { version: this._version }; // send only changed fields to avoid user privilege violation if (misc.arraysDiffer(this._names, this._orig._names, true)) { @@ -63,11 +99,13 @@ class Tag extends events.EventTarget { } if (misc.arraysDiffer(this._implications, this._orig._implications)) { detail.implications = this._implications.map( - relation => relation.names[0]); + (relation) => relation.names[0] + ); } if (misc.arraysDiffer(this._suggestions, this._orig._suggestions)) { detail.suggestions = this._suggestions.map( - relation => relation.names[0]); + (relation) => relation.names[0] + ); } if (this._metric !== this._orig._metric) { detail.metric = { @@ -76,88 +114,99 @@ class Tag extends events.EventTarget { }; } - let promise = this._origName ? - api.put(uri.formatApiLink('tag', this._origName), detail) : - api.post(uri.formatApiLink('tags'), detail); - return promise - .then(response => { - this._updateFromResponse(response); - this.dispatchEvent(new CustomEvent('change', { + let promise = this._origName + ? api.put(uri.formatApiLink("tag", this._origName), detail) + : api.post(uri.formatApiLink("tags"), detail); + return promise.then((response) => { + this._updateFromResponse(response); + this.dispatchEvent( + new CustomEvent("change", { detail: { tag: this, }, - })); - return Promise.resolve(); - }); + }) + ); + return Promise.resolve(); + }); } merge(targetName, addAlias) { - return api.get(uri.formatApiLink('tag', targetName)) - .then(response => { - return api.post(uri.formatApiLink('tag-merge'), { + return api + .get(uri.formatApiLink("tag", targetName)) + .then((response) => { + return api.post(uri.formatApiLink("tag-merge"), { removeVersion: this._version, remove: this._origName, mergeToVersion: response.version, mergeTo: targetName, }); - }).then(response => { + }) + .then((response) => { if (!addAlias) { return Promise.resolve(response); } - return api.put(uri.formatApiLink('tag', targetName), { + return api.put(uri.formatApiLink("tag", targetName), { version: response.version, names: response.names.concat(this._names), }); - }).then(response => { + }) + .then((response) => { this._updateFromResponse(response); - this.dispatchEvent(new CustomEvent('change', { - detail: { - tag: this, - }, - })); + this.dispatchEvent( + new CustomEvent("change", { + detail: { + tag: this, + }, + }) + ); return Promise.resolve(); }); } delete() { - return api.delete( - uri.formatApiLink('tag', this._origName), - {version: this._version}) - .then(response => { - this.dispatchEvent(new CustomEvent('delete', { - detail: { - tag: this, - }, - })); + return api + .delete(uri.formatApiLink("tag", this._origName), { + version: this._version, + }) + .then((response) => { + this.dispatchEvent( + new CustomEvent("delete", { + detail: { + tag: this, + }, + }) + ); return Promise.resolve(); }); } deleteMetric() { return api.delete( - uri.formatApiLink('metric', this._origName), + uri.formatApiLink("metric", this._origName), {version: this.metric.version}) - .then(response => { - this.dispatchEvent(new CustomEvent('delete', { - detail: { - metric: this.metric, - }, - })); + .then((response) => { + this.dispatchEvent( + new CustomEvent("delete", { + detail: { + metric: this.metric, + }, + }) + ); return Promise.resolve(); }); } _updateFromResponse(response) { const map = { - _version: response.version, - _origName: response.names ? response.names[0] : null, - _names: response.names || [], - _category: response.category, - _description: response.description, + _version: response.version, + _origName: response.names ? response.names[0] : null, + _names: response.names || [], + _category: response.category, + _description: response.description, _creationTime: response.creationTime, _lastEditTime: response.lastEditTime, - _postCount: response.usages || 0, - _metric: response.metric, + _postCount: response.usages || 0, + _metric: response.metric, }; for (let obj of [this, this._orig]) { @@ -168,6 +217,6 @@ class Tag extends events.EventTarget { Object.assign(this, map); Object.assign(this._orig, map); } -}; +} module.exports = Tag; diff --git a/client/js/models/tag_category.js b/client/js/models/tag_category.js index 04bd8fe..1641862 100644 --- a/client/js/models/tag_category.js +++ b/client/js/models/tag_category.js @@ -1,28 +1,57 @@ -'use strict'; +"use strict"; -const api = require('../api.js'); -const uri = require('../util/uri.js'); -const events = require('../events.js'); +const api = require("../api.js"); +const uri = require("../util/uri.js"); +const events = require("../events.js"); class TagCategory extends events.EventTarget { constructor() { super(); - this._name = ''; - this._color = '#000000'; - this._tagCount = 0; + this._name = ""; + this._color = "#000000"; + this._order = 1; + this._tagCount = 0; this._isDefault = false; - this._origName = null; + this._origName = null; this._origColor = null; + this._origOrder = null; } - get name() { return this._name; } - get color() { return this._color; } - get tagCount() { return this._tagCount; } - get isDefault() { return this._isDefault; } - get isTransient() { return !this._origName; } + get name() { + return this._name; + } + + get color() { + return this._color; + } + + get order() { + return this._order; + } + + get tagCount() { + return this._tagCount; + } + + get isDefault() { + return this._isDefault; + } - set name(value) { this._name = value; } - set color(value) { this._color = value; } + get isTransient() { + return !this._origName; + } + + set name(value) { + this._name = value; + } + + set color(value) { + this._color = value; + } + + set order(value) { + this._order = value; + } static fromResponse(response) { const ret = new TagCategory(); @@ -31,7 +60,7 @@ class TagCategory extends events.EventTarget { } save() { - const detail = {version: this._version}; + const detail = { version: this._version }; if (this.name !== this._origName) { detail.name = this.name; @@ -39,51 +68,61 @@ class TagCategory extends events.EventTarget { if (this.color !== this._origColor) { detail.color = this.color; } + if (this.order !== this._origOrder) { + detail.order = this.order; + } if (!Object.keys(detail).length) { return Promise.resolve(); } - let promise = this._origName ? - api.put( - uri.formatApiLink('tag-category', this._origName), - detail) : - api.post(uri.formatApiLink('tag-categories'), detail); + let promise = this._origName + ? api.put( + uri.formatApiLink("tag-category", this._origName), + detail + ) + : api.post(uri.formatApiLink("tag-categories"), detail); - return promise - .then(response => { - this._updateFromResponse(response); - this.dispatchEvent(new CustomEvent('change', { + return promise.then((response) => { + this._updateFromResponse(response); + this.dispatchEvent( + new CustomEvent("change", { detail: { tagCategory: this, }, - })); - return Promise.resolve(); - }); + }) + ); + return Promise.resolve(); + }); } delete() { - return api.delete( - uri.formatApiLink('tag-category', this._origName), - {version: this._version}) - .then(response => { - this.dispatchEvent(new CustomEvent('delete', { - detail: { - tagCategory: this, - }, - })); + return api + .delete(uri.formatApiLink("tag-category", this._origName), { + version: this._version, + }) + .then((response) => { + this.dispatchEvent( + new CustomEvent("delete", { + detail: { + tagCategory: this, + }, + }) + ); return Promise.resolve(); }); } _updateFromResponse(response) { - this._version = response.version; - this._name = response.name; - this._color = response.color; + this._version = response.version; + this._name = response.name; + this._color = response.color; + this._order = response.order; this._isDefault = response.default; - this._tagCount = response.usages; - this._origName = this.name; + this._tagCount = response.usages; + this._origName = this.name; this._origColor = this.color; + this._origOrder = this.order; } } diff --git a/client/js/models/tag_category_list.js b/client/js/models/tag_category_list.js index 6c1182f..2fc1522 100644 --- a/client/js/models/tag_category_list.js +++ b/client/js/models/tag_category_list.js @@ -1,9 +1,9 @@ -'use strict'; +"use strict"; -const api = require('../api.js'); -const uri = require('../util/uri.js'); -const AbstractList = require('./abstract_list.js'); -const TagCategory = require('./tag_category.js'); +const api = require("../api.js"); +const uri = require("../util/uri.js"); +const AbstractList = require("./abstract_list.js"); +const TagCategory = require("./tag_category.js"); class TagCategoryList extends AbstractList { constructor() { @@ -11,7 +11,7 @@ class TagCategoryList extends AbstractList { this._defaultCategory = null; this._origDefaultCategory = null; this._deletedCategories = []; - this.addEventListener('remove', e => this._evtCategoryDeleted(e)); + this.addEventListener("remove", (e) => this._evtCategoryDeleted(e)); } static fromResponse(response) { @@ -27,12 +27,16 @@ class TagCategoryList extends AbstractList { } static get() { - return api.get(uri.formatApiLink('tag-categories')) - .then(response => { - return Promise.resolve(Object.assign( - {}, - response, - {results: TagCategoryList.fromResponse(response.results)})); + return api + .get(uri.formatApiLink("tag-categories")) + .then((response) => { + return Promise.resolve( + Object.assign({}, response, { + results: TagCategoryList.fromResponse( + response.results + ), + }) + ); }); } @@ -57,16 +61,18 @@ class TagCategoryList extends AbstractList { promises.push( api.put( uri.formatApiLink( - 'tag-category', + "tag-category", this._defaultCategory.name, - 'default'))); + "default" + ) + ) + ); } - return Promise.all(promises) - .then(response => { - this._deletedCategories = []; - return Promise.resolve(); - }); + return Promise.all(promises).then((response) => { + this._deletedCategories = []; + return Promise.resolve(); + }); } _evtCategoryDeleted(e) { @@ -77,6 +83,6 @@ class TagCategoryList extends AbstractList { } TagCategoryList._itemClass = TagCategory; -TagCategoryList._itemName = 'tagCategory'; +TagCategoryList._itemName = "tagCategory"; module.exports = TagCategoryList; diff --git a/client/js/models/tag_list.js b/client/js/models/tag_list.js index 1478f8b..7882164 100644 --- a/client/js/models/tag_list.js +++ b/client/js/models/tag_list.js @@ -1,25 +1,27 @@ -'use strict'; +"use strict"; -const api = require('../api.js'); -const uri = require('../util/uri.js'); -const AbstractList = require('./abstract_list.js'); -const Tag = require('./tag.js'); +const api = require("../api.js"); +const uri = require("../util/uri.js"); +const AbstractList = require("./abstract_list.js"); +const Tag = require("./tag.js"); class TagList extends AbstractList { static search(text, offset, limit, fields) { - return api.get( - uri.formatApiLink( - 'tags', { - query: text, - offset: offset, - limit: limit, - fields: fields.join(','), - })) - .then(response => { - return Promise.resolve(Object.assign( - {}, - response, - {results: TagList.fromResponse(response.results)})); + return api + .get( + uri.formatApiLink("tags", { + query: text, + offset: offset, + limit: limit, + fields: fields.join(","), + }) + ) + .then((response) => { + return Promise.resolve( + Object.assign({}, response, { + results: TagList.fromResponse(response.results), + }) + ); }); } @@ -52,10 +54,12 @@ class TagList extends AbstractList { this.add(tag); if (addImplications !== false) { - return Tag.get(tag.names[0]).then(actualTag => { + return Tag.get(tag.names[0]).then((actualTag) => { return Promise.all( - actualTag.implications.map( - relation => this.addByName(relation.names[0], true))); + actualTag.implications.map((relation) => + this.addByName(relation.names[0], true) + ) + ); }); } @@ -78,6 +82,6 @@ class TagList extends AbstractList { } TagList._itemClass = Tag; -TagList._itemName = 'tag'; +TagList._itemName = "tag"; module.exports = TagList; diff --git a/client/js/models/top_navigation.js b/client/js/models/top_navigation.js index bf2cffe..a469a03 100644 --- a/client/js/models/top_navigation.js +++ b/client/js/models/top_navigation.js @@ -1,7 +1,7 @@ -'use strict'; +"use strict"; -const events = require('../events.js'); -const api = require('../api.js'); +const events = require("../events.js"); +const api = require("../api.js"); class TopNavigationItem { constructor(accessKey, title, url, available, imageUrl) { @@ -12,7 +12,7 @@ class TopNavigationItem { this.imageUrl = imageUrl === undefined ? null : imageUrl; this.key = null; } -}; +} class TopNavigation extends events.EventTarget { constructor() { @@ -44,18 +44,20 @@ class TopNavigation extends events.EventTarget { activate(key) { this.activeItem = null; - this.dispatchEvent(new CustomEvent('activate', { - detail: { - key: key, - item: key ? this.get(key) : null, - }, - })); + this.dispatchEvent( + new CustomEvent("activate", { + detail: { + key: key, + item: key ? this.get(key) : null, + }, + }) + ); } setTitle(title) { api.fetchConfig().then(() => { document.oldTitle = null; - document.title = api.getName() + (title ? (' – ' + title) : ''); + document.title = api.getName() + (title ? " – " + title : ""); }); } @@ -72,27 +74,26 @@ class TopNavigation extends events.EventTarget { hide(key) { this.get(key).available = false; } -}; +} function _makeTopNavigation() { const ret = new TopNavigation(); - ret.add('home', new TopNavigationItem('H', 'Home', '')); - ret.add('posts', new TopNavigationItem('P', 'Posts', 'posts')); - ret.add('upload', new TopNavigationItem('U', 'Upload', 'upload')); - ret.add('comments', new TopNavigationItem('C', 'Comments', 'comments')); - ret.add('tags', new TopNavigationItem('T', 'Tags', 'tags')); - ret.add('users', new TopNavigationItem('S', 'Users', 'users')); - ret.add('account', new TopNavigationItem('A', 'Account', 'user/{me}')); - ret.add('register', new TopNavigationItem('R', 'Register', 'register')); - ret.add('login', new TopNavigationItem('L', 'Log in', 'login')); - ret.add('logout', new TopNavigationItem('O', 'Logout', 'logout')); - ret.add('help', new TopNavigationItem('E', 'Help', 'help')); + ret.add("home", new TopNavigationItem("H", "Home", "")); + ret.add("posts", new TopNavigationItem("P", "Posts", "posts")); + ret.add("upload", new TopNavigationItem("U", "Upload", "upload")); + ret.add("comments", new TopNavigationItem("C", "Comments", "comments")); + ret.add("tags", new TopNavigationItem("T", "Tags", "tags")); + ret.add("pools", new TopNavigationItem("O", "Pools", "pools")); + ret.add("users", new TopNavigationItem("S", "Users", "users")); + ret.add("account", new TopNavigationItem("A", "Account", "user/{me}")); + ret.add("register", new TopNavigationItem("R", "Register", "register")); + ret.add("login", new TopNavigationItem("L", "Log in", "login")); + ret.add("logout", new TopNavigationItem("O", "Logout", "logout")); + ret.add("help", new TopNavigationItem("E", "Help", "help")); ret.add( - 'settings', - new TopNavigationItem( - null, - '<i class=\'fa fa-cog\'></i>', - 'settings')); + "settings", + new TopNavigationItem(null, "<i class='fa fa-cog'></i>", "settings") + ); return ret; } diff --git a/client/js/models/user.js b/client/js/models/user.js index abb5f57..28dc3ef 100644 --- a/client/js/models/user.js +++ b/client/js/models/user.js @@ -1,8 +1,8 @@ -'use strict'; +"use strict"; -const api = require('../api.js'); -const uri = require('../util/uri.js'); -const events = require('../events.js'); +const api = require("../api.js"); +const uri = require("../util/uri.js"); +const events = require("../events.js"); class User extends events.EventTarget { constructor() { @@ -11,28 +11,89 @@ class User extends events.EventTarget { this._updateFromResponse({}); } - get name() { return this._name; } - get rank() { return this._rank; } - get email() { return this._email; } - get avatarStyle() { return this._avatarStyle; } - get avatarUrl() { return this._avatarUrl; } - get creationTime() { return this._creationTime; } - get lastLoginTime() { return this._lastLoginTime; } - get commentCount() { return this._commentCount; } - get favoritePostCount() { return this._favoritePostCount; } - get uploadedPostCount() { return this._uploadedPostCount; } - get likedPostCount() { return this._likedPostCount; } - get dislikedPostCount() { return this._dislikedPostCount; } - get rankName() { return api.rankNames.get(this.rank); } - get avatarContent() { throw 'Invalid operation'; } - get password() { throw 'Invalid operation'; } + get name() { + return this._name; + } + + get rank() { + return this._rank; + } + + get email() { + return this._email; + } + + get avatarStyle() { + return this._avatarStyle; + } + + get avatarUrl() { + return this._avatarUrl; + } + + get creationTime() { + return this._creationTime; + } + + get lastLoginTime() { + return this._lastLoginTime; + } + + get commentCount() { + return this._commentCount; + } + + get favoritePostCount() { + return this._favoritePostCount; + } + + get uploadedPostCount() { + return this._uploadedPostCount; + } + + get likedPostCount() { + return this._likedPostCount; + } + + get dislikedPostCount() { + return this._dislikedPostCount; + } - set name(value) { this._name = value; } - set rank(value) { this._rank = value; } - set email(value) { this._email = value || null; } - set avatarStyle(value) { this._avatarStyle = value; } - set avatarContent(value) { this._avatarContent = value; } - set password(value) { this._password = value; } + get rankName() { + return api.rankNames.get(this.rank); + } + + get avatarContent() { + throw "Invalid operation"; + } + + get password() { + throw "Invalid operation"; + } + + set name(value) { + this._name = value; + } + + set rank(value) { + this._rank = value; + } + + set email(value) { + this._email = value || null; + } + + set avatarStyle(value) { + this._avatarStyle = value; + } + + set avatarContent(value) { + this._avatarContent = value; + } + + set password(value) { + this._password = value; + } static fromResponse(response) { const ret = new User(); @@ -41,15 +102,14 @@ class User extends events.EventTarget { } static get(name) { - return api.get(uri.formatApiLink('user', name)) - .then(response => { - return Promise.resolve(User.fromResponse(response)); - }); + return api.get(uri.formatApiLink("user", name)).then((response) => { + return Promise.resolve(User.fromResponse(response)); + }); } save() { const files = []; - const detail = {version: this._version}; + const detail = { version: this._version }; const transient = this._orig._name; if (this._name !== this._orig._name) { @@ -72,60 +132,67 @@ class User extends events.EventTarget { detail.password = this._password; } - let promise = this._orig._name ? - api.put( - uri.formatApiLink('user', this._orig._name), detail, files) : - api.post(uri.formatApiLink('users'), detail, files); + let promise = this._orig._name + ? api.put( + uri.formatApiLink("user", this._orig._name), + detail, + files + ) + : api.post(uri.formatApiLink("users"), detail, files); - return promise - .then(response => { - this._updateFromResponse(response); - this.dispatchEvent(new CustomEvent('change', { + return promise.then((response) => { + this._updateFromResponse(response); + this.dispatchEvent( + new CustomEvent("change", { detail: { user: this, }, - })); - return Promise.resolve(); - }); + }) + ); + return Promise.resolve(); + }); } delete() { - return api.delete( - uri.formatApiLink('user', this._orig._name), - {version: this._version}) - .then(response => { - this.dispatchEvent(new CustomEvent('delete', { - detail: { - user: this, - }, - })); + return api + .delete(uri.formatApiLink("user", this._orig._name), { + version: this._version, + }) + .then((response) => { + this.dispatchEvent( + new CustomEvent("delete", { + detail: { + user: this, + }, + }) + ); return Promise.resolve(); }); } _updateFromResponse(response) { const map = { - _version: response.version, - _name: response.name, - _rank: response.rank, - _email: response.email, - _avatarStyle: response.avatarStyle, - _avatarUrl: response.avatarUrl, - _creationTime: response.creationTime, - _lastLoginTime: response.lastLoginTime, - _commentCount: response.commentCount, + _version: response.version, + _name: response.name, + _rank: response.rank, + _email: response.email, + _avatarStyle: response.avatarStyle, + _avatarUrl: response.avatarUrl, + _creationTime: response.creationTime, + _lastLoginTime: response.lastLoginTime, + _commentCount: response.commentCount, _favoritePostCount: response.favoritePostCount, _uploadedPostCount: response.uploadedPostCount, - _likedPostCount: response.likedPostCount, + _likedPostCount: response.likedPostCount, _dislikedPostCount: response.dislikedPostCount, }; Object.assign(this, map); Object.assign(this._orig, map); - this._password = null; - this._avatarContent = null; + this._password = null; + this._avatarContent = null; } -}; +} module.exports = User; diff --git a/client/js/models/user_list.js b/client/js/models/user_list.js index c48fc88..c537f8f 100644 --- a/client/js/models/user_list.js +++ b/client/js/models/user_list.js @@ -1,25 +1,31 @@ -'use strict'; +"use strict"; -const api = require('../api.js'); -const uri = require('../util/uri.js'); -const AbstractList = require('./abstract_list.js'); -const User = require('./user.js'); +const api = require("../api.js"); +const uri = require("../util/uri.js"); +const AbstractList = require("./abstract_list.js"); +const User = require("./user.js"); class UserList extends AbstractList { static search(text, offset, limit) { - return api.get( - uri.formatApiLink( - 'users', {query: text, offset: offset, limit: limit})) - .then(response => { - return Promise.resolve(Object.assign( - {}, - response, - {results: UserList.fromResponse(response.results)})); + return api + .get( + uri.formatApiLink("users", { + query: text, + offset: offset, + limit: limit, + }) + ) + .then((response) => { + return Promise.resolve( + Object.assign({}, response, { + results: UserList.fromResponse(response.results), + }) + ); }); } } UserList._itemClass = User; -UserList._itemName = 'user'; +UserList._itemName = "user"; module.exports = UserList; diff --git a/client/js/models/user_token.js b/client/js/models/user_token.js index 6e70a94..c9d28a2 100644 --- a/client/js/models/user_token.js +++ b/client/js/models/user_token.js @@ -1,8 +1,8 @@ -'use strict'; +"use strict"; -const api = require('../api.js'); -const uri = require('../util/uri.js'); -const events = require('../events.js'); +const api = require("../api.js"); +const uri = require("../util/uri.js"); +const events = require("../events.js"); class UserToken extends events.EventTarget { constructor() { @@ -11,24 +11,49 @@ class UserToken extends events.EventTarget { this._updateFromResponse({}); } - get token() { return this._token; } - get note() { return this._note; } - get enabled() { return this._enabled; } - get version() { return this._version; } - get expirationTime() { return this._expirationTime; } - get creationTime() { return this._creationTime; } - get lastEditTime() { return this._lastEditTime; } - get lastUsageTime() { return this._lastUsageTime; } + get token() { + return this._token; + } - set note(value) { this._note = value; } + get note() { + return this._note; + } + + get enabled() { + return this._enabled; + } + + get version() { + return this._version; + } + + get expirationTime() { + return this._expirationTime; + } + + get creationTime() { + return this._creationTime; + } + + get lastEditTime() { + return this._lastEditTime; + } + + get lastUsageTime() { + return this._lastUsageTime; + } + + set note(value) { + this._note = value; + } static fromResponse(response) { - if (typeof response.results !== 'undefined') { + if (typeof response.results !== "undefined") { let tokenList = []; for (let responseToken of response.results) { const token = new UserToken(); token._updateFromResponse(responseToken); - tokenList.push(token) + tokenList.push(token); } return tokenList; } else { @@ -39,15 +64,16 @@ class UserToken extends events.EventTarget { } static get(userName) { - return api.get(uri.formatApiLink('user-tokens', userName)) - .then(response => { + return api + .get(uri.formatApiLink("user-tokens", userName)) + .then((response) => { return Promise.resolve(UserToken.fromResponse(response)); }); } static create(userName, note, expirationTime) { let userTokenRequest = { - enabled: true + enabled: true, }; if (note) { userTokenRequest.note = note; @@ -55,57 +81,68 @@ class UserToken extends events.EventTarget { if (expirationTime) { userTokenRequest.expirationTime = expirationTime; } - return api.post(uri.formatApiLink('user-token', userName), userTokenRequest) - .then(response => { - return Promise.resolve(UserToken.fromResponse(response)) + return api + .post(uri.formatApiLink("user-token", userName), userTokenRequest) + .then((response) => { + return Promise.resolve(UserToken.fromResponse(response)); }); } save(userName) { - const detail = {version: this._version}; + const detail = { version: this._version }; if (this._note !== this._orig._note) { detail.note = this._note; } - return api.put( - uri.formatApiLink('user-token', userName, this._orig._token), - detail) - .then(response => { + return api + .put( + uri.formatApiLink("user-token", userName, this._orig._token), + detail + ) + .then((response) => { this._updateFromResponse(response); - this.dispatchEvent(new CustomEvent('change', { - detail: { - userToken: this, - }, - })); + this.dispatchEvent( + new CustomEvent("change", { + detail: { + userToken: this, + }, + }) + ); return Promise.resolve(this); }); } delete(userName) { - return api.delete( - uri.formatApiLink('user-token', userName, this._orig._token), - {version: this._version}) - .then(response => { - this.dispatchEvent(new CustomEvent('delete', { - detail: { - userToken: this, - }, - })); + return api + .delete( + uri.formatApiLink("user-token", userName, this._orig._token), + { + version: this._version, + } + ) + .then((response) => { + this.dispatchEvent( + new CustomEvent("delete", { + detail: { + userToken: this, + }, + }) + ); return Promise.resolve(); }); } _updateFromResponse(response) { const map = { - _token: response.token, - _note: response.note, - _enabled: response.enabled, - _expirationTime: response.expirationTime, - _version: response.version, - _creationTime: response.creationTime, - _lastEditTime: response.lastEditTime, - _lastUsageTime: response.lastUsageTime, + _token: response.token, + _note: response.note, + _enabled: response.enabled, + _expirationTime: response.expirationTime, + _version: response.version, + _creationTime: response.creationTime, + _lastEditTime: response.lastEditTime, + _lastUsageTime: response.lastUsageTime, }; Object.assign(this, map); diff --git a/client/js/pools.js b/client/js/pools.js new file mode 100644 index 0000000..aa58d5e --- /dev/null +++ b/client/js/pools.js @@ -0,0 +1,27 @@ +"use strict"; + +const misc = require("./util/misc.js"); +const PoolCategoryList = require("./models/pool_category_list.js"); + +let _stylesheet = null; + +function refreshCategoryColorMap() { + return PoolCategoryList.get().then((response) => { + if (_stylesheet) { + document.head.removeChild(_stylesheet); + } + _stylesheet = document.createElement("style"); + document.head.appendChild(_stylesheet); + for (let category of response.results) { + const ruleName = misc.makeCssName(category.name, "pool"); + _stylesheet.sheet.insertRule( + `.${ruleName} { color: ${category.color} }`, + _stylesheet.sheet.cssRules.length + ); + } + }); +} + +module.exports = { + refreshCategoryColorMap: refreshCategoryColorMap, +}; diff --git a/client/js/router.js b/client/js/router.js index dd9b9e1..460a641 100644 --- a/client/js/router.js +++ b/client/js/router.js @@ -1,4 +1,4 @@ -'use strict'; +"use strict"; // modified page.js by visionmedia // - changed regexes to components @@ -10,13 +10,17 @@ // - rename .save() to .replaceState() // - offer .url -const clickEvent = document.ontouchstart ? 'touchstart' : 'click'; -const uri = require('./util/uri.js'); +const clickEvent = document.ontouchstart ? "touchstart" : "click"; +const uri = require("./util/uri.js"); let location = window.history.location || window.location; function _getOrigin() { - return location.protocol + '//' + location.hostname - + (location.port ? (':' + location.port) : ''); + return ( + location.protocol + + "//" + + location.hostname + + (location.port ? ":" + location.port : "") + ); } function _isSameOrigin(href) { @@ -24,15 +28,16 @@ function _isSameOrigin(href) { } function _getBaseHref() { - const bases = document.getElementsByTagName('base'); - return bases.length > 0 ? - bases[0].href.replace(_getOrigin(), '').replace(/\/+$/, '') : ''; + const bases = document.getElementsByTagName("base"); + return bases.length > 0 + ? bases[0].href.replace(_getOrigin(), "").replace(/\/+$/, "") + : ""; } class Context { constructor(path, state) { const base = _getBaseHref(); - path = path.indexOf('/') !== 0 ? '/' + path : path; + path = path.indexOf("/") !== 0 ? "/" + path : path; path = path.indexOf(base) !== 0 ? base + path : path; this.canonicalPath = path; @@ -51,11 +56,11 @@ class Context { replaceState() { history.replaceState(this.state, this.title, this.canonicalPath); } -}; +} class Route { constructor(path) { - this.method = 'GET'; + this.method = "GET"; this.path = path; this.parameterNames = []; @@ -64,16 +69,17 @@ class Route { } else { let parts = []; for (let component of this.path) { - if (component[0] === ':') { - parts.push('([^/]+)'); + if (component[0] === ":") { + parts.push("([^/]+)"); this.parameterNames.push(component.substr(1)); - } else { // assert [a-z]+ + } else { + // assert [a-z]+ parts.push(component); } } - let regexString = '^/' + parts.join('/'); - regexString += '(?:/*|/((?:(?:[a-z]+=[^/]+);)*(?:[a-z]+=[^/]+)))$'; - this.parameterNames.push('variable'); + let regexString = "^/" + parts.join("/"); + regexString += "(?:/*|/((?:(?:[a-z]+=[^/]+);)*(?:[a-z]+=[^/]+)))$"; + this.parameterNames.push("variable"); this.regex = new RegExp(regexString); } } @@ -88,7 +94,7 @@ class Route { } match(path, parameters) { - const qsIndex = path.indexOf('?'); + const qsIndex = path.indexOf("?"); const pathname = ~qsIndex ? path.slice(0, qsIndex) : path; const match = this.regex.exec(pathname); @@ -104,8 +110,8 @@ class Route { continue; } - if (name === 'variable') { - for (let word of (value || '').split(/;/)) { + if (name === "variable") { + for (let word of (value || "").split(/;/)) { const [key, subvalue] = word.split(/=/, 2); parameters[key] = uri.unescapeParam(subvalue); } @@ -119,7 +125,7 @@ class Route { return true; } -}; +} class Router { constructor() { @@ -148,7 +154,7 @@ class Router { this._running = true; this._onPopState = _onPopState(this); this._onClick = _onClick(this); - window.addEventListener('popstate', this._onPopState, false); + window.addEventListener("popstate", this._onPopState, false); document.addEventListener(clickEvent, this._onClick, false); const url = location.pathname + location.search + location.hash; return this.replace(url, history.state, true); @@ -160,7 +166,7 @@ class Router { } this._running = false; document.removeEventListener(clickEvent, this._onClick, false); - window.removeEventListener('popstate', this._onPopState, false); + window.removeEventListener("popstate", this._onPopState, false); } showNoDispatch(path, state) { @@ -199,11 +205,11 @@ class Router { middle(); next(); }; - const callChain = (this.ctx ? this._exits : []) - .concat( - [swap], - this._callbacks, - [this._unhandled, (ctx, next) => {}]); + const callChain = (this.ctx ? this._exits : []).concat( + [swap], + this._callbacks, + [this._unhandled, (ctx, next) => {}] + ); let i = 0; let fn = () => { @@ -217,29 +223,27 @@ class Router { if (current === ctx.canonicalPath) { return; } - router.stop(); + this.stop(); location.href = ctx.canonicalPath; } get url() { return location.pathname + location.search + location.hash; } -}; +} -const _onPopState = router => { +const _onPopState = (router) => { let loaded = false; - if (document.readyState === 'complete') { + if (document.readyState === "complete") { loaded = true; } else { - window.addEventListener( - 'load', - () => { - setTimeout(() => { - loaded = true; - }, 0); - }); + window.addEventListener("load", () => { + setTimeout(() => { + loaded = true; + }, 0); + }); } - return e => { + return (e) => { if (!loaded) { return; } @@ -247,16 +251,13 @@ const _onPopState = router => { const path = e.state.path; router.replace(path, e.state, true); } else { - router.show( - location.pathname + location.hash, - undefined, - false); + router.show(location.pathname + location.hash, undefined, false); } }; }; -const _onClick = router => { - return e => { +const _onClick = (router) => { + return (e) => { if (1 !== _which(e)) { return; } @@ -268,23 +269,25 @@ const _onClick = router => { } let el = e.path ? e.path[0] : e.target; - while (el && el.nodeName !== 'A') { + while (el && el.nodeName !== "A") { el = el.parentNode; } - if (!el || el.nodeName !== 'A') { + if (!el || el.nodeName !== "A") { return; } - if (el.hasAttribute('download') || - el.getAttribute('rel') === 'external') { + if ( + el.hasAttribute("download") || + el.getAttribute("rel") === "external" + ) { return; } - const link = el.getAttribute('href'); - if (el.pathname === location.pathname && (el.hash || '#' === link)) { + const link = el.getAttribute("href"); + if (el.pathname === location.pathname && (el.hash || "#" === link)) { return; } - if (link && link.indexOf('mailto:') > -1) { + if (link && link.indexOf("mailto:") > -1) { return; } if (el.target) { @@ -295,7 +298,7 @@ const _onClick = router => { } const base = _getBaseHref(); - const orig = el.pathname + el.search + (el.hash || ''); + const orig = el.pathname + el.search + (el.hash || ""); const path = !orig.indexOf(base) ? orig.slice(base.length) : orig; if (base && orig === path) { diff --git a/client/js/tags.js b/client/js/tags.js index cff6f20..2f2732d 100644 --- a/client/js/tags.js +++ b/client/js/tags.js @@ -1,36 +1,38 @@ -'use strict'; +"use strict"; -const misc = require('./util/misc.js'); -const TagCategoryList = require('./models/tag_category_list.js'); -const Tag = require('./models/tag.js'); +const misc = require("./util/misc.js"); +const TagCategoryList = require("./models/tag_category_list.js"); +const Tag = require("./models/tag.js"); let _stylesheet = null; function refreshCategoryColorMap() { - return TagCategoryList.get().then(response => { + return TagCategoryList.get().then((response) => { if (_stylesheet) { document.head.removeChild(_stylesheet); } - _stylesheet = document.createElement('style'); + _stylesheet = document.createElement("style"); document.head.appendChild(_stylesheet); for (let category of response.results) { - const ruleName = misc.makeCssName(category.name, 'tag'); + const ruleName = misc.makeCssName(category.name, "tag"); _stylesheet.sheet.insertRule( `.${ruleName} { color: ${category.color}; border-color: ${category.color} }`, - _stylesheet.sheet.cssRules.length); + _stylesheet.sheet.cssRules.length + ); _stylesheet.sheet.insertRule( `.${ruleName}.selected { color: white; background-color: ${category.color} }`, - _stylesheet.sheet.cssRules.length); + _stylesheet.sheet.cssRules.length + ); } }); } function parseTagAndCategory(text) { - let nameAndCat = text.split(':'); + let nameAndCat = text.split(":"); if (nameAndCat.length > 1) { // "cat:my:tag" should parse to category "cat" and tag "my:tag" let category = nameAndCat.shift(); - let name = nameAndCat.join(':'); + let name = nameAndCat.join(":"); return {name: name, category: category}; } else { return {name: text, category: null}; @@ -43,13 +45,13 @@ function resolveTagAndCategory(text) { } function _createTagByCategoryAndName(category, name) { - category = category ? category.trim() : 'default'; + category = category ? category.trim() : "default"; name = name.trim(); if (!name) { - return Promise.reject(new Error('Empty tag name')); + return Promise.reject(new Error("Empty tag name")); } // if tag with this name already exists, existing category will be used - return Tag.get(name).then(tag => { + return Tag.get(name).then((tag) => { return Promise.resolve(tag); }, () => { const tag = new Tag(); diff --git a/client/js/templates.js b/client/js/templates.js index f378cbc..88bd568 100644 --- a/client/js/templates.js +++ b/client/js/templates.js @@ -1,3 +1,3 @@ -'use strict'; +"use strict"; -module.exports = require('./.templates.autogen.js'); +module.exports = require("./.templates.autogen.js"); diff --git a/client/js/util/keyboard.js b/client/js/util/keyboard.js index 3e3158a..c66eda1 100644 --- a/client/js/util/keyboard.js +++ b/client/js/util/keyboard.js @@ -1,11 +1,12 @@ -'use strict'; +"use strict"; -const mousetrap = require('mousetrap'); -const settings = require('../models/settings.js'); +const mousetrap = require("mousetrap"); +const settings = require("../models/settings.js"); let paused = false; const _originalStopCallback = mousetrap.prototype.stopCallback; -mousetrap.prototype.stopCallback = function(...args) { +// eslint-disable-next-line func-names +mousetrap.prototype.stopCallback = function (...args) { var self = this; if (paused) { return true; @@ -36,6 +37,10 @@ module.exports = { bind: bind, bindElement: bindElement, unbind: unbind, - pause: () => { paused = true; }, - unpause: () => { paused = false; }, + pause: () => { + paused = true; + }, + unpause: () => { + paused = false; + }, }; diff --git a/client/js/util/markdown.js b/client/js/util/markdown.js index f326ebb..22cdae5 100644 --- a/client/js/util/markdown.js +++ b/client/js/util/markdown.js @@ -1,6 +1,7 @@ -'use strict'; +"use strict"; -const marked = require('marked'); +const marked = require("marked"); +const DOMPurify = require("dompurify"); class BaseMarkdownWrapper { preprocess(text) { @@ -20,62 +21,68 @@ class SjisWrapper extends BaseMarkdownWrapper { preprocess(text) { return text.replace( - /\[sjis\]((?:[^\[]|\[(?!\/?sjis\]))+)\[\/sjis\]/ig, + /\[sjis\]((?:[^\[]|\[(?!\/?sjis\]))+)\[\/sjis\]/gi, (match, capture) => { - var ret = '%%%SJIS' + this.buf.length; + var ret = "%%%SJIS" + this.buf.length; this.buf.push(capture); return ret; - }); + } + ); } postprocess(text) { return text.replace( /(?:<p>)?%%%SJIS(\d+)(?:<\/p>)?/, (match, capture) => { - return '<div class="sjis">' + this.buf[capture] + '</div>'; - }); + return '<div class="sjis">' + this.buf[capture] + "</div>"; + } + ); } } // fix \ before ~ being stripped away class TildeWrapper extends BaseMarkdownWrapper { preprocess(text) { - return text.replace(/\\~/g, '%%%T'); + return text.replace(/\\~/g, "%%%T"); } postprocess(text) { - return text.replace(/%%%T/g, '\\~'); + return text.replace(/%%%T/g, "\\~"); } } -//prevent ^#... from being treated as headers, due to tag permalinks +// prevent ^#... from being treated as headers, due to tag permalinks class TagPermalinkFixWrapper extends BaseMarkdownWrapper { preprocess(text) { - return text.replace(/^#/g, '%%%#'); + return text.replace(/^#/g, "%%%#"); } postprocess(text) { - return text.replace(/%%%#/g, '#'); + return text.replace(/%%%#/g, "#"); } } -//post, user and tags permalinks +// 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/post/(\\d+)/?\\b', 'g'), '@$1'); - text = text.replace( - new RegExp('\\b/tag/([a-zA-Z0-9_-]+?)/?', 'g'), '#$1'); + new RegExp("\\b/tag/([a-zA-Z0-9_-]+?)/?", "g"), + "#$1" + ); text = text.replace( - new RegExp('\\b/user/([a-zA-Z0-9_-]+?)/?', 'g'), '+$1'); + new RegExp("\\b/user/([a-zA-Z0-9_-]+?)/?", "g"), + "+$1" + ); text = text.replace( /(^|^\(|(?:[^\]])\(|[\s<>\[\]\)])([+#@][a-zA-Z0-9_-]+)/g, - '$1[$2]($2)'); - text = text.replace(/\]\(@(\d+)\)/g, '](/post/$1)'); - text = text.replace(/\]\(\+([a-zA-Z0-9_-]+)\)/g, '](/user/$1)'); - text = text.replace(/\]\(#([a-zA-Z0-9_-]+)\)/g, '](/posts/query=$1)'); + "$1[$2]($2)" + ); + text = text.replace(/\]\(@(\d+)\)/g, "](/post/$1)"); + text = text.replace(/\]\(\+([a-zA-Z0-9_-]+)\)/g, "](/user/$1)"); + text = text.replace(/\]\(#([a-zA-Z0-9_-]+)\)/g, "](/posts/query=$1)"); return text; } } @@ -83,51 +90,58 @@ class EntityPermalinkWrapper extends BaseMarkdownWrapper { class SearchPermalinkWrapper extends BaseMarkdownWrapper { postprocess(text) { return text.replace( - /\[search\]((?:[^\[]|\[(?!\/?search\]))+)\[\/search\]/ig, - '<a href="/posts/query=$1"><code>$1</code></a>'); + /\[search\]((?:[^\[]|\[(?!\/?search\]))+)\[\/search\]/gi, + '<a href="/posts/query=$1"><code>$1</code></a>' + ); } } class SpoilersWrapper extends BaseMarkdownWrapper { postprocess(text) { return text.replace( - /\[spoiler\]((?:[^\[]|\[(?!\/?spoiler\]))+)\[\/spoiler\]/ig, - '<span class="spoiler">$1</span>'); + /\[spoiler\]((?:[^\[]|\[(?!\/?spoiler\]))+)\[\/spoiler\]/gi, + '<span class="spoiler">$1</span>' + ); } } class SmallWrapper extends BaseMarkdownWrapper { postprocess(text) { return text.replace( - /\[small\]((?:[^\[]|\[(?!\/?small\]))+)\[\/small\]/ig, - '<small>$1</small>'); + /\[small\]((?:[^\[]|\[(?!\/?small\]))+)\[\/small\]/gi, + "<small>$1</small>" + ); } } class StrikeThroughWrapper extends BaseMarkdownWrapper { postprocess(text) { - text = text.replace(/(^|[^\\])(~~|~)([^~]+)\2/g, '$1<del>$3</del>'); - return text.replace(/\\~/g, '~'); + text = text.replace(/(^|[^\\])(~~|~)([^~]+)\2/g, "$1<del>$3</del>"); + return text.replace(/\\~/g, "~"); } } function createRenderer() { function sanitize(str) { - return str.replace(/&<"/g, m => { - if (m === '&') { - return '&'; + return str.replace(/&<"/g, (m) => { + if (m === "&") { + return "&"; } - if (m === '<') { - return '<'; + if (m === "<") { + return "<"; } - return '"'; + return """; }); } 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 = '<img src="' + sanitize(url) + '" alt="' + sanitize(alt); if (width) { res += '" width="' + width; @@ -145,7 +159,6 @@ function formatMarkdown(text) { const options = { renderer: renderer, breaks: true, - sanitize: true, smartypants: true, }; let wrappers = [ @@ -166,7 +179,7 @@ function formatMarkdown(text) { for (let wrapper of wrappers) { text = wrapper.postprocess(text); } - return text; + return DOMPurify.sanitize(text); } function formatInlineMarkdown(text) { @@ -174,7 +187,6 @@ function formatInlineMarkdown(text) { const options = { renderer: renderer, breaks: true, - sanitize: true, smartypants: true, }; let wrappers = [ @@ -193,7 +205,7 @@ function formatInlineMarkdown(text) { for (let wrapper of wrappers) { text = wrapper.postprocess(text); } - return text; + return DOMPurify.sanitize(text); } module.exports = { diff --git a/client/js/util/misc.js b/client/js/util/misc.js index d825d99..4f9d6d3 100644 --- a/client/js/util/misc.js +++ b/client/js/util/misc.js @@ -1,18 +1,19 @@ -'use strict'; +"use strict"; -const markdown = require('./markdown.js'); -const uri = require('./uri.js'); +const markdown = require("./markdown.js"); +const uri = require("./uri.js"); +const settings = require("../models/settings.js"); function decamelize(str, sep) { - sep = sep === undefined ? '-' : sep; + sep = sep === undefined ? "-" : sep; return str - .replace(/([a-z\d])([A-Z])/g, '$1' + sep + '$2') - .replace(/([A-Z]+)([A-Z][a-z\d]+)/g, '$1' + sep + '$2') + .replace(/([a-z\d])([A-Z])/g, "$1" + sep + "$2") + .replace(/([A-Z]+)([A-Z][a-z\d]+)/g, "$1" + sep + "$2") .toLowerCase(); } -function* range(start=0, end=null, step=1) { - if (end == null) { +function* range(start = 0, end = null, step = 1) { + if (end === null) { end = start; start = 0; } @@ -44,16 +45,17 @@ function formatFileSize(fileSize) { return _formatUnits( fileSize, 1024, - ['B', 'K', 'M', 'G'], + ["B", "K", "M", "G"], (number, suffix) => { - const decimalPlaces = number < 20 && suffix !== 'B' ? 1 : 0; + const decimalPlaces = number < 20 && suffix !== "B" ? 1 : 0; return number.toFixed(decimalPlaces) + suffix; - }); + } + ); } function formatRelativeTime(timeString) { if (!timeString) { - return 'never'; + return "never"; } const then = Date.parse(timeString); @@ -62,17 +64,17 @@ function formatRelativeTime(timeString) { const future = now < then; const descriptions = [ - [60, 'a few seconds', null], - [60 * 2, 'a minute', null], - [60 * 60, '% minutes', 60], - [60 * 60 * 2, 'an hour', null], - [60 * 60 * 24, '% hours', 60 * 60], - [60 * 60 * 24 * 2, 'a day', null], - [60 * 60 * 24 * 30.42, '% days', 60 * 60 * 24], - [60 * 60 * 24 * 30.42 * 2, 'a month', null], - [60 * 60 * 24 * 30.42 * 12, '% months', 60 * 60 * 24 * 30.42], - [60 * 60 * 24 * 30.42 * 12 * 2, 'a year', null], - [8640000000000000 /*max*/, '% years', 60 * 60 * 24 * 30.42 * 12], + [60, "a few seconds", null], + [60 * 2, "a minute", null], + [60 * 60, "% minutes", 60], + [60 * 60 * 2, "an hour", null], + [60 * 60 * 24, "% hours", 60 * 60], + [60 * 60 * 24 * 2, "a day", null], + [60 * 60 * 24 * 30.42, "% days", 60 * 60 * 24], + [60 * 60 * 24 * 30.42 * 2, "a month", null], + [60 * 60 * 24 * 30.42 * 12, "% months", 60 * 60 * 24 * 30.42], + [60 * 60 * 24 * 30.42 * 12 * 2, "a year", null], + [8640000000000000 /* max*/, "% years", 60 * 60 * 24 * 30.42 * 12], ]; let text = null; @@ -86,10 +88,10 @@ function formatRelativeTime(timeString) { } } - if (text === 'a day') { - return future ? 'tomorrow' : 'yesterday'; + if (text === "a day") { + return future ? "tomorrow" : "yesterday"; } - return future ? 'in ' + text : text + ' ago'; + return future ? "in " + text : text + " ago"; } function formatMarkdown(text) { @@ -101,7 +103,7 @@ function formatInlineMarkdown(text) { } function splitByWhitespace(str) { - return str.split(/\s+/).filter(s => s); + return str.split(/\s+/).filter((s) => s); } function unindent(callSite, ...args) { @@ -109,28 +111,30 @@ function unindent(callSite, ...args) { let size = -1; return str.replace(/\n(\s+)/g, (m, m1) => { if (size < 0) { - size = m1.replace(/\t/g, ' ').length; + size = m1.replace(/\t/g, " ").length; } - return '\n' + m1.slice(Math.min(m1.length, size)); + return "\n" + m1.slice(Math.min(m1.length, size)); }); } - if (typeof callSite === 'string') { + if (typeof callSite === "string") { return format(callSite); } - if (typeof callSite === 'function') { + if (typeof callSite === "function") { return (...args) => format(callSite(...args)); } let output = callSite .slice(0, args.length + 1) - .map((text, i) => (i === 0 ? '' : args[i - 1]) + text) - .join(''); + .map((text, i) => (i === 0 ? "" : args[i - 1]) + text) + .join(""); return format(output); } function enableExitConfirmation() { - window.onbeforeunload = e => { - return 'Are you sure you want to leave? ' + - 'Data you have entered may not be saved.'; + window.onbeforeunload = (e) => { + return ( + "Are you sure you want to leave? " + + "Data you have entered may not be saved." + ); }; } @@ -149,16 +153,17 @@ function confirmPageExit() { } function makeCssName(text, suffix) { - return suffix + '-' + text.replace(/[^a-z0-9]/g, '_'); + return suffix + "-" + text.replace(/[^a-z0-9]/g, "_"); } function escapeHtml(unsafe) { - return unsafe.toString() - .replace(/&/g, '&') - .replace(/</g, '<') - .replace(/>/g, '>') - .replace(/"/g, '"') - .replace(/'/g, '''); + return unsafe + .toString() + .replace(/&/g, "&") + .replace(/</g, "<") + .replace(/>/g, ">") + .replace(/"/g, """) + .replace(/'/g, "'"); } function arraysDiffer(source1, source2, orderImportant) { @@ -176,42 +181,52 @@ function arraysDiffer(source1, source2, orderImportant) { return false; } return ( - source1.filter(value => !source2.includes(value)).length > 0 || - source2.filter(value => !source1.includes(value)).length > 0); + source1.filter((value) => !source2.includes(value)).length > 0 || + source2.filter((value) => !source1.includes(value)).length > 0 + ); } function escapeSearchTerm(text) { - return text.replace(/([a-z_-]):/g, '$1\\:'); + return text.replace(/([a-z_-]):/g, "$1\\:"); } function dataURItoBlob(dataURI) { - const chunks = dataURI.split(','); - const byteString = chunks[0].indexOf('base64') >= 0 ? - window.atob(chunks[1]) : - unescape(chunks[1]); - const mimeString = chunks[0].split(':')[1].split(';')[0]; + const chunks = dataURI.split(","); + const byteString = + chunks[0].indexOf("base64") >= 0 + ? window.atob(chunks[1]) + : unescape(chunks[1]); + const mimeString = chunks[0].split(":")[1].split(";")[0]; const data = new Uint8Array(byteString.length); - for (var i = 0; i < byteString.length; i++) { + for (let i = 0; i < byteString.length; i++) { data[i] = byteString.charCodeAt(i); } - return new Blob([data], {type: mimeString}); + return new Blob([data], { type: mimeString }); +} + +function getPrettyName(tag) { + if (settings.get().tagUnderscoresAsSpaces) { + return tag.replace(/_/g, " "); + } + return tag; } module.exports = { - range: range, - formatRelativeTime: formatRelativeTime, - formatFileSize: formatFileSize, - formatMarkdown: formatMarkdown, - formatInlineMarkdown: formatInlineMarkdown, - unindent: unindent, - enableExitConfirmation: enableExitConfirmation, + range: range, + formatRelativeTime: formatRelativeTime, + formatFileSize: formatFileSize, + formatMarkdown: formatMarkdown, + formatInlineMarkdown: formatInlineMarkdown, + unindent: unindent, + enableExitConfirmation: enableExitConfirmation, disableExitConfirmation: disableExitConfirmation, - confirmPageExit: confirmPageExit, - escapeHtml: escapeHtml, - makeCssName: makeCssName, - splitByWhitespace: splitByWhitespace, - arraysDiffer: arraysDiffer, - decamelize: decamelize, - escapeSearchTerm: escapeSearchTerm, - dataURItoBlob: dataURItoBlob, + confirmPageExit: confirmPageExit, + escapeHtml: escapeHtml, + makeCssName: makeCssName, + splitByWhitespace: splitByWhitespace, + arraysDiffer: arraysDiffer, + decamelize: decamelize, + escapeSearchTerm: escapeSearchTerm, + dataURItoBlob: dataURItoBlob, + getPrettyName: getPrettyName, }; diff --git a/client/js/util/optimized_resize.js b/client/js/util/optimized_resize.js index b8e1342..d45df4a 100644 --- a/client/js/util/optimized_resize.js +++ b/client/js/util/optimized_resize.js @@ -1,4 +1,4 @@ -'use strict'; +"use strict"; let callbacks = []; let running = false; @@ -15,7 +15,7 @@ function resize() { } function runCallbacks() { - callbacks.forEach(function(callback) { + callbacks.forEach((callback) => { callback(); }); running = false; @@ -26,8 +26,8 @@ function add(callback) { } function remove(callback) { - callbacks = callbacks.filter(c => c !== callback); + callbacks = callbacks.filter((c) => c !== callback); } -window.addEventListener('resize', resize); -module.exports = {add: add, remove: remove}; +window.addEventListener("resize", resize); +module.exports = { add: add, remove: remove }; diff --git a/client/js/util/polyfill.js b/client/js/util/polyfill.js index 71ee972..ec809ff 100644 --- a/client/js/util/polyfill.js +++ b/client/js/util/polyfill.js @@ -1,9 +1,11 @@ -'use strict'; +/* eslint-disable func-names, no-extend-native */ + +"use strict"; // fix iterating over NodeList in Chrome and Opera NodeList.prototype[Symbol.iterator] = Array.prototype[Symbol.iterator]; -NodeList.prototype.querySelector = function(...args) { +NodeList.prototype.querySelector = function (...args) { for (let node of this) { if (node.nodeType === 3) { continue; @@ -16,7 +18,7 @@ NodeList.prototype.querySelector = function(...args) { return null; }; -NodeList.prototype.querySelectorAll = function(...args) { +NodeList.prototype.querySelectorAll = function (...args) { let result = []; for (let node of this) { if (node.nodeType === 3) { @@ -30,7 +32,7 @@ NodeList.prototype.querySelectorAll = function(...args) { }; // non standard -Node.prototype.prependChild = function(child) { +Node.prototype.prependChild = function (child) { if (this.firstChild) { this.insertBefore(child, this.firstChild); } else { @@ -39,29 +41,25 @@ Node.prototype.prependChild = function(child) { }; // non standard -Promise.prototype.always = function(onResolveOrReject) { - return this.then( - onResolveOrReject, - reason => { - onResolveOrReject(reason); - throw reason; - }); +Promise.prototype.always = function (onResolveOrReject) { + return this.then(onResolveOrReject, (reason) => { + onResolveOrReject(reason); + throw reason; + }); }; // non standard -Number.prototype.between = function(a, b, inclusive) { +Number.prototype.between = function (a, b, inclusive) { const min = Math.min(a, b); const max = Math.max(a, b); - return inclusive ? - this >= min && this <= max : - this > min && this < max; + return inclusive ? this >= min && this <= max : this > min && this < max; }; // non standard Promise.prototype.abort = () => {}; // non standard -Date.prototype.addDays = function(days) { +Date.prototype.addDays = function (days) { let dat = new Date(this.valueOf()); dat.setDate(dat.getDate() + days); return dat; diff --git a/client/js/util/progress.js b/client/js/util/progress.js index 365d7ae..d6d12cb 100644 --- a/client/js/util/progress.js +++ b/client/js/util/progress.js @@ -1,4 +1,6 @@ -const nprogress = require('nprogress'); +"use strict"; + +const nprogress = require("nprogress"); let nesting = 0; @@ -20,5 +22,5 @@ function done() { module.exports = { start: start, - done: done, + done: done, }; diff --git a/client/js/util/search.js b/client/js/util/search.js index 9cfa8cb..578b4fd 100644 --- a/client/js/util/search.js +++ b/client/js/util/search.js @@ -1,15 +1,17 @@ -'use strict'; +"use strict"; -const misc = require('./misc.js'); -const keyboard = require('../util/keyboard.js'); -const views = require('./views.js'); +const misc = require("./misc.js"); +const keyboard = require("../util/keyboard.js"); +const views = require("./views.js"); function searchInputNodeFocusHelper(inputNode) { - keyboard.bind('q', (e) => { + keyboard.bind("q", () => { e.preventDefault(); inputNode.focus(); inputNode.setSelectionRange( - inputNode.value.length, inputNode.value.length); + inputNode.value.length, + inputNode.value.length + ); }); } diff --git a/client/js/util/touch.js b/client/js/util/touch.js index d6aefd1..9903c71 100644 --- a/client/js/util/touch.js +++ b/client/js/util/touch.js @@ -1,9 +1,11 @@ +"use strict"; + const direction = { NONE: null, - LEFT: 'left', - RIGHT: 'right', - DOWN: 'down', - UP: 'up' + LEFT: "left", + RIGHT: "right", + DOWN: "down", + UP: "up", }; function getSwipeThresholdInPx() { @@ -69,6 +71,7 @@ function handleTouchEnd(handler, evt) { break; case direction.UP: handler._swipeUpTask(evt); + // no default } handler._xStart = null; @@ -76,11 +79,13 @@ function handleTouchEnd(handler, evt) { } class Touch { - constructor(target, - swipeLeft = () => {}, - swipeRight = () => {}, - swipeUp = () => {}, - swipeDown = () => {}) { + constructor( + target, + swipeLeft = () => {}, + swipeRight = () => {}, + swipeUp = () => {}, + swipeDown = () => {} + ) { this._target = target; this._swipeLeftTask = swipeLeft; @@ -92,13 +97,16 @@ class Touch { this._yStart = null; this._direction = direction.NONE; - this._target.addEventListener('touchstart', - (evt) => { handleTouchStart(this, evt); }); - this._target.addEventListener('touchmove', - (evt) => { handleTouchMove(this, evt); }); - this._target.addEventListener('touchend', - (evt) => { handleTouchEnd(this, evt); }); + this._target.addEventListener("touchstart", (evt) => { + handleTouchStart(this, evt); + }); + this._target.addEventListener("touchmove", (evt) => { + handleTouchMove(this, evt); + }); + this._target.addEventListener("touchend", (evt) => { + handleTouchEnd(this, evt); + }); } } -module.exports = Touch;
\ No newline at end of file +module.exports = Touch; diff --git a/client/js/util/uri.js b/client/js/util/uri.js index 1fbadf5..c366912 100644 --- a/client/js/util/uri.js +++ b/client/js/util/uri.js @@ -1,4 +1,4 @@ -'use strict'; +"use strict"; function formatApiLink(...values) { let parts = []; @@ -9,18 +9,19 @@ function formatApiLink(...values) { for (let key of Object.keys(value)) { if (value[key]) { variableParts.push( - key + '=' + encodeURIComponent(value[key].toString())); + key + "=" + encodeURIComponent(value[key].toString()) + ); } } if (variableParts.length) { - parts.push('?' + variableParts.join('&')); + parts.push("?" + variableParts.join("&")); } break; } else { parts.push(encodeURIComponent(value.toString())); } } - return '/' + parts.join('/'); + return "/" + parts.join("/"); } function escapeParam(text) { @@ -32,13 +33,13 @@ function unescapeParam(text) { } function getPostsQuery(parameters) { - let normalQuery = parameters.query || ''; - let metricQuery = (parameters.metrics || '') - .split(' ') //see metric_header_control + let normalQuery = parameters.query || ""; + let metricQuery = (parameters.metrics || "") + .split(" ") //see metric_header_control .filter(m => m) - .map(m => m + ' sort:metric-' + m) - .join(' '); - return normalQuery + ' ' + metricQuery; + .map(m => m + " sort:metric-" + m) + .join(" "); + return normalQuery + " " + metricQuery; } function formatClientLink(...values) { @@ -50,57 +51,61 @@ function formatClientLink(...values) { for (let key of Object.keys(value)) { if (value[key]) { variableParts.push( - key + '=' + escapeParam(value[key].toString())); + key + "=" + escapeParam(value[key].toString()) + ); } } if (variableParts.length) { - parts.push(variableParts.join(';')); + parts.push(variableParts.join(";")); } break; } else { parts.push(escapeParam(value.toString())); } } - return parts.join('/'); + return parts.join("/"); } function extractHostname(url) { // https://stackoverflow.com/a/23945027 return url - .split('/')[url.indexOf("//") > -1 ? 2 : 0] - .split(':')[0] - .split('?')[0]; + .split("/") + [url.indexOf("//") > -1 ? 2 : 0].split(":")[0] + .split("?")[0]; } function extractRootDomain(url) { // https://stackoverflow.com/a/23945027 let domain = extractHostname(url); - let splitArr = domain.split('.'); + let splitArr = domain.split("."); let arrLen = splitArr.length; // if there is a subdomain if (arrLen > 2) { - domain = splitArr[arrLen - 2] + '.' + splitArr[arrLen - 1]; + domain = splitArr[arrLen - 2] + "." + splitArr[arrLen - 1]; // check to see if it's using a Country Code Top Level Domain (ccTLD) (i.e. ".me.uk") - if (splitArr[arrLen - 2].length == 2 && splitArr[arrLen - 1].length == 2) { + if ( + splitArr[arrLen - 2].length === 2 && + splitArr[arrLen - 1].length === 2 + ) { // this is using a ccTLD - domain = splitArr[arrLen - 3] + '.' + domain; + domain = splitArr[arrLen - 3] + "." + domain; } } return domain; } function escapeColons(text) { - return text.replace(new RegExp(':', 'g'), '\\:'); + return text.replace(new RegExp(":", "g"), "\\:"); } module.exports = { - getPostsQuery: getPostsQuery, - formatClientLink: formatClientLink, - formatApiLink: formatApiLink, - escapeColons: escapeColons, - escapeParam: escapeParam, - unescapeParam: unescapeParam, - extractHostname: extractHostname, + getPostsQuery: getPostsQuery, + formatClientLink: formatClientLink, + formatApiLink: formatApiLink, + escapeColons: escapeColons, + escapeParam: escapeParam, + unescapeParam: unescapeParam, + extractHostname: extractHostname, extractRootDomain: extractRootDomain, }; diff --git a/client/js/util/views.js b/client/js/util/views.js index 09546ce..bc83153 100644 --- a/client/js/util/views.js +++ b/client/js/util/views.js @@ -1,27 +1,27 @@ -'use strict'; +"use strict"; -require('../util/polyfill.js'); -const api = require('../api.js'); -const templates = require('../templates.js'); +require("../util/polyfill.js"); +const api = require("../api.js"); +const templates = require("../templates.js"); const domParser = new DOMParser(); -const misc = require('./misc.js'); -const uri = require('./uri.js'); +const misc = require("./misc.js"); +const uri = require("./uri.js"); function _imbueId(options) { if (!options.id) { - options.id = 'gen-' + Math.random().toString(36).substring(7); + options.id = "gen-" + Math.random().toString(36).substring(7); } } function _makeLabel(options, attrs) { if (!options.text) { - return ''; + return ""; } if (!attrs) { attrs = {}; } attrs.for = options.id; - return makeElement('label', attrs, options.text); + return makeElement("label", attrs, options.text); } function makeFileSize(fileSize) { @@ -34,253 +34,308 @@ function makeMarkdown(text) { function makeRelativeTime(time) { return makeElement( - 'time', {datetime: time, title: time}, misc.formatRelativeTime(time)); + "time", + { datetime: time, title: time }, + misc.formatRelativeTime(time) + ); } function makeThumbnail(url) { return makeElement( - 'span', - url ? - {class: 'thumbnail', style: `background-image: url(\'${url}\')`} : - {class: 'thumbnail empty'}, - makeElement('img', {alt: 'thumbnail', src: url})); + "span", + url + ? { + class: "thumbnail", + style: `background-image: url(\'${url}\')`, + } + : { class: "thumbnail empty" }, + makeElement("img", { alt: "thumbnail", src: url }) + ); } function makeRadio(options) { _imbueId(options); return makeElement( - 'label', - {for: options.id}, - makeElement( - 'input', - { - id: options.id, - name: options.name, - value: options.value, - type: 'radio', - checked: options.selectedValue === options.value, - disabled: options.readonly, - required: options.required, - }), - makeElement('span', {class: 'radio'}, options.text)); + "label", + { for: options.id }, + makeElement("input", { + id: options.id, + name: options.name, + value: options.value, + type: "radio", + checked: options.selectedValue === options.value, + disabled: options.readonly, + required: options.required, + }), + makeElement("span", { class: "radio" }, options.text) + ); } function makeCheckbox(options) { _imbueId(options); return makeElement( - 'label', - {for: options.id}, - makeElement( - 'input', - { - id: options.id, - name: options.name, - value: options.value, - type: 'checkbox', - checked: options.checked !== undefined ? - options.checked : false, - disabled: options.readonly, - required: options.required, - }), - makeElement('span', {class: 'checkbox ' + (options.class || '')}, options.text)); + "label", + { for: options.id }, + makeElement("input", { + id: options.id, + name: options.name, + value: options.value, + type: "checkbox", + checked: options.checked !== undefined ? options.checked : false, + disabled: options.readonly, + required: options.required, + }), + makeElement("span", { + class: "checkbox " + (options.class || '') + }, options.text) + ); } function makeSelect(options) { - return _makeLabel(options) + + return ( + _makeLabel(options) + makeElement( - 'select', + "select", { id: options.id, name: options.name, disabled: options.readonly, }, - ...Object.keys(options.keyValues).map(key => + ...Object.keys(options.keyValues).map((key) => makeElement( - 'option', - {value: key, selected: key === options.selectedKey}, - options.keyValues[key]))); + "option", + { value: key, selected: key === options.selectedKey }, + options.keyValues[key] + ) + ) + ) + ); } function makeInput(options) { - options.value = options.value === 0 ? 0 : options.value || ''; - return _makeLabel(options) + makeElement('input', options); + options.value = options.value === 0 ? 0 : options.value || ""; + return _makeLabel(options) + makeElement("input", options); } function makeButton(options) { - options.type = 'button'; + options.type = "button"; return makeInput(options); } function makeTextInput(options) { - options.type = 'text'; + options.type = "text"; return makeInput(options); } function makeTextarea(options) { - const value = options.value || ''; + const value = options.value || ""; delete options.value; - return _makeLabel(options) + makeElement('textarea', options, value); + return _makeLabel(options) + makeElement("textarea", options, value); } function makePasswordInput(options) { - options.type = 'password'; + options.type = "password"; return makeInput(options); } function makeEmailInput(options) { - options.type = 'email'; + options.type = "email"; return makeInput(options); } function makeColorInput(options) { - const textInput = makeElement( - 'input', { - type: 'text', - value: options.value || '', - required: options.required, - class: 'color', - }); - const backgroundPreviewNode = makeElement( - 'div', - { - class: 'preview background-preview', - style: - `border-color: ${options.value}; + const textInput = makeElement("input", { + type: "text", + value: options.value || "", + required: options.required, + class: "color", + }); + const backgroundPreviewNode = makeElement("div", { + class: "preview background-preview", + style: `border-color: ${options.value}; background-color: ${options.value}`, - }); - const textPreviewNode = makeElement( - 'div', - { - class: 'preview text-preview', - style: - `border-color: ${options.value}; + }); + const textPreviewNode = makeElement("div", { + class: "preview text-preview", + style: `border-color: ${options.value}; color: ${options.value}`, - }); + }); return makeElement( - 'label', {class: 'color'}, + "label", + { class: "color" }, textInput, backgroundPreviewNode, - textPreviewNode); + textPreviewNode + ); } function makeNumericInput(options) { - options.type = 'number'; + options.type = "number"; return makeInput(options); } function makeDateInput(options) { - options.type = 'date'; - return makeInput(options) + options.type = "date"; + return makeInput(options); } function getPostUrl(id, parameters) { return uri.formatClientLink( - 'post', id, + "post", + id, parameters ? { query: parameters.query, metrics: parameters.metrics, cachenumber: parameters.cachenumber, - } : {}); + } : {} + ); } function getPostEditUrl(id, parameters) { return uri.formatClientLink( - 'post', id, 'edit', + "post", + id, + "edit", parameters ? { query: parameters.query, metrics: parameters.metrics, cachenumber: parameters.cachenumber, - } : {}); + } : {} + ); } function getMetricSorterUrl(id, parameters) { return uri.formatClientLink( - 'post', id, 'metric-sorter', + "post", + id, + "metric-sorter", parameters ? { query: parameters.query, metrics: parameters.metrics, - } : {}); + } : {} + ); } function makePostLink(id, includeHash) { let text = id; if (includeHash) { - text = '@' + id; + text = "@" + id; } - return api.hasPrivilege('posts:view') ? - makeElement( - 'a', - {href: uri.formatClientLink('post', id)}, - misc.escapeHtml(text)) : - misc.escapeHtml(text); + return api.hasPrivilege("posts:view") + ? makeElement( + "a", + { href: uri.formatClientLink("post", id) }, + misc.escapeHtml(text) + ) + : misc.escapeHtml(text); } function makeTagLink(name, includeHash, includeCount, tag) { - const category = tag ? tag.category : 'unknown'; - let text = name; + const category = tag ? tag.category : "unknown"; + let text = misc.getPrettyName(name); if (includeHash === true) { - text = '#' + text; + text = "#" + text; } if (includeCount === true) { - text += ' (' + (tag ? tag.postCount : 0) + ')'; + text += " (" + (tag ? tag.postCount : 0) + ")"; } - return api.hasPrivilege('tags:view') ? - makeElement( - 'a', - { - href: uri.formatClientLink('tag', name), - class: misc.makeCssName(category, 'tag'), - }, - misc.escapeHtml(text)) : - makeElement( - 'span', - {class: misc.makeCssName(category, 'tag')}, - misc.escapeHtml(text)); + return api.hasPrivilege("tags:view") + ? makeElement( + "a", + { + href: uri.formatClientLink("tag", name), + class: misc.makeCssName(category, "tag"), + }, + misc.escapeHtml(text) + ) + : makeElement( + "span", + { class: misc.makeCssName(category, "tag") }, + misc.escapeHtml(text) + ); +} + +function makePoolLink(id, includeHash, includeCount, pool, name) { + const category = pool ? pool.category : "unknown"; + let text = misc.getPrettyName( + name ? name : pool ? pool.names[0] : "unknown" + ); + if (includeHash === true) { + text = "#" + text; + } + if (includeCount === true) { + text += " (" + (pool ? pool.postCount : 0) + ")"; + } + return api.hasPrivilege("pools:view") + ? makeElement( + "a", + { + href: uri.formatClientLink("pool", id), + class: misc.makeCssName(category, "pool"), + }, + misc.escapeHtml(text) + ) + : makeElement( + "span", + { class: misc.makeCssName(category, "pool") }, + misc.escapeHtml(text) + ); } function makeUserLink(user) { let text = makeThumbnail(user ? user.avatarUrl : null); - text += user && user.name ? misc.escapeHtml(user.name) : 'Anonymous'; - const link = user && api.hasPrivilege('users:view') ? - makeElement( - 'a', {href: uri.formatClientLink('user', user.name)}, text) : - text; - return makeElement('span', {class: 'user'}, link); + text += user && user.name ? misc.escapeHtml(user.name) : "Anonymous"; + const link = + user && api.hasPrivilege("users:view") + ? makeElement( + "a", + { href: uri.formatClientLink("user", user.name) }, + text + ) + : text; + return makeElement("span", { class: "user" }, link); } function makeFlexboxAlign(options) { return [...misc.range(20)] - .map(() => '<li class="flexbox-dummy"></li>').join(''); + .map(() => '<li class="flexbox-dummy"></li>') + .join(""); } function makeAccessKey(html, key) { - const regex = new RegExp('(' + key + ')', 'i'); + const regex = new RegExp("(" + key + ")", "i"); html = html.replace( - regex, '<span class="access-key" data-accesskey="$1">$1</span>'); + regex, + '<span class="access-key" data-accesskey="$1">$1</span>' + ); return html; } function _serializeElement(name, attributes) { return [name] - .concat(Object.keys(attributes).map(key => { - if (attributes[key] === true) { - return key; - } else if (attributes[key] === false || - attributes[key] === undefined) { - return ''; - } else if (attributes[key] === 0) { - return `${key}="0"`; - } - const attribute = misc.escapeHtml(attributes[key] || ''); - return `${key}="${attribute}"`; - })) - .join(' '); + .concat( + Object.keys(attributes).map((key) => { + if (attributes[key] === true) { + return key; + } else if ( + attributes[key] === false || + attributes[key] === undefined + ) { + return ""; + } else if (attributes[key] === 0) { + return `${key}="0"`; + } + const attribute = misc.escapeHtml(attributes[key] || ""); + return `${key}="${attribute}"`; + }) + ) + .join(" "); } function makeElement(name, attrs, ...content) { - return content.length !== undefined ? - `<${_serializeElement(name, attrs)}>${content.join('')}</${name}>` : - `<${_serializeElement(name, attrs)}/>`; + return content.length !== undefined + ? `<${_serializeElement(name, attrs)}>${content.join("")}</${name}>` + : `<${_serializeElement(name, attrs)}/>`; } function emptyContent(target) { @@ -304,25 +359,25 @@ function replaceContent(target, source) { function showMessage(target, message, className) { if (!message) { - message = 'Unknown message'; + message = "Unknown message"; } - const messagesHolderNode = target.querySelector('.messages'); + const messagesHolderNode = target.querySelector(".messages"); if (!messagesHolderNode) { return false; } - const textNode = document.createElement('div'); - textNode.innerHTML = message.replace(/\n/g, '<br/>'); - textNode.classList.add('message'); + const textNode = document.createElement("div"); + textNode.innerHTML = message.replace(/\n/g, "<br/>"); + textNode.classList.add("message"); textNode.classList.add(className); - const wrapperNode = document.createElement('div'); - wrapperNode.classList.add('message-wrapper'); + const wrapperNode = document.createElement("div"); + wrapperNode.classList.add("message-wrapper"); wrapperNode.appendChild(textNode); messagesHolderNode.appendChild(wrapperNode); return true; } function appendExclamationMark() { - if (!document.title.startsWith('!')) { + if (!document.title.startsWith("!")) { document.oldTitle = document.title; document.title = `! ${document.title}`; } @@ -330,15 +385,15 @@ function appendExclamationMark() { function showError(target, message) { appendExclamationMark(); - return showMessage(target, misc.formatInlineMarkdown(message), 'error'); + return showMessage(target, misc.formatInlineMarkdown(message), "error"); } function showSuccess(target, message) { - return showMessage(target, misc.formatInlineMarkdown(message), 'success'); + return showMessage(target, misc.formatInlineMarkdown(message), "success"); } function showInfo(target, message) { - return showMessage(target, misc.formatInlineMarkdown(message), 'info'); + return showMessage(target, misc.formatInlineMarkdown(message), "info"); } function clearMessages(target) { @@ -346,7 +401,7 @@ function clearMessages(target) { document.title = document.oldTitle; document.oldTitle = null; } - for (let messagesHolderNode of target.querySelectorAll('.messages')) { + for (let messagesHolderNode of target.querySelectorAll(".messages")) { emptyContent(messagesHolderNode); } } @@ -354,25 +409,24 @@ function clearMessages(target) { function htmlToDom(html) { // code taken from jQuery + Krasimir Tsonev's blog const wrapMap = { - _: [1, '<div>', '</div>'], - option: [1, '<select multiple>', '</select>'], - legend: [1, '<fieldset>', '</fieldset>'], - area: [1, '<map>', '</map>'], - param: [1, '<object>', '</object>'], - thead: [1, '<table>', '</table>'], - tr: [2, '<table><tbody>', '</tbody></table>'], - td: [3, '<table><tbody><tr>', '</tr></tbody></table>'], - col: [2, '<table><tbody></tbody><colgroup>', '</colgroup></table>'], + _: [1, "<div>", "</div>"], + option: [1, "<select multiple>", "</select>"], + legend: [1, "<fieldset>", "</fieldset>"], + area: [1, "<map>", "</map>"], + param: [1, "<object>", "</object>"], + thead: [1, "<table>", "</table>"], + tr: [2, "<table><tbody>", "</tbody></table>"], + td: [3, "<table><tbody><tr>", "</tr></tbody></table>"], + col: [2, "<table><tbody></tbody><colgroup>", "</colgroup></table>"], }; wrapMap.optgroup = wrapMap.option; - wrapMap.tbody = - wrapMap.tfoot = - wrapMap.colgroup = - wrapMap.caption = - wrapMap.thead; + wrapMap.tbody = wrapMap.thead; + wrapMap.tfoot = wrapMap.thead; + wrapMap.colgroup = wrapMap.thead; + wrapMap.caption = wrapMap.thead; wrapMap.th = wrapMap.td; - let element = document.createElement('div'); + let element = document.createElement("div"); const match = /<\s*(\w+)[^>]*?>/g.exec(html); if (match) { @@ -385,9 +439,9 @@ function htmlToDom(html) { } else { element.innerHTML = html; } - return element.childNodes.length > 1 ? - element.childNodes : - element.firstChild; + return element.childNodes.length > 1 + ? element.childNodes + : element.firstChild; } function getTemplate(templatePath) { @@ -395,39 +449,40 @@ function getTemplate(templatePath) { throw `Missing template: ${templatePath}`; } const templateFactory = templates[templatePath]; - return ctx => { + return (ctx) => { if (!ctx) { ctx = {}; } Object.assign(ctx, { - getPostUrl: getPostUrl, - getPostEditUrl: getPostEditUrl, - getMetricSorterUrl:getMetricSorterUrl, - makeRelativeTime: makeRelativeTime, - makeFileSize: makeFileSize, - makeMarkdown: makeMarkdown, - makeThumbnail: makeThumbnail, - makeRadio: makeRadio, - makeCheckbox: makeCheckbox, - makeSelect: makeSelect, - makeInput: makeInput, - makeButton: makeButton, - makeTextarea: makeTextarea, - makeTextInput: makeTextInput, + getPostUrl: getPostUrl, + getPostEditUrl: getPostEditUrl, + getMetricSorterUrl: getMetricSorterUrl, + makeRelativeTime: makeRelativeTime, + makeFileSize: makeFileSize, + makeMarkdown: makeMarkdown, + makeThumbnail: makeThumbnail, + makeRadio: makeRadio, + makeCheckbox: makeCheckbox, + makeSelect: makeSelect, + makeInput: makeInput, + makeButton: makeButton, + makeTextarea: makeTextarea, + makeTextInput: makeTextInput, makePasswordInput: makePasswordInput, - makeEmailInput: makeEmailInput, - makeColorInput: makeColorInput, - makeDateInput: makeDateInput, - makePostLink: makePostLink, - makeTagLink: makeTagLink, - makeUserLink: makeUserLink, - makeFlexboxAlign: makeFlexboxAlign, - makeAccessKey: makeAccessKey, - makeElement: makeElement, - makeCssName: misc.makeCssName, - makeNumericInput: makeNumericInput, - formatClientLink: uri.formatClientLink, - escapeColons: uri.escapeColons, + makeEmailInput: makeEmailInput, + makeColorInput: makeColorInput, + makeDateInput: makeDateInput, + makePostLink: makePostLink, + makeTagLink: makeTagLink, + makePoolLink: makePoolLink, + makeUserLink: makeUserLink, + makeFlexboxAlign: makeFlexboxAlign, + makeAccessKey: makeAccessKey, + makeElement: makeElement, + makeCssName: misc.makeCssName, + makeNumericInput: makeNumericInput, + formatClientLink: uri.formatClientLink, + escapeColons: uri.escapeColons, }); return htmlToDom(templateFactory(ctx)); }; @@ -436,49 +491,51 @@ function getTemplate(templatePath) { function decorateValidator(form) { // postpone showing form fields validity until user actually tries // to submit it (seeing red/green form w/o doing anything breaks POLA) - let submitButton = form.querySelector('.buttons input'); + let submitButton = form.querySelector(".buttons input"); if (!submitButton) { - submitButton = form.querySelector('input[type=submit]'); + submitButton = form.querySelector("input[type=submit]"); } if (submitButton) { - submitButton.addEventListener('click', e => { - form.classList.add('show-validation'); + submitButton.addEventListener("click", (e) => { + form.classList.add("show-validation"); }); } - form.addEventListener('submit', e => { - form.classList.remove('show-validation'); + form.addEventListener("submit", (e) => { + form.classList.remove("show-validation"); }); } function disableForm(form) { - for (let input of form.querySelectorAll('input')) { + for (let input of form.querySelectorAll("input")) { input.disabled = true; } } function enableForm(form) { - for (let input of form.querySelectorAll('input')) { + for (let input of form.querySelectorAll("input")) { input.disabled = false; } } function syncScrollPosition() { - window.requestAnimationFrame( - () => { - if (history.state && history.state.hasOwnProperty('scrollX')) { - window.scrollTo(history.state.scrollX, history.state.scrollY); - } else { - window.scrollTo(0, 0); - } - }); + window.requestAnimationFrame(() => { + if ( + history.state && + Object.prototype.hasOwnProperty.call(history.state, "scrollX") + ) { + window.scrollTo(history.state.scrollX, history.state.scrollY); + } else { + window.scrollTo(0, 0); + } + }); } function slideDown(element) { const duration = 500; return new Promise((resolve, reject) => { const height = element.getBoundingClientRect().height; - element.style.maxHeight = '0'; - element.style.overflow = 'hidden'; + element.style.maxHeight = "0"; + element.style.overflow = "hidden"; window.setTimeout(() => { element.style.transition = `all ${duration}ms ease`; element.style.maxHeight = `${height}px`; @@ -493,7 +550,7 @@ function slideUp(element) { const duration = 500; return new Promise((resolve, reject) => { const height = element.getBoundingClientRect().height; - element.style.overflow = 'hidden'; + element.style.overflow = "hidden"; element.style.maxHeight = `${height}px`; element.style.transition = `all ${duration}ms ease`; window.setTimeout(() => { @@ -506,26 +563,27 @@ function slideUp(element) { } function monitorNodeRemoval(monitoredNode, callback) { - const mutationObserver = new MutationObserver( - mutations => { - for (let mutation of mutations) { - for (let node of mutation.removedNodes) { - if (node.contains(monitoredNode)) { - mutationObserver.disconnect(); - callback(); - return; - } + const mutationObserver = new MutationObserver((mutations) => { + for (let mutation of mutations) { + for (let node of mutation.removedNodes) { + if (node.contains(monitoredNode)) { + mutationObserver.disconnect(); + callback(); + return; } } - }); - mutationObserver.observe( - document.body, {childList: true, subtree: true}); + } + }); + mutationObserver.observe(document.body, { + childList: true, + subtree: true, + }); } -document.addEventListener('input', e => { - if (e.target.classList.contains('color')) { - let bkNode = e.target.parentNode.querySelector('.background-preview'); - let textNode = e.target.parentNode.querySelector('.text-preview'); +document.addEventListener("input", (e) => { + if (e.target.classList.contains("color")) { + let bkNode = e.target.parentNode.querySelector(".background-preview"); + let textNode = e.target.parentNode.querySelector(".text-preview"); bkNode.style.backgroundColor = e.target.value; bkNode.style.borderColor = e.target.value; textNode.style.color = e.target.value; @@ -534,32 +592,33 @@ document.addEventListener('input', e => { }); // prevent opening buttons in new tabs -document.addEventListener('click', e => { - if (e.target.getAttribute('href') === '' && e.which === 2) { +document.addEventListener("click", (e) => { + if (e.target.getAttribute("href") === "" && e.which === 2) { e.preventDefault(); } }); module.exports = { - htmlToDom: htmlToDom, - getTemplate: getTemplate, - emptyContent: emptyContent, - replaceContent: replaceContent, - enableForm: enableForm, - disableForm: disableForm, - decorateValidator: decorateValidator, - makeTagLink: makeTagLink, - makePostLink: makePostLink, - makeCheckbox: makeCheckbox, - makeRadio: makeRadio, - syncScrollPosition: syncScrollPosition, - slideDown: slideDown, - slideUp: slideUp, - monitorNodeRemoval: monitorNodeRemoval, - clearMessages: clearMessages, + htmlToDom: htmlToDom, + getTemplate: getTemplate, + emptyContent: emptyContent, + replaceContent: replaceContent, + enableForm: enableForm, + disableForm: disableForm, + decorateValidator: decorateValidator, + makeTagLink: makeTagLink, + makePostLink: makePostLink, + makePoolLink: makePoolLink, + makeCheckbox: makeCheckbox, + makeRadio: makeRadio, + syncScrollPosition: syncScrollPosition, + slideDown: slideDown, + slideUp: slideUp, + monitorNodeRemoval: monitorNodeRemoval, + clearMessages: clearMessages, appendExclamationMark: appendExclamationMark, - showError: showError, - showSuccess: showSuccess, - showInfo: showInfo, - getMetricSorterUrl: getMetricSorterUrl, + showError: showError, + showSuccess: showSuccess, + showInfo: showInfo, + getMetricSorterUrl: getMetricSorterUrl, }; diff --git a/client/js/views/comments_page_view.js b/client/js/views/comments_page_view.js index d5bb294..5648b3a 100644 --- a/client/js/views/comments_page_view.js +++ b/client/js/views/comments_page_view.js @@ -1,10 +1,10 @@ -'use strict'; +"use strict"; -const events = require('../events.js'); -const views = require('../util/views.js'); -const CommentListControl = require('../controls/comment_list_control.js'); +const events = require("../events.js"); +const views = require("../util/views.js"); +const CommentListControl = require("../controls/comment_list_control.js"); -const template = views.getTemplate('comments-page'); +const template = views.getTemplate("comments-page"); class CommentsPageView extends events.EventTarget { constructor(ctx) { @@ -16,12 +16,14 @@ class CommentsPageView extends events.EventTarget { for (let post of ctx.response.results) { const commentListControl = new CommentListControl( sourceNode.querySelector( - `.comments-container[data-for="${post.id}"]`), + `.comments-container[data-for="${post.id}"]` + ), post.comments, - true); - events.proxyEvent(commentListControl, this, 'submit'); - events.proxyEvent(commentListControl, this, 'score'); - events.proxyEvent(commentListControl, this, 'delete'); + true + ); + events.proxyEvent(commentListControl, this, "submit"); + events.proxyEvent(commentListControl, this, "score"); + events.proxyEvent(commentListControl, this, "delete"); } views.replaceContent(this._hostNode, sourceNode); diff --git a/client/js/views/empty_view.js b/client/js/views/empty_view.js index 21843d7..59d336d 100644 --- a/client/js/views/empty_view.js +++ b/client/js/views/empty_view.js @@ -1,15 +1,16 @@ -'use strict'; +"use strict"; -const views = require('../util/views.js'); +const views = require("../util/views.js"); const template = () => { return views.htmlToDom( - '<div class="wrapper"><div class="messages"></div></div>'); + '<div class="wrapper"><div class="messages"></div></div>' + ); }; class EmptyView { constructor() { - this._hostNode = document.getElementById('content-holder'); + this._hostNode = document.getElementById("content-holder"); views.replaceContent(this._hostNode, template()); views.syncScrollPosition(); } diff --git a/client/js/views/endless_page_view.js b/client/js/views/endless_page_view.js index ab40662..f94c371 100644 --- a/client/js/views/endless_page_view.js +++ b/client/js/views/endless_page_view.js @@ -1,25 +1,23 @@ -'use strict'; +"use strict"; -const router = require('../router.js'); -const views = require('../util/views.js'); +const router = require("../router.js"); +const views = require("../util/views.js"); -const holderTemplate = views.getTemplate('endless-pager'); -const pageTemplate = views.getTemplate('endless-pager-page'); +const holderTemplate = views.getTemplate("endless-pager"); +const pageTemplate = views.getTemplate("endless-pager-page"); function isScrolledIntoView(element) { let top = 0; do { top += element.offsetTop || 0; element = element.offsetParent; - } while(element); - return ( - (top >= window.scrollY) && - (top <= window.scrollY + window.innerHeight)); + } while (element); + return top >= window.scrollY && top <= window.scrollY + window.innerHeight; } class EndlessPageView { constructor(ctx) { - this._hostNode = document.getElementById('content-holder'); + this._hostNode = document.getElementById("content-holder"); views.replaceContent(this._hostNode, holderTemplate()); } @@ -40,12 +38,13 @@ class EndlessPageView { this.defaultLimit = parseInt(ctx.parameters.limit || ctx.defaultLimit); const initialOffset = parseInt(ctx.parameters.offset || 0); - this._loadPage(ctx, initialOffset, this.defaultLimit, true) - .then(pageNode => { + this._loadPage(ctx, initialOffset, this.defaultLimit, true).then( + (pageNode) => { if (initialOffset !== 0) { pageNode.scrollIntoView(); } - }); + } + ); this._timeout = window.setInterval(() => { window.requestAnimationFrame(() => { @@ -58,19 +57,19 @@ class EndlessPageView { } get pageHeaderHolderNode() { - return this._hostNode.querySelector('.page-header-holder'); + return this._hostNode.querySelector(".page-header-holder"); } get topPageGuardNode() { - return this._hostNode.querySelector('.page-guard.top'); + return this._hostNode.querySelector(".page-guard.top"); } get bottomPageGuardNode() { - return this._hostNode.querySelector('.page-guard.bottom'); + return this._hostNode.querySelector(".page-guard.bottom"); } get _pagesHolderNode() { - return this._hostNode.querySelector('.pages-holder'); + return this._hostNode.querySelector(".pages-holder"); } _destroy() { @@ -82,9 +81,10 @@ class EndlessPageView { let topPageNode = null; let element = document.elementFromPoint( window.innerWidth / 2, - window.innerHeight / 2); + window.innerHeight / 2 + ); while (element.parentNode !== null) { - if (element.classList.contains('page')) { + if (element.classList.contains("page")) { topPageNode = element; break; } @@ -93,15 +93,17 @@ class EndlessPageView { if (!topPageNode) { return; } - let topOffset = parseInt(topPageNode.getAttribute('data-offset')); - let topLimit = parseInt(topPageNode.getAttribute('data-limit')); + let topOffset = parseInt(topPageNode.getAttribute("data-offset")); + let topLimit = parseInt(topPageNode.getAttribute("data-limit")); if (topOffset !== this.currentOffset) { router.replace( ctx.getClientUrlForPage( topOffset, - topLimit === ctx.defaultLimit ? null : topLimit), + topLimit === ctx.defaultLimit ? null : topLimit + ), ctx.state, - false); + false + ); this.currentOffset = topOffset; } } @@ -115,43 +117,47 @@ class EndlessPageView { return; } - if (this.minOffsetShown > 0 && - isScrolledIntoView(this.topPageGuardNode)) { + if ( + this.minOffsetShown > 0 && + isScrolledIntoView(this.topPageGuardNode) + ) { this._loadPage( ctx, this.minOffsetShown - this.defaultLimit, this.defaultLimit, - false); + false + ); } - if (this.maxOffsetShown < this.totalRecords && - isScrolledIntoView(this.bottomPageGuardNode)) { - this._loadPage( - ctx, - this.maxOffsetShown, - this.defaultLimit, - true); + if ( + this.maxOffsetShown < this.totalRecords && + isScrolledIntoView(this.bottomPageGuardNode) + ) { + this._loadPage(ctx, this.maxOffsetShown, this.defaultLimit, true); } } _loadPage(ctx, offset, limit, append) { this._runningRequests++; return new Promise((resolve, reject) => { - ctx.requestPage(offset, limit).then(response => { - if (!this._active) { + ctx.requestPage(offset, limit).then( + (response) => { + if (!this._active) { + this._runningRequests--; + return Promise.reject(); + } + window.requestAnimationFrame(() => { + let pageNode = this._renderPage(ctx, append, response); + this._runningRequests--; + resolve(pageNode); + }); + }, + (error) => { + this.showError(error.message); this._runningRequests--; - return Promise.reject(); + reject(); } - window.requestAnimationFrame(() => { - let pageNode = this._renderPage(ctx, append, response); - this._runningRequests--; - resolve(pageNode); - }); - }, error => { - this.showError(error.message); - this._runningRequests--; - reject(); - }); + ); }); } @@ -162,30 +168,35 @@ class EndlessPageView { pageNode = pageTemplate({ totalPages: Math.ceil(response.total / response.limit), page: Math.ceil( - (response.offset + response.limit) / response.limit), + (response.offset + response.limit) / response.limit + ), }); - pageNode.setAttribute('data-offset', response.offset); - pageNode.setAttribute('data-limit', response.limit); + pageNode.setAttribute("data-offset", response.offset); + pageNode.setAttribute("data-limit", response.limit); ctx.pageRenderer({ parameters: ctx.parameters, response: response, - hostNode: pageNode.querySelector('.page-content-holder'), + hostNode: pageNode.querySelector(".page-content-holder"), }); this.totalRecords = response.total; - if (response.offset < this.minOffsetShown || - this.minOffsetShown === null) { + if ( + response.offset < this.minOffsetShown || + this.minOffsetShown === null + ) { this.minOffsetShown = response.offset; } - if (response.offset + response.results.length - > this.maxOffsetShown || - this.maxOffsetShown === null) { + if ( + response.offset + response.results.length > + this.maxOffsetShown || + this.maxOffsetShown === null + ) { this.maxOffsetShown = response.offset + response.results.length; } - response.results.addEventListener('remove', e => { + response.results.addEventListener("remove", (e) => { this.maxOffsetShown--; this.totalRecords--; }); @@ -200,10 +211,11 @@ class EndlessPageView { window.scroll( window.scrollX, - window.scrollY + pageNode.offsetHeight); + window.scrollY + pageNode.offsetHeight + ); } } else if (!response.results.length) { - this.showInfo('No data to show'); + this.showInfo("No data to show"); } this._initialPageLoad = false; diff --git a/client/js/views/help_view.js b/client/js/views/help_view.js index 11f6a39..a88b016 100644 --- a/client/js/views/help_view.js +++ b/client/js/views/help_view.js @@ -1,72 +1,81 @@ -'use strict'; +"use strict"; -const api = require('../api.js'); -const views = require('../util/views.js'); +const api = require("../api.js"); +const views = require("../util/views.js"); -const template = views.getTemplate('help'); +const template = views.getTemplate("help"); const sectionTemplates = { - 'about': views.getTemplate('help-about'), - 'keyboard': views.getTemplate('help-keyboard'), - 'search': views.getTemplate('help-search'), - 'comments': views.getTemplate('help-comments'), - 'tos': views.getTemplate('help-tos'), + about: views.getTemplate("help-about"), + keyboard: views.getTemplate("help-keyboard"), + search: views.getTemplate("help-search"), + comments: views.getTemplate("help-comments"), + tos: views.getTemplate("help-tos"), }; const subsectionTemplates = { - 'search': { - 'default': views.getTemplate('help-search-general'), - 'posts': views.getTemplate('help-search-posts'), - 'users': views.getTemplate('help-search-users'), - 'tags': views.getTemplate('help-search-tags'), + search: { + default: views.getTemplate("help-search-general"), + posts: views.getTemplate("help-search-posts"), + users: views.getTemplate("help-search-users"), + tags: views.getTemplate("help-search-tags"), + pools: views.getTemplate("help-search-pools"), }, }; class HelpView { constructor(section, subsection) { - this._hostNode = document.getElementById('content-holder'); + this._hostNode = document.getElementById("content-holder"); const sourceNode = template(); const ctx = { name: api.getName(), }; - section = section || 'about'; + section = section || "about"; if (section in sectionTemplates) { views.replaceContent( - sourceNode.querySelector('.content'), - sectionTemplates[section](ctx)); + sourceNode.querySelector(".content"), + sectionTemplates[section](ctx) + ); } - subsection = subsection || 'default'; - if (section in subsectionTemplates && - subsection in subsectionTemplates[section]) { + subsection = subsection || "default"; + if ( + section in subsectionTemplates && + subsection in subsectionTemplates[section] + ) { views.replaceContent( - sourceNode.querySelector('.subcontent'), - subsectionTemplates[section][subsection](ctx)); + sourceNode.querySelector(".subcontent"), + subsectionTemplates[section][subsection](ctx) + ); } views.replaceContent(this._hostNode, sourceNode); - for (let itemNode of - sourceNode.querySelectorAll('.primary [data-name]')) { + for (let itemNode of sourceNode.querySelectorAll( + ".primary [data-name]" + )) { itemNode.classList.toggle( - 'active', - itemNode.getAttribute('data-name') === section); - if (itemNode.getAttribute('data-name') === section) { + "active", + itemNode.getAttribute("data-name") === section + ); + if (itemNode.getAttribute("data-name") === section) { itemNode.parentNode.scrollLeft = itemNode.getBoundingClientRect().left - - itemNode.parentNode.getBoundingClientRect().left + itemNode.parentNode.getBoundingClientRect().left; } } - for (let itemNode of - sourceNode.querySelectorAll('.secondary [data-name]')) { + for (let itemNode of sourceNode.querySelectorAll( + ".secondary [data-name]" + )) { itemNode.classList.toggle( - 'active', - itemNode.getAttribute('data-name') === subsection); - if (itemNode.getAttribute('data-name') === subsection) { + "active", + itemNode.getAttribute("data-name") === subsection + ); + if (itemNode.getAttribute("data-name") === subsection) { itemNode.parentNode.scrollLeft = itemNode.getBoundingClientRect().left - - itemNode.parentNode.getBoundingClientRect().left + itemNode.parentNode.getBoundingClientRect().left; } } diff --git a/client/js/views/home_view.js b/client/js/views/home_view.js index c926705..c91363b 100644 --- a/client/js/views/home_view.js +++ b/client/js/views/home_view.js @@ -1,22 +1,20 @@ -'use strict'; +"use strict"; -const router = require('../router.js'); -const uri = require('../util/uri.js'); -const misc = require('../util/misc.js'); -const views = require('../util/views.js'); -const PostContentControl = require('../controls/post_content_control.js'); -const PostNotesOverlayControl - = require('../controls/post_notes_overlay_control.js'); -const TagAutoCompleteControl = - require('../controls/tag_auto_complete_control.js'); +const router = require("../router.js"); +const uri = require("../util/uri.js"); +const misc = require("../util/misc.js"); +const views = require("../util/views.js"); +const PostContentControl = require("../controls/post_content_control.js"); +const PostNotesOverlayControl = require("../controls/post_notes_overlay_control.js"); +const TagAutoCompleteControl = require("../controls/tag_auto_complete_control.js"); -const template = views.getTemplate('home'); -const footerTemplate = views.getTemplate('home-footer'); -const featuredPostTemplate = views.getTemplate('home-featured-post'); +const template = views.getTemplate("home"); +const footerTemplate = views.getTemplate("home-footer"); +const featuredPostTemplate = views.getTemplate("home-featured-post"); class HomeView { constructor(ctx) { - this._hostNode = document.getElementById('content-holder'); + this._hostNode = document.getElementById("content-holder"); this._ctx = ctx; const sourceNode = template(ctx); @@ -27,12 +25,16 @@ class HomeView { this._autoCompleteControl = new TagAutoCompleteControl( this._searchInputNode, { - confirm: tag => + confirm: (tag) => this._autoCompleteControl.replaceSelectedText( - misc.escapeSearchTerm(tag.names[0]), true), - }); - this._formNode.addEventListener( - 'submit', e => this._evtFormSubmit(e)); + misc.escapeSearchTerm(tag.names[0]), + true + ), + } + ); + this._formNode.addEventListener("submit", (e) => + this._evtFormSubmit(e) + ); } } @@ -47,60 +49,67 @@ class HomeView { setStats(stats) { views.replaceContent( this._footerContainerNode, - footerTemplate(Object.assign({}, stats, this._ctx))); + footerTemplate(Object.assign({}, stats, this._ctx)) + ); } setFeaturedPost(postInfo) { views.replaceContent( - this._postInfoContainerNode, featuredPostTemplate(postInfo)); + this._postInfoContainerNode, + featuredPostTemplate(postInfo) + ); if (this._postContainerNode && postInfo.featuredPost) { this._postContentControl = new PostContentControl( this._postContainerNode, postInfo.featuredPost, () => { - return [ - window.innerWidth * 0.8, - window.innerHeight * 0.7, - ]; + return [window.innerWidth * 0.8, window.innerHeight * 0.7]; }, - 'fit-both'); + "fit-both" + ); this._postNotesOverlay = new PostNotesOverlayControl( - this._postContainerNode.querySelector('.post-overlay'), - postInfo.featuredPost); + this._postContainerNode.querySelector(".post-overlay"), + postInfo.featuredPost + ); - if (postInfo.featuredPost.type === 'video' - || postInfo.featuredPost.type === 'flash') { + if ( + postInfo.featuredPost.type === "video" || + postInfo.featuredPost.type === "flash" + ) { this._postContentControl.disableOverlay(); } } } get _footerContainerNode() { - return this._hostNode.querySelector('.footer-container'); + return this._hostNode.querySelector(".footer-container"); } get _postInfoContainerNode() { - return this._hostNode.querySelector('.post-info-container'); + return this._hostNode.querySelector(".post-info-container"); } get _postContainerNode() { - return this._hostNode.querySelector('.post-container'); + return this._hostNode.querySelector(".post-container"); } get _formNode() { - return this._hostNode.querySelector('form'); + return this._hostNode.querySelector("form"); } get _searchInputNode() { - return this._formNode.querySelector('input[name=search-text]'); + return this._formNode.querySelector("input[name=search-text]"); } _evtFormSubmit(e) { e.preventDefault(); this._searchInputNode.blur(); - router.show(uri.formatClientLink('posts', { - query: this._searchInputNode.value})); + router.show( + uri.formatClientLink("posts", { + query: this._searchInputNode.value, + }) + ); } } diff --git a/client/js/views/login_view.js b/client/js/views/login_view.js index 2c05332..64d49f9 100644 --- a/client/js/views/login_view.js +++ b/client/js/views/login_view.js @@ -1,52 +1,63 @@ -'use strict'; +"use strict"; -const events = require('../events.js'); -const api = require('../api.js'); -const views = require('../util/views.js'); +const events = require("../events.js"); +const api = require("../api.js"); +const views = require("../util/views.js"); -const template = views.getTemplate('login'); +const template = views.getTemplate("login"); class LoginView extends events.EventTarget { constructor() { super(); - this._hostNode = document.getElementById('content-holder'); + this._hostNode = document.getElementById("content-holder"); - views.replaceContent(this._hostNode, template({ - userNamePattern: api.getUserNameRegex(), - passwordPattern: api.getPasswordRegex(), - canSendMails: api.canSendMails(), - })); + views.replaceContent( + this._hostNode, + template({ + userNamePattern: api.getUserNameRegex(), + passwordPattern: api.getPasswordRegex(), + canSendMails: api.canSendMails(), + }) + ); views.syncScrollPosition(); views.decorateValidator(this._formNode); - this._userNameInputNode.setAttribute('pattern', api.getUserNameRegex()); - this._passwordInputNode.setAttribute('pattern', api.getPasswordRegex()); - this._formNode.addEventListener('submit', e => { + this._userNameInputNode.setAttribute( + "pattern", + api.getUserNameRegex() + ); + this._passwordInputNode.setAttribute( + "pattern", + api.getPasswordRegex() + ); + this._formNode.addEventListener("submit", (e) => { e.preventDefault(); - this.dispatchEvent(new CustomEvent('submit', { - detail: { - name: this._userNameInputNode.value, - password: this._passwordInputNode.value, - remember: this._rememberInputNode.checked, - }, - })); + this.dispatchEvent( + new CustomEvent("submit", { + detail: { + name: this._userNameInputNode.value, + password: this._passwordInputNode.value, + remember: this._rememberInputNode.checked, + }, + }) + ); }); } get _formNode() { - return this._hostNode.querySelector('form'); + return this._hostNode.querySelector("form"); } get _userNameInputNode() { - return this._formNode.querySelector('[name=name]'); + return this._formNode.querySelector("[name=name]"); } get _passwordInputNode() { - return this._formNode.querySelector('[name=password]'); + return this._formNode.querySelector("[name=password]"); } get _rememberInputNode() { - return this._formNode.querySelector('[name=remember-user]'); + return this._formNode.querySelector("[name=remember-user]"); } disableForm() { diff --git a/client/js/views/manual_page_view.js b/client/js/views/manual_page_view.js index 94394a3..390994d 100644 --- a/client/js/views/manual_page_view.js +++ b/client/js/views/manual_page_view.js @@ -1,15 +1,15 @@ -'use strict'; +"use strict"; -const router = require('../router.js'); -const keyboard = require('../util/keyboard.js'); -const views = require('../util/views.js'); +const router = require("../router.js"); +const keyboard = require("../util/keyboard.js"); +const views = require("../util/views.js"); -const holderTemplate = views.getTemplate('manual-pager'); -const navTemplate = views.getTemplate('manual-pager-nav'); +const holderTemplate = views.getTemplate("manual-pager"); +const navTemplate = views.getTemplate("manual-pager-nav"); function _removeConsecutiveDuplicates(a) { return a.filter((item, pos, ary) => { - return !pos || item != ary[pos - 1]; + return !pos || item !== ary[pos - 1]; }); } @@ -22,32 +22,36 @@ function _getVisiblePageNumbers(currentPage, totalPages) { for (let i = totalPages - threshold; i <= totalPages; i++) { pagesVisible.push(i); } - for (let i = currentPage - threshold; - i <= currentPage + threshold; - i++) { + for (let i = currentPage - threshold; i <= currentPage + threshold; i++) { pagesVisible.push(i); } pagesVisible = pagesVisible.filter((item, pos, ary) => { return item >= 1 && item <= totalPages; }); - pagesVisible = pagesVisible.sort((a, b) => { return a - b; }); + pagesVisible = pagesVisible.sort((a, b) => { + return a - b; + }); pagesVisible = _removeConsecutiveDuplicates(pagesVisible); return pagesVisible; } function _getPages( - currentPage, pageNumbers, limit, defaultLimit, removedItems) { + currentPage, + pageNumbers, + limit, + defaultLimit, + removedItems +) { const pages = new Map(); let prevPage = 0; for (let page of pageNumbers) { if (page !== prevPage + 1) { - pages.set(page - 1, {ellipsis: true}); + pages.set(page - 1, { ellipsis: true }); } pages.set(page, { number: page, offset: - (page - 1) * limit - - (page > currentPage ? removedItems : 0), + (page - 1) * limit - (page > currentPage ? removedItems : 0), limit: limit === defaultLimit ? null : limit, active: currentPage === page, }); @@ -58,7 +62,7 @@ function _getPages( class ManualPageView { constructor(ctx) { - this._hostNode = document.getElementById('content-holder'); + this._hostNode = document.getElementById("content-holder"); views.replaceContent(this._hostNode, holderTemplate()); } @@ -68,52 +72,65 @@ class ManualPageView { this.clearMessages(); views.emptyContent(this._pageNavNode); - ctx.requestPage(offset, limit).then(response => { - ctx.pageRenderer({ - parameters: ctx.parameters, - response: response, - hostNode: this._pageContentHolderNode, - }); + ctx.requestPage(offset, limit).then( + (response) => { + ctx.pageRenderer({ + parameters: ctx.parameters, + response: response, + hostNode: this._pageContentHolderNode, + }); - keyboard.bind(['a', 'left'], () => { - this._navigateToPrevNextPage('prev'); - }); - keyboard.bind(['d', 'right'], () => { - this._navigateToPrevNextPage('next'); - }); + keyboard.bind(["a", "left"], () => { + this._navigateToPrevNextPage("prev"); + }); + keyboard.bind(["d", "right"], () => { + this._navigateToPrevNextPage("next"); + }); - let removedItems = 0; - if (response.total) { - this._refreshNav( - offset, limit, response.total, removedItems, ctx); - } + let removedItems = 0; + if (response.total) { + this._refreshNav( + offset, + limit, + response.total, + removedItems, + ctx + ); + } - if (!response.results.length) { - this.showInfo('No data to show'); - } + if (!response.results.length) { + this.showInfo("No data to show"); + } - response.results.addEventListener('remove', e => { - removedItems++; - this._refreshNav( - offset, limit, response.total, removedItems, ctx); - }); + response.results.addEventListener("remove", (e) => { + removedItems++; + this._refreshNav( + offset, + limit, + response.total, + removedItems, + ctx + ); + }); - views.syncScrollPosition(); - }, response => { - this.showError(response.message); - }); + views.syncScrollPosition(); + }, + (response) => { + this.showError(response.message); + } + ); } get pageHeaderHolderNode() { - return this._hostNode.querySelector('.page-header-holder'); + return this._hostNode.querySelector(".page-header-holder"); } get _pageContentHolderNode() { - return this._hostNode.querySelector('.page-content-holder'); + return this._hostNode.querySelector(".page-content-holder"); } get _pageNavNode() { - return this._hostNode.querySelector('.page-nav'); + return this._hostNode.querySelector(".page-nav"); } clearMessages() { @@ -133,11 +150,11 @@ class ManualPageView { } _navigateToPrevNextPage(className) { - const linkNode = this._hostNode.querySelector('a.' + className); - if (linkNode.classList.contains('disabled')) { + const linkNode = this._hostNode.querySelector("a." + className); + if (linkNode.classList.contains("disabled")) { return; } - router.show(linkNode.getAttribute('href')); + router.show(linkNode.getAttribute("href")); } _refreshNav(offset, limit, total, removedItems, ctx) { @@ -145,7 +162,12 @@ class ManualPageView { const totalPages = Math.ceil((total - removedItems) / limit); const pageNumbers = _getVisiblePageNumbers(currentPage, totalPages); const pages = _getPages( - currentPage, pageNumbers, limit, ctx.defaultLimit, removedItems); + currentPage, + pageNumbers, + limit, + ctx.defaultLimit, + removedItems + ); views.replaceContent( this._pageNavNode, @@ -156,7 +178,8 @@ class ManualPageView { currentPage: currentPage, totalPages: totalPages, pages: pages, - })); + }) + ); } } diff --git a/client/js/views/not_found_view.js b/client/js/views/not_found_view.js index 487613b..c930b09 100644 --- a/client/js/views/not_found_view.js +++ b/client/js/views/not_found_view.js @@ -1,14 +1,14 @@ -'use strict'; +"use strict"; -const views = require('../util/views.js'); +const views = require("../util/views.js"); -const template = views.getTemplate('not-found'); +const template = views.getTemplate("not-found"); class NotFoundView { constructor(path) { - this._hostNode = document.getElementById('content-holder'); + this._hostNode = document.getElementById("content-holder"); - const sourceNode = template({path: path}); + const sourceNode = template({ path: path }); views.replaceContent(this._hostNode, sourceNode); views.syncScrollPosition(); } diff --git a/client/js/views/password_reset_view.js b/client/js/views/password_reset_view.js index 685fe5a..82a7d50 100644 --- a/client/js/views/password_reset_view.js +++ b/client/js/views/password_reset_view.js @@ -1,30 +1,35 @@ -'use strict'; +"use strict"; -const events = require('../events.js'); -const api = require('../api.js'); -const views = require('../util/views.js'); +const events = require("../events.js"); +const api = require("../api.js"); +const views = require("../util/views.js"); -const template = views.getTemplate('password-reset'); +const template = views.getTemplate("password-reset"); class PasswordResetView extends events.EventTarget { constructor() { super(); - this._hostNode = document.getElementById('content-holder'); + this._hostNode = document.getElementById("content-holder"); - views.replaceContent(this._hostNode, template({ - canSendMails: api.canSendMails(), - contactEmail: api.getContactEmail(), - })); + views.replaceContent( + this._hostNode, + template({ + canSendMails: api.canSendMails(), + contactEmail: api.getContactEmail(), + }) + ); views.syncScrollPosition(); views.decorateValidator(this._formNode); - this._formNode.addEventListener('submit', e => { + this._formNode.addEventListener("submit", (e) => { e.preventDefault(); - this.dispatchEvent(new CustomEvent('submit', { - detail: { - userNameOrEmail: this._userNameOrEmailFieldNode.value, - }, - })); + this.dispatchEvent( + new CustomEvent("submit", { + detail: { + userNameOrEmail: this._userNameOrEmailFieldNode.value, + }, + }) + ); }); } @@ -49,11 +54,11 @@ class PasswordResetView extends events.EventTarget { } get _formNode() { - return this._hostNode.querySelector('form'); + return this._hostNode.querySelector("form"); } get _userNameOrEmailFieldNode() { - return this._formNode.querySelector('[name=user-name]'); + return this._formNode.querySelector("[name=user-name]"); } } diff --git a/client/js/views/pool_categories_view.js b/client/js/views/pool_categories_view.js new file mode 100644 index 0000000..ac381d5 --- /dev/null +++ b/client/js/views/pool_categories_view.js @@ -0,0 +1,174 @@ +"use strict"; + +const events = require("../events.js"); +const views = require("../util/views.js"); +const PoolCategory = require("../models/pool_category.js"); + +const template = views.getTemplate("pool-categories"); +const rowTemplate = views.getTemplate("pool-category-row"); + +class PoolCategoriesView extends events.EventTarget { + constructor(ctx) { + super(); + this._ctx = ctx; + this._hostNode = document.getElementById("content-holder"); + + views.replaceContent(this._hostNode, template(ctx)); + views.syncScrollPosition(); + views.decorateValidator(this._formNode); + + const categoriesToAdd = Array.from(ctx.poolCategories); + categoriesToAdd.sort((a, b) => { + if (b.isDefault) { + return 1; + } else if (a.isDefault) { + return -1; + } + return a.name.localeCompare(b.name); + }); + for (let poolCategory of categoriesToAdd) { + this._addPoolCategoryRowNode(poolCategory); + } + + if (this._addLinkNode) { + this._addLinkNode.addEventListener("click", (e) => + this._evtAddButtonClick(e) + ); + } + + ctx.poolCategories.addEventListener("add", (e) => + this._evtPoolCategoryAdded(e) + ); + + ctx.poolCategories.addEventListener("remove", (e) => + this._evtPoolCategoryDeleted(e) + ); + + this._formNode.addEventListener("submit", (e) => + this._evtSaveButtonClick(e, ctx) + ); + } + + enableForm() { + views.enableForm(this._formNode); + } + + disableForm() { + views.disableForm(this._formNode); + } + + clearMessages() { + views.clearMessages(this._hostNode); + } + + showSuccess(message) { + views.showSuccess(this._hostNode, message); + } + + showError(message) { + views.showError(this._hostNode, message); + } + + get _formNode() { + return this._hostNode.querySelector("form"); + } + + get _tableBodyNode() { + return this._hostNode.querySelector("tbody"); + } + + get _addLinkNode() { + return this._hostNode.querySelector("a.add"); + } + + _addPoolCategoryRowNode(poolCategory) { + const rowNode = rowTemplate( + Object.assign({}, this._ctx, { poolCategory: poolCategory }) + ); + + const nameInput = rowNode.querySelector(".name input"); + if (nameInput) { + nameInput.addEventListener("change", (e) => + this._evtNameChange(e, rowNode) + ); + } + + const colorInput = rowNode.querySelector(".color input"); + if (colorInput) { + colorInput.addEventListener("change", (e) => + this._evtColorChange(e, rowNode) + ); + } + + const removeLinkNode = rowNode.querySelector(".remove a"); + if (removeLinkNode) { + removeLinkNode.addEventListener("click", (e) => + this._evtDeleteButtonClick(e, rowNode) + ); + } + + const defaultLinkNode = rowNode.querySelector(".set-default a"); + if (defaultLinkNode) { + defaultLinkNode.addEventListener("click", (e) => + this._evtSetDefaultButtonClick(e, rowNode) + ); + } + + this._tableBodyNode.appendChild(rowNode); + + rowNode._poolCategory = poolCategory; + poolCategory._rowNode = rowNode; + } + + _removePoolCategoryRowNode(poolCategory) { + const rowNode = poolCategory._rowNode; + rowNode.parentNode.removeChild(rowNode); + } + + _evtPoolCategoryAdded(e) { + this._addPoolCategoryRowNode(e.detail.poolCategory); + } + + _evtPoolCategoryDeleted(e) { + this._removePoolCategoryRowNode(e.detail.poolCategory); + } + + _evtAddButtonClick(e) { + e.preventDefault(); + this._ctx.poolCategories.add(new PoolCategory()); + } + + _evtNameChange(e, rowNode) { + rowNode._poolCategory.name = e.target.value; + } + + _evtColorChange(e, rowNode) { + e.target.value = e.target.value.toLowerCase(); + rowNode._poolCategory.color = e.target.value; + } + + _evtDeleteButtonClick(e, rowNode, link) { + e.preventDefault(); + if (e.target.classList.contains("inactive")) { + return; + } + this._ctx.poolCategories.remove(rowNode._poolCategory); + } + + _evtSetDefaultButtonClick(e, rowNode) { + e.preventDefault(); + this._ctx.poolCategories.defaultCategory = rowNode._poolCategory; + const oldRowNode = rowNode.parentNode.querySelector("tr.default"); + if (oldRowNode) { + oldRowNode.classList.remove("default"); + } + rowNode.classList.add("default"); + } + + _evtSaveButtonClick(e, ctx) { + e.preventDefault(); + this.dispatchEvent(new CustomEvent("submit")); + } +} + +module.exports = PoolCategoriesView; diff --git a/client/js/views/pool_create_view.js b/client/js/views/pool_create_view.js new file mode 100644 index 0000000..fc75f45 --- /dev/null +++ b/client/js/views/pool_create_view.js @@ -0,0 +1,140 @@ +"use strict"; + +const events = require("../events.js"); +const api = require("../api.js"); +const misc = require("../util/misc.js"); +const views = require("../util/views.js"); +const Pool = require("../models/pool.js"); + +const template = views.getTemplate("pool-create"); + +class PoolCreateView extends events.EventTarget { + constructor(ctx) { + super(); + + this._hostNode = document.getElementById("content-holder"); + views.replaceContent(this._hostNode, template(ctx)); + + views.decorateValidator(this._formNode); + + if (this._namesFieldNode) { + this._namesFieldNode.addEventListener("input", (e) => + this._evtNameInput(e) + ); + } + + if (this._postsFieldNode) { + this._postsFieldNode.addEventListener("input", (e) => + this._evtPostsInput(e) + ); + } + + for (let node of this._formNode.querySelectorAll( + "input, select, textarea, posts" + )) { + node.addEventListener("change", (e) => { + this.dispatchEvent(new CustomEvent("change")); + }); + } + + this._formNode.addEventListener("submit", (e) => this._evtSubmit(e)); + } + + clearMessages() { + views.clearMessages(this._hostNode); + } + + enableForm() { + views.enableForm(this._formNode); + } + + disableForm() { + views.disableForm(this._formNode); + } + + showSuccess(message) { + views.showSuccess(this._hostNode, message); + } + + showError(message) { + views.showError(this._hostNode, message); + } + + _evtNameInput(e) { + const regex = new RegExp(api.getPoolNameRegex()); + const list = misc.splitByWhitespace(this._namesFieldNode.value); + + if (!list.length) { + this._namesFieldNode.setCustomValidity( + "Pools must have at least one name." + ); + return; + } + + for (let item of list) { + if (!regex.test(item)) { + this._namesFieldNode.setCustomValidity( + `Pool name "${item}" contains invalid symbols.` + ); + return; + } + } + + this._namesFieldNode.setCustomValidity(""); + } + + _evtPostsInput(e) { + const regex = /^\d+$/; + const list = misc.splitByWhitespace(this._postsFieldNode.value); + + for (let item of list) { + if (!regex.test(item)) { + this._postsFieldNode.setCustomValidity( + `Pool ID "${item}" is not an integer.` + ); + return; + } + } + + this._postsFieldNode.setCustomValidity(""); + } + + _evtSubmit(e) { + e.preventDefault(); + + this.dispatchEvent( + new CustomEvent("submit", { + detail: { + names: misc.splitByWhitespace(this._namesFieldNode.value), + category: this._categoryFieldNode.value, + description: this._descriptionFieldNode.value, + posts: misc + .splitByWhitespace(this._postsFieldNode.value) + .map((i) => parseInt(i)), + }, + }) + ); + } + + get _formNode() { + return this._hostNode.querySelector("form"); + } + + get _namesFieldNode() { + return this._formNode.querySelector(".names input"); + } + + get _categoryFieldNode() { + return this._formNode.querySelector(".category select"); + } + + get _descriptionFieldNode() { + return this._formNode.querySelector(".description textarea"); + } + + get _postsFieldNode() { + return this._formNode.querySelector(".posts input"); + } +} + +module.exports = PoolCreateView; diff --git a/client/js/views/pool_delete_view.js b/client/js/views/pool_delete_view.js new file mode 100644 index 0000000..fa92a49 --- /dev/null +++ b/client/js/views/pool_delete_view.js @@ -0,0 +1,55 @@ +"use strict"; + +const events = require("../events.js"); +const views = require("../util/views.js"); + +const template = views.getTemplate("pool-delete"); + +class PoolDeleteView extends events.EventTarget { + constructor(ctx) { + super(); + + this._hostNode = ctx.hostNode; + this._pool = ctx.pool; + views.replaceContent(this._hostNode, template(ctx)); + views.decorateValidator(this._formNode); + this._formNode.addEventListener("submit", (e) => this._evtSubmit(e)); + } + + clearMessages() { + views.clearMessages(this._hostNode); + } + + enableForm() { + views.enableForm(this._formNode); + } + + disableForm() { + views.disableForm(this._formNode); + } + + showSuccess(message) { + views.showSuccess(this._hostNode, message); + } + + showError(message) { + views.showError(this._hostNode, message); + } + + _evtSubmit(e) { + e.preventDefault(); + this.dispatchEvent( + new CustomEvent("submit", { + detail: { + pool: this._pool, + }, + }) + ); + } + + get _formNode() { + return this._hostNode.querySelector("form"); + } +} + +module.exports = PoolDeleteView; diff --git a/client/js/views/pool_edit_view.js b/client/js/views/pool_edit_view.js new file mode 100644 index 0000000..b30ab9b --- /dev/null +++ b/client/js/views/pool_edit_view.js @@ -0,0 +1,151 @@ +"use strict"; + +const events = require("../events.js"); +const api = require("../api.js"); +const misc = require("../util/misc.js"); +const views = require("../util/views.js"); +const Post = require("../models/post.js"); + +const template = views.getTemplate("pool-edit"); + +class PoolEditView extends events.EventTarget { + constructor(ctx) { + super(); + + this._pool = ctx.pool; + this._hostNode = ctx.hostNode; + views.replaceContent(this._hostNode, template(ctx)); + + views.decorateValidator(this._formNode); + + if (this._namesFieldNode) { + this._namesFieldNode.addEventListener("input", (e) => + this._evtNameInput(e) + ); + } + + if (this._postsFieldNode) { + this._postsFieldNode.addEventListener("input", (e) => + this._evtPostsInput(e) + ); + } + + for (let node of this._formNode.querySelectorAll( + "input, select, textarea, posts" + )) { + node.addEventListener("change", (e) => { + this.dispatchEvent(new CustomEvent("change")); + }); + } + + this._formNode.addEventListener("submit", (e) => this._evtSubmit(e)); + } + + clearMessages() { + views.clearMessages(this._hostNode); + } + + enableForm() { + views.enableForm(this._formNode); + } + + disableForm() { + views.disableForm(this._formNode); + } + + showSuccess(message) { + views.showSuccess(this._hostNode, message); + } + + showError(message) { + views.showError(this._hostNode, message); + } + + _evtNameInput(e) { + const regex = new RegExp(api.getPoolNameRegex()); + const list = misc.splitByWhitespace(this._namesFieldNode.value); + + if (!list.length) { + this._namesFieldNode.setCustomValidity( + "Pools must have at least one name." + ); + return; + } + + for (let item of list) { + if (!regex.test(item)) { + this._namesFieldNode.setCustomValidity( + `Pool name "${item}" contains invalid symbols.` + ); + return; + } + } + + this._namesFieldNode.setCustomValidity(""); + } + + _evtPostsInput(e) { + const regex = /^\d+$/; + const list = misc.splitByWhitespace(this._postsFieldNode.value); + + for (let item of list) { + if (!regex.test(item)) { + this._postsFieldNode.setCustomValidity( + `Pool ID "${item}" is not an integer.` + ); + return; + } + } + + this._postsFieldNode.setCustomValidity(""); + } + + _evtSubmit(e) { + e.preventDefault(); + this.dispatchEvent( + new CustomEvent("submit", { + detail: { + pool: this._pool, + + names: this._namesFieldNode + ? misc.splitByWhitespace(this._namesFieldNode.value) + : undefined, + + category: this._categoryFieldNode + ? this._categoryFieldNode.value + : undefined, + + description: this._descriptionFieldNode + ? this._descriptionFieldNode.value + : undefined, + + posts: this._postsFieldNode + ? misc.splitByWhitespace(this._postsFieldNode.value) + : undefined, + }, + }) + ); + } + + get _formNode() { + return this._hostNode.querySelector("form"); + } + + get _namesFieldNode() { + return this._formNode.querySelector(".names input"); + } + + get _categoryFieldNode() { + return this._formNode.querySelector(".category select"); + } + + get _descriptionFieldNode() { + return this._formNode.querySelector(".description textarea"); + } + + get _postsFieldNode() { + return this._formNode.querySelector(".posts input"); + } +} + +module.exports = PoolEditView; diff --git a/client/js/views/pool_merge_view.js b/client/js/views/pool_merge_view.js new file mode 100644 index 0000000..f0ca15a --- /dev/null +++ b/client/js/views/pool_merge_view.js @@ -0,0 +1,84 @@ +"use strict"; + +const events = require("../events.js"); +const api = require("../api.js"); +const views = require("../util/views.js"); +const PoolAutoCompleteControl = require("../controls/pool_auto_complete_control.js"); + +const template = views.getTemplate("pool-merge"); + +class PoolMergeView extends events.EventTarget { + constructor(ctx) { + super(); + + this._pool = ctx.pool; + this._hostNode = ctx.hostNode; + this._targetPoolId = null; + ctx.poolNamePattern = api.getPoolNameRegex(); + views.replaceContent(this._hostNode, template(ctx)); + + views.decorateValidator(this._formNode); + if (this._targetPoolFieldNode) { + this._autoCompleteControl = new PoolAutoCompleteControl( + this._targetPoolFieldNode, + { + confirm: (pool) => { + this._targetPoolId = pool.id; + this._autoCompleteControl.replaceSelectedText( + pool.names[0], + false + ); + }, + } + ); + } + + this._formNode.addEventListener("submit", (e) => this._evtSubmit(e)); + } + + clearMessages() { + views.clearMessages(this._hostNode); + } + + enableForm() { + views.enableForm(this._formNode); + } + + disableForm() { + views.disableForm(this._formNode); + } + + showSuccess(message) { + views.showSuccess(this._hostNode, message); + } + + showError(message) { + views.showError(this._hostNode, message); + } + + _evtSubmit(e) { + e.preventDefault(); + this.dispatchEvent( + new CustomEvent("submit", { + detail: { + pool: this._pool, + targetPoolId: this._targetPoolId, + }, + }) + ); + } + + get _formNode() { + return this._hostNode.querySelector("form"); + } + + get _targetPoolFieldNode() { + return this._formNode.querySelector("input[name=target-pool]"); + } + + get _addAliasCheckboxNode() { + return this._formNode.querySelector("input[name=alias]"); + } +} + +module.exports = PoolMergeView; diff --git a/client/js/views/pool_summary_view.js b/client/js/views/pool_summary_view.js new file mode 100644 index 0000000..37ce1c2 --- /dev/null +++ b/client/js/views/pool_summary_view.js @@ -0,0 +1,23 @@ +"use strict"; + +const views = require("../util/views.js"); + +const template = views.getTemplate("pool-summary"); + +class PoolSummaryView { + constructor(ctx) { + this._pool = ctx.pool; + this._hostNode = ctx.hostNode; + views.replaceContent(this._hostNode, template(ctx)); + } + + showSuccess(message) { + views.showSuccess(this._hostNode, message); + } + + showError(message) { + views.showError(this._hostNode, message); + } +} + +module.exports = PoolSummaryView; diff --git a/client/js/views/pool_view.js b/client/js/views/pool_view.js new file mode 100644 index 0000000..99c1b02 --- /dev/null +++ b/client/js/views/pool_view.js @@ -0,0 +1,108 @@ +"use strict"; + +const events = require("../events.js"); +const views = require("../util/views.js"); +const misc = require("../util/misc.js"); +const PoolSummaryView = require("./pool_summary_view.js"); +const PoolEditView = require("./pool_edit_view.js"); +const PoolMergeView = require("./pool_merge_view.js"); +const PoolDeleteView = require("./pool_delete_view.js"); +const EmptyView = require("../views/empty_view.js"); + +const template = views.getTemplate("pool"); + +class PoolView extends events.EventTarget { + constructor(ctx) { + super(); + + this._ctx = ctx; + ctx.pool.addEventListener("change", (e) => this._evtChange(e)); + ctx.section = ctx.section || "summary"; + ctx.getPrettyName = misc.getPrettyName; + + this._hostNode = document.getElementById("content-holder"); + this._install(); + } + + _install() { + const ctx = this._ctx; + views.replaceContent(this._hostNode, template(ctx)); + + for (let item of this._hostNode.querySelectorAll("[data-name]")) { + item.classList.toggle( + "active", + item.getAttribute("data-name") === ctx.section + ); + if (item.getAttribute("data-name") === ctx.section) { + item.parentNode.scrollLeft = + item.getBoundingClientRect().left - + item.parentNode.getBoundingClientRect().left; + } + } + + ctx.hostNode = this._hostNode.querySelector(".pool-content-holder"); + if (ctx.section === "edit") { + if (!this._ctx.canEditAnything) { + this._view = new EmptyView(); + this._view.showError( + "You don't have privileges to edit pools." + ); + } else { + this._view = new PoolEditView(ctx); + events.proxyEvent(this._view, this, "submit"); + } + } else if (ctx.section === "merge") { + if (!this._ctx.canMerge) { + this._view = new EmptyView(); + this._view.showError( + "You don't have privileges to merge pools." + ); + } else { + this._view = new PoolMergeView(ctx); + events.proxyEvent(this._view, this, "submit", "merge"); + } + } else if (ctx.section === "delete") { + if (!this._ctx.canDelete) { + this._view = new EmptyView(); + this._view.showError( + "You don't have privileges to delete pools." + ); + } else { + this._view = new PoolDeleteView(ctx); + events.proxyEvent(this._view, this, "submit", "delete"); + } + } else { + this._view = new PoolSummaryView(ctx); + } + + events.proxyEvent(this._view, this, "change"); + views.syncScrollPosition(); + } + + clearMessages() { + this._view.clearMessages(); + } + + enableForm() { + this._view.enableForm(); + } + + disableForm() { + this._view.disableForm(); + } + + showSuccess(message) { + this._view.showSuccess(message); + } + + showError(message) { + this._view.showError(message); + } + + _evtChange(e) { + this._ctx.pool = e.detail.pool; + this._install(this._ctx); + } +} + +module.exports = PoolView; diff --git a/client/js/views/pools_header_view.js b/client/js/views/pools_header_view.js new file mode 100644 index 0000000..cfc6c8b --- /dev/null +++ b/client/js/views/pools_header_view.js @@ -0,0 +1,60 @@ +"use strict"; + +const events = require("../events.js"); +const misc = require("../util/misc.js"); +const search = require("../util/search.js"); +const views = require("../util/views.js"); +const PoolAutoCompleteControl = require("../controls/pool_auto_complete_control.js"); + +const template = views.getTemplate("pools-header"); + +class PoolsHeaderView extends events.EventTarget { + constructor(ctx) { + super(); + + this._hostNode = ctx.hostNode; + views.replaceContent(this._hostNode, template(ctx)); + + if (this._queryInputNode) { + this._autoCompleteControl = new PoolAutoCompleteControl( + this._queryInputNode, + { + confirm: (pool) => + this._autoCompleteControl.replaceSelectedText( + misc.escapeSearchTerm(pool.names[0]), + true + ), + } + ); + } + + search.searchInputNodeFocusHelper(this._queryInputNode); + + this._formNode.addEventListener("submit", (e) => this._evtSubmit(e)); + } + + get _formNode() { + return this._hostNode.querySelector("form"); + } + + get _queryInputNode() { + return this._hostNode.querySelector("[name=search-text]"); + } + + _evtSubmit(e) { + e.preventDefault(); + this._queryInputNode.blur(); + this.dispatchEvent( + new CustomEvent("navigate", { + detail: { + parameters: { + query: this._queryInputNode.value, + page: 1, + }, + }, + }) + ); + } +} + +module.exports = PoolsHeaderView; diff --git a/client/js/views/pools_page_view.js b/client/js/views/pools_page_view.js new file mode 100644 index 0000000..6230ef6 --- /dev/null +++ b/client/js/views/pools_page_view.js @@ -0,0 +1,13 @@ +"use strict"; + +const views = require("../util/views.js"); + +const template = views.getTemplate("pools-page"); + +class PoolsPageView { + constructor(ctx) { + views.replaceContent(ctx.hostNode, template(ctx)); + } +} + +module.exports = PoolsPageView; diff --git a/client/js/views/post_detail_view.js b/client/js/views/post_detail_view.js index 14786d3..587c41f 100644 --- a/client/js/views/post_detail_view.js +++ b/client/js/views/post_detail_view.js @@ -1,21 +1,21 @@ -'use strict'; +"use strict"; -const events = require('../events.js'); -const views = require('../util/views.js'); -const PostMergeView = require('./post_merge_view.js'); -const EmptyView = require('../views/empty_view.js'); +const events = require("../events.js"); +const views = require("../util/views.js"); +const PostMergeView = require("./post_merge_view.js"); +const EmptyView = require("../views/empty_view.js"); -const template = views.getTemplate('post-detail'); +const template = views.getTemplate("post-detail"); class PostDetailView extends events.EventTarget { constructor(ctx) { super(); this._ctx = ctx; - ctx.post.addEventListener('change', e => this._evtChange(e)); - ctx.section = ctx.section || 'summary'; + ctx.post.addEventListener("change", (e) => this._evtChange(e)); + ctx.section = ctx.section || "summary"; - this._hostNode = document.getElementById('content-holder'); + this._hostNode = document.getElementById("content-holder"); this._install(); } @@ -23,28 +23,30 @@ class PostDetailView extends events.EventTarget { const ctx = this._ctx; views.replaceContent(this._hostNode, template(ctx)); - for (let item of this._hostNode.querySelectorAll('[data-name]')) { + for (let item of this._hostNode.querySelectorAll("[data-name]")) { item.classList.toggle( - 'active', item.getAttribute('data-name') === ctx.section); - if (item.getAttribute('data-name') === ctx.section) { + "active", + item.getAttribute("data-name") === ctx.section + ); + if (item.getAttribute("data-name") === ctx.section) { item.parentNode.scrollLeft = item.getBoundingClientRect().left - - item.parentNode.getBoundingClientRect().left + item.parentNode.getBoundingClientRect().left; } } - ctx.hostNode = this._hostNode.querySelector('.post-content-holder'); - if (ctx.section === 'merge') { + ctx.hostNode = this._hostNode.querySelector(".post-content-holder"); + if (ctx.section === "merge") { if (!this._ctx.canMerge) { this._view = new EmptyView(); this._view.showError( - 'You don\'t have privileges to merge posts.'); + "You don't have privileges to merge posts." + ); } else { this._view = new PostMergeView(ctx); - events.proxyEvent(this._view, this, 'select'); - events.proxyEvent(this._view, this, 'submit', 'merge'); + events.proxyEvent(this._view, this, "select"); + events.proxyEvent(this._view, this, "submit", "merge"); } - } else { // this._view = new PostSummaryView(ctx); } diff --git a/client/js/views/post_main_view.js b/client/js/views/post_main_view.js index fbaabd2..5db8842 100644 --- a/client/js/views/post_main_view.js +++ b/client/js/views/post_main_view.js @@ -1,35 +1,33 @@ -'use strict'; +"use strict"; -const iosCorrectedInnerHeight = require('ios-inner-height'); -const router = require('../router.js'); -const views = require('../util/views.js'); -const uri = require('../util/uri.js'); -const keyboard = require('../util/keyboard.js'); -const Touch = require('../util/touch.js'); -const PostContentControl = require('../controls/post_content_control.js'); -const PostNotesOverlayControl = - require('../controls/post_notes_overlay_control.js'); -const PostReadonlySidebarControl = - require('../controls/post_readonly_sidebar_control.js'); -const PostEditSidebarControl = - require('../controls/post_edit_sidebar_control.js'); -const CommentControl = require('../controls/comment_control.js'); -const CommentListControl = require('../controls/comment_list_control.js'); +const iosCorrectedInnerHeight = require("ios-inner-height"); +const router = require("../router.js"); +const views = require("../util/views.js"); +const uri = require("../util/uri.js"); +const keyboard = require("../util/keyboard.js"); +const Touch = require("../util/touch.js"); +const PostContentControl = require("../controls/post_content_control.js"); +const PostNotesOverlayControl = require("../controls/post_notes_overlay_control.js"); +const PostReadonlySidebarControl = require("../controls/post_readonly_sidebar_control.js"); +const PostEditSidebarControl = require("../controls/post_edit_sidebar_control.js"); +const CommentControl = require("../controls/comment_control.js"); +const CommentListControl = require("../controls/comment_list_control.js"); -const template = views.getTemplate('post-main'); +const template = views.getTemplate("post-main"); class PostMainView { constructor(ctx) { - this._hostNode = document.getElementById('content-holder'); + this._hostNode = document.getElementById("content-holder"); const sourceNode = template(ctx); - const postContainerNode = sourceNode.querySelector('.post-container'); - const sidebarNode = sourceNode.querySelector('.sidebar'); + const postContainerNode = sourceNode.querySelector(".post-container"); + const sidebarNode = sourceNode.querySelector(".sidebar"); views.replaceContent(this._hostNode, sourceNode); views.syncScrollPosition(); - const topNavigationNode = - document.body.querySelector('#top-navigation'); + const topNavigationNode = document.body.querySelector( + "#top-navigation" + ); this._postContentControl = new PostContentControl( postContainerNode, @@ -37,15 +35,18 @@ class PostMainView { () => { return [ postContainerNode.getBoundingClientRect().width, - iosCorrectedInnerHeight() - postContainerNode.getBoundingClientRect().top, + iosCorrectedInnerHeight() - + postContainerNode.getBoundingClientRect().top, ]; - }); + } + ); this._postNotesOverlayControl = new PostNotesOverlayControl( - postContainerNode.querySelector('.post-overlay'), - ctx.post); + postContainerNode.querySelector(".post-overlay"), + ctx.post + ); - if (ctx.post.type === 'video' || ctx.post.type === 'flash') { + if (ctx.post.type === "video" || ctx.post.type === "flash") { this._postContentControl.disableOverlay(); } @@ -72,17 +73,17 @@ class PostMainView { } }; - keyboard.bind('e', () => { + keyboard.bind("e", () => { if (ctx.editMode) { - router.show(uri.formatClientLink('post', ctx.post.id)); + router.show(uri.formatClientLink("post", ctx.post.id)); } else { - router.show(uri.formatClientLink('post', ctx.post.id, 'edit')); + router.show(uri.formatClientLink("post", ctx.post.id, "edit")); } }); - keyboard.bind(['a', 'left'], showPreviousImage); - keyboard.bind(['d', 'right'], showNextImage); - keyboard.bind('r', showRandomImage); - keyboard.bind('del', (e) => { + keyboard.bind(["a", "left"], showPreviousImage); + keyboard.bind(["d", "right"], showNextImage); + keyboard.bind("r", showRandomImage); + keyboard.bind("del", (e) => { if (ctx.editMode) { this.sidebarControl._evtDeleteClick(e); } @@ -92,68 +93,82 @@ class PostMainView { postContainerNode, () => { if (!ctx.editMode) { - showNextImage() + showNextImage(); } }, () => { if (!ctx.editMode) { - showPreviousImage() + showPreviousImage(); } }, () => {}, (e) => { if (!ctx.editMode && e.startScrollY === 0) { - showRandomImage() + showRandomImage(); } } - ) + ); } _installSidebar(ctx) { const sidebarContainerNode = document.querySelector( - '#content-holder .sidebar-container'); + "#content-holder .sidebar-container" + ); if (ctx.editMode) { this.sidebarControl = new PostEditSidebarControl( sidebarContainerNode, ctx, this._postContentControl, - this._postNotesOverlayControl); + this._postNotesOverlayControl + ); } else { this.sidebarControl = new PostReadonlySidebarControl( - sidebarContainerNode, ctx, this._postContentControl); + sidebarContainerNode, + ctx, + this._postContentControl + ); } } _installCommentForm() { const commentFormContainer = document.querySelector( - '#content-holder .comment-form-container'); + "#content-holder .comment-form-container" + ); if (!commentFormContainer) { return null; } this.commentControl = new CommentControl( - commentFormContainer, null, true); + commentFormContainer, + null, + true + ); return commentFormContainer; } _installAddCommentButton(commentForm) { - const addCommentButton = document.querySelector('#add-comment-button'); + const addCommentButton = document.querySelector("#add-comment-button"); if (!addCommentButton || !commentForm) { return; } commentForm.hidden = true; // collapse by default - addCommentButton.addEventListener('click', () => {commentForm.hidden = !commentForm.hidden}); + addCommentButton.addEventListener("click", () => { + commentForm.hidden = !commentForm.hidden + }); } _installComments(comments) { const commentsContainerNode = document.querySelector( - '#content-holder .comments-container'); + "#content-holder .comments-container" + ); if (!commentsContainerNode) { return; } this.commentListControl = new CommentListControl( - commentsContainerNode, comments); + commentsContainerNode, + comments + ); } } diff --git a/client/js/views/post_merge_view.js b/client/js/views/post_merge_view.js index 3e987b3..20924d3 100644 --- a/client/js/views/post_merge_view.js +++ b/client/js/views/post_merge_view.js @@ -1,11 +1,11 @@ -'use strict'; +"use strict"; -const events = require('../events.js'); -const views = require('../util/views.js'); +const events = require("../events.js"); +const views = require("../util/views.js"); const KEY_RETURN = 13; -const template = views.getTemplate('post-merge'); -const sideTemplate = views.getTemplate('post-merge-side'); +const template = views.getTemplate("post-merge"); +const sideTemplate = views.getTemplate("post-merge-side"); class PostMergeView extends events.EventTarget { constructor(ctx) { @@ -23,7 +23,7 @@ class PostMergeView extends events.EventTarget { this._refreshLeftSide(); this._refreshRightSide(); - this._formNode.addEventListener('submit', e => this._evtSubmit(e)); + this._formNode.addEventListener("submit", (e) => this._evtSubmit(e)); } clearMessages() { @@ -52,48 +52,61 @@ class PostMergeView extends events.EventTarget { } _refreshLeftSide() { - this._refreshSide(this._leftPost, this._leftSideNode, 'left', false); + this._refreshSide(this._leftPost, this._leftSideNode, "left", false); } _refreshRightSide() { - this._refreshSide(this._rightPost, this._rightSideNode, 'right', true); + this._refreshSide(this._rightPost, this._rightSideNode, "right", true); } _refreshSide(post, sideNode, sideName, isEditable) { views.replaceContent( sideNode, - sideTemplate(Object.assign({}, this._ctx, { - post: post, - name: sideName, - editable: isEditable}))); + sideTemplate( + Object.assign({}, this._ctx, { + post: post, + name: sideName, + editable: isEditable, + }) + ) + ); - let postIdNode = sideNode.querySelector('input[type=text]'); - let searchButtonNode = sideNode.querySelector('input[type=button]'); + let postIdNode = sideNode.querySelector("input[type=text]"); + let searchButtonNode = sideNode.querySelector("input[type=button]"); if (isEditable) { - postIdNode.addEventListener( - 'keydown', e => this._evtPostSearchFieldKeyDown(e)); - searchButtonNode.addEventListener( - 'click', e => this._evtPostSearchButtonClick(e, postIdNode)); + postIdNode.addEventListener("keydown", (e) => + this._evtPostSearchFieldKeyDown(e) + ); + searchButtonNode.addEventListener("click", (e) => + this._evtPostSearchButtonClick(e, postIdNode) + ); } } _evtSubmit(e) { e.preventDefault(); const checkedTargetPost = this._formNode.querySelector( - '.target-post :checked').value; + ".target-post :checked" + ).value; const checkedTargetPostContent = this._formNode.querySelector( - '.target-post-content :checked').value; - this.dispatchEvent(new CustomEvent('submit', { - detail: { - post: checkedTargetPost == 'left' ? - this._rightPost : - this._leftPost, - targetPost: checkedTargetPost == 'left' ? - this._leftPost : - this._rightPost, - useOldContent: checkedTargetPostContent !== checkedTargetPost, - }, - })); + ".target-post-content :checked" + ).value; + this.dispatchEvent( + new CustomEvent("submit", { + detail: { + post: + checkedTargetPost === "left" + ? this._rightPost + : this._leftPost, + targetPost: + checkedTargetPost === "left" + ? this._leftPost + : this._rightPost, + useOldContent: + checkedTargetPostContent !== checkedTargetPost, + }, + }) + ); } _evtPostSearchFieldKeyDown(e) { @@ -103,33 +116,37 @@ class PostMergeView extends events.EventTarget { } e.target.blur(); e.preventDefault(); - this.dispatchEvent(new CustomEvent('select', { - detail: { - postId: e.target.value, - }, - })); + this.dispatchEvent( + new CustomEvent("select", { + detail: { + postId: e.target.value, + }, + }) + ); } _evtPostSearchButtonClick(e, textNode) { e.target.blur(); e.preventDefault(); - this.dispatchEvent(new CustomEvent('select', { - detail: { - postId: textNode.value, - }, - })); + this.dispatchEvent( + new CustomEvent("select", { + detail: { + postId: textNode.value, + }, + }) + ); } get _formNode() { - return this._hostNode.querySelector('form'); + return this._hostNode.querySelector("form"); } get _leftSideNode() { - return this._hostNode.querySelector('.left-post-container'); + return this._hostNode.querySelector(".left-post-container"); } get _rightSideNode() { - return this._hostNode.querySelector('.right-post-container'); + return this._hostNode.querySelector(".right-post-container"); } } diff --git a/client/js/views/post_upload_view.js b/client/js/views/post_upload_view.js index 926344a..a9f4659 100644 --- a/client/js/views/post_upload_view.js +++ b/client/js/views/post_upload_view.js @@ -1,27 +1,30 @@ -'use strict'; +"use strict"; -const events = require('../events.js'); -const views = require('../util/views.js'); -const FileDropperControl = require('../controls/file_dropper_control.js'); -const TagList = require('../models/tag_list.js'); -const TagInputControl = require('../controls/tag_input_control.js'); +const events = require("../events.js"); +const views = require("../util/views.js"); +const FileDropperControl = require("../controls/file_dropper_control.js"); +const TagList = require("../models/tag_list.js"); +const TagInputControl = require("../controls/tag_input_control.js"); -const template = views.getTemplate('post-upload'); -const rowTemplate = views.getTemplate('post-upload-row'); +const template = views.getTemplate("post-upload"); +const rowTemplate = views.getTemplate("post-upload-row"); -const misc = require('../util/misc.js'); +const misc = require("../util/misc.js"); const TagAutoCompleteControl = - require('../controls/tag_auto_complete_control.js'); + require("../controls/tag_auto_complete_control.js"); function _mimeTypeToPostType(mimeType) { - return { - 'application/x-shockwave-flash': 'flash', - 'image/gif': 'image', - 'image/jpeg': 'image', - 'image/png': 'image', - 'video/mp4': 'video', - 'video/webm': 'video', - }[mimeType] || 'unknown'; + return ( + { + "application/x-shockwave-flash": "flash", + "image/gif": "image", + "image/jpeg": "image", + "image/png": "image", + "image/webp": "image", + "video/mp4": "video", + "video/webm": "video", + }[mimeType] || "unknown" + ); } class Uploadable extends events.EventTarget { @@ -29,18 +32,17 @@ class Uploadable extends events.EventTarget { super(); this.lookalikes = []; this.lookalikesConfirmed = false; - this.safety = 'safe'; + this.safety = "safe"; this.flags = []; this.tags = []; this.relations = []; this.anonymous = false; } - destroy() { - } + destroy() {} get mimeType() { - return 'application/octet-stream'; + return "application/octet-stream"; } get type() { @@ -48,17 +50,11 @@ class Uploadable extends events.EventTarget { } get key() { - throw new Error('Not implemented'); + throw new Error("Not implemented"); } get name() { - throw new Error('Not implemented'); - } - - _initComplete() { - if (['video'].includes(this.type)) { - this.flags.push('loop'); - } + throw new Error("Not implemented"); } } @@ -73,13 +69,13 @@ class File extends Uploadable { } else { let reader = new FileReader(); reader.readAsDataURL(file); - reader.addEventListener('load', e => { + reader.addEventListener("load", (e) => { this._previewUrl = e.target.result; this.dispatchEvent( - new CustomEvent('finish', {detail: {uploadable: this}})); + new CustomEvent("finish", { detail: { uploadable: this } }) + ); }); } - this._initComplete(); } destroy() { @@ -109,25 +105,25 @@ class Url extends Uploadable { constructor(url) { super(); this.url = url; - this.dispatchEvent(new CustomEvent('finish')); - this._initComplete(); + this.dispatchEvent(new CustomEvent("finish")); } get mimeType() { let mime = { - 'swf': 'application/x-shockwave-flash', - 'jpg': 'image/jpeg', - 'png': 'image/png', - 'gif': 'image/gif', - 'mp4': 'video/mp4', - 'webm': 'video/webm', + swf: "application/x-shockwave-flash", + jpg: "image/jpeg", + png: "image/png", + gif: "image/gif", + webp: "image/webp", + mp4: "video/mp4", + webm: "video/webm", }; for (let extension of Object.keys(mime)) { - if (this.url.toLowerCase().indexOf('.' + extension) !== -1) { + if (this.url.toLowerCase().indexOf("." + extension) !== -1) { return mime[extension]; } } - return 'unknown'; + return "unknown"; } get previewUrl() { @@ -147,7 +143,7 @@ class PostUploadView extends events.EventTarget { constructor(ctx) { super(); this._ctx = ctx; - this._hostNode = document.getElementById('content-holder'); + this._hostNode = document.getElementById("content-holder"); views.replaceContent(this._hostNode, template()); views.syncScrollPosition(); @@ -155,50 +151,56 @@ class PostUploadView extends events.EventTarget { this._cancelButtonNode.disabled = true; this._uploadables = []; - this._uploadables.find = u => { - return this._uploadables.findIndex(u2 => u.key === u2.key); + this._uploadables.find = (u) => { + return this._uploadables.findIndex((u2) => u.key === u2.key); }; this._contentFileDropper = new FileDropperControl( this._contentInputNode, { extraText: - 'Allowed extensions: .jpg, .png, .gif, .webm, .mp4, .swf', + "Allowed extensions: .jpg, .png, .gif, .webm, .mp4, .swf", allowUrls: true, allowMultiple: true, lock: false, - }); - this._contentFileDropper.addEventListener( - 'fileadd', e => this._evtFilesAdded(e)); - this._contentFileDropper.addEventListener( - 'urladd', e => this._evtUrlsAdded(e)); + } + ); + this._contentFileDropper.addEventListener("fileadd", (e) => + this._evtFilesAdded(e) + ); + this._contentFileDropper.addEventListener("urladd", (e) => + this._evtUrlsAdded(e) + ); - this._skipDuplicatesCheckboxNode.addEventListener( - 'change', e => this._evtSkipDuplicatesCheck(e, this._skipDuplicatesCheckboxNode.checked) + this._skipDuplicatesCheckboxNode.addEventListener("change", e => + this._evtSkipDuplicatesCheck(e, this._skipDuplicatesCheckboxNode.checked) ); this._copyTagsToOriginalsSpanNode.hidden = true; - this._cancelButtonNode.addEventListener( - 'click', e => this._evtCancelButtonClick(e)); - this._formNode.addEventListener('submit', e => this._evtFormSubmit(e)); - this._formNode.classList.add('inactive'); + this._cancelButtonNode.addEventListener("click", (e) => + this._evtCancelButtonClick(e) + ); + this._formNode.addEventListener("submit", (e) => + this._evtFormSubmit(e) + ); + this._formNode.classList.add("inactive"); if (this._tagInputNode) { this._tagControl = new TagInputControl( - this._tagInputNode, new TagList(), 'Type common tags…'); + this._tagInputNode, new TagList(), "Type common tags…"); } } enableForm() { views.enableForm(this._formNode); this._cancelButtonNode.disabled = true; - this._formNode.classList.remove('uploading'); + this._formNode.classList.remove("uploading"); } disableForm() { views.disableForm(this._formNode); this._cancelButtonNode.disabled = false; - this._formNode.classList.add('uploading'); + this._formNode.classList.add("uploading"); } clearMessages() { @@ -223,7 +225,7 @@ class PostUploadView extends events.EventTarget { } addUploadables(uploadables) { - this._formNode.classList.remove('inactive'); + this._formNode.classList.remove("inactive"); let duplicatesFound = 0; for (let uploadable of uploadables) { uploadable.safety = this._ctx.defaultSafety || uploadable.safety; @@ -232,20 +234,22 @@ class PostUploadView extends events.EventTarget { continue; } this._uploadables.push(uploadable); - this._emit('change'); + this._emit("change"); this._renderRowNode(uploadable); - uploadable.addEventListener( - 'finish', e => this._updateThumbnailNode(e.detail.uploadable)); + uploadable.addEventListener("finish", (e) => + this._updateThumbnailNode(e.detail.uploadable) + ); } if (duplicatesFound) { let message = null; if (duplicatesFound < uploadables.length) { - message = 'Some of the files were already added ' + - 'and have been skipped.'; + message = + "Some of the files were already added " + + "and have been skipped."; } else if (duplicatesFound === 1) { - message = 'This file was already added.'; + message = "This file was already added."; } else { - message = 'These files were already added.'; + message = "These files were already added."; } alert(message); } @@ -258,10 +262,10 @@ class PostUploadView extends events.EventTarget { uploadable.destroy(); uploadable.rowNode.parentNode.removeChild(uploadable.rowNode); this._uploadables.splice(this._uploadables.find(uploadable), 1); - this._emit('change'); + this._emit("change"); if (!this._uploadables.length) { - this._formNode.classList.add('inactive'); - this._submitButtonNode.value = 'Upload all'; + this._formNode.classList.add("inactive"); + this._submitButtonNode.value = "Upload all"; } } @@ -271,11 +275,11 @@ class PostUploadView extends events.EventTarget { } _evtFilesAdded(e) { - this.addUploadables(e.detail.files.map(file => new File(file))); + this.addUploadables(e.detail.files.map((file) => new File(file))); } _evtUrlsAdded(e) { - this.addUploadables(e.detail.urls.map(url => new Url(url))); + this.addUploadables(e.detail.urls.map((url) => new Url(url))); } _evtSkipDuplicatesCheck(e, checked) { @@ -289,7 +293,7 @@ class PostUploadView extends events.EventTarget { _evtCancelButtonClick(e) { e.preventDefault(); - this._emit('cancel'); + this._emit("cancel"); } _evtFormSubmit(e) { @@ -297,47 +301,47 @@ class PostUploadView extends events.EventTarget { for (let uploadable of this._uploadables) { this._updateUploadableFromDom(uploadable); } - this._submitButtonNode.value = 'Resume upload'; - this._emit('submit'); + this._submitButtonNode.value = "Resume upload"; + this._emit("submit"); } _updateUploadableFromDom(uploadable) { const rowNode = uploadable.rowNode; - const safetyNode = rowNode.querySelector('.safety input:checked'); + const safetyNode = rowNode.querySelector(".safety input:checked"); if (safetyNode) { uploadable.safety = safetyNode.value; } - const anonymousNode = rowNode.querySelector('.anonymous input:checked'); + const anonymousNode = rowNode.querySelector( + ".anonymous input:checked" + ); if (anonymousNode) { uploadable.anonymous = true; } - uploadable.flags = []; - if (rowNode.querySelector('.loop-video input:checked')) { - uploadable.flags.push('loop'); - } - uploadable.tags = []; if (this._tagControl) { uploadable.tags = this._tagControl.tags.map(tag => tag.names[0]); } - + uploadable.relations = []; for (let [i, lookalike] of uploadable.lookalikes.entries()) { let lookalikeNode = rowNode.querySelector( - `.lookalikes li:nth-child(${i + 1})`); - if ((lookalikeNode.querySelector('[name=copy-tags]') || '').checked) { + `.lookalikes li:nth-child(${i + 1})` + ); + if ((lookalikeNode.querySelector("[name=copy-tags]") || "").checked) { if (lookalike.distance === 0.0) { // found exact match, copy tags to it instead uploadable.foundOriginal = lookalike.post; } else { - uploadable.tags = uploadable.tags.concat(lookalike.post.tagNames); + uploadable.tags = uploadable.tags.concat( + lookalike.post.tagNames + ); uploadable.foundOriginal = undefined; } } - if ((lookalikeNode.querySelector('[name=add-relation]') || '').checked) { + if ((lookalikeNode.querySelector("[name=add-relation]") || "").checked) { uploadable.relations.push(lookalike.post.id); } } @@ -364,91 +368,111 @@ class PostUploadView extends events.EventTarget { this._uploadables[index + delta] = uploadable1; if (delta === 1) { this._listNode.insertBefore( - uploadable2.rowNode, uploadable1.rowNode); + uploadable2.rowNode, + uploadable1.rowNode + ); } else { this._listNode.insertBefore( - uploadable1.rowNode, uploadable2.rowNode); + uploadable1.rowNode, + uploadable2.rowNode + ); } } } _emit(eventType) { this.dispatchEvent( - new CustomEvent( - eventType, - {detail: { + new CustomEvent(eventType, { + detail: { uploadables: this._uploadables, skipDuplicates: this._skipDuplicatesCheckboxNode.checked, - copyTagsToOriginals: this._copyTagsToOriginalsCheckboxNode.checked, - }})); + copyTagsToOriginals: + this._copyTagsToOriginalsCheckboxNode.checked, + }, + }) + ); } _renderRowNode(uploadable) { - const rowNode = rowTemplate(Object.assign( - {}, this._ctx, {uploadable: uploadable})); + const rowNode = rowTemplate( + Object.assign({}, this._ctx, { uploadable: uploadable }) + ); if (uploadable.rowNode) { uploadable.rowNode.parentNode.replaceChild( - rowNode, uploadable.rowNode); + rowNode, + uploadable.rowNode + ); } else { this._listNode.appendChild(rowNode); } uploadable.rowNode = rowNode; - rowNode.querySelector('a.remove').addEventListener('click', - e => this._evtRemoveClick(e, uploadable)); - rowNode.querySelector('a.move-up').addEventListener('click', - e => this._evtMoveClick(e, uploadable, -1)); - rowNode.querySelector('a.move-down').addEventListener('click', - e => this._evtMoveClick(e, uploadable, 1)); + rowNode + .querySelector("a.remove") + .addEventListener("click", (e) => + this._evtRemoveClick(e, uploadable) + ); + rowNode + .querySelector("a.move-up") + .addEventListener("click", (e) => + this._evtMoveClick(e, uploadable, -1) + ); + rowNode + .querySelector("a.move-down") + .addEventListener("click", (e) => + this._evtMoveClick(e, uploadable, 1) + ); } _updateThumbnailNode(uploadable) { - const rowNode = rowTemplate(Object.assign( - {}, this._ctx, {uploadable: uploadable})); + const rowNode = rowTemplate( + Object.assign({}, this._ctx, { uploadable: uploadable }) + ); views.replaceContent( - uploadable.rowNode.querySelector('.thumbnail'), - rowNode.querySelector('.thumbnail').childNodes); + uploadable.rowNode.querySelector(".thumbnail"), + rowNode.querySelector(".thumbnail").childNodes + ); } get _uploading() { - return this._formNode.classList.contains('uploading'); + return this._formNode.classList.contains("uploading"); } get _listNode() { - return this._hostNode.querySelector('.uploadables-container'); + return this._hostNode.querySelector(".uploadables-container"); } get _formNode() { - return this._hostNode.querySelector('form'); + return this._hostNode.querySelector("form"); } get _skipDuplicatesCheckboxNode() { - return this._hostNode.querySelector('form [name=skip-duplicates]'); + return this._hostNode.querySelector("form [name=skip-duplicates]"); } get _copyTagsToOriginalsSpanNode() { - return this._hostNode.querySelector('.copy-tags-to-originals'); + return this._hostNode.querySelector(".copy-tags-to-originals"); } get _copyTagsToOriginalsCheckboxNode() { - return this._hostNode.querySelector('form [name=copy-tags-to-originals]'); + return this._hostNode.querySelector("form [name=copy-tags-to-originals]"); } get _submitButtonNode() { - return this._hostNode.querySelector('form [type=submit]'); + return this._hostNode.querySelector("form [type=submit]"); } get _cancelButtonNode() { - return this._hostNode.querySelector('form .cancel'); + return this._hostNode.querySelector("form .cancel"); } get _contentInputNode() { - return this._formNode.querySelector('.dropper-container'); + return this._formNode.querySelector(".dropper-container"); } get _tagInputNode() { - return this._formNode.querySelector('.tags input'); + return this._formNode.querySelector(".tags input"); } } diff --git a/client/js/views/posts_header_view.js b/client/js/views/posts_header_view.js index d8a87ea..3df9cd9 100644 --- a/client/js/views/posts_header_view.js +++ b/client/js/views/posts_header_view.js @@ -1,72 +1,72 @@ -'use strict'; +"use strict"; -const events = require('../events.js'); -const settings = require('../models/settings.js'); -const keyboard = require('../util/keyboard.js'); -const misc = require('../util/misc.js'); -const search = require('../util/search.js'); -const views = require('../util/views.js'); -const TagAutoCompleteControl = - require('../controls/tag_auto_complete_control.js'); -const MetricHeaderControl = require('../controls/metric_header_control'); +const events = require("../events.js"); +const settings = require("../models/settings.js"); +const keyboard = require("../util/keyboard.js"); +const misc = require("../util/misc.js"); +const search = require("../util/search.js"); +const views = require("../util/views.js"); +const TagList = require("../models/tag_list.js"); +const TagAutoCompleteControl = require("../controls/tag_auto_complete_control.js"); +const MetricHeaderControl = require("../controls/metric_header_control"); -const template = views.getTemplate('posts-header'); +const template = views.getTemplate("posts-header"); class BulkEditor extends events.EventTarget { constructor(hostNode) { super(); this._hostNode = hostNode; - this._openLinkNode.addEventListener( - 'click', e => this._evtOpenLinkClick(e)); - this._closeLinkNode.addEventListener( - 'click', e => this._evtCloseLinkClick(e)); + this._openLinkNode.addEventListener("click", (e) => + this._evtOpenLinkClick(e) + ); + this._closeLinkNode.addEventListener("click", (e) => + this._evtCloseLinkClick(e) + ); } get opened() { - return this._hostNode.classList.contains('opened') && - !this._hostNode.classList.contains('hidden'); + return ( + this._hostNode.classList.contains("opened") && + !this._hostNode.classList.contains("hidden") + ); } get _openLinkNode() { - return this._hostNode.querySelector('.open'); + return this._hostNode.querySelector(".open"); } get _closeLinkNode() { - return this._hostNode.querySelector('.close'); + return this._hostNode.querySelector(".close"); } toggleOpen(state) { - this._hostNode.classList.toggle('opened', state); + this._hostNode.classList.toggle("opened", state); } toggleHide(state) { - this._hostNode.classList.toggle('hidden', state); + this._hostNode.classList.toggle("hidden", state); } _evtOpenLinkClick(e) { - throw new Error('Not implemented'); + throw new Error("Not implemented"); } _evtCloseLinkClick(e) { - throw new Error('Not implemented'); + throw new Error("Not implemented"); } } class BulkSafetyEditor extends BulkEditor { - constructor(hostNode) { - super(hostNode); - } - _evtOpenLinkClick(e) { e.preventDefault(); this.toggleOpen(true); - this.dispatchEvent(new CustomEvent('open', {detail: {}})); + this.dispatchEvent(new CustomEvent("open", { detail: {} })); } _evtCloseLinkClick(e) { e.preventDefault(); this.toggleOpen(false); - this.dispatchEvent(new CustomEvent('close', {detail: {}})); + this.dispatchEvent(new CustomEvent("close", { detail: {} })); } } @@ -76,11 +76,32 @@ class BulkTagEditor extends BulkEditor { this._autoCompleteControl = new TagAutoCompleteControl( this._inputNode, { - confirm: tag => - this._autoCompleteControl.replaceSelectedText( - tag.names[0], false), - }); - this._hostNode.addEventListener('submit', e => this._evtFormSubmit(e)); + confirm: (tag) => { + let tag_list = new TagList(); + tag_list + .addByName(tag.names[0], true) + .then( + () => { + return tag_list + .map((s) => s.names[0]) + .join(" "); + }, + (err) => { + return tag.names[0]; + } + ) + .then((tag_str) => { + this._autoCompleteControl.replaceSelectedText( + tag_str, + false + ); + }); + }, + } + ); + this._hostNode.addEventListener("submit", (e) => + this._evtFormSubmit(e) + ); } get value() { @@ -88,7 +109,7 @@ class BulkTagEditor extends BulkEditor { } get _inputNode() { - return this._hostNode.querySelector('input[name=tag]'); + return this._hostNode.querySelector("input[name=tag]"); } focus() { @@ -102,22 +123,22 @@ class BulkTagEditor extends BulkEditor { _evtFormSubmit(e) { e.preventDefault(); - this.dispatchEvent(new CustomEvent('submit', {detail: {}})); + this.dispatchEvent(new CustomEvent("submit", { detail: {} })); } _evtOpenLinkClick(e) { e.preventDefault(); this.toggleOpen(true); this.focus(); - this.dispatchEvent(new CustomEvent('open', {detail: {}})); + this.dispatchEvent(new CustomEvent("open", { detail: {} })); } _evtCloseLinkClick(e) { e.preventDefault(); - this._inputNode.value = ''; + this._inputNode.value = ""; this.toggleOpen(false); this.blur(); - this.dispatchEvent(new CustomEvent('close', {detail: {}})); + this.dispatchEvent(new CustomEvent("close", { detail: {} })); } } @@ -129,13 +150,13 @@ class BulkAddRelationEditor extends BulkEditor { _evtOpenLinkClick(e) { e.preventDefault(); this.toggleOpen(true); - this.dispatchEvent(new CustomEvent('open', {detail: {}})); + this.dispatchEvent(new CustomEvent("open", { detail: {} })); } _evtCloseLinkClick(e) { e.preventDefault(); this.toggleOpen(false); - this.dispatchEvent(new CustomEvent('close', {detail: {}})); + this.dispatchEvent(new CustomEvent("close", { detail: {} })); } } @@ -151,20 +172,28 @@ class PostsHeaderView extends events.EventTarget { this._autoCompleteControl = new TagAutoCompleteControl( this._queryInputNode, { - confirm: tag => + confirm: (tag) => this._autoCompleteControl.replaceSelectedText( - misc.escapeSearchTerm(tag.names[0]), true), - }); + misc.escapeSearchTerm(tag.names[0]), + true + ), + } + ); - keyboard.bind('p', () => this._focusFirstPostNode()); + keyboard.bind("p", () => this._focusFirstPostNode()); search.searchInputNodeFocusHelper(this._queryInputNode); for (let safetyButtonNode of this._safetyButtonNodes) { - safetyButtonNode.addEventListener( - 'click', e => this._evtSafetyButtonClick(e)); + safetyButtonNode.addEventListener("click", (e) => + this._evtSafetyButtonClick(e) + ); } - this._formNode.addEventListener('submit', e => this._evtFormSubmit(e)); - this._randomizeButtonNode.addEventListener('click', e => this._evtRandomizeButtonClick(e)); + this._formNode.addEventListener("submit", (e) => + this._evtFormSubmit(e) + ); + this._randomizeButtonNode.addEventListener("click", (e) => + this._evtRandomizeButtonClick(e) + ); this._bulkEditors = []; if (this._bulkEditTagsNode) { @@ -174,41 +203,47 @@ class PostsHeaderView extends events.EventTarget { if (this._bulkEditSafetyNode) { this._bulkSafetyEditor = new BulkSafetyEditor( - this._bulkEditSafetyNode); + this._bulkEditSafetyNode + ); this._bulkEditors.push(this._bulkSafetyEditor); } if (this._bulkAddRelationNode) { this._bulkAddRelationEditor = new BulkAddRelationEditor( - this._bulkAddRelationNode); + this._bulkAddRelationNode + ); this._bulkEditors.push(this._bulkAddRelationEditor); } - this._bulkEditOpenButtonNode.addEventListener( - 'click', e => this._evtOpenBulkEditBtnClick(e)); - this._bulkEditCloseButtonNode.addEventListener( - 'click', e => this._evtCloseBulkEditBtnClick(e)); + this._bulkEditOpenButtonNode.addEventListener("click", (e) => + this._evtOpenBulkEditBtnClick(e) + ); + this._bulkEditCloseButtonNode.addEventListener("click", (e) => + this._evtCloseBulkEditBtnClick(e) + ); if (this._metricsButtonHolderNode) { this._metricControl = new MetricHeaderControl(this._metricsBlockNode, ctx); - this._metricControl.addEventListener('submit', e => { - this._navigate(); - }); - this._metricsOpenButtonNode.addEventListener( - 'click', e => this._evtOpenMetricsBtnClick(e)); - this._metricsCloseButtonNode.addEventListener( - 'click', e => this._evtCloseMetricsBtnClick(e)); + this._metricControl.addEventListener("submit", (e) => + this._navigate() + ); + this._metricsOpenButtonNode.addEventListener("click", (e) => + this._evtOpenMetricsBtnClick(e) + ); + this._metricsCloseButtonNode.addEventListener("click", (e) => + this._evtCloseMetricsBtnClick(e) + ); } for (let editor of this._bulkEditors) { - editor.addEventListener('submit', e => { + editor.addEventListener("submit", (e) => { this._navigate(); }); - editor.addEventListener('open', e => { + editor.addEventListener("open", (e) => { this._hideBulkEditorsExcept(editor); this._navigate(); }); - editor.addEventListener('close', e => { + editor.addEventListener("close", (e) => { this._closeAndShowAllBulkEditors(); this._navigate(); }); @@ -227,63 +262,63 @@ class PostsHeaderView extends events.EventTarget { } get _formNode() { - return this._hostNode.querySelector('form.search'); + return this._hostNode.querySelector("form.search"); } get _safetyButtonNodes() { - return this._hostNode.querySelectorAll('form .safety'); + return this._hostNode.querySelectorAll("form .safety"); } get _queryInputNode() { - return this._hostNode.querySelector('form [name=search-text]'); + return this._hostNode.querySelector("form [name=search-text]"); } get _randomizeButtonNode() { - return this._hostNode.querySelector('#randomize-button'); + return this._hostNode.querySelector("#randomize-button"); } get _bulkEditBtnHolderNode() { - return this._hostNode.querySelector('.bulk-edit-btn-holder'); + return this._hostNode.querySelector(".bulk-edit-btn-holder"); } get _bulkEditOpenButtonNode() { - return this._hostNode.querySelector('.bulk-edit-btn.open'); + return this._hostNode.querySelector(".bulk-edit-btn.open"); } get _bulkEditCloseButtonNode() { - return this._hostNode.querySelector('.bulk-edit-btn.close'); + return this._hostNode.querySelector(".bulk-edit-btn.close"); } get _bulkEditBlockNode() { - return this._hostNode.querySelector('.bulk-edit-block'); + return this._hostNode.querySelector(".bulk-edit-block"); } get _bulkEditTagsNode() { - return this._hostNode.querySelector('.bulk-edit-tags'); + return this._hostNode.querySelector(".bulk-edit-tags"); } get _bulkEditSafetyNode() { - return this._hostNode.querySelector('.bulk-edit-safety'); + return this._hostNode.querySelector(".bulk-edit-safety"); } get _bulkAddRelationNode() { - return this._hostNode.querySelector('.bulk-add-relation'); + return this._hostNode.querySelector(".bulk-add-relation"); } get _metricsButtonHolderNode() { - return this._hostNode.querySelector('.metrics-btn-holder'); + return this._hostNode.querySelector(".metrics-btn-holder"); } get _metricsOpenButtonNode() { - return this._hostNode.querySelector('.metrics-btn.open'); + return this._hostNode.querySelector(".metrics-btn.open"); } get _metricsCloseButtonNode() { - return this._hostNode.querySelector('.metrics-btn.close'); + return this._hostNode.querySelector(".metrics-btn.close"); } get _metricsBlockNode() { - return this._hostNode.querySelector('.metrics-block'); + return this._hostNode.querySelector(".metrics-block"); } _evtOpenBulkEditBtnClick(e) { @@ -306,8 +341,8 @@ class PostsHeaderView extends events.EventTarget { } _toggleBulkEditBlock(open) { - this._bulkEditBtnHolderNode.classList.toggle('opened', open); - this._bulkEditBlockNode.classList.toggle('hidden', !open); + this._bulkEditBtnHolderNode.classList.toggle("opened", open); + this._bulkEditBlockNode.classList.toggle("hidden", !open); } _hideBulkEditorsExcept(editor) { @@ -339,26 +374,29 @@ class PostsHeaderView extends events.EventTarget { } _toggleMetricsBlock(open) { - this._metricsButtonHolderNode.classList.toggle('opened', open); - this._metricsBlockNode.classList.toggle('hidden', !open); + this._metricsButtonHolderNode.classList.toggle("opened", open); + this._metricsBlockNode.classList.toggle("hidden", !open); } _evtSafetyButtonClick(e, url) { e.preventDefault(); - e.target.classList.toggle('disabled'); - const safety = e.target.getAttribute('data-safety'); + 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', { - detail: { - parameters: Object.assign( - {}, this._ctx.parameters, {tag: null, offset: 0}), - }, - })); + new CustomEvent("navigate", { + detail: { + parameters: Object.assign({}, this._ctx.parameters, { + tag: null, + offset: 0, + }), + }, + }) + ); } _evtFormSubmit(e) { @@ -370,8 +408,8 @@ class PostsHeaderView extends events.EventTarget { } _evtRandomizeButtonClick(e) { e.preventDefault(); - if (!this._queryInputNode.value.includes('sort:random')) { - this._queryInputNode.value += ' sort:random'; + if (!this._queryInputNode.value.includes("sort:random")) { + this._queryInputNode.value += " sort:random"; } this._ctx.parameters.cachenumber = Math.round(Math.random() * 1000); this._navigate(); @@ -382,29 +420,38 @@ class PostsHeaderView extends events.EventTarget { let parameters = { query: this._queryInputNode.value, cachenumber: this._ctx.parameters.cachenumber, - metrics: this._ctx.parameters.metrics, + metrics: this._ctx.parameters.metrics }; - parameters.offset = parameters.query === this._ctx.parameters.query ? - this._ctx.parameters.offset : 0; + + // convert falsy values to an empty string "" so that we can correctly compare with the current query + const prevQuery = this._ctx.parameters.query + ? this._ctx.parameters.query + : ""; + parameters.offset = + parameters.query === prevQuery ? this._ctx.parameters.offset : 0; if (this._bulkTagEditor && this._bulkTagEditor.opened) { parameters.tag = this._bulkTagEditor.value; this._bulkTagEditor.blur(); } else { parameters.tag = null; } - parameters.safety = ( - this._bulkSafetyEditor && - this._bulkSafetyEditor.opened ? '1' : null); - parameters.relations = ( - this._bulkAddRelationEditor && - this._bulkAddRelationEditor.opened ? this._ctx.parameters.relations || ' ' : null); + parameters.safety = + this._bulkSafetyEditor && this._bulkSafetyEditor.opened + ? "1" + : null; + parameters.relations = + this._bulkAddRelationEditor && this._bulkAddRelationEditor.opened + ? this._ctx.parameters.relations || " " + : null; this.dispatchEvent( - new CustomEvent('navigate', {detail: {parameters: parameters}})); + new CustomEvent("navigate", { detail: { parameters: parameters } }) + ); } _focusFirstPostNode() { - const firstPostNode = - document.body.querySelector('.post-list li:first-child a'); + const firstPostNode = document.body.querySelector( + ".post-list li:first-child a" + ); if (firstPostNode) { firstPostNode.focus(); } diff --git a/client/js/views/posts_page_view.js b/client/js/views/posts_page_view.js index d7a64d6..9885602 100644 --- a/client/js/views/posts_page_view.js +++ b/client/js/views/posts_page_view.js @@ -1,10 +1,10 @@ -'use strict'; +"use strict"; -const events = require('../events.js'); -const tags = require('../tags.js'); -const views = require('../util/views.js'); +const events = require("../events.js"); +const tags = require("../tags.js"); +const views = require("../util/views.js"); -const template = views.getTemplate('posts-page'); +const template = views.getTemplate("posts-page"); class PostsPageView extends events.EventTarget { constructor(ctx) { @@ -16,32 +16,35 @@ class PostsPageView extends events.EventTarget { this._postIdToPost = {}; for (let post of ctx.response.results) { this._postIdToPost[post.id] = post; - post.addEventListener('change', e => this._evtPostChange(e)); + post.addEventListener("change", (e) => this._evtPostChange(e)); } this._postIdToListItemNode = {}; for (let listItemNode of this._listItemNodes) { - const postId = listItemNode.getAttribute('data-post-id'); + const postId = listItemNode.getAttribute("data-post-id"); const post = this._postIdToPost[postId]; this._postIdToListItemNode[postId] = listItemNode; const tagFlipperNode = this._getTagFlipperNode(listItemNode); if (tagFlipperNode) { - tagFlipperNode.addEventListener( - 'click', e => this._evtBulkEditTagsClick(e, post)); + tagFlipperNode.addEventListener("click", (e) => + this._evtBulkEditTagsClick(e, post) + ); } const relationFlipperNode = this._getRelationFlipperNode(listItemNode); if (relationFlipperNode) { - relationFlipperNode.addEventListener( - 'click', e => this._evtBulkAddRelationClick(e, post)); + relationFlipperNode.addEventListener("click", e => + this._evtBulkAddRelationClick(e, post) + ); } const safetyFlipperNode = this._getSafetyFlipperNode(listItemNode); if (safetyFlipperNode) { - for (let linkNode of safetyFlipperNode.querySelectorAll('a')) { - linkNode.addEventListener( - 'click', e => this._evtBulkEditSafetyClick(e, post)); + for (let linkNode of safetyFlipperNode.querySelectorAll("a")) { + linkNode.addEventListener("click", (e) => + this._evtBulkEditSafetyClick(e, post) + ); } } } @@ -50,25 +53,25 @@ class PostsPageView extends events.EventTarget { } get _listItemNodes() { - return this._hostNode.querySelectorAll('li'); + return this._hostNode.querySelectorAll("li"); } _getTagFlipperNode(listItemNode) { - return listItemNode.querySelector('.tag-flipper'); + return listItemNode.querySelector(".tag-flipper"); } _getSafetyFlipperNode(listItemNode) { - return listItemNode.querySelector('.safety-flipper'); + return listItemNode.querySelector(".safety-flipper"); } _getRelationFlipperNode(listItemNode) { - return listItemNode.querySelector('.relation-flipper'); + return listItemNode.querySelector(".relation-flipper"); } _evtPostChange(e) { const listItemNode = this._postIdToListItemNode[e.detail.post.id]; - for (let node of listItemNode.querySelectorAll('[data-disabled]')) { - node.removeAttribute('data-disabled'); + for (let node of listItemNode.querySelectorAll("[data-disabled]")) { + node.removeAttribute("data-disabled"); } this._syncBulkEditorsHighlights(); } @@ -76,48 +79,58 @@ class PostsPageView extends events.EventTarget { _evtBulkEditTagsClick(e, post) { e.preventDefault(); const linkNode = e.target; - if (linkNode.getAttribute('data-disabled')) { + if (linkNode.getAttribute("data-disabled")) { return; } - linkNode.setAttribute('data-disabled', true); + linkNode.setAttribute("data-disabled", true); this.dispatchEvent( new CustomEvent( - linkNode.classList.contains('tagged') ? 'untag' : 'tag', - {detail: {post: post}})); + linkNode.classList.contains("tagged") ? "untag" : "tag", + { + detail: { post: post }, + } + ) + ); } _evtBulkAddRelationClick(e, post) { e.preventDefault(); const linkNode = e.target; - if (linkNode.getAttribute('data-disabled')) { + if (linkNode.getAttribute("data-disabled")) { return; } - linkNode.setAttribute('data-disabled', true); + linkNode.setAttribute("data-disabled", true); this.dispatchEvent( new CustomEvent( - linkNode.classList.contains('related') ? 'removeRelation' : 'addRelation', - {detail: {post: post}})); + linkNode.classList.contains("related") ? "removeRelation" : "addRelation", + { + detail: { post: post } + } + ) + ); } _evtBulkEditSafetyClick(e, post) { e.preventDefault(); const linkNode = e.target; - if (linkNode.getAttribute('data-disabled')) { + if (linkNode.getAttribute("data-disabled")) { return; } - const newSafety = linkNode.getAttribute('data-safety'); + const newSafety = linkNode.getAttribute("data-safety"); if (post.safety === newSafety) { return; } - linkNode.setAttribute('data-disabled', true); + linkNode.setAttribute("data-disabled", true); this.dispatchEvent( - new CustomEvent( - 'changeSafety', {detail: {post: post, safety: newSafety}})); + new CustomEvent("changeSafety", { + detail: { post: post, safety: newSafety }, + }) + ); } _syncBulkEditorsHighlights() { for (let listItemNode of this._listItemNodes) { - const postId = listItemNode.getAttribute('data-post-id'); + const postId = listItemNode.getAttribute("data-post-id"); const post = this._postIdToPost[postId]; const tagFlipperNode = this._getTagFlipperNode(listItemNode); @@ -125,23 +138,26 @@ class PostsPageView extends events.EventTarget { let tagged = true; for (let tag of this._ctx.bulkEdit.tags) { let tagData = tags.parseTagAndCategory(tag); - tagged = tagged & post.tags.isTaggedWith(tagData.name); + tagged &= post.tags.isTaggedWith(tagData.name); } - tagFlipperNode.classList.toggle('tagged', tagged); + tagFlipperNode.classList.toggle("tagged", tagged); } const safetyFlipperNode = this._getSafetyFlipperNode(listItemNode); if (safetyFlipperNode) { - for (let linkNode of safetyFlipperNode.querySelectorAll('a')) { - const safety = linkNode.getAttribute('data-safety'); - linkNode.classList.toggle('active', post.safety == safety); + for (let linkNode of safetyFlipperNode.querySelectorAll("a")) { + const safety = linkNode.getAttribute("data-safety"); + linkNode.classList.toggle( + "active", + post.safety === safety + ); } } const relationFlipperNode = this._getRelationFlipperNode(listItemNode); if (relationFlipperNode) { let related = this._ctx.parameters.relations.includes(post.id); - relationFlipperNode.classList.toggle('related', related); + relationFlipperNode.classList.toggle("related", related); } } } diff --git a/client/js/views/registration_view.js b/client/js/views/registration_view.js index 48034dd..0a08de2 100644 --- a/client/js/views/registration_view.js +++ b/client/js/views/registration_view.js @@ -1,22 +1,25 @@ -'use strict'; +"use strict"; -const events = require('../events.js'); -const api = require('../api.js'); -const views = require('../util/views.js'); +const events = require("../events.js"); +const api = require("../api.js"); +const views = require("../util/views.js"); -const template = views.getTemplate('user-registration'); +const template = views.getTemplate("user-registration"); class RegistrationView extends events.EventTarget { constructor() { super(); - this._hostNode = document.getElementById('content-holder'); - views.replaceContent(this._hostNode, template({ - userNamePattern: api.getUserNameRegex(), - passwordPattern: api.getPasswordRegex(), - })); + this._hostNode = document.getElementById("content-holder"); + views.replaceContent( + this._hostNode, + template({ + userNamePattern: api.getUserNameRegex(), + passwordPattern: api.getPasswordRegex(), + }) + ); views.syncScrollPosition(); views.decorateValidator(this._formNode); - this._formNode.addEventListener('submit', e => this._evtSubmit(e)); + this._formNode.addEventListener("submit", (e) => this._evtSubmit(e)); } clearMessages() { @@ -37,29 +40,31 @@ class RegistrationView extends events.EventTarget { _evtSubmit(e) { e.preventDefault(); - this.dispatchEvent(new CustomEvent('submit', { - detail: { - name: this._userNameFieldNode.value, - password: this._passwordFieldNode.value, - email: this._emailFieldNode.value, - }, - })); + this.dispatchEvent( + new CustomEvent("submit", { + detail: { + name: this._userNameFieldNode.value, + password: this._passwordFieldNode.value, + email: this._emailFieldNode.value, + }, + }) + ); } get _formNode() { - return this._hostNode.querySelector('form'); + return this._hostNode.querySelector("form"); } get _userNameFieldNode() { - return this._formNode.querySelector('[name=name]'); + return this._formNode.querySelector("[name=name]"); } get _passwordFieldNode() { - return this._formNode.querySelector('[name=password]'); + return this._formNode.querySelector("[name=password]"); } get _emailFieldNode() { - return this._formNode.querySelector('[name=email]'); + return this._formNode.querySelector("[name=email]"); } } diff --git a/client/js/views/settings_view.js b/client/js/views/settings_view.js index eb5fa6d..a19ac5f 100644 --- a/client/js/views/settings_view.js +++ b/client/js/views/settings_view.js @@ -1,21 +1,23 @@ -'use strict'; +"use strict"; -const events = require('../events.js'); -const views = require('../util/views.js'); +const events = require("../events.js"); +const views = require("../util/views.js"); -const template = views.getTemplate('settings'); +const template = views.getTemplate("settings"); class SettingsView extends events.EventTarget { constructor(ctx) { super(); - this._hostNode = document.getElementById('content-holder'); + this._hostNode = document.getElementById("content-holder"); views.replaceContent( - this._hostNode, template({browsingSettings: ctx.settings})); + this._hostNode, + template({ browsingSettings: ctx.settings }) + ); views.syncScrollPosition(); views.decorateValidator(this._formNode); - this._formNode.addEventListener('submit', e => this._evtSubmit(e)); + this._formNode.addEventListener("submit", (e) => this._evtSubmit(e)); } clearMessages() { @@ -28,34 +30,42 @@ class SettingsView extends events.EventTarget { _evtSubmit(e) { e.preventDefault(); - this.dispatchEvent(new CustomEvent('submit', { - detail: { - upscaleSmallPosts: this._find('upscale-small-posts').checked, - endlessScroll: this._find('endless-scroll').checked, - keyboardShortcuts: this._find('keyboard-shortcuts').checked, - transparencyGrid: this._find('transparency-grid').checked, - tagSuggestions: this._find('tag-suggestions').checked, - autoplayVideos: this._find('autoplay-videos').checked, - postsPerPage: this._find('posts-per-page').value, - uploadSafety: this._safetyButtonNodes.length ? - Array.from(this._safetyButtonNodes) - .filter(node => node.checked)[0] - .value.toLowerCase() : - undefined, - }, - })); + this.dispatchEvent( + new CustomEvent("submit", { + detail: { + upscaleSmallPosts: this._find("upscale-small-posts") + .checked, + endlessScroll: this._find("endless-scroll").checked, + keyboardShortcuts: this._find("keyboard-shortcuts") + .checked, + transparencyGrid: this._find("transparency-grid").checked, + tagSuggestions: this._find("tag-suggestions").checked, + autoplayVideos: this._find("autoplay-videos").checked, + postsPerPage: this._find("posts-per-page").value, + tagUnderscoresAsSpaces: this._find("underscores-as-spaces") + .checked, + darkTheme: this._find("dark-theme").checked, + postFlow: this._find("post-flow").checked, + uploadSafety: this._safetyButtonNodes.length ? + Array.from(this._safetyButtonNodes) + .filter(node => node.checked)[0] + .value.toLowerCase() : + undefined, + }, + }) + ); } get _formNode() { - return this._hostNode.querySelector('form'); + return this._hostNode.querySelector("form"); } get _safetyButtonNodes() { - return this._formNode.querySelectorAll('.uploadSafety input'); + return this._formNode.querySelectorAll(".uploadSafety input"); } _find(nodeName) { - return this._formNode.querySelector('[name=' + nodeName + ']'); + return this._formNode.querySelector("[name=" + nodeName + "]"); } } diff --git a/client/js/views/snapshots_page_view.js b/client/js/views/snapshots_page_view.js index 77fbe13..a665ea0 100644 --- a/client/js/views/snapshots_page_view.js +++ b/client/js/views/snapshots_page_view.js @@ -1,8 +1,8 @@ -'use strict'; +"use strict"; -const views = require('../util/views.js'); +const views = require("../util/views.js"); -const template = views.getTemplate('snapshots-page'); +const template = views.getTemplate("snapshots-page"); function _extend(target, source) { target.push.apply(target, source); @@ -10,18 +10,18 @@ function _extend(target, source) { function _formatBasicChange(diff, text) { const lines = []; - if (diff.type === 'list change') { + if (diff.type === "list change") { const addedItems = diff.added; const removedItems = diff.removed; if (addedItems && addedItems.length) { - lines.push(`Added ${text} (${addedItems.join(', ')})`); + lines.push(`Added ${text} (${addedItems.join(", ")})`); } if (removedItems && removedItems.length) { - lines.push(`Removed ${text} (${removedItems.join(', ')})`); + lines.push(`Removed ${text} (${removedItems.join(", ")})`); } - } else if (diff.type === 'primitive change') { - const oldValue = diff['old-value']; - const newValue = diff['new-value']; + } else if (diff.type === "primitive change") { + const oldValue = diff["old-value"]; + const newValue = diff["new-value"]; lines.push(`Changed ${text} (${oldValue} → ${newValue})`); } else { lines.push(`Changed ${text}`); @@ -30,12 +30,14 @@ function _formatBasicChange(diff, text) { } function _makeResourceLink(type, id) { - if (type === 'post') { + if (type === "post") { return views.makePostLink(id, true); - } else if (type === 'tag') { + } else if (type === "tag") { return views.makeTagLink(id, true); - } else if (type === 'tag_category') { + } else if (type === "tag_category") { return 'category "' + id + '"'; + } else if (type === "pool") { + return views.makePoolLink(id, true); } } @@ -48,83 +50,102 @@ function _makeItemCreation(type, data) { let text = key[0].toUpperCase() + key.substr(1).toLowerCase(); if (Array.isArray(data[key])) { if (data[key].length) { - lines.push(`${text}: ${data[key].join(', ')}`); + lines.push(`${text}: ${data[key].join(", ")}`); } } else { lines.push(`${text}: ${data[key]}`); } } - return lines.join('<br/>'); + return lines.join("<br/>"); } function _makeItemModification(type, data) { const lines = []; const diff = data.value; - if (type === 'tag_category') { + if (type === "tag_category") { if (diff.name) { - _extend(lines, _formatBasicChange(diff.name, 'name')); + _extend(lines, _formatBasicChange(diff.name, "name")); } if (diff.color) { - _extend(lines, _formatBasicChange(diff.color, 'color')); + _extend(lines, _formatBasicChange(diff.color, "color")); } if (diff.default) { - _extend(lines, ['Made into default category']); + _extend(lines, ["Made into default category"]); } - - } else if (type === 'tag') { + } else if (type === "tag") { if (diff.names) { - _extend(lines, _formatBasicChange(diff.names, 'names')); + _extend(lines, _formatBasicChange(diff.names, "names")); } if (diff.category) { - _extend( - lines, _formatBasicChange(diff.category, 'category')); + _extend(lines, _formatBasicChange(diff.category, "category")); } if (diff.suggestions) { _extend( - lines, _formatBasicChange(diff.suggestions, 'suggestions')); + lines, + _formatBasicChange(diff.suggestions, "suggestions") + ); } if (diff.implications) { _extend( - lines, _formatBasicChange(diff.implications, 'implications')); + lines, + _formatBasicChange(diff.implications, "implications") + ); } - - } else if (type === 'post') { + } else if (type === "post") { if (diff.checksum) { - _extend(lines, ['Changed content']); + _extend(lines, ["Changed content"]); } if (diff.featured) { - _extend(lines, ['Featured on front page']); + _extend(lines, ["Featured on front page"]); } if (diff.source) { - _extend(lines, _formatBasicChange(diff.source, 'source')); + _extend(lines, _formatBasicChange(diff.source, "source")); } if (diff.safety) { - _extend(lines, _formatBasicChange(diff.safety, 'safety')); + _extend(lines, _formatBasicChange(diff.safety, "safety")); } if (diff.tags) { - _extend(lines, _formatBasicChange(diff.tags, 'tags')); + _extend(lines, _formatBasicChange(diff.tags, "tags")); } if (diff.relations) { - _extend(lines, _formatBasicChange(diff.relations, 'relations')); + _extend(lines, _formatBasicChange(diff.relations, "relations")); } if (diff.notes) { - _extend(lines, ['Changed notes']); + _extend(lines, ["Changed notes"]); } if (diff.flags) { - _extend(lines, ['Changed flags']); + _extend(lines, ["Changed flags"]); + } + } else if (type === "pool") { + if (diff.names) { + _extend(lines, _formatBasicChange(diff.names, "names")); + } + if (diff.category) { + _extend(lines, _formatBasicChange(diff.category, "category")); + } + if (diff.posts) { + _extend(lines, _formatBasicChange(diff.posts, "posts")); } } - return lines.join('<br/>'); + return lines.join("<br/>"); } class SnapshotsPageView { constructor(ctx) { - views.replaceContent(ctx.hostNode, template(Object.assign({ - makeResourceLink: _makeResourceLink, - makeItemCreation: _makeItemCreation, - makeItemModification: _makeItemModification, - }, ctx))); + views.replaceContent( + ctx.hostNode, + template( + Object.assign( + { + makeResourceLink: _makeResourceLink, + makeItemCreation: _makeItemCreation, + makeItemModification: _makeItemModification, + }, + ctx + ) + ) + ); } } diff --git a/client/js/views/tag_categories_view.js b/client/js/views/tag_categories_view.js index 7e1000d..e052fd4 100644 --- a/client/js/views/tag_categories_view.js +++ b/client/js/views/tag_categories_view.js @@ -1,17 +1,17 @@ -'use strict'; +"use strict"; -const events = require('../events.js'); -const views = require('../util/views.js'); -const TagCategory = require('../models/tag_category.js'); +const events = require("../events.js"); +const views = require("../util/views.js"); +const TagCategory = require("../models/tag_category.js"); -const template = views.getTemplate('tag-categories'); -const rowTemplate = views.getTemplate('tag-category-row'); +const template = views.getTemplate("tag-categories"); +const rowTemplate = views.getTemplate("tag-category-row"); class TagCategoriesView extends events.EventTarget { constructor(ctx) { super(); this._ctx = ctx; - this._hostNode = document.getElementById('content-holder'); + this._hostNode = document.getElementById("content-holder"); views.replaceContent(this._hostNode, template(ctx)); views.syncScrollPosition(); @@ -24,25 +24,31 @@ class TagCategoriesView extends events.EventTarget { } else if (a.isDefault) { return -1; } - return a.name.localeCompare(b.name); + return a.order == b.order + ? a.name.localeCompare(b.name) + : a.order - b.order; }); for (let tagCategory of categoriesToAdd) { this._addTagCategoryRowNode(tagCategory); } if (this._addLinkNode) { - this._addLinkNode.addEventListener( - 'click', e => this._evtAddButtonClick(e)); + this._addLinkNode.addEventListener("click", (e) => + this._evtAddButtonClick(e) + ); } - ctx.tagCategories.addEventListener( - 'add', e => this._evtTagCategoryAdded(e)); + ctx.tagCategories.addEventListener("add", (e) => + this._evtTagCategoryAdded(e) + ); - ctx.tagCategories.addEventListener( - 'remove', e => this._evtTagCategoryDeleted(e)); + ctx.tagCategories.addEventListener("remove", (e) => + this._evtTagCategoryDeleted(e) + ); - this._formNode.addEventListener( - 'submit', e => this._evtSaveButtonClick(e, ctx)); + this._formNode.addEventListener("submit", (e) => + this._evtSaveButtonClick(e, ctx) + ); } enableForm() { @@ -66,44 +72,55 @@ class TagCategoriesView extends events.EventTarget { } get _formNode() { - return this._hostNode.querySelector('form'); + return this._hostNode.querySelector("form"); } get _tableBodyNode() { - return this._hostNode.querySelector('tbody'); + return this._hostNode.querySelector("tbody"); } get _addLinkNode() { - return this._hostNode.querySelector('a.add'); + return this._hostNode.querySelector("a.add"); } _addTagCategoryRowNode(tagCategory) { const rowNode = rowTemplate( - Object.assign( - {}, this._ctx, {tagCategory: tagCategory})); + Object.assign({}, this._ctx, { tagCategory: tagCategory }) + ); - const nameInput = rowNode.querySelector('.name input'); + const nameInput = rowNode.querySelector(".name input"); if (nameInput) { - nameInput.addEventListener( - 'change', e => this._evtNameChange(e, rowNode)); + nameInput.addEventListener("change", (e) => + this._evtNameChange(e, rowNode) + ); } - const colorInput = rowNode.querySelector('.color input'); + const colorInput = rowNode.querySelector(".color input"); if (colorInput) { - colorInput.addEventListener( - 'change', e => this._evtColorChange(e, rowNode)); + colorInput.addEventListener("change", (e) => + this._evtColorChange(e, rowNode) + ); } - const removeLinkNode = rowNode.querySelector('.remove a'); + const orderInput = rowNode.querySelector(".order input"); + if (orderInput) { + orderInput.addEventListener("change", (e) => + this._evtOrderChange(e, rowNode) + ); + } + + const removeLinkNode = rowNode.querySelector(".remove a"); if (removeLinkNode) { - removeLinkNode.addEventListener( - 'click', e => this._evtDeleteButtonClick(e, rowNode)); + removeLinkNode.addEventListener("click", (e) => + this._evtDeleteButtonClick(e, rowNode) + ); } - const defaultLinkNode = rowNode.querySelector('.set-default a'); + const defaultLinkNode = rowNode.querySelector(".set-default a"); if (defaultLinkNode) { - defaultLinkNode.addEventListener( - 'click', e => this._evtSetDefaultButtonClick(e, rowNode)); + defaultLinkNode.addEventListener("click", (e) => + this._evtSetDefaultButtonClick(e, rowNode) + ); } this._tableBodyNode.appendChild(rowNode); @@ -139,9 +156,13 @@ class TagCategoriesView extends events.EventTarget { rowNode._tagCategory.color = e.target.value; } + _evtOrderChange(e, rowNode) { + rowNode._tagCategory.order = e.target.value; + } + _evtDeleteButtonClick(e, rowNode, link) { e.preventDefault(); - if (e.target.classList.contains('inactive')) { + if (e.target.classList.contains("inactive")) { return; } this._ctx.tagCategories.remove(rowNode._tagCategory); @@ -150,16 +171,16 @@ class TagCategoriesView extends events.EventTarget { _evtSetDefaultButtonClick(e, rowNode) { e.preventDefault(); this._ctx.tagCategories.defaultCategory = rowNode._tagCategory; - const oldRowNode = rowNode.parentNode.querySelector('tr.default'); + const oldRowNode = rowNode.parentNode.querySelector("tr.default"); if (oldRowNode) { - oldRowNode.classList.remove('default'); + oldRowNode.classList.remove("default"); } - rowNode.classList.add('default'); + rowNode.classList.add("default"); } _evtSaveButtonClick(e, ctx) { e.preventDefault(); - this.dispatchEvent(new CustomEvent('submit')); + this.dispatchEvent(new CustomEvent("submit")); } } diff --git a/client/js/views/tag_delete_view.js b/client/js/views/tag_delete_view.js index 4d27f15..4246ec3 100644 --- a/client/js/views/tag_delete_view.js +++ b/client/js/views/tag_delete_view.js @@ -1,9 +1,9 @@ -'use strict'; +"use strict"; -const events = require('../events.js'); -const views = require('../util/views.js'); +const events = require("../events.js"); +const views = require("../util/views.js"); -const template = views.getTemplate('tag-delete'); +const template = views.getTemplate("tag-delete"); class TagDeleteView extends events.EventTarget { constructor(ctx) { @@ -13,7 +13,7 @@ class TagDeleteView extends events.EventTarget { this._tag = ctx.tag; views.replaceContent(this._hostNode, template(ctx)); views.decorateValidator(this._formNode); - this._formNode.addEventListener('submit', e => this._evtSubmit(e)); + this._formNode.addEventListener("submit", (e) => this._evtSubmit(e)); } clearMessages() { @@ -38,15 +38,17 @@ class TagDeleteView extends events.EventTarget { _evtSubmit(e) { e.preventDefault(); - this.dispatchEvent(new CustomEvent('submit', { - detail: { - tag: this._tag, - }, - })); + this.dispatchEvent( + new CustomEvent("submit", { + detail: { + tag: this._tag, + }, + }) + ); } get _formNode() { - return this._hostNode.querySelector('form'); + return this._hostNode.querySelector("form"); } } diff --git a/client/js/views/tag_edit_view.js b/client/js/views/tag_edit_view.js index 5b517d4..58c1bc4 100644 --- a/client/js/views/tag_edit_view.js +++ b/client/js/views/tag_edit_view.js @@ -1,12 +1,12 @@ -'use strict'; +"use strict"; -const events = require('../events.js'); -const api = require('../api.js'); -const misc = require('../util/misc.js'); -const views = require('../util/views.js'); -const TagInputControl = require('../controls/tag_input_control.js'); +const events = require("../events.js"); +const api = require("../api.js"); +const misc = require("../util/misc.js"); +const views = require("../util/views.js"); +const TagInputControl = require("../controls/tag_input_control.js"); -const template = views.getTemplate('tag-edit'); +const template = views.getTemplate("tag-edit"); class TagEditView extends events.EventTarget { constructor(ctx) { @@ -19,28 +19,33 @@ class TagEditView extends events.EventTarget { views.decorateValidator(this._formNode); if (this._namesFieldNode) { - this._namesFieldNode.addEventListener( - 'input', e => this._evtNameInput(e)); + this._namesFieldNode.addEventListener("input", (e) => + this._evtNameInput(e) + ); } if (this._implicationsFieldNode) { new TagInputControl( - this._implicationsFieldNode, this._tag.implications); + this._implicationsFieldNode, + this._tag.implications + ); } if (this._suggestionsFieldNode) { new TagInputControl( - this._suggestionsFieldNode, this._tag.suggestions); + this._suggestionsFieldNode, + this._tag.suggestions + ); } for (let node of this._formNode.querySelectorAll( - 'input, select, textarea')) { - node.addEventListener( - 'change', e => { - this.dispatchEvent(new CustomEvent('change')); - }); + "input, select, textarea" + )) { + node.addEventListener("change", (e) => { + this.dispatchEvent(new CustomEvent("change")); + }); } - this._formNode.addEventListener('submit', e => this._evtSubmit(e)); + this._formNode.addEventListener("submit", (e) => this._evtSubmit(e)); } clearMessages() { @@ -69,72 +74,80 @@ class TagEditView extends events.EventTarget { if (!list.length) { this._namesFieldNode.setCustomValidity( - 'Tags must have at least one name.'); + "Tags must have at least one name." + ); return; } for (let item of list) { if (!regex.test(item)) { this._namesFieldNode.setCustomValidity( - `Tag name "${item}" contains invalid symbols.`); + `Tag name "${item}" contains invalid symbols.` + ); return; } } - this._namesFieldNode.setCustomValidity(''); + this._namesFieldNode.setCustomValidity(""); } _evtSubmit(e) { e.preventDefault(); - this.dispatchEvent(new CustomEvent('submit', { - detail: { - tag: this._tag, + this.dispatchEvent( + new CustomEvent("submit", { + detail: { + tag: this._tag, - names: this._namesFieldNode ? - misc.splitByWhitespace(this._namesFieldNode.value) : - undefined, + names: this._namesFieldNode + ? misc.splitByWhitespace(this._namesFieldNode.value) + : undefined, - category: this._categoryFieldNode ? - this._categoryFieldNode.value : - undefined, + category: this._categoryFieldNode + ? this._categoryFieldNode.value + : undefined, - implications: this._implicationsFieldNode ? - misc.splitByWhitespace(this._implicationsFieldNode.value) : - undefined, + implications: this._implicationsFieldNode + ? misc.splitByWhitespace( + this._implicationsFieldNode.value + ) + : undefined, - suggestions: this._suggestionsFieldNode ? - misc.splitByWhitespace(this._suggestionsFieldNode.value) : - undefined, + suggestions: this._suggestionsFieldNode + ? misc.splitByWhitespace( + this._suggestionsFieldNode.value + ) + : undefined, - description: this._descriptionFieldNode ? - this._descriptionFieldNode.value : - undefined, - }, - })); + description: this._descriptionFieldNode + ? this._descriptionFieldNode.value + : undefined, + }, + }) + ); } get _formNode() { - return this._hostNode.querySelector('form'); + return this._hostNode.querySelector("form"); } get _namesFieldNode() { - return this._formNode.querySelector('.names input'); + return this._formNode.querySelector(".names input"); } get _categoryFieldNode() { - return this._formNode.querySelector('.category select'); + return this._formNode.querySelector(".category select"); } get _implicationsFieldNode() { - return this._formNode.querySelector('.implications input'); + return this._formNode.querySelector(".implications input"); } get _suggestionsFieldNode() { - return this._formNode.querySelector('.suggestions input'); + return this._formNode.querySelector(".suggestions input"); } get _descriptionFieldNode() { - return this._formNode.querySelector('.description textarea'); + return this._formNode.querySelector(".description textarea"); } } diff --git a/client/js/views/tag_merge_view.js b/client/js/views/tag_merge_view.js index c975a50..f823ca4 100644 --- a/client/js/views/tag_merge_view.js +++ b/client/js/views/tag_merge_view.js @@ -1,12 +1,11 @@ -'use strict'; +"use strict"; -const events = require('../events.js'); -const api = require('../api.js'); -const views = require('../util/views.js'); -const TagAutoCompleteControl = - require('../controls/tag_auto_complete_control.js'); +const events = require("../events.js"); +const api = require("../api.js"); +const views = require("../util/views.js"); +const TagAutoCompleteControl = require("../controls/tag_auto_complete_control.js"); -const template = views.getTemplate('tag-merge'); +const template = views.getTemplate("tag-merge"); class TagMergeView extends events.EventTarget { constructor(ctx) { @@ -22,13 +21,16 @@ class TagMergeView extends events.EventTarget { this._autoCompleteControl = new TagAutoCompleteControl( this._targetTagFieldNode, { - confirm: tag => + confirm: (tag) => this._autoCompleteControl.replaceSelectedText( - tag.names[0], false), - }); + tag.names[0], + false + ), + } + ); } - this._formNode.addEventListener('submit', e => this._evtSubmit(e)); + this._formNode.addEventListener("submit", (e) => this._evtSubmit(e)); } clearMessages() { @@ -53,25 +55,27 @@ class TagMergeView extends events.EventTarget { _evtSubmit(e) { e.preventDefault(); - this.dispatchEvent(new CustomEvent('submit', { - detail: { - tag: this._tag, - targetTagName: this._targetTagFieldNode.value, - addAlias: this._addAliasCheckboxNode.checked, - }, - })); + this.dispatchEvent( + new CustomEvent("submit", { + detail: { + tag: this._tag, + targetTagName: this._targetTagFieldNode.value, + addAlias: this._addAliasCheckboxNode.checked, + }, + }) + ); } get _formNode() { - return this._hostNode.querySelector('form'); + return this._hostNode.querySelector("form"); } get _targetTagFieldNode() { - return this._formNode.querySelector('input[name=target-tag]'); + return this._formNode.querySelector("input[name=target-tag]"); } get _addAliasCheckboxNode() { - return this._formNode.querySelector('input[name=alias]'); + return this._formNode.querySelector("input[name=alias]"); } } diff --git a/client/js/views/tag_summary_view.js b/client/js/views/tag_summary_view.js index 019e72b..11c6191 100644 --- a/client/js/views/tag_summary_view.js +++ b/client/js/views/tag_summary_view.js @@ -1,8 +1,8 @@ -'use strict'; +"use strict"; -const views = require('../util/views.js'); +const views = require("../util/views.js"); -const template = views.getTemplate('tag-summary'); +const template = views.getTemplate("tag-summary"); class TagSummaryView { constructor(ctx) { diff --git a/client/js/views/tag_view.js b/client/js/views/tag_view.js index 3037a29..163d701 100644 --- a/client/js/views/tag_view.js +++ b/client/js/views/tag_view.js @@ -1,25 +1,27 @@ -'use strict'; +"use strict"; -const events = require('../events.js'); -const views = require('../util/views.js'); -const TagSummaryView = require('./tag_summary_view.js'); -const TagEditView = require('./tag_edit_view.js'); -const TagMetricView = require('./tag_metric_view.js'); -const TagMergeView = require('./tag_merge_view.js'); -const TagDeleteView = require('./tag_delete_view.js'); -const EmptyView = require('../views/empty_view.js'); +const events = require("../events.js"); +const views = require("../util/views.js"); +const misc = require("../util/misc.js"); +const TagSummaryView = require("./tag_summary_view.js"); +const TagEditView = require("./tag_edit_view.js"); +const TagMergeView = require("./tag_merge_view.js"); +const TagMetricView = require("./tag_metric_view.js"); +const TagDeleteView = require("./tag_delete_view.js"); +const EmptyView = require("../views/empty_view.js"); -const template = views.getTemplate('tag'); +const template = views.getTemplate("tag"); class TagView extends events.EventTarget { constructor(ctx) { super(); this._ctx = ctx; - ctx.tag.addEventListener('change', e => this._evtChange(e)); - ctx.section = ctx.section || 'summary'; + ctx.tag.addEventListener("change", (e) => this._evtChange(e)); + ctx.section = ctx.section || "summary"; + ctx.getPrettyName = misc.getPrettyName; - this._hostNode = document.getElementById('content-holder'); + this._hostNode = document.getElementById("content-holder"); this._install(); } @@ -27,64 +29,67 @@ class TagView extends events.EventTarget { const ctx = this._ctx; views.replaceContent(this._hostNode, template(ctx)); - for (let item of this._hostNode.querySelectorAll('[data-name]')) { + for (let item of this._hostNode.querySelectorAll("[data-name]")) { item.classList.toggle( - 'active', item.getAttribute('data-name') === ctx.section); - if (item.getAttribute('data-name') === ctx.section) { + "active", + item.getAttribute("data-name") === ctx.section + ); + if (item.getAttribute("data-name") === ctx.section) { item.parentNode.scrollLeft = item.getBoundingClientRect().left - - item.parentNode.getBoundingClientRect().left + item.parentNode.getBoundingClientRect().left; } } - ctx.hostNode = this._hostNode.querySelector('.tag-content-holder'); - if (ctx.section === 'edit') { + ctx.hostNode = this._hostNode.querySelector(".tag-content-holder"); + if (ctx.section === "edit") { if (!this._ctx.canEditAnything) { this._view = new EmptyView(); this._view.showError( - 'You don\'t have privileges to edit tags.'); + "You don't have privileges to edit tags." + ); } else { this._view = new TagEditView(ctx); - events.proxyEvent(this._view, this, 'submit'); + events.proxyEvent(this._view, this, "submit"); } - } else if (ctx.section === 'metric') { + } else if (ctx.section === "metric") { const metricExists = this._ctx.tag.metric; if (!metricExists && !this._ctx.canCreateMetric) { this._view = new EmptyView(); this._view.showError( - 'You don\'t have privileges to create metrics.'); + "You don\"t have privileges to create metrics."); } else { this._view = new TagMetricView(ctx); - events.proxyEvent(this._view, this, 'submit', 'metricUpdate'); - events.proxyEvent(this._view, this, 'delete', 'metricDelete'); + events.proxyEvent(this._view, this, "submit", "metricUpdate"); + events.proxyEvent(this._view, this, "delete", "metricDelete"); } - } else if (ctx.section === 'merge') { + } else if (ctx.section === "merge") { if (!this._ctx.canMerge) { this._view = new EmptyView(); this._view.showError( - 'You don\'t have privileges to merge tags.'); + "You don't have privileges to merge tags." + ); } else { this._view = new TagMergeView(ctx); - events.proxyEvent(this._view, this, 'submit', 'merge'); + events.proxyEvent(this._view, this, "submit", "merge"); } - - } else if (ctx.section === 'delete') { + } else if (ctx.section === "delete") { if (!this._ctx.canDelete) { this._view = new EmptyView(); this._view.showError( - 'You don\'t have privileges to delete tags.'); + "You don't have privileges to delete tags." + ); } else { this._view = new TagDeleteView(ctx); - events.proxyEvent(this._view, this, 'submit', 'delete'); + events.proxyEvent(this._view, this, "submit", "delete"); } - } else { this._view = new TagSummaryView(ctx); } - events.proxyEvent(this._view, this, 'change'); + events.proxyEvent(this._view, this, "change"); views.syncScrollPosition(); } diff --git a/client/js/views/tags_header_view.js b/client/js/views/tags_header_view.js index 05b32f3..6dd7626 100644 --- a/client/js/views/tags_header_view.js +++ b/client/js/views/tags_header_view.js @@ -1,13 +1,12 @@ -'use strict'; +"use strict"; -const events = require('../events.js'); -const misc = require('../util/misc.js'); -const search = require('../util/search.js'); -const views = require('../util/views.js'); -const TagAutoCompleteControl = - require('../controls/tag_auto_complete_control.js'); +const events = require("../events.js"); +const misc = require("../util/misc.js"); +const search = require("../util/search.js"); +const views = require("../util/views.js"); +const TagAutoCompleteControl = require("../controls/tag_auto_complete_control.js"); -const template = views.getTemplate('tags-header'); +const template = views.getTemplate("tags-header"); class TagsHeaderView extends events.EventTarget { constructor(ctx) { @@ -20,32 +19,41 @@ class TagsHeaderView extends events.EventTarget { this._autoCompleteControl = new TagAutoCompleteControl( this._queryInputNode, { - confirm: tag => + confirm: (tag) => this._autoCompleteControl.replaceSelectedText( - misc.escapeSearchTerm(tag.names[0]), true), - }); + misc.escapeSearchTerm(tag.names[0]), + true + ), + } + ); } search.searchInputNodeFocusHelper(this._queryInputNode); - this._formNode.addEventListener('submit', e => this._evtSubmit(e)); + this._formNode.addEventListener("submit", (e) => this._evtSubmit(e)); } get _formNode() { - return this._hostNode.querySelector('form'); + return this._hostNode.querySelector("form"); } get _queryInputNode() { - return this._hostNode.querySelector('[name=search-text]'); + return this._hostNode.querySelector("[name=search-text]"); } _evtSubmit(e) { e.preventDefault(); this._queryInputNode.blur(); - this.dispatchEvent(new CustomEvent('navigate', {detail: {parameters: { - query: this._queryInputNode.value, - page: 1, - }}})); + this.dispatchEvent( + new CustomEvent("navigate", { + detail: { + parameters: { + query: this._queryInputNode.value, + page: 1, + }, + }, + }) + ); } } diff --git a/client/js/views/tags_page_view.js b/client/js/views/tags_page_view.js index cd335a4..e2b2820 100644 --- a/client/js/views/tags_page_view.js +++ b/client/js/views/tags_page_view.js @@ -1,8 +1,8 @@ -'use strict'; +"use strict"; -const views = require('../util/views.js'); +const views = require("../util/views.js"); -const template = views.getTemplate('tags-page'); +const template = views.getTemplate("tags-page"); class TagsPageView { constructor(ctx) { diff --git a/client/js/views/top_navigation_view.js b/client/js/views/top_navigation_view.js index 2f99187..efe75a9 100644 --- a/client/js/views/top_navigation_view.js +++ b/client/js/views/top_navigation_view.js @@ -1,24 +1,24 @@ -'use strict'; +"use strict"; -const views = require('../util/views.js'); +const views = require("../util/views.js"); -const template = views.getTemplate('top-navigation'); +const template = views.getTemplate("top-navigation"); class TopNavigationView { constructor() { - this._hostNode = document.getElementById('top-navigation-holder'); + this._hostNode = document.getElementById("top-navigation-holder"); } get _mobileNavigationToggleNode() { - return this._hostNode.querySelector('#mobile-navigation-toggle'); + return this._hostNode.querySelector("#mobile-navigation-toggle"); } get _navigationListNode() { - return this._hostNode.querySelector('nav > ul'); + return this._hostNode.querySelector("nav > ul"); } get _navigationLinkNodes() { - return this._navigationListNode.querySelectorAll('li > a'); + return this._navigationListNode.querySelectorAll("li > a"); } render(ctx) { @@ -28,28 +28,32 @@ class TopNavigationView { } activate(key) { - for (let itemNode of this._hostNode.querySelectorAll('[data-name]')) { + for (let itemNode of this._hostNode.querySelectorAll("[data-name]")) { itemNode.classList.toggle( - 'active', itemNode.getAttribute('data-name') === key); + "active", + itemNode.getAttribute("data-name") === key + ); } } _bindMobileNavigationEvents() { - this._mobileNavigationToggleNode.addEventListener( - 'click', e => this._mobileNavigationToggleClick(e)); + this._mobileNavigationToggleNode.addEventListener("click", (e) => + this._mobileNavigationToggleClick(e) + ); for (let navigationLinkNode of this._navigationLinkNodes) { - navigationLinkNode.addEventListener( - 'click', e => this._navigationLinkClick(e)); + navigationLinkNode.addEventListener("click", (e) => + this._navigationLinkClick(e) + ); } } _mobileNavigationToggleClick(e) { - this._navigationListNode.classList.toggle('opened'); + this._navigationListNode.classList.toggle("opened"); } _navigationLinkClick(e) { - this._navigationListNode.classList.remove('opened'); + this._navigationListNode.classList.remove("opened"); } } diff --git a/client/js/views/user_delete_view.js b/client/js/views/user_delete_view.js index bdaf9e6..37de52f 100644 --- a/client/js/views/user_delete_view.js +++ b/client/js/views/user_delete_view.js @@ -1,9 +1,9 @@ -'use strict'; +"use strict"; -const events = require('../events.js'); -const views = require('../util/views.js'); +const events = require("../events.js"); +const views = require("../util/views.js"); -const template = views.getTemplate('user-delete'); +const template = views.getTemplate("user-delete"); class UserDeleteView extends events.EventTarget { constructor(ctx) { @@ -14,7 +14,7 @@ class UserDeleteView extends events.EventTarget { views.replaceContent(this._hostNode, template(ctx)); views.decorateValidator(this._formNode); - this._formNode.addEventListener('submit', e => this._evtSubmit(e)); + this._formNode.addEventListener("submit", (e) => this._evtSubmit(e)); } clearMessages() { @@ -39,16 +39,17 @@ class UserDeleteView extends events.EventTarget { _evtSubmit(e) { e.preventDefault(); - this.dispatchEvent(new CustomEvent('submit', { - detail: { - user: this._user, - }, - })); + this.dispatchEvent( + new CustomEvent("submit", { + detail: { + user: this._user, + }, + }) + ); } get _formNode() { - return this._hostNode.querySelector('form'); - + return this._hostNode.querySelector("form"); } } diff --git a/client/js/views/user_edit_view.js b/client/js/views/user_edit_view.js index 8a6f1a4..4886726 100644 --- a/client/js/views/user_edit_view.js +++ b/client/js/views/user_edit_view.js @@ -1,11 +1,11 @@ -'use strict'; +"use strict"; -const events = require('../events.js'); -const api = require('../api.js'); -const views = require('../util/views.js'); -const FileDropperControl = require('../controls/file_dropper_control.js'); +const events = require("../events.js"); +const api = require("../api.js"); +const views = require("../util/views.js"); +const FileDropperControl = require("../controls/file_dropper_control.js"); -const template = views.getTemplate('user-edit'); +const template = views.getTemplate("user-edit"); class UserEditView extends events.EventTarget { constructor(ctx) { @@ -22,24 +22,26 @@ class UserEditView extends events.EventTarget { this._avatarContent = null; if (this._avatarContentInputNode) { this._avatarFileDropper = new FileDropperControl( - this._avatarContentInputNode, {lock: true}); - this._avatarFileDropper.addEventListener('fileadd', e => { + this._avatarContentInputNode, + { lock: true } + ); + this._avatarFileDropper.addEventListener("fileadd", (e) => { this._hostNode.querySelector( - '[name=avatar-style][value=manual]').checked = true; + "[name=avatar-style][value=manual]" + ).checked = true; this._avatarContent = e.detail.files[0]; }); } - for (let node of this._formNode.querySelectorAll('input, select')) { - node.addEventListener( - 'change', e => { - if (!e.target.classList.contains('anticomplete')) { - this.dispatchEvent(new CustomEvent('change')); - } - }); + for (let node of this._formNode.querySelectorAll("input, select")) { + node.addEventListener("change", (e) => { + if (!e.target.classList.contains("anticomplete")) { + this.dispatchEvent(new CustomEvent("change")); + } + }); } - this._formNode.addEventListener('submit', e => this._evtSubmit(e)); + this._formNode.addEventListener("submit", (e) => this._evtSubmit(e)); } clearMessages() { @@ -64,61 +66,63 @@ class UserEditView extends events.EventTarget { _evtSubmit(e) { e.preventDefault(); - this.dispatchEvent(new CustomEvent('submit', { - detail: { - user: this._user, + this.dispatchEvent( + new CustomEvent("submit", { + detail: { + user: this._user, - name: this._userNameInputNode ? - this._userNameInputNode.value : - undefined, + name: this._userNameInputNode + ? this._userNameInputNode.value + : undefined, - email: this._emailInputNode ? - this._emailInputNode.value : - undefined, + email: this._emailInputNode + ? this._emailInputNode.value + : undefined, - rank: this._rankInputNode ? - this._rankInputNode.value : - undefined, + rank: this._rankInputNode + ? this._rankInputNode.value + : undefined, - avatarStyle: this._avatarStyleInputNode ? - this._avatarStyleInputNode.value : - undefined, + avatarStyle: this._avatarStyleInputNode + ? this._avatarStyleInputNode.value + : undefined, - password: this._passwordInputNode ? - this._passwordInputNode.value : - undefined, + password: this._passwordInputNode + ? this._passwordInputNode.value + : undefined, - avatarContent: this._avatarContent, - }, - })); + avatarContent: this._avatarContent, + }, + }) + ); } get _formNode() { - return this._hostNode.querySelector('form'); + return this._hostNode.querySelector("form"); } get _rankInputNode() { - return this._formNode.querySelector('[name=rank]'); + return this._formNode.querySelector("[name=rank]"); } get _emailInputNode() { - return this._formNode.querySelector('[name=email]'); + return this._formNode.querySelector("[name=email]"); } get _userNameInputNode() { - return this._formNode.querySelector('[name=name]'); + return this._formNode.querySelector("[name=name]"); } get _passwordInputNode() { - return this._formNode.querySelector('[name=password]'); + return this._formNode.querySelector("[name=password]"); } get _avatarContentInputNode() { - return this._formNode.querySelector('#avatar-content'); + return this._formNode.querySelector("#avatar-content"); } get _avatarStyleInputNode() { - return this._formNode.querySelector('[name=avatar-style]:checked'); + return this._formNode.querySelector("[name=avatar-style]:checked"); } } diff --git a/client/js/views/user_summary_view.js b/client/js/views/user_summary_view.js index d8463f3..eab827f 100644 --- a/client/js/views/user_summary_view.js +++ b/client/js/views/user_summary_view.js @@ -1,8 +1,8 @@ -'use strict'; +"use strict"; -const views = require('../util/views.js'); +const views = require("../util/views.js"); -const template = views.getTemplate('user-summary'); +const template = views.getTemplate("user-summary"); class UserSummaryView { constructor(ctx) { diff --git a/client/js/views/user_tokens_view.js b/client/js/views/user_tokens_view.js index f6c8480..68707f0 100644 --- a/client/js/views/user_tokens_view.js +++ b/client/js/views/user_tokens_view.js @@ -1,9 +1,9 @@ -'use strict'; +"use strict"; -const events = require('../events.js'); -const views = require('../util/views.js'); +const events = require("../events.js"); +const views = require("../util/views.js"); -const template = views.getTemplate('user-tokens'); +const template = views.getTemplate("user-tokens"); class UserTokenView extends events.EventTarget { constructor(ctx) { @@ -16,7 +16,7 @@ class UserTokenView extends events.EventTarget { views.replaceContent(this._hostNode, template(ctx)); views.decorateValidator(this._formNode); - this._formNode.addEventListener('submit', e => this._evtSubmit(e)); + this._formNode.addEventListener("submit", (e) => this._evtSubmit(e)); this._decorateTokenForms(); this._decorateTokenNoteChangeLinks(); @@ -26,8 +26,9 @@ class UserTokenView extends events.EventTarget { this._tokenFormNodes = []; for (let i = 0; i < this._tokens.length; i++) { let formNode = this._hostNode.querySelector( - '.token[data-token-id=\"' + i + '\"]'); - formNode.addEventListener('submit', e => this._evtDelete(e)); + '.token[data-token-id="' + i + '"]' + ); + formNode.addEventListener("submit", (e) => this._evtDelete(e)); this._tokenFormNodes.push(formNode); } } @@ -35,9 +36,11 @@ class UserTokenView extends events.EventTarget { _decorateTokenNoteChangeLinks() { for (let i = 0; i < this._tokens.length; i++) { let linkNode = this._hostNode.querySelector( - '.token-change-note[data-token-id=\"' + i + '\"]'); - linkNode.addEventListener( - 'click', e => this._evtChangeNoteClick(e)); + '.token-change-note[data-token-id="' + i + '"]' + ); + linkNode.addEventListener("click", (e) => + this._evtChangeNoteClick(e) + ); } } @@ -69,65 +72,75 @@ class UserTokenView extends events.EventTarget { _evtDelete(e) { e.preventDefault(); - const userToken = this._tokens[parseInt( - e.target.getAttribute('data-token-id'))]; - this.dispatchEvent(new CustomEvent('delete', { - detail: { - user: this._user, - userToken: userToken, - }, - })); + const userToken = this._tokens[ + parseInt(e.target.getAttribute("data-token-id")) + ]; + this.dispatchEvent( + new CustomEvent("delete", { + detail: { + user: this._user, + userToken: userToken, + }, + }) + ); } _evtSubmit(e) { e.preventDefault(); - this.dispatchEvent(new CustomEvent('submit', { - detail: { - user: this._user, + this.dispatchEvent( + new CustomEvent("submit", { + detail: { + user: this._user, - note: this._userTokenNoteInputNode ? - this._userTokenNoteInputNode.value : - undefined, + note: this._userTokenNoteInputNode + ? this._userTokenNoteInputNode.value + : undefined, - expirationTime: - (this._userTokenExpirationTimeInputNode - && this._userTokenExpirationTimeInputNode.value) ? - new Date(this._userTokenExpirationTimeInputNode.value) - .toISOString() : - undefined, - }, - })); + expirationTime: + this._userTokenExpirationTimeInputNode && + this._userTokenExpirationTimeInputNode.value + ? new Date( + this._userTokenExpirationTimeInputNode.value + ).toISOString() + : undefined, + }, + }) + ); } _evtChangeNoteClick(e) { e.preventDefault(); const userToken = this._tokens[ - parseInt(e.target.getAttribute('data-token-id'))]; + parseInt(e.target.getAttribute("data-token-id")) + ]; const text = window.prompt( - 'Please enter the new name:', - userToken.note !== null ? userToken.note : undefined); + "Please enter the new name:", + userToken.note !== null ? userToken.note : undefined + ); if (!text) { return; } - this.dispatchEvent(new CustomEvent('update', { - detail: { - user: this._user, - userToken: userToken, - note: text ? text : undefined, - }, - })); + this.dispatchEvent( + new CustomEvent("update", { + detail: { + user: this._user, + userToken: userToken, + note: text ? text : undefined, + }, + }) + ); } get _formNode() { - return this._hostNode.querySelector('#create-token-form'); + return this._hostNode.querySelector("#create-token-form"); } get _userTokenNoteInputNode() { - return this._formNode.querySelector('.note input'); + return this._formNode.querySelector(".note input"); } get _userTokenExpirationTimeInputNode() { - return this._formNode.querySelector('.expirationTime input'); + return this._formNode.querySelector(".expirationTime input"); } } diff --git a/client/js/views/user_view.js b/client/js/views/user_view.js index 75fd154..2eebfe9 100644 --- a/client/js/views/user_view.js +++ b/client/js/views/user_view.js @@ -1,24 +1,24 @@ -'use strict'; +"use strict"; -const events = require('../events.js'); -const views = require('../util/views.js'); -const UserDeleteView = require('./user_delete_view.js'); -const UserTokensView = require('./user_tokens_view.js'); -const UserSummaryView = require('./user_summary_view.js'); -const UserEditView = require('./user_edit_view.js'); -const EmptyView = require('../views/empty_view.js'); +const events = require("../events.js"); +const views = require("../util/views.js"); +const UserDeleteView = require("./user_delete_view.js"); +const UserTokensView = require("./user_tokens_view.js"); +const UserSummaryView = require("./user_summary_view.js"); +const UserEditView = require("./user_edit_view.js"); +const EmptyView = require("../views/empty_view.js"); -const template = views.getTemplate('user'); +const template = views.getTemplate("user"); class UserView extends events.EventTarget { constructor(ctx) { super(); this._ctx = ctx; - ctx.user.addEventListener('change', e => this._evtChange(e)); - ctx.section = ctx.section || 'summary'; + ctx.user.addEventListener("change", (e) => this._evtChange(e)); + ctx.section = ctx.section || "summary"; - this._hostNode = document.getElementById('content-holder'); + this._hostNode = document.getElementById("content-holder"); this._install(); } @@ -26,52 +26,56 @@ class UserView extends events.EventTarget { const ctx = this._ctx; views.replaceContent(this._hostNode, template(ctx)); - for (let item of this._hostNode.querySelectorAll('[data-name]')) { + for (let item of this._hostNode.querySelectorAll("[data-name]")) { item.classList.toggle( - 'active', item.getAttribute('data-name') === ctx.section); - if (item.getAttribute('data-name') === ctx.section) { + "active", + item.getAttribute("data-name") === ctx.section + ); + if (item.getAttribute("data-name") === ctx.section) { item.parentNode.scrollLeft = item.getBoundingClientRect().left - - item.parentNode.getBoundingClientRect().left + item.parentNode.getBoundingClientRect().left; } } - ctx.hostNode = this._hostNode.querySelector('#user-content-holder'); - if (ctx.section == 'edit') { + ctx.hostNode = this._hostNode.querySelector("#user-content-holder"); + if (ctx.section === "edit") { if (!this._ctx.canEditAnything) { this._view = new EmptyView(); this._view.showError( - 'You don\'t have privileges to edit users.'); + "You don't have privileges to edit users." + ); } else { this._view = new UserEditView(ctx); - events.proxyEvent(this._view, this, 'submit'); + events.proxyEvent(this._view, this, "submit"); } - } else if (ctx.section == 'list-tokens') { + } else if (ctx.section === "list-tokens") { if (!this._ctx.canListTokens) { this._view = new EmptyView(); this._view.showError( - 'You don\'t have privileges to view user tokens.'); + "You don't have privileges to view user tokens." + ); } else { this._view = new UserTokensView(ctx); - events.proxyEvent(this._view, this, 'delete', 'delete-token'); - events.proxyEvent(this._view, this, 'submit', 'create-token'); - events.proxyEvent(this._view, this, 'update', 'update-token'); + events.proxyEvent(this._view, this, "delete", "delete-token"); + events.proxyEvent(this._view, this, "submit", "create-token"); + events.proxyEvent(this._view, this, "update", "update-token"); } - } else if (ctx.section == 'delete') { + } else if (ctx.section === "delete") { if (!this._ctx.canDelete) { this._view = new EmptyView(); this._view.showError( - 'You don\'t have privileges to delete users.'); + "You don't have privileges to delete users." + ); } else { this._view = new UserDeleteView(ctx); - events.proxyEvent(this._view, this, 'submit', 'delete'); + events.proxyEvent(this._view, this, "submit", "delete"); } - } else { this._view = new UserSummaryView(ctx); } - events.proxyEvent(this._view, this, 'change'); + events.proxyEvent(this._view, this, "change"); views.syncScrollPosition(); } diff --git a/client/js/views/users_header_view.js b/client/js/views/users_header_view.js index 08b7620..d6b7c6a 100644 --- a/client/js/views/users_header_view.js +++ b/client/js/views/users_header_view.js @@ -1,10 +1,10 @@ -'use strict'; +"use strict"; -const events = require('../events.js'); -const search = require('../util/search.js'); -const views = require('../util/views.js'); +const events = require("../events.js"); +const search = require("../util/search.js"); +const views = require("../util/views.js"); -const template = views.getTemplate('users-header'); +const template = views.getTemplate("users-header"); class UsersHeaderView extends events.EventTarget { constructor(ctx) { @@ -15,23 +15,29 @@ class UsersHeaderView extends events.EventTarget { search.searchInputNodeFocusHelper(this._queryInputNode); - this._formNode.addEventListener('submit', e => this._evtSubmit(e)); + this._formNode.addEventListener("submit", (e) => this._evtSubmit(e)); } get _formNode() { - return this._hostNode.querySelector('form'); + return this._hostNode.querySelector("form"); } get _queryInputNode() { - return this._formNode.querySelector('[name=search-text]'); + return this._formNode.querySelector("[name=search-text]"); } _evtSubmit(e) { e.preventDefault(); - this.dispatchEvent(new CustomEvent('navigate', {detail: {parameters: { - query: this._queryInputNode.value, - page: 1, - }}})); + this.dispatchEvent( + new CustomEvent("navigate", { + detail: { + parameters: { + query: this._queryInputNode.value, + page: 1, + }, + }, + }) + ); } } diff --git a/client/js/views/users_page_view.js b/client/js/views/users_page_view.js index f772d4e..2689433 100644 --- a/client/js/views/users_page_view.js +++ b/client/js/views/users_page_view.js @@ -1,8 +1,8 @@ -'use strict'; +"use strict"; -const views = require('../util/views.js'); +const views = require("../util/views.js"); -const template = views.getTemplate('users-page'); +const template = views.getTemplate("users-page"); class UsersPageView { constructor(ctx) { diff --git a/client/nginx.conf.docker b/client/nginx.conf.docker index 6d8c9c1..98c18b3 100644 --- a/client/nginx.conf.docker +++ b/client/nginx.conf.docker @@ -15,10 +15,9 @@ http { log_format main '$remote_addr -> $request [$status] - ' 'referer: $http_referer $http_x_forwarded_for'; access_log /dev/stdout main; + server_tokens off; - sendfile on; keepalive_timeout 65; - client_max_body_size 0; upstream backend { server __BACKEND__:6666; @@ -32,27 +31,71 @@ http { } location ~ ^/api/(.*)$ { - if ($request_uri ~* "/api/(.*)") { - proxy_pass http://backend/$1; + tcp_nodelay on; + + add_header 'Access-Control-Allow-Origin' '*'; + if ($request_method = 'OPTIONS') { + add_header 'Access-Control-Allow-Methods' + 'GET, POST, PUT, DELETE, OPTIONS'; + add_header 'Access-Control-Allow-Headers' + 'Authorization, Content-Type'; + return 200; } + + client_max_body_size 1073741824; + gzip on; gzip_comp_level 3; gzip_min_length 20; gzip_proxied expired no-cache no-store private auth; gzip_types text/plain application/json; + + if ($request_uri ~* "/api/(.*)") { + proxy_pass http://backend/$1; + } + + error_page 500 502 503 504 @badproxy; } location /data/ { rewrite ^/data/(.*) /$1 break; root /data; + + sendfile on; + tcp_nopush on; + tcp_nodelay on; + + error_page 403 @unauthorized; + error_page 404 @notfound; } location / { root /var/www; try_files $uri /index.htm; + + sendfile on; + tcp_nopush on; + tcp_nodelay on; + gzip_static on; gzip_proxied expired no-cache no-store private auth; } + + location @unauthorized { + return 403 "Unauthorized"; + default_type text/plain; + } + + location @notfound { + return 404 "Not Found"; + default_type text/plain; + } + + location @badproxy { + return 502 "Failed to connect to szurubooru REST API"; + default_type text/plain; + } } } + daemon off; diff --git a/client/package-lock.json b/client/package-lock.json index 31ce761..bf1040d 100644 --- a/client/package-lock.json +++ b/client/package-lock.json @@ -4,9 +4,9 @@ "lockfileVersion": 1, "dependencies": { "@babel/runtime": { - "version": "7.9.2", - "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.9.2.tgz", - "integrity": "sha512-NE2DtOdufG7R5vnfQUTehdTfNycfUANEtCa9PssN9O/xmTzP4E08UI797ixaei6hBEVL9BI/PsdJS5x7mWoB9Q==", + "version": "7.10.3", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.10.3.tgz", + "integrity": "sha512-RzGO0RLSdokm9Ipe/YD+7ww8X2Ro79qiXZF3HU9ljrM+qnJmH1Vqth+hbiQZy761LnMJTMitHDuKVYTk3k4dLw==", "dev": true, "requires": { "regenerator-runtime": "^0.13.4" @@ -20,42 +20,27 @@ } } }, - "@fortawesome/fontawesome-free": { - "version": "5.8.1", - "resolved": "https://registry.npmjs.org/@fortawesome/fontawesome-free/-/fontawesome-free-5.8.1.tgz", - "integrity": "sha512-GJtx6e55qLEOy2gPOsok2lohjpdWNGrYGtQx0FFT/++K4SYx+Z8LlPHdQBaFzKEwH5IbBB4fNgb//uyZjgYXoA==" - }, "@jimp/bmp": { - "version": "0.10.1", - "resolved": "https://registry.npmjs.org/@jimp/bmp/-/bmp-0.10.1.tgz", - "integrity": "sha512-gy4ftMCgBZOx1e/Q+MaJlmU4qgqVqwGdf7G6byYTLEMUzzGK2Ipxf3nlXfkMOZqw7Bhc17zDnBIKluo7k0kX5Q==", + "version": "0.13.0", + "resolved": "https://registry.npmjs.org/@jimp/bmp/-/bmp-0.13.0.tgz", + "integrity": "sha512-7i/XZLoK5JETBKO0VL7qjnr6WDVl1X8mmaUk6Lzq06/veMPC5IwUIZi1JRVAXPEwTf5uUegq0WFnmUS0lVYzFw==", "dev": true, "requires": { "@babel/runtime": "^7.7.2", - "@jimp/utils": "^0.10.1", - "bmp-js": "^0.1.0", - "core-js": "^3.4.1" - }, - "dependencies": { - "core-js": { - "version": "3.6.5", - "resolved": "https://registry.npmjs.org/core-js/-/core-js-3.6.5.tgz", - "integrity": "sha512-vZVEEwZoIsI+vPEuoF9Iqf5H7/M3eeQqWlQnYa8FSKKePuYTf5MWnxb5SDAzCa60b3JBRS5g9b+Dq7b1y/RCrA==", - "dev": true - } + "@jimp/utils": "^0.13.0", + "bmp-js": "^0.1.0" } }, "@jimp/core": { - "version": "0.10.1", - "resolved": "https://registry.npmjs.org/@jimp/core/-/core-0.10.1.tgz", - "integrity": "sha512-ChyLkGb1+x2mRpsdcnQuRNb523qVqUc7+zCbuO/VAMaqvbMKuRalVz3aHXcVwNi8vOAOgce4LOBT7kjdKTtR/w==", + "version": "0.13.0", + "resolved": "https://registry.npmjs.org/@jimp/core/-/core-0.13.0.tgz", + "integrity": "sha512-BMFEUm5HbRP4yCo4Q23CJFx/v6Yr3trw7rERmS1GKUEooDq9ktApZWWTvWq/vggKyysKX0nQ+KT+FaFD/75Q+Q==", "dev": true, "requires": { "@babel/runtime": "^7.7.2", - "@jimp/utils": "^0.10.1", + "@jimp/utils": "^0.13.0", "any-base": "^1.1.0", "buffer": "^5.2.0", - "core-js": "^3.4.1", "exif-parser": "^0.1.12", "file-type": "^9.0.0", "load-bmfont": "^1.3.1", @@ -66,603 +51,339 @@ }, "dependencies": { "buffer": { - "version": "5.5.0", - "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.5.0.tgz", - "integrity": "sha512-9FTEDjLjwoAkEwyMGDjYJQN2gfRgOKBKRfiglhvibGbpeeU/pQn1bJxQqm32OD/AIeEuHxU9roxXxg34Byp/Ww==", + "version": "5.6.0", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.6.0.tgz", + "integrity": "sha512-/gDYp/UtU0eA1ys8bOs9J6a+E/KWIY+DZ+Q2WESNUA0jFRsJOc0SNUO6xJ5SGA1xueg3NL65W6s+NY5l9cunuw==", "dev": true, "requires": { "base64-js": "^1.0.2", "ieee754": "^1.1.4" } - }, - "core-js": { - "version": "3.6.5", - "resolved": "https://registry.npmjs.org/core-js/-/core-js-3.6.5.tgz", - "integrity": "sha512-vZVEEwZoIsI+vPEuoF9Iqf5H7/M3eeQqWlQnYa8FSKKePuYTf5MWnxb5SDAzCa60b3JBRS5g9b+Dq7b1y/RCrA==", - "dev": true } } }, "@jimp/custom": { - "version": "0.10.1", - "resolved": "https://registry.npmjs.org/@jimp/custom/-/custom-0.10.1.tgz", - "integrity": "sha512-hiiOL5sGcV1p8hCFTabALUOmXs4VP9VwhfBZtsFueKGbwWz6dmaZvkMBsk3Mz1ukBP3xb09goWG+zAIdTm88fw==", + "version": "0.13.0", + "resolved": "https://registry.npmjs.org/@jimp/custom/-/custom-0.13.0.tgz", + "integrity": "sha512-Zir/CHoLRhQDGfPWueCIQbVjVUlayNIUch9fulq4M9V2S+ynHx9BqRn58z8wy+mk8jm1WlpRVhvZ8QUenbL0vg==", "dev": true, "requires": { "@babel/runtime": "^7.7.2", - "@jimp/core": "^0.10.1", - "core-js": "^3.4.1" - }, - "dependencies": { - "core-js": { - "version": "3.6.5", - "resolved": "https://registry.npmjs.org/core-js/-/core-js-3.6.5.tgz", - "integrity": "sha512-vZVEEwZoIsI+vPEuoF9Iqf5H7/M3eeQqWlQnYa8FSKKePuYTf5MWnxb5SDAzCa60b3JBRS5g9b+Dq7b1y/RCrA==", - "dev": true - } + "@jimp/core": "^0.13.0" } }, "@jimp/gif": { - "version": "0.10.1", - "resolved": "https://registry.npmjs.org/@jimp/gif/-/gif-0.10.1.tgz", - "integrity": "sha512-xJwZ6JI6+GmrgTw///YdV8GY1z2dp4AAxEdm/KPozTvS2rygC8OZJcTmlswFws0UCH43rKzJlQUXa4Jb3ybB6w==", + "version": "0.13.0", + "resolved": "https://registry.npmjs.org/@jimp/gif/-/gif-0.13.0.tgz", + "integrity": "sha512-7FO2Fa9FZluqGt1MM/L8s6P5UEedxrIQT2eBAxzB8Z82YTTSWQXw4bdrZWCwiQjBFZwKTIaULIfw6+TxA/Q2XA==", "dev": true, "requires": { "@babel/runtime": "^7.7.2", - "@jimp/utils": "^0.10.1", - "core-js": "^3.4.1", + "@jimp/utils": "^0.13.0", + "gifwrap": "^0.9.2", "omggif": "^1.0.9" - }, - "dependencies": { - "core-js": { - "version": "3.6.5", - "resolved": "https://registry.npmjs.org/core-js/-/core-js-3.6.5.tgz", - "integrity": "sha512-vZVEEwZoIsI+vPEuoF9Iqf5H7/M3eeQqWlQnYa8FSKKePuYTf5MWnxb5SDAzCa60b3JBRS5g9b+Dq7b1y/RCrA==", - "dev": true - } } }, "@jimp/jpeg": { - "version": "0.10.1", - "resolved": "https://registry.npmjs.org/@jimp/jpeg/-/jpeg-0.10.1.tgz", - "integrity": "sha512-PXxrBWZNNGpm7PRpdi2jt9fpTpQwe2Gf4juwuHXP/dBFHLk3wiI/npKkVITplwzzoQ6D4N0cb279c9bOpuQeJQ==", + "version": "0.13.0", + "resolved": "https://registry.npmjs.org/@jimp/jpeg/-/jpeg-0.13.0.tgz", + "integrity": "sha512-Fol/DxA1lnIzCsNx/CckIEoyWImQHiWPgFAWL5s7VIVaJrEFnnbRqfOxmvr8yWg8mh3hWLeXNcxqA82CKXgg+Q==", "dev": true, "requires": { "@babel/runtime": "^7.7.2", - "@jimp/utils": "^0.10.1", - "core-js": "^3.4.1", - "jpeg-js": "^0.3.4" - }, - "dependencies": { - "core-js": { - "version": "3.6.5", - "resolved": "https://registry.npmjs.org/core-js/-/core-js-3.6.5.tgz", - "integrity": "sha512-vZVEEwZoIsI+vPEuoF9Iqf5H7/M3eeQqWlQnYa8FSKKePuYTf5MWnxb5SDAzCa60b3JBRS5g9b+Dq7b1y/RCrA==", - "dev": true - } + "@jimp/utils": "^0.13.0", + "jpeg-js": "^0.4.0" } }, "@jimp/plugin-blit": { - "version": "0.10.1", - "resolved": "https://registry.npmjs.org/@jimp/plugin-blit/-/plugin-blit-0.10.1.tgz", - "integrity": "sha512-53647EfRvPQJKQCMBc5AJGSZHyl6eueaOQq7PrfxEEq9Q3IjVcikAWYrZ4bHSZY7J12IIuz/5bSLJJZfegNQtA==", + "version": "0.13.0", + "resolved": "https://registry.npmjs.org/@jimp/plugin-blit/-/plugin-blit-0.13.0.tgz", + "integrity": "sha512-roCShFZosJgRMLCLzuOT1pRZysQF/p3avQieZiu9pfg2F9X09f91OauU2Lf3/yOp0TZCWbheqbem9MPlhyED8w==", "dev": true, "requires": { "@babel/runtime": "^7.7.2", - "@jimp/utils": "^0.10.1", - "core-js": "^3.4.1" - }, - "dependencies": { - "core-js": { - "version": "3.6.5", - "resolved": "https://registry.npmjs.org/core-js/-/core-js-3.6.5.tgz", - "integrity": "sha512-vZVEEwZoIsI+vPEuoF9Iqf5H7/M3eeQqWlQnYa8FSKKePuYTf5MWnxb5SDAzCa60b3JBRS5g9b+Dq7b1y/RCrA==", - "dev": true - } + "@jimp/utils": "^0.13.0" } }, "@jimp/plugin-blur": { - "version": "0.10.1", - "resolved": "https://registry.npmjs.org/@jimp/plugin-blur/-/plugin-blur-0.10.1.tgz", - "integrity": "sha512-0PzdzPGuv8RlhiMbLcM0tIekkHhuaPTY+frEWmO8BuCeqW9Tg9W4RxdwZtMqIVRG+kZBgyltYee31Q4JWlu9Hg==", + "version": "0.13.0", + "resolved": "https://registry.npmjs.org/@jimp/plugin-blur/-/plugin-blur-0.13.0.tgz", + "integrity": "sha512-LeBhQe72bRk2fe2AftcqcDaWgSu6vFD0fxiAYYMy3pHa8pnPAwnw2W3u4bV/gc5XJt6AJzoRyc7WVG2pE4A3gg==", "dev": true, "requires": { "@babel/runtime": "^7.7.2", - "@jimp/utils": "^0.10.1", - "core-js": "^3.4.1" - }, - "dependencies": { - "core-js": { - "version": "3.6.5", - "resolved": "https://registry.npmjs.org/core-js/-/core-js-3.6.5.tgz", - "integrity": "sha512-vZVEEwZoIsI+vPEuoF9Iqf5H7/M3eeQqWlQnYa8FSKKePuYTf5MWnxb5SDAzCa60b3JBRS5g9b+Dq7b1y/RCrA==", - "dev": true - } + "@jimp/utils": "^0.13.0" } }, "@jimp/plugin-circle": { - "version": "0.10.1", - "resolved": "https://registry.npmjs.org/@jimp/plugin-circle/-/plugin-circle-0.10.1.tgz", - "integrity": "sha512-sqiiEddN81N9xHZbPVjaJlbpQabaCeNGLW/x+0BfuNxnMPq7OkOx8IRpqIDYiGuuPhiR5hWxmmpws8ZAhjwsVw==", + "version": "0.13.0", + "resolved": "https://registry.npmjs.org/@jimp/plugin-circle/-/plugin-circle-0.13.0.tgz", + "integrity": "sha512-INwIl8zgWnJYXxYkNhIjG8TXg2Q1nh008SDKyC+Pug4ce/XRJC8w/Gk6HS+U9Z2tIO2/zXv473k/JaiwvDMu1w==", "dev": true, "requires": { "@babel/runtime": "^7.7.2", - "@jimp/utils": "^0.10.1", - "core-js": "^3.4.1" - }, - "dependencies": { - "core-js": { - "version": "3.6.5", - "resolved": "https://registry.npmjs.org/core-js/-/core-js-3.6.5.tgz", - "integrity": "sha512-vZVEEwZoIsI+vPEuoF9Iqf5H7/M3eeQqWlQnYa8FSKKePuYTf5MWnxb5SDAzCa60b3JBRS5g9b+Dq7b1y/RCrA==", - "dev": true - } + "@jimp/utils": "^0.13.0" } }, "@jimp/plugin-color": { - "version": "0.10.1", - "resolved": "https://registry.npmjs.org/@jimp/plugin-color/-/plugin-color-0.10.1.tgz", - "integrity": "sha512-SmW2+hFtNmQ33WYVsgKvreS8peCc5qItAvqGR58lKNoIMEZSNpyGwIu9g83HtDIImGsXpz3DWGMR1h8sLYCFcQ==", + "version": "0.13.0", + "resolved": "https://registry.npmjs.org/@jimp/plugin-color/-/plugin-color-0.13.0.tgz", + "integrity": "sha512-e71UDivZdZGOhQRLjDo4a0BKgrH858HJ7zFk7/Yti58LwgeIGjYHhuYc+xQOdnBWPzGSD47TuFX5GXmf/x1nmg==", "dev": true, "requires": { "@babel/runtime": "^7.7.2", - "@jimp/utils": "^0.10.1", - "core-js": "^3.4.1", + "@jimp/utils": "^0.13.0", "tinycolor2": "^1.4.1" - }, - "dependencies": { - "core-js": { - "version": "3.6.5", - "resolved": "https://registry.npmjs.org/core-js/-/core-js-3.6.5.tgz", - "integrity": "sha512-vZVEEwZoIsI+vPEuoF9Iqf5H7/M3eeQqWlQnYa8FSKKePuYTf5MWnxb5SDAzCa60b3JBRS5g9b+Dq7b1y/RCrA==", - "dev": true - } } }, "@jimp/plugin-contain": { - "version": "0.10.1", - "resolved": "https://registry.npmjs.org/@jimp/plugin-contain/-/plugin-contain-0.10.1.tgz", - "integrity": "sha512-1PkmUPd5iAicAI7QjO9r1Mp/Ia7ElJPwXTCNLsQkDxYS/L4u7vQ0xCkQkokAeR49Ul3GTWLqj9paWr7VSBG9Fg==", + "version": "0.13.0", + "resolved": "https://registry.npmjs.org/@jimp/plugin-contain/-/plugin-contain-0.13.0.tgz", + "integrity": "sha512-qPYS+ccMP4mEnf7BB0bcaszUTth8OxeRX0MdMvU6PDEI0nIvVVNwmuI6YtNqqs12PwuYxgPkq6AFenyLyoNP1g==", "dev": true, "requires": { "@babel/runtime": "^7.7.2", - "@jimp/utils": "^0.10.1", - "core-js": "^3.4.1" - }, - "dependencies": { - "core-js": { - "version": "3.6.5", - "resolved": "https://registry.npmjs.org/core-js/-/core-js-3.6.5.tgz", - "integrity": "sha512-vZVEEwZoIsI+vPEuoF9Iqf5H7/M3eeQqWlQnYa8FSKKePuYTf5MWnxb5SDAzCa60b3JBRS5g9b+Dq7b1y/RCrA==", - "dev": true - } + "@jimp/utils": "^0.13.0" } }, "@jimp/plugin-cover": { - "version": "0.10.1", - "resolved": "https://registry.npmjs.org/@jimp/plugin-cover/-/plugin-cover-0.10.1.tgz", - "integrity": "sha512-fCiz+ugrRwffNixUHFxtRKhTYm8sFAoYbNNzV0WdiG8dS0qhoYjbOJPtLcIw9CyJbMZ5eXjGOTxhTAGzBng9DA==", + "version": "0.13.0", + "resolved": "https://registry.npmjs.org/@jimp/plugin-cover/-/plugin-cover-0.13.0.tgz", + "integrity": "sha512-S2GkbXNgIb0afof/NLLq9IJDZPOcFtu1mc32ngt9S8HzXsNHgRAzONW7cg56bwQ6p0+sz/dS5tB4ctOW/pu/Dw==", "dev": true, "requires": { "@babel/runtime": "^7.7.2", - "@jimp/utils": "^0.10.1", - "core-js": "^3.4.1" - }, - "dependencies": { - "core-js": { - "version": "3.6.5", - "resolved": "https://registry.npmjs.org/core-js/-/core-js-3.6.5.tgz", - "integrity": "sha512-vZVEEwZoIsI+vPEuoF9Iqf5H7/M3eeQqWlQnYa8FSKKePuYTf5MWnxb5SDAzCa60b3JBRS5g9b+Dq7b1y/RCrA==", - "dev": true - } + "@jimp/utils": "^0.13.0" } }, "@jimp/plugin-crop": { - "version": "0.10.1", - "resolved": "https://registry.npmjs.org/@jimp/plugin-crop/-/plugin-crop-0.10.1.tgz", - "integrity": "sha512-KjW+RuuNcsIvvNF1ejbBjcDNus/fLz69LGAU2gwhFzw7A0iOUEJJgTWzkGBLZ/YvUaWMDtPnznF3X3oYHeOj6A==", + "version": "0.13.0", + "resolved": "https://registry.npmjs.org/@jimp/plugin-crop/-/plugin-crop-0.13.0.tgz", + "integrity": "sha512-Y1Ug3kOzsq72EjLiWQlwkHuvUvdSmFUDtxpyOXh3RxeWF7wmdjH8FvdhPj8hWvFLsDYFgWGaLI4Z6SXOr+N8nA==", "dev": true, "requires": { "@babel/runtime": "^7.7.2", - "@jimp/utils": "^0.10.1", - "core-js": "^3.4.1" - }, - "dependencies": { - "core-js": { - "version": "3.6.5", - "resolved": "https://registry.npmjs.org/core-js/-/core-js-3.6.5.tgz", - "integrity": "sha512-vZVEEwZoIsI+vPEuoF9Iqf5H7/M3eeQqWlQnYa8FSKKePuYTf5MWnxb5SDAzCa60b3JBRS5g9b+Dq7b1y/RCrA==", - "dev": true - } + "@jimp/utils": "^0.13.0" } }, "@jimp/plugin-displace": { - "version": "0.10.1", - "resolved": "https://registry.npmjs.org/@jimp/plugin-displace/-/plugin-displace-0.10.1.tgz", - "integrity": "sha512-NUJcjMRb6iR9rwJSC7t8LMJgRs5Z0mzflYBymvttkjlcIoB4RPIOu3gWn5iT5IepB50YTy9zJuCaofMYn4DnaA==", + "version": "0.13.0", + "resolved": "https://registry.npmjs.org/@jimp/plugin-displace/-/plugin-displace-0.13.0.tgz", + "integrity": "sha512-c80VIUjIqQoavafthLpYRZdzANCxbOCHzqkFVbZ0kNKJnDDk6fW55mvVW4TJLDToDU81WMlttCmNV0oXcX93gQ==", "dev": true, "requires": { "@babel/runtime": "^7.7.2", - "@jimp/utils": "^0.10.1", - "core-js": "^3.4.1" - }, - "dependencies": { - "core-js": { - "version": "3.6.5", - "resolved": "https://registry.npmjs.org/core-js/-/core-js-3.6.5.tgz", - "integrity": "sha512-vZVEEwZoIsI+vPEuoF9Iqf5H7/M3eeQqWlQnYa8FSKKePuYTf5MWnxb5SDAzCa60b3JBRS5g9b+Dq7b1y/RCrA==", - "dev": true - } + "@jimp/utils": "^0.13.0" } }, "@jimp/plugin-dither": { - "version": "0.10.1", - "resolved": "https://registry.npmjs.org/@jimp/plugin-dither/-/plugin-dither-0.10.1.tgz", - "integrity": "sha512-V/PCL44R16w5EDCgMvFXBaWFQ0f3LLDD3CQxJFOw6awswkP60m13nUUtWHm7QB54Gghhgk8JEOD/mZo6JsnaBg==", + "version": "0.13.0", + "resolved": "https://registry.npmjs.org/@jimp/plugin-dither/-/plugin-dither-0.13.0.tgz", + "integrity": "sha512-EUz/y/AaQ00TnaiVLVAXLz8n8Nx7S36lKi4VXPeYy5a5FyzBimxNiKxdITVe9zooN7+H4FP++/xGFGFMpfWtRg==", "dev": true, "requires": { "@babel/runtime": "^7.7.2", - "@jimp/utils": "^0.10.1", - "core-js": "^3.4.1" - }, - "dependencies": { - "core-js": { - "version": "3.6.5", - "resolved": "https://registry.npmjs.org/core-js/-/core-js-3.6.5.tgz", - "integrity": "sha512-vZVEEwZoIsI+vPEuoF9Iqf5H7/M3eeQqWlQnYa8FSKKePuYTf5MWnxb5SDAzCa60b3JBRS5g9b+Dq7b1y/RCrA==", - "dev": true - } + "@jimp/utils": "^0.13.0" } }, "@jimp/plugin-fisheye": { - "version": "0.10.1", - "resolved": "https://registry.npmjs.org/@jimp/plugin-fisheye/-/plugin-fisheye-0.10.1.tgz", - "integrity": "sha512-GgIAY6ryL+2RwRb6j+APo972f8VjftolnvjVM1ilxO4pdfJf7gdg3Bp4igka9fgW94BBgdJlCPUeWIVap3AYaA==", + "version": "0.13.0", + "resolved": "https://registry.npmjs.org/@jimp/plugin-fisheye/-/plugin-fisheye-0.13.0.tgz", + "integrity": "sha512-O7h5pNTk2sYcTKxLvV6+zzUpLx8qzdNl6qiP9x1S0CKy64oZ9IwtK1eR1eLom0YA8tUR7rX5Ra4pB8bhq8Oyqw==", "dev": true, "requires": { "@babel/runtime": "^7.7.2", - "@jimp/utils": "^0.10.1", - "core-js": "^3.4.1" - }, - "dependencies": { - "core-js": { - "version": "3.6.5", - "resolved": "https://registry.npmjs.org/core-js/-/core-js-3.6.5.tgz", - "integrity": "sha512-vZVEEwZoIsI+vPEuoF9Iqf5H7/M3eeQqWlQnYa8FSKKePuYTf5MWnxb5SDAzCa60b3JBRS5g9b+Dq7b1y/RCrA==", - "dev": true - } + "@jimp/utils": "^0.13.0" } }, "@jimp/plugin-flip": { - "version": "0.10.1", - "resolved": "https://registry.npmjs.org/@jimp/plugin-flip/-/plugin-flip-0.10.1.tgz", - "integrity": "sha512-CkSwULwmVYOo6ZY/QnR0/mrfMaf/3g+sDo7osAHaf0GvuoIw76NSh6LwaANAsLZ9erknZ67XuqCjZWoMwNrbaw==", + "version": "0.13.0", + "resolved": "https://registry.npmjs.org/@jimp/plugin-flip/-/plugin-flip-0.13.0.tgz", + "integrity": "sha512-gWk+Q0LmCvupUuWRfoGyETmH/+lJKZuPCeA9K6UHJldq5Cdg/3MrlKUNS1HcPCDXjw+dWDGC8QnNslvMTaY5VQ==", "dev": true, "requires": { "@babel/runtime": "^7.7.2", - "@jimp/utils": "^0.10.1", - "core-js": "^3.4.1" - }, - "dependencies": { - "core-js": { - "version": "3.6.5", - "resolved": "https://registry.npmjs.org/core-js/-/core-js-3.6.5.tgz", - "integrity": "sha512-vZVEEwZoIsI+vPEuoF9Iqf5H7/M3eeQqWlQnYa8FSKKePuYTf5MWnxb5SDAzCa60b3JBRS5g9b+Dq7b1y/RCrA==", - "dev": true - } + "@jimp/utils": "^0.13.0" } }, "@jimp/plugin-gaussian": { - "version": "0.10.1", - "resolved": "https://registry.npmjs.org/@jimp/plugin-gaussian/-/plugin-gaussian-0.10.1.tgz", - "integrity": "sha512-RnmIWUr9RB1RrgqXG2H7C3dyncak/D2CPoP2DehAgxxHprDdxm9HoFjhGQ9eh+Ygr0nXE7t2+fDE12U3VW3ApA==", + "version": "0.13.0", + "resolved": "https://registry.npmjs.org/@jimp/plugin-gaussian/-/plugin-gaussian-0.13.0.tgz", + "integrity": "sha512-0ctRqbCcLdy8y9IrSIH2McWNPLnEwjoe8qxtqoi51zRsM3z3mwjiTC2q8AWeF0SdIdWwV+YV/eP0494AJqjTsg==", "dev": true, "requires": { "@babel/runtime": "^7.7.2", - "@jimp/utils": "^0.10.1", - "core-js": "^3.4.1" - }, - "dependencies": { - "core-js": { - "version": "3.6.5", - "resolved": "https://registry.npmjs.org/core-js/-/core-js-3.6.5.tgz", - "integrity": "sha512-vZVEEwZoIsI+vPEuoF9Iqf5H7/M3eeQqWlQnYa8FSKKePuYTf5MWnxb5SDAzCa60b3JBRS5g9b+Dq7b1y/RCrA==", - "dev": true - } + "@jimp/utils": "^0.13.0" } }, "@jimp/plugin-invert": { - "version": "0.10.1", - "resolved": "https://registry.npmjs.org/@jimp/plugin-invert/-/plugin-invert-0.10.1.tgz", - "integrity": "sha512-PpTUbnjsAkw0nZnbZWrKdsEW46MARhzzabBXy/XCjvutG3jzoO8EL19VeEtcrxBml9duJbaOzdzYmbFkQsNINQ==", + "version": "0.13.0", + "resolved": "https://registry.npmjs.org/@jimp/plugin-invert/-/plugin-invert-0.13.0.tgz", + "integrity": "sha512-k7TWx/la0MrTcT1dMtncV6I9IuGToRm9Q0ekzfb3k8bHzWRYX4SUtt/WrZ/I+/znD/fGorLtFI057v7mcLJc5A==", "dev": true, "requires": { "@babel/runtime": "^7.7.2", - "@jimp/utils": "^0.10.1", - "core-js": "^3.4.1" - }, - "dependencies": { - "core-js": { - "version": "3.6.5", - "resolved": "https://registry.npmjs.org/core-js/-/core-js-3.6.5.tgz", - "integrity": "sha512-vZVEEwZoIsI+vPEuoF9Iqf5H7/M3eeQqWlQnYa8FSKKePuYTf5MWnxb5SDAzCa60b3JBRS5g9b+Dq7b1y/RCrA==", - "dev": true - } + "@jimp/utils": "^0.13.0" } }, "@jimp/plugin-mask": { - "version": "0.10.1", - "resolved": "https://registry.npmjs.org/@jimp/plugin-mask/-/plugin-mask-0.10.1.tgz", - "integrity": "sha512-Fm75MSucSGI19n9beXGVPSR98flXpzJLyOfSj0+zefXafrO+kmYO9eWtzNd3TE6jzyGe5y7mPJPHB0PWXKgw4g==", + "version": "0.13.0", + "resolved": "https://registry.npmjs.org/@jimp/plugin-mask/-/plugin-mask-0.13.0.tgz", + "integrity": "sha512-U3OdsgtMNpbCYc1lzzu96zdSgyz7BK9eD8IoFHdw4Ma8gCuM8kp9gdBJlKnzOh8eyYvssdCMZOWz9Xbxc5xH9A==", "dev": true, "requires": { "@babel/runtime": "^7.7.2", - "@jimp/utils": "^0.10.1", - "core-js": "^3.4.1" - }, - "dependencies": { - "core-js": { - "version": "3.6.5", - "resolved": "https://registry.npmjs.org/core-js/-/core-js-3.6.5.tgz", - "integrity": "sha512-vZVEEwZoIsI+vPEuoF9Iqf5H7/M3eeQqWlQnYa8FSKKePuYTf5MWnxb5SDAzCa60b3JBRS5g9b+Dq7b1y/RCrA==", - "dev": true - } + "@jimp/utils": "^0.13.0" } }, "@jimp/plugin-normalize": { - "version": "0.10.1", - "resolved": "https://registry.npmjs.org/@jimp/plugin-normalize/-/plugin-normalize-0.10.1.tgz", - "integrity": "sha512-mxjRCmUB/yD973pgjAKb3HCYMyh1GvGtdo71+pJn+ChefvTJ0LDB1FknwTVjDtJuy4mBh0TkBqBp4PNAtdBL6w==", + "version": "0.13.0", + "resolved": "https://registry.npmjs.org/@jimp/plugin-normalize/-/plugin-normalize-0.13.0.tgz", + "integrity": "sha512-yKOgZSvOxSHNlh+U9NOQH4Drgca0Dwb7DQRk3vj67gvHQC96JafIpGwN+9V4fP89lA3rkItbw6xgN6C/28HEUQ==", "dev": true, "requires": { "@babel/runtime": "^7.7.2", - "@jimp/utils": "^0.10.1", - "core-js": "^3.4.1" - }, - "dependencies": { - "core-js": { - "version": "3.6.5", - "resolved": "https://registry.npmjs.org/core-js/-/core-js-3.6.5.tgz", - "integrity": "sha512-vZVEEwZoIsI+vPEuoF9Iqf5H7/M3eeQqWlQnYa8FSKKePuYTf5MWnxb5SDAzCa60b3JBRS5g9b+Dq7b1y/RCrA==", - "dev": true - } + "@jimp/utils": "^0.13.0" } }, "@jimp/plugin-print": { - "version": "0.10.1", - "resolved": "https://registry.npmjs.org/@jimp/plugin-print/-/plugin-print-0.10.1.tgz", - "integrity": "sha512-eSPTbdES/ISR9nMHV8e449UMs0Dx0eY9OixU7gIYTDnTmnhyYXq9bqIY/IXqVU3fOj330MIpIpi6pavmtJXdLA==", + "version": "0.13.0", + "resolved": "https://registry.npmjs.org/@jimp/plugin-print/-/plugin-print-0.13.0.tgz", + "integrity": "sha512-Tv7r/1t7z63oLeRuEWw9xbm0G5uuBE54986+BOu8OFaHBpV/BbVHrE7ouApA0mKVZqMZCVjhO6Ph8+uFzRjdOw==", "dev": true, "requires": { "@babel/runtime": "^7.7.2", - "@jimp/utils": "^0.10.1", - "core-js": "^3.4.1", + "@jimp/utils": "^0.13.0", "load-bmfont": "^1.4.0" - }, - "dependencies": { - "core-js": { - "version": "3.6.5", - "resolved": "https://registry.npmjs.org/core-js/-/core-js-3.6.5.tgz", - "integrity": "sha512-vZVEEwZoIsI+vPEuoF9Iqf5H7/M3eeQqWlQnYa8FSKKePuYTf5MWnxb5SDAzCa60b3JBRS5g9b+Dq7b1y/RCrA==", - "dev": true - } } }, "@jimp/plugin-resize": { - "version": "0.10.1", - "resolved": "https://registry.npmjs.org/@jimp/plugin-resize/-/plugin-resize-0.10.1.tgz", - "integrity": "sha512-aG42+tRmhAYKvybZteSD7s48dAcYSkipyM+e2aizRa0D0FHNIQlIHribiKfRTiX+ewx/fhHVu0vpFKOg0N2hDw==", + "version": "0.13.0", + "resolved": "https://registry.npmjs.org/@jimp/plugin-resize/-/plugin-resize-0.13.0.tgz", + "integrity": "sha512-XOo0Skn7aq/aGxV9czFx6EaBUbAsAGCVbAS26fMM0AZ4YAWWUEleKTpHunEo92giIPhvlxeFFjQR2jQ9UcB3uQ==", "dev": true, "requires": { "@babel/runtime": "^7.7.2", - "@jimp/utils": "^0.10.1", - "core-js": "^3.4.1" - }, - "dependencies": { - "core-js": { - "version": "3.6.5", - "resolved": "https://registry.npmjs.org/core-js/-/core-js-3.6.5.tgz", - "integrity": "sha512-vZVEEwZoIsI+vPEuoF9Iqf5H7/M3eeQqWlQnYa8FSKKePuYTf5MWnxb5SDAzCa60b3JBRS5g9b+Dq7b1y/RCrA==", - "dev": true - } + "@jimp/utils": "^0.13.0" } }, "@jimp/plugin-rotate": { - "version": "0.10.1", - "resolved": "https://registry.npmjs.org/@jimp/plugin-rotate/-/plugin-rotate-0.10.1.tgz", - "integrity": "sha512-R+Qpb3cwKl6L5m9RUkJatY5D5JuPg/uUfFbdFPaBhc5infC46Rsyt0j923eUyXkisechRDmzoTbG3fcc1MjzSA==", + "version": "0.13.0", + "resolved": "https://registry.npmjs.org/@jimp/plugin-rotate/-/plugin-rotate-0.13.0.tgz", + "integrity": "sha512-BaNeh655kF9Rz01ZV+Bkc8hLsHpNu3QnzigruVDjGt9Paoig0EBr+Dgyjje+7eTLu20kyuyxwPUAxLSOiPiraQ==", "dev": true, "requires": { "@babel/runtime": "^7.7.2", - "@jimp/utils": "^0.10.1", - "core-js": "^3.4.1" - }, - "dependencies": { - "core-js": { - "version": "3.6.5", - "resolved": "https://registry.npmjs.org/core-js/-/core-js-3.6.5.tgz", - "integrity": "sha512-vZVEEwZoIsI+vPEuoF9Iqf5H7/M3eeQqWlQnYa8FSKKePuYTf5MWnxb5SDAzCa60b3JBRS5g9b+Dq7b1y/RCrA==", - "dev": true - } + "@jimp/utils": "^0.13.0" } }, "@jimp/plugin-scale": { - "version": "0.10.1", - "resolved": "https://registry.npmjs.org/@jimp/plugin-scale/-/plugin-scale-0.10.1.tgz", - "integrity": "sha512-MKmW3Rr0sSApglUYFqJ8LgGy9Nm9w2oRgSB2twxA5bp1waM9fdOILcRyEnltwHIIDSoyR0me8XmpuwpyqaqSdA==", + "version": "0.13.0", + "resolved": "https://registry.npmjs.org/@jimp/plugin-scale/-/plugin-scale-0.13.0.tgz", + "integrity": "sha512-e/f7lvii+DmRMgYF+uBKQy437f+J66WbL0FcFEataCF/W9UkTIQGeXdECwJSPfqr81SxC5mGbSBbsdbMKChzAQ==", "dev": true, "requires": { "@babel/runtime": "^7.7.2", - "@jimp/utils": "^0.10.1", - "core-js": "^3.4.1" - }, - "dependencies": { - "core-js": { - "version": "3.6.5", - "resolved": "https://registry.npmjs.org/core-js/-/core-js-3.6.5.tgz", - "integrity": "sha512-vZVEEwZoIsI+vPEuoF9Iqf5H7/M3eeQqWlQnYa8FSKKePuYTf5MWnxb5SDAzCa60b3JBRS5g9b+Dq7b1y/RCrA==", - "dev": true - } + "@jimp/utils": "^0.13.0" } }, "@jimp/plugin-shadow": { - "version": "0.10.1", - "resolved": "https://registry.npmjs.org/@jimp/plugin-shadow/-/plugin-shadow-0.10.1.tgz", - "integrity": "sha512-H4JSVimD19vbFiX8SL3Ci5Htsbb3xG8zQrkjf+ui/2MXbeP0FyWlIv3g1Ahil4oE+5Wi4zbq20Dyba2GS4aINw==", + "version": "0.13.0", + "resolved": "https://registry.npmjs.org/@jimp/plugin-shadow/-/plugin-shadow-0.13.0.tgz", + "integrity": "sha512-qObtH63dmfPLze5wE8XDRjDsBOUnAfEWib4YbjPXGBZVxeKD7+2oPGemsK56HqC/+rYzIynkbi4MUIV1Q0dGjA==", "dev": true, "requires": { "@babel/runtime": "^7.7.2", - "@jimp/utils": "^0.10.1", - "core-js": "^3.4.1" - }, - "dependencies": { - "core-js": { - "version": "3.6.5", - "resolved": "https://registry.npmjs.org/core-js/-/core-js-3.6.5.tgz", - "integrity": "sha512-vZVEEwZoIsI+vPEuoF9Iqf5H7/M3eeQqWlQnYa8FSKKePuYTf5MWnxb5SDAzCa60b3JBRS5g9b+Dq7b1y/RCrA==", - "dev": true - } + "@jimp/utils": "^0.13.0" } }, "@jimp/plugin-threshold": { - "version": "0.10.1", - "resolved": "https://registry.npmjs.org/@jimp/plugin-threshold/-/plugin-threshold-0.10.1.tgz", - "integrity": "sha512-o2nBEz2tLvfYNMdac9qho5SrjFoBfbJow8PWXMWz14N6zLKIw4DQT+inSbEKxbxlqKseds3zUsOIM+0hLbSDeQ==", + "version": "0.13.0", + "resolved": "https://registry.npmjs.org/@jimp/plugin-threshold/-/plugin-threshold-0.13.0.tgz", + "integrity": "sha512-ACF7jk0ogso+2RK+0EsvBupVfE3IMq39wGFQWgpnHR9Tj12mSO279f6i/H8bcj1ZXmHot22nwLOG0wO4AlAaRg==", "dev": true, "requires": { "@babel/runtime": "^7.7.2", - "@jimp/utils": "^0.10.1", - "core-js": "^3.4.1" - }, - "dependencies": { - "core-js": { - "version": "3.6.5", - "resolved": "https://registry.npmjs.org/core-js/-/core-js-3.6.5.tgz", - "integrity": "sha512-vZVEEwZoIsI+vPEuoF9Iqf5H7/M3eeQqWlQnYa8FSKKePuYTf5MWnxb5SDAzCa60b3JBRS5g9b+Dq7b1y/RCrA==", - "dev": true - } + "@jimp/utils": "^0.13.0" } }, "@jimp/plugins": { - "version": "0.10.1", - "resolved": "https://registry.npmjs.org/@jimp/plugins/-/plugins-0.10.1.tgz", - "integrity": "sha512-gpdoh7XITd33ZClObVKYV8ASpZnrwebNuY4C5njeJfLxfyRQ2wSK9TDAb/5OYcyrbMDIqBaKLg9AXHPBphwXtg==", + "version": "0.13.0", + "resolved": "https://registry.npmjs.org/@jimp/plugins/-/plugins-0.13.0.tgz", + "integrity": "sha512-onu8GnSnFjLFuFVFq8+aTYFIDfH8kwZuBHeGaDyScPFFn6QMKsPl4TeLzQ5vwIPvcpkADuFFfuAshE4peutjjA==", "dev": true, "requires": { "@babel/runtime": "^7.7.2", - "@jimp/plugin-blit": "^0.10.1", - "@jimp/plugin-blur": "^0.10.1", - "@jimp/plugin-circle": "^0.10.1", - "@jimp/plugin-color": "^0.10.1", - "@jimp/plugin-contain": "^0.10.1", - "@jimp/plugin-cover": "^0.10.1", - "@jimp/plugin-crop": "^0.10.1", - "@jimp/plugin-displace": "^0.10.1", - "@jimp/plugin-dither": "^0.10.1", - "@jimp/plugin-fisheye": "^0.10.1", - "@jimp/plugin-flip": "^0.10.1", - "@jimp/plugin-gaussian": "^0.10.1", - "@jimp/plugin-invert": "^0.10.1", - "@jimp/plugin-mask": "^0.10.1", - "@jimp/plugin-normalize": "^0.10.1", - "@jimp/plugin-print": "^0.10.1", - "@jimp/plugin-resize": "^0.10.1", - "@jimp/plugin-rotate": "^0.10.1", - "@jimp/plugin-scale": "^0.10.1", - "@jimp/plugin-shadow": "^0.10.1", - "@jimp/plugin-threshold": "^0.10.1", - "core-js": "^3.4.1", + "@jimp/plugin-blit": "^0.13.0", + "@jimp/plugin-blur": "^0.13.0", + "@jimp/plugin-circle": "^0.13.0", + "@jimp/plugin-color": "^0.13.0", + "@jimp/plugin-contain": "^0.13.0", + "@jimp/plugin-cover": "^0.13.0", + "@jimp/plugin-crop": "^0.13.0", + "@jimp/plugin-displace": "^0.13.0", + "@jimp/plugin-dither": "^0.13.0", + "@jimp/plugin-fisheye": "^0.13.0", + "@jimp/plugin-flip": "^0.13.0", + "@jimp/plugin-gaussian": "^0.13.0", + "@jimp/plugin-invert": "^0.13.0", + "@jimp/plugin-mask": "^0.13.0", + "@jimp/plugin-normalize": "^0.13.0", + "@jimp/plugin-print": "^0.13.0", + "@jimp/plugin-resize": "^0.13.0", + "@jimp/plugin-rotate": "^0.13.0", + "@jimp/plugin-scale": "^0.13.0", + "@jimp/plugin-shadow": "^0.13.0", + "@jimp/plugin-threshold": "^0.13.0", "timm": "^1.6.1" - }, - "dependencies": { - "core-js": { - "version": "3.6.5", - "resolved": "https://registry.npmjs.org/core-js/-/core-js-3.6.5.tgz", - "integrity": "sha512-vZVEEwZoIsI+vPEuoF9Iqf5H7/M3eeQqWlQnYa8FSKKePuYTf5MWnxb5SDAzCa60b3JBRS5g9b+Dq7b1y/RCrA==", - "dev": true - } } }, "@jimp/png": { - "version": "0.10.1", - "resolved": "https://registry.npmjs.org/@jimp/png/-/png-0.10.1.tgz", - "integrity": "sha512-/2CUaOjbq5GVaXKRGzM4nhhFpnVdWNazsuVZ3Et8sgMxJxep7v6k2hmvL8rr0/A4UPPWzEbFsagz/YBbN9fANw==", + "version": "0.13.0", + "resolved": "https://registry.npmjs.org/@jimp/png/-/png-0.13.0.tgz", + "integrity": "sha512-9MVU0BLMQKJ6Kaiwjrq6dLDnDktZzeHtxz4qthRHaGOyHLx3RpxmbhaDuK9dDg6NASX3JuXznEhaOP4lqQODpQ==", "dev": true, "requires": { "@babel/runtime": "^7.7.2", - "@jimp/utils": "^0.10.1", - "core-js": "^3.4.1", + "@jimp/utils": "^0.13.0", "pngjs": "^3.3.3" - }, - "dependencies": { - "core-js": { - "version": "3.6.5", - "resolved": "https://registry.npmjs.org/core-js/-/core-js-3.6.5.tgz", - "integrity": "sha512-vZVEEwZoIsI+vPEuoF9Iqf5H7/M3eeQqWlQnYa8FSKKePuYTf5MWnxb5SDAzCa60b3JBRS5g9b+Dq7b1y/RCrA==", - "dev": true - } } }, "@jimp/tiff": { - "version": "0.10.1", - "resolved": "https://registry.npmjs.org/@jimp/tiff/-/tiff-0.10.1.tgz", - "integrity": "sha512-lvCzid4RwCZr/Zz3W4xTD/UKe4xjVdPk2dGGozrW0Puo2TCz5L+ghXYEWxzoj/rm/wOFmQbaYdopv0UQ72HqWw==", + "version": "0.13.0", + "resolved": "https://registry.npmjs.org/@jimp/tiff/-/tiff-0.13.0.tgz", + "integrity": "sha512-8lLGgEmhVRRjzZfn/QgpM3+mijq5ORYqRHtLcqDgcQaUY/q/OU1CxLYX777pozyQ3KIq1O+jyyHZm2xu3RZkPA==", "dev": true, "requires": { "@babel/runtime": "^7.7.2", - "core-js": "^3.4.1", "utif": "^2.0.1" - }, - "dependencies": { - "core-js": { - "version": "3.6.5", - "resolved": "https://registry.npmjs.org/core-js/-/core-js-3.6.5.tgz", - "integrity": "sha512-vZVEEwZoIsI+vPEuoF9Iqf5H7/M3eeQqWlQnYa8FSKKePuYTf5MWnxb5SDAzCa60b3JBRS5g9b+Dq7b1y/RCrA==", - "dev": true - } } }, "@jimp/types": { - "version": "0.10.1", - "resolved": "https://registry.npmjs.org/@jimp/types/-/types-0.10.1.tgz", - "integrity": "sha512-PaPhpeHE41Yn6myvnHmrb1FNvn+XmF+EpizhP8JR3DmyveUPblrssM8qA5iMe+Q+wG3vJ02LHNgbfFCUYJr3zA==", + "version": "0.13.0", + "resolved": "https://registry.npmjs.org/@jimp/types/-/types-0.13.0.tgz", + "integrity": "sha512-qGq9qVHiRTgtIy061FSBr9l7OFrSiFLkKyQVnOBndEjwls2XLBKXkMmSD2U3oiHcNuf3ACsDSTIzK3KX/hDHvg==", "dev": true, "requires": { "@babel/runtime": "^7.7.2", - "@jimp/bmp": "^0.10.1", - "@jimp/gif": "^0.10.1", - "@jimp/jpeg": "^0.10.1", - "@jimp/png": "^0.10.1", - "@jimp/tiff": "^0.10.1", - "core-js": "^3.4.1", + "@jimp/bmp": "^0.13.0", + "@jimp/gif": "^0.13.0", + "@jimp/jpeg": "^0.13.0", + "@jimp/png": "^0.13.0", + "@jimp/tiff": "^0.13.0", "timm": "^1.6.1" - }, - "dependencies": { - "core-js": { - "version": "3.6.5", - "resolved": "https://registry.npmjs.org/core-js/-/core-js-3.6.5.tgz", - "integrity": "sha512-vZVEEwZoIsI+vPEuoF9Iqf5H7/M3eeQqWlQnYa8FSKKePuYTf5MWnxb5SDAzCa60b3JBRS5g9b+Dq7b1y/RCrA==", - "dev": true - } } }, "@jimp/utils": { - "version": "0.10.1", - "resolved": "https://registry.npmjs.org/@jimp/utils/-/utils-0.10.1.tgz", - "integrity": "sha512-Q0ZT2FGPQo3lXkUheAsg0dVWo0Ko+vYCVJLEUxQMxmPiDLUquE22iya+tMONPOaRj1GG3cznaSqaEHDNgoyYbw==", + "version": "0.13.0", + "resolved": "https://registry.npmjs.org/@jimp/utils/-/utils-0.13.0.tgz", + "integrity": "sha512-zA4573jE4FIpBKiYpPGo66JOAGdv/FS/N9fW9GpkbwJeTu12fV+r4R1ARSyt8UEKdE4DMBatBmQC0U2FGZijOA==", "dev": true, "requires": { "@babel/runtime": "^7.7.2", - "core-js": "^3.4.1", "regenerator-runtime": "^0.13.3" }, "dependencies": { - "core-js": { - "version": "3.6.5", - "resolved": "https://registry.npmjs.org/core-js/-/core-js-3.6.5.tgz", - "integrity": "sha512-vZVEEwZoIsI+vPEuoF9Iqf5H7/M3eeQqWlQnYa8FSKKePuYTf5MWnxb5SDAzCa60b3JBRS5g9b+Dq7b1y/RCrA==", - "dev": true - }, "regenerator-runtime": { "version": "0.13.5", "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.5.tgz", @@ -2019,6 +1740,11 @@ "integrity": "sha512-jnjyiM6eRyZl2H+W8Q/zLMA481hzi0eszAaBUzIVnmYVDBbnLxVNnfu1HgEBvCbL+71FrxMl3E6lpKH7Ge3OXA==", "dev": true }, + "dompurify": { + "version": "2.0.11", + "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-2.0.11.tgz", + "integrity": "sha512-qVoGPjIW9IqxRij7klDQQ2j6nSe4UNWANBhZNLnsS7ScTtLb+3YdxkRY8brNTpkUiTtcXsCJO+jS0UCDfenLuA==" + }, "duplexer2": { "version": "0.1.4", "resolved": "https://registry.npmjs.org/duplexer2/-/duplexer2-0.1.4.tgz", @@ -2035,9 +1761,9 @@ "dev": true }, "elliptic": { - "version": "6.4.0", - "resolved": "https://registry.npmjs.org/elliptic/-/elliptic-6.4.0.tgz", - "integrity": "sha1-ysmvh2LIWDYYcAPI3+GT5eLq5d8=", + "version": "6.5.3", + "resolved": "https://registry.npmjs.org/elliptic/-/elliptic-6.5.3.tgz", + "integrity": "sha512-IMqzv5wNQf+E6aHeIqATs0tOLeOTwj1QKbRcS3jBbYkl5oLAserA8yJTT7/VyHUYG91PRmPyeQDObKLPpeS4dw==", "dev": true, "requires": { "bn.js": "^4.4.0", @@ -2094,6 +1820,11 @@ "integrity": "sha512-Qe/5NJrgIOlwijpq3B7BEpzPFcgzggOTagZmkXQY4LA6bsXKTUstK7Wp12lEJ/mLKTpvIZxmIuRcLYWT6ov9lw==", "dev": true }, + "font-awesome": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/font-awesome/-/font-awesome-4.7.0.tgz", + "integrity": "sha1-j6jPBBGhoxr9B7BtKQK7n8gVoTM=" + }, "form-data": { "version": "2.3.2", "resolved": "https://registry.npmjs.org/form-data/-/form-data-2.3.2.tgz", @@ -2127,6 +1858,16 @@ "integrity": "sha512-mBBwmeGTrxEMO4pMaaf/uUEFHnYtwr8FTe8Y/mer4rcV/bye0qGm6pw1bGZFGStxC5O76c5ZAVBGnqHmOaJpdQ==", "dev": true }, + "gifwrap": { + "version": "0.9.2", + "resolved": "https://registry.npmjs.org/gifwrap/-/gifwrap-0.9.2.tgz", + "integrity": "sha512-fcIswrPaiCDAyO8xnWvHSZdWChjKXUanKKpAiWWJ/UTkEi/aYKn5+90e7DE820zbEaVR9CE2y4z9bzhQijZ0BA==", + "dev": true, + "requires": { + "image-q": "^1.1.1", + "omggif": "^1.0.10" + } + }, "glob": { "version": "7.1.2", "resolved": "https://registry.npmjs.org/glob/-/glob-7.1.2.tgz", @@ -2263,6 +2004,12 @@ "integrity": "sha512-GguP+DRY+pJ3soyIiGPTvdiVXjZ+DbXOxGpXn3eMvNW4x4irjqXm4wHKscC+TfxSJ0yw/S1F24tqdMNsMZTiLA==", "dev": true }, + "image-q": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/image-q/-/image-q-1.1.1.tgz", + "integrity": "sha1-/IQJlmRGC5DKhi2TALa/u7+/gFY=", + "dev": true + }, "inflight": { "version": "1.0.6", "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", @@ -2335,9 +2082,9 @@ } }, "is-function": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/is-function/-/is-function-1.0.1.tgz", - "integrity": "sha1-Es+5i2W1fdPRk6MSH19uL0N2ArU=", + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/is-function/-/is-function-1.0.2.tgz", + "integrity": "sha512-lw7DUp0aWXYg+CBCN+JKkcE0Q2RayZnSvnZBlwgxHBQhqt5pZNVy4Ri7H9GmmXkdu7LUthszM+Tor1u/2iBcpQ==", "dev": true }, "isarray": { @@ -2346,25 +2093,18 @@ "integrity": "sha1-u5NdSFgsuhaMBoNJV6VKPgcSTxE=" }, "jimp": { - "version": "0.10.1", - "resolved": "https://registry.npmjs.org/jimp/-/jimp-0.10.1.tgz", - "integrity": "sha512-dRs9hdUXseS7y214GxZxeLiXNQZUXnjdzv9I5+FVWAnyK0bdIGgjUn5WLuNF4uAKkzIH3cFA24EbviOlBIpRDg==", + "version": "0.13.0", + "resolved": "https://registry.npmjs.org/jimp/-/jimp-0.13.0.tgz", + "integrity": "sha512-N/iG8L7Qe+AcHhrgcL0m7PTP/14iybmSIuOqCDvuel9gcIKEzxbbGuPCJVMchwXzusc2E7h9UjO9LZDfXb/09w==", "dev": true, "requires": { "@babel/runtime": "^7.7.2", - "@jimp/custom": "^0.10.1", - "@jimp/plugins": "^0.10.1", - "@jimp/types": "^0.10.1", - "core-js": "^3.4.1", + "@jimp/custom": "^0.13.0", + "@jimp/plugins": "^0.13.0", + "@jimp/types": "^0.13.0", "regenerator-runtime": "^0.13.3" }, "dependencies": { - "core-js": { - "version": "3.6.5", - "resolved": "https://registry.npmjs.org/core-js/-/core-js-3.6.5.tgz", - "integrity": "sha512-vZVEEwZoIsI+vPEuoF9Iqf5H7/M3eeQqWlQnYa8FSKKePuYTf5MWnxb5SDAzCa60b3JBRS5g9b+Dq7b1y/RCrA==", - "dev": true - }, "regenerator-runtime": { "version": "0.13.5", "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.5.tgz", @@ -2374,9 +2114,9 @@ } }, "jpeg-js": { - "version": "0.3.7", - "resolved": "https://registry.npmjs.org/jpeg-js/-/jpeg-js-0.3.7.tgz", - "integrity": "sha512-9IXdWudL61npZjvLuVe/ktHiA41iE8qFyLB+4VDTblEsWBzeg8WQTlktdUK4CdncUqtUgUg0bbOmTE2bKBKaBQ==", + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/jpeg-js/-/jpeg-js-0.4.0.tgz", + "integrity": "sha512-960VHmtN1vTpasX/1LupLohdP5odwAT7oK/VSm6mW0M58LbrBnowLAPWAZhWGhDAGjzbMnPXZxzB/QYgBwkN0w==", "dev": true }, "js-cookie": { @@ -2459,9 +2199,9 @@ } }, "lodash": { - "version": "4.17.15", - "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.15.tgz", - "integrity": "sha512-8xOcRHvCjnocdS5cpwXQXVzmmh5e5+saE2QGoeQmbKmRS6J3VQppPOIt0MnmE+4xlZoumy0GPG0D0MVIQbNA1A==", + "version": "4.17.19", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.19.tgz", + "integrity": "sha512-JNvd8XER9GQX0v2qJgsaN/mzFCNA5BRe/j8JN9d+tWyGLSodKQHKFicdwNYzWwI3wjRnaKPsGj1XkBjx/F96DQ==", "dev": true }, "lodash.memoize": { @@ -2486,9 +2226,9 @@ "dev": true }, "marked": { - "version": "0.8.2", - "resolved": "https://registry.npmjs.org/marked/-/marked-0.8.2.tgz", - "integrity": "sha512-EGwzEeCcLniFX51DhTpmTom+dSA/MG/OBUDjnWtHbEnjAH180VzUeAw+oE4+Zv+CoYBWyRlYOTR0N8SO9R1PVw==" + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/marked/-/marked-0.7.0.tgz", + "integrity": "sha512-c+yYdCZJQrsRjTPhUx7VKkApw9bwDkNbHUKo1ovgcfDjb2kc8rLuRbIFyXL5WOEUwzSSKo3IXpph2K6DqB/KZg==" }, "md5.js": { "version": "1.3.4", diff --git a/client/package.json b/client/package.json index daea1fb..e581cd6 100644 --- a/client/package.json +++ b/client/package.json @@ -3,13 +3,14 @@ "private": true, "scripts": { "build": "node build.js", - "watch": "c1=\"\";while :;do c2=$(find html js css img -type f -and -not -iname '*autogen*'|sort|xargs cat|md5sum);[[ $c1 != $c2 ]]&&npm run build -- --no-vendor-js;c1=$c2;sleep 1;done" + "watch": "c1=\"\";while :;do c2=$(find html js css img -type f -and -not -iname '*autogen*'|sort|xargs cat|md5sum);[[ $c1 != $c2 ]]&&npm run build -- --debug --no-vendor-js;c1=$c2;sleep 1;done" }, "dependencies": { - "@fortawesome/fontawesome-free": "^5.1.0", + "dompurify": "^2.0.11", + "font-awesome": "^4.7.0", "ios-inner-height": "^1.0.3", "js-cookie": "^2.2.0", - "marked": "^0.8.2", + "marked": "^0.7.0", "mousetrap": "^1.6.2", "nprogress": "^0.2.0", "superagent": "^3.8.3" @@ -23,7 +24,7 @@ "csso": "^3.5.1", "glob": "^7.1.2", "html-minifier": "^3.5.18", - "jimp": "^0.10.1", + "jimp": "^0.13.0", "stylus": "^0.54.5", "terser": "^3.7.7", "underscore": "^1.9.1" @@ -13,6 +13,7 @@ - [Error handling](#error-handling) - [Field selecting](#field-selecting) - [Versioning](#versioning) + - [Webhooks](#webhooks) 2. [API reference](#api-reference) @@ -44,6 +45,20 @@ - [Getting featured post](#getting-featured-post) - [Featuring post](#featuring-post) - [Reverse image search](#reverse-image-search) + - Pool categories + - [Listing pool categories](#listing-pool-categories) + - [Creating pool category](#creating-pool-category) + - [Updating pool category](#updating-pool-category) + - [Getting pool category](#getting-pool-category) + - [Deleting pool category](#deleting-pool-category) + - [Setting default pool category](#setting-default-pool-category) + - Pools + - [Listing pools](#listing-pool) + - [Creating pool](#creating-pool) + - [Updating pool](#updating-pool) + - [Getting pool](#getting-pool) + - [Deleting pool](#deleting-pool) + - [Merging pools](#merging-pools) - Comments - [Listing comments](#listing-comments) - [Creating comment](#creating-comment) @@ -82,6 +97,9 @@ - [Micro tag](#micro-tag) - [Post](#post) - [Micro post](#micro-post) + - [Pool category](#pool-category) + - [Pool](#pool) + - [Micro pool](#micro-pool) - [Note](#note) - [Comment](#comment) - [Snapshot](#snapshot) @@ -145,7 +163,10 @@ way. The files, however, should be passed as regular fields appended with a `Url` suffix. For example, to use `http://example.com/file.jpg` in an API that accepts a file named `content`, the client should pass `{"contentUrl":"http://example.com/file.jpg"}` as a part of the JSON message -body. +body. When creating or updating post content using this method, the server can +also be configured to employ [youtube-dl](https://github.com/ytdl-org/youtube-dl) +to download content from popular sites such as youtube, gfycat, etc. Access to +youtube-dl can be configured with the `'uploads:use_downloader'` permission Finally, in some cases the user might want to reuse one file between the requests to save the bandwidth (for example, reverse search + consecutive @@ -257,6 +278,15 @@ reject the request as well, in which case the client is encouraged to notify the user about the situation. +## Webhooks + +System administrators can choose to configure webhooks to track events. +Webhook URIs can be configured in `config.yaml` (See `config.yaml.dist` for +example). Upon any event, the API will send a `POST` request to the listed +URIs with a [snapshot resource](#snapshot) generated with anonymous user +privileges as the message body, in JSON format. + + # API reference Depending on the deployment, the URLs might be relative to some base path such @@ -291,7 +321,8 @@ data. ```json5 { "name": <name>, - "color": <color> + "color": <color>, + "order": <order> // optional } ``` @@ -324,6 +355,7 @@ data. "version": <version>, "name": <name>, // optional "color": <color>, // optional + "order": <order> // optional } ``` @@ -721,6 +753,7 @@ data. | `submit` | alias of upload | | `comment` | commented by given user (accepts wildcards) | | `fav` | favorited by given user (accepts wildcards) | + | `pool` | belonging to the pool with the given ID | | `tag-count` | having given number of tags | | `comment-count` | having given number of comments | | `fav-count` | favorited by given number of users | @@ -843,12 +876,13 @@ data. automatically created. Tags created automatically have no implications, no suggestions, one name and their category is set to the first tag category found. Safety must be any of `"safe"`, `"sketchy"` or `"unsafe"`. Relations - must contain valid post IDs. `<flag>` currently can be only `"loop"` to - enable looping for video posts. Sending empty `thumbnail` will cause the - post to use default thumbnail. If `anonymous` is set to truthy value, the - uploader name won't be recorded (privilege verification still applies; it's - possible to disallow anonymous uploads completely from config.) For details - how to pass `content` and `thumbnail`, see [file uploads](#file-uploads). + must contain valid post IDs. If `<flag>` is omitted, they will be defined + by default (`"loop"` will be set for all video posts, and `"sound"` will be + auto-detected). Sending empty `thumbnail` will cause the post to use default + thumbnail. If `anonymous` is set to truthy value, the uploader name won't be + recorded (privilege verification still applies; it's possible to disallow + anonymous uploads completely from config.) For details on how to pass `content` + and `thumbnail`, see [file uploads](#file-uploads). ## Updating post - **Request** @@ -892,9 +926,9 @@ data. automatically created. Tags created automatically have no implications, no suggestions, one name and their category is set to the first tag category found. Safety must be any of `"safe"`, `"sketchy"` or `"unsafe"`. Relations - must contain valid post IDs. `<flag>` currently can be only `"loop"` to - enable looping for video posts. Sending empty `thumbnail` will reset the - post thumbnail to default. For details how to pass `content` and + must contain valid post IDs. `<flag>` can be either `"loop"` to enable looping + for video posts or `"sound"` to indicate sound. Sending empty `thumbnail` will + reset the post thumbnail to default. For details how to pass `content` and `thumbnail`, see [file uploads](#file-uploads). All fields except the [`version`](#versioning) are optional - update concerns only provided fields. @@ -1114,6 +1148,383 @@ data. Retrieves posts that look like the input image. +## Listing pool categories +- **Request** + + `GET /pool-categories` + +- **Output** + + An [unpaged search result](#unpaged-search-result), for which `<resource>` + is a [pool category resource](#pool-category). + +- **Errors** + + - privileges are too low + +- **Description** + + Lists all pool categories. Doesn't use paging. + +## Creating pool category +- **Request** + + `POST /pool-categories` + +- **Input** + + ```json5 + { + "name": <name>, + "color": <color> + } + ``` + +- **Output** + + A [pool category resource](#pool-category). + +- **Errors** + + - the name is used by an existing pool category (names are case insensitive) + - the name is invalid or missing + - the color is invalid or missing + - privileges are too low + +- **Description** + + Creates a new pool category using specified parameters. Name must match + `pool_category_name_regex` from server's configuration. First category + created becomes the default category. + +## Updating pool category +- **Request** + + `PUT /pool-category/<name>` + +- **Input** + + ```json5 + { + "version": <version>, + "name": <name>, // optional + "color": <color>, // optional + } + ``` + +- **Output** + + A [pool category resource](#pool-category). + +- **Errors** + + - the version is outdated + - the pool category does not exist + - the name is used by an existing pool category (names are case insensitive) + - the name is invalid + - the color is invalid + - privileges are too low + +- **Description** + + Updates an existing pool category using specified parameters. Name must + match `pool_category_name_regex` from server's configuration. All fields + except the [`version`](#versioning) are optional - update concerns only + provided fields. + +## Getting pool category +- **Request** + + `GET /pool-category/<name>` + +- **Output** + + A [pool category resource](#pool-category). + +- **Errors** + + - the pool category does not exist + - privileges are too low + +- **Description** + + Retrieves information about an existing pool category. + +## Deleting pool category +- **Request** + + `DELETE /pool-category/<name>` + +- **Input** + + ```json5 + { + "version": <version> + } + ``` + +- **Output** + + ```json5 + {} + ``` + +- **Errors** + + - the version is outdated + - the pool category does not exist + - the pool category is used by some pools + - the pool category is the last pool category available + - privileges are too low + +- **Description** + + Deletes existing pool category. The pool category to be deleted must have no + usages. + +## Setting default pool category +- **Request** + + `PUT /pool-category/<name>/default` + +- **Input** + + ```json5 + {} + ``` + +- **Output** + + A [pool category resource](#pool-category). + +- **Errors** + + - the pool category does not exist + - privileges are too low + +- **Description** + + Sets given pool category as default. All new pools created manually or + automatically will have this category. + +## Listing pools +- **Request** + + `GET /pools/?offset=<initial-pos>&limit=<page-size>&query=<query>` + +- **Output** + + A [paged search result resource](#paged-search-result), for which + `<resource>` is a [pool resource](#pool). + +- **Errors** + + - privileges are too low + +- **Description** + + Searches for pools. + + **Anonymous tokens** + + Same as `name` token. + + **Named tokens** + + | `<key>` | Description | + | ------------------- | ----------------------------------------- | + | `name` | having given name (accepts wildcards) | + | `category` | having given category (accepts wildcards) | + | `creation-date` | created at given date | + | `creation-time` | alias of `creation-date` | + | `last-edit-date` | edited at given date | + | `last-edit-time` | alias of `last-edit-date` | + | `edit-date` | alias of `last-edit-date` | + | `edit-time` | alias of `last-edit-date` | + | `post-count` | used in given number of posts | + + **Sort style tokens** + + | `<value>` | Description | + | ------------------- | ---------------------------- | + | `random` | as random as it can get | + | `name` | A to Z | + | `category` | category (A to Z) | + | `creation-date` | recently created first | + | `creation-time` | alias of `creation-date` | + | `last-edit-date` | recently edited first | + | `last-edit-time` | alias of `creation-time` | + | `edit-date` | alias of `creation-time` | + | `edit-time` | alias of `creation-time` | + | `post-count` | used in most posts first | + + **Special tokens** + + None. + +## Creating pool +- **Request** + + `POST /pools/create` + +- **Input** + + ```json5 + { + "names": [<name1>, <name2>, ...], + "category": <category>, + "description": <description>, // optional + "posts": [<id1>, <id2>, ...], // optional + } + ``` + +- **Output** + + A [pool resource](#pool). + +- **Errors** + + - any name is invalid + - category is invalid + - no name was specified + - there is at least one duplicate post + - at least one post ID does not exist + - privileges are too low + +- **Description** + + Creates a new pool using specified parameters. Names, suggestions and + implications must match `pool_name_regex` from server's configuration. + Category must exist and is the same as `name` field within + [`<pool-category>` resource](#pool-category). `posts` is an optional list of + integer post IDs. If the specified posts do not exist, an error will be + thrown. + +## Updating pool +- **Request** + + `PUT /pool/<id>` + +- **Input** + + ```json5 + { + "version": <version>, + "names": [<name1>, <name2>, ...], // optional + "category": <category>, // optional + "description": <description>, // optional + "posts": [<id1>, <id2>, ...], // optional + } + ``` + +- **Output** + + A [pool resource](#pool). + +- **Errors** + + - the version is outdated + - the pool does not exist + - any name is invalid + - category is invalid + - no name was specified + - there is at least one duplicate post + - at least one post ID does not exist + - privileges are too low + +- **Description** + + Updates an existing pool using specified parameters. Names, suggestions and + implications must match `pool_name_regex` from server's configuration. + Category must exist and is the same as `name` field within + [`<pool-category>` resource](#pool-category). `posts` is an optional list of + integer post IDs. If the specified posts do not exist yet, an error will be + thrown. The full list of post IDs must be provided if they are being + updated, and the previous list of posts will be replaced with the new one. + All fields except the [`version`](#versioning) are optional - update + concerns only provided fields. + +## Getting pool +- **Request** + + `GET /pool/<id>` + +- **Output** + + A [pool resource](#pool). + +- **Errors** + + - the pool does not exist + - privileges are too low + +- **Description** + + Retrieves information about an existing pool. + +## Deleting pool +- **Request** + + `DELETE /pool/<name>` + +- **Input** + + ```json5 + { + "version": <version> + } + ``` + +- **Output** + + ```json5 + {} + ``` + +- **Errors** + + - the version is outdated + - the pool does not exist + - privileges are too low + +- **Description** + + Deletes existing pool. All posts in the pool will only have their relation + to the pool removed. + +## Merging pools +- **Request** + + `POST /pool-merge/` + +- **Input** + + ```json5 + { + "removeVersion": <source-pool-version>, + "remove": <source-pool-id>, + "mergeToVersion": <target-pool-version>, + "mergeTo": <target-pool-id> + } + ``` + +- **Output** + + A [pool resource](#pool) containing the merged pool. + +- **Errors** + + - the version of either pool is outdated + - the source or target pool does not exist + - the source pool is the same as the target pool + - privileges are too low + +- **Description** + + Removes source pool and merges all of its posts with the target pool. Other + pool properties such as category and aliases do not get transferred and are + discarded. + ## Listing comments - **Request** @@ -1879,7 +2290,8 @@ experience. "version": <version>, "name": <name>, "color": <color>, - "usages": <usages> + "usages": <usages>, + "order": <order>, "default": <is-default> } ``` @@ -1890,6 +2302,7 @@ experience. - `<name>`: the category name. - `<color>`: the category color. - `<usages>`: how many tags is the given category used with. +- `<order>`: the order in which tags with this category are displayed, ascending. - `<is-default>`: whether the tag category is the default one. ## Tag @@ -1979,6 +2392,11 @@ One file together with its metadata posted to the site. <comment>, <comment>, <comment> + ], + "pools": [ + <pool>, + <pool>, + <pool> ] } ``` @@ -2042,6 +2460,7 @@ One file together with its metadata posted to the site. - `<mime-type>`: subsidiary to `<type>`, used to tell exact content format; useful for `<video>` tags for instance. - `<comment>`: a [comment resource](#comment) for given post. +- `<pool>`: a [micro pool resource](#micro-pool) in which the post is a member of. ## Micro post **Description** @@ -2069,6 +2488,74 @@ A text annotation rendered on top of the post. will draw it inside the post's upper left quarter. - `<text>`: the annotation text. The client should render is as Markdown. +## Pool category +**Description** + +A single pool category. The primary purpose of pool categories is to distinguish +certain pool types (such as series, relations etc.), which improves user +experience. + +**Structure** + +```json5 +{ + "version": <version>, + "name": <name>, + "color": <color>, + "usages": <usages>, + "default": <is-default> +} +``` + +**Field meaning** + +- `<version>`: resource version. See [versioning](#versioning). +- `<name>`: the category name. +- `<color>`: the category color. +- `<usages>`: how many pools is the given category used with. +- `<is-default>`: whether the pool category is the default one. + +## Pool +**Description** + +An ordered list of posts, with a description and category. + +**Structure** + +```json5 +{ + "version": <version>, + "id": <id>, + "names": <names>, + "category": <category>, + "posts": <suggestions>, + "creationTime": <creation-time>, + "lastEditTime": <last-edit-time>, + "postCount": <post-count>, + "description": <description> +} +``` + +**Field meaning** + +- `<version>`: resource version. See [versioning](#versioning). +- `<id>`: the pool identifier. +- `<names>`: a list of pool names (aliases). +- `<category>`: the name of the category the given pool belongs to. +- `<posts>`: an ordered list of posts, serialized as [micro + post resource](#micro-post). Posts are ordered by insertion by default. +- `<creation-time>`: time the pool was created, formatted as per RFC 3339. +- `<last-edit-time>`: time the pool was edited, formatted as per RFC 3339. +- `<post-count>`: the number of posts the pool has. +- `<description>`: the pool description (instructions how to use, history etc.) + The client should render it as Markdown. + +## Micro pool +**Description** + +A [pool resource](#pool) stripped down to `id`, `names`, `category`, +`description` and `postCount` fields. + ## Comment **Description** @@ -2081,7 +2568,7 @@ A comment under a post. "version": <version>, "id": <id>, "postId": <post-id>, - "user": <author> + "user": <author>, "text": <text>, "creationTime": <creation-time>, "lastEditTime": <last-edit-time>, @@ -2140,6 +2627,7 @@ A snapshot is a version of a database resource. | `"tag"` | first tag name at given time | | `"tag_category"` | tag category name at given time | | `"post"` | post ID | + | `"pool"` | pool ID | - `<issuer>`: a [micro user resource](#micro-user) representing the user who has made the change. @@ -2228,7 +2716,7 @@ dictionaries as created by creation snapshots, which is described below. }, "primitive-property": { - "type": "primitive change": + "type": "primitive change", "old-value": "<primitive>", "new-value": "<primitive>" }, diff --git a/doc/INSTALL.md b/doc/INSTALL.md new file mode 100644 index 0000000..d978e4a --- /dev/null +++ b/doc/INSTALL.md @@ -0,0 +1,174 @@ +This assumes that you have Docker (version 17.05 or greater) +and Docker Compose (version 1.6.0 or greater) already installed. + +### Prepare things + +1. Download the `szurubooru` source: + + ```console + user@host:~$ git clone https://github.com/rr-/szurubooru.git szuru + user@host:~$ cd szuru + ``` +2. Configure the application: + + ```console + user@host:szuru$ cp server/config.yaml.dist server/config.yaml + user@host:szuru$ edit server/config.yaml + ``` + + Pay extra attention to these fields: + + - secret + - the `smtp` section. + + You can omit lines when you want to use the defaults of that field. + +3. Configure Docker Compose: + + ```console + user@host:szuru$ cp doc/example.env .env + user@host:szuru$ edit .env + ``` + + Change the values of the variables in `.env` as needed. + Read the comments to guide you. Note that `.env` should be in the root + directory of this repository. + +### Running the Application + +Download containers: +```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 +``` + +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) +``` + +To stop all containers: +```console +user@host:szuru$ docker-compose down +``` + +### Additional Features + +1. **CLI-level administrative tools** + + You can use the included `szuru-admin` script to perform various + administrative tasks such as changing or resetting a user password. To + run from docker: + + ```console + user@host:szuru$ docker-compose run server ./szuru-admin --help + ``` + + will give you a breakdown on all available commands. + +2. **Using a seperate domain to host static files (image content)** + + If you want to host your website on, (`http://example.com/`) but want + to serve the images on a different domain, (`http://static.example.com/`) + then you can run the backend container with an additional environment + variable `DATA_URL=http://static.example.com/`. Make sure that this + additional host has access contents to the `/data` volume mounted in the + backend. + +3. **Setting a specific base URI for proxying** + + Some users may wish to access the service at a different base URI, such + as `http://example.com/szuru/`, commonly when sharing multiple HTTP + services on one domain using a reverse proxy. In this case, simply set + `BASE_URL="/szuru/"` in your `.env` file. + + Note that this will require a reverse proxy to function. You should set + your reverse proxy to proxy `http(s)://example.com/szuru` to + `http://<internal IP or hostname of frontend container>/`. For an NGINX + reverse proxy, that will appear as: + + ```nginx + location /szuru { + proxy_http_version 1.1; + proxy_pass http://<internal IP or hostname of frontend container>/; + + proxy_set_header Host $http_host; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Scheme $scheme; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header X-Script-Name /szuru; + } + ``` + +4. **Preparing for production** + + If you plan on using szurubooru in a production setting, you may opt to + use a reverse proxy for added security and caching capabilities. Start + by having the client docker listen only on localhost by changing `PORT` + in your `.env` file to `127.0.0.1:8080` instead of simply `:8080`. Then + configure NGINX (or your caching/reverse proxy server of your choice) + to proxy_pass `http://127.0.0.1:8080`. An example config is shown below: + + ```nginx + # ideally, use ssl termination + cdn with a provider such as cloudflare. + # modify as needed! + + # rate limiting zone + # poor man's ddos protection, essentially + limit_req_zone $binary_remote_addr zone=throttle:10m rate=25r/s; + + # www -> non-www + server { + listen 80; + listen [::]:80; + server_tokens off; + server_name www.example.com + return 301 http://example.com$request_uri; + } + + server { + server_name example.com; + client_max_body_size 100M; + client_body_timeout 30s; + server_tokens off; + location / { + limit_req zone=throttle burst=5 delay=3; + proxy_http_version 1.1; + proxy_pass http://127.0.0.1:8080; + proxy_set_header Host $http_host; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Scheme $scheme; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header X-Script-Name /szuru; + error_page 500 501 502 504 505 506 507 508 509 510 511 @err; + error_page 503 @throttle; + } + + location @err { + return 500 "server error. please try again later."; + default_type text/plain; + } + location @throttle { + return 503 "we've detected abuse on your ip. please wait and try again later."; + default_type text/plain; + } + listen 80; + listen [::]:80; + } + ``` diff --git a/doc/developer-utils/create-alembic-migration.sh b/doc/developer-utils/create-alembic-migration.sh new file mode 100755 index 0000000..df7a29e --- /dev/null +++ b/doc/developer-utils/create-alembic-migration.sh @@ -0,0 +1,28 @@ +#!/bin/sh + +# Helper script to create an alembic migration file via Docker + +if [ $# -lt 1 ]; then + echo "Need to pass a name for your migration file" > /dev/stderr + exit 1 +fi + +# Create a dummy container +WORKDIR="$(git rev-parse --show-toplevel)/server" +IMAGE=$(docker build -q "${WORKDIR}") +CONTAINER=$(docker run -d ${IMAGE} tail -f /dev/null) + +# Create the migration script +docker exec -i \ + -e PYTHONPATH='/opt/app' \ + -e POSTGRES_HOST='x' \ + -e POSTGRES_USER='x' \ + -e POSTGRES_PASSWORD='x' \ + ${CONTAINER} alembic revision -m "$1" + +# Copy the file over from the container +docker cp ${CONTAINER}:/opt/app/szurubooru/migrations/versions/ \ + "${WORKDIR}/szurubooru/migrations/" + +# Destroy the dummy container +docker rm -f ${CONTAINER} > /dev/null diff --git a/example.env b/doc/example.env index 37c9cfa..59e1e85 100644 --- a/example.env +++ b/doc/example.env @@ -10,9 +10,12 @@ BUILD_INFO=latest # otherwise the port specified here will be publicly accessible PORT=8080 +# URL base to run szurubooru under +# See "Additional Features" section in INSTALL.md +BASE_URL=/ + # Directory to store image data MOUNT_DATA=/var/local/szurubooru/data # Directory to store database files MOUNT_SQL=/var/local/szurubooru/sql - diff --git a/docker-compose.yml b/docker-compose.yml index 108613f..1da23bd 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -5,60 +5,43 @@ version: '2' services: - api: - build: - context: ./server + + server: + image: szurubooru/server:latest depends_on: - sql - - elasticsearch environment: - ## These should be the names of the dependent containers listed above, + ## These should be the names of the dependent containers listed below, ## or FQDNs/IP addresses if these services are running outside of Docker POSTGRES_HOST: sql - ESEARCH_HOST: elasticsearch ## Credentials for database: POSTGRES_USER: POSTGRES_PASSWORD: ## Commented Values are Default: #POSTGRES_DB: defaults to same as POSTGRES_USER #POSTGRES_PORT: 5432 - #ESEARCH_PORT: 9200 - #ESEARCH_INDEX: szurubooru #LOG_SQL: 0 (1 for verbose SQL logs) volumes: - "${MOUNT_DATA}:/data" - "./server/config.yaml:/opt/app/config.yaml" + client: - build: - context: ./client - args: - BUILD_INFO: + image: szurubooru/client:latest depends_on: - - api + - server environment: - BACKEND_HOST: api + BACKEND_HOST: server + BASE_URL: volumes: - "${MOUNT_DATA}:/data:ro" ports: - "${PORT}:80" + sql: - image: postgres:alpine + image: postgres:11-alpine restart: unless-stopped environment: POSTGRES_USER: POSTGRES_PASSWORD: volumes: - "${MOUNT_SQL}:/var/lib/postgresql/data" - elasticsearch: - image: docker.elastic.co/elasticsearch/elasticsearch-oss:6.3.1 - environment: - ## Specifies the Java heap size used - ## Read - ## https://www.elastic.co/guide/en/elasticsearch/reference/current/docker.html - ## for more info - ES_JAVA_OPTS: -Xms512m -Xmx512m - volumes: - - index:/usr/share/elasticsearch/data - -volumes: - index: # Scratch space for ElasticSearch index, will be rebuilt if lost diff --git a/nginx.vhost.production b/nginx.vhost.production deleted file mode 100644 index 5a95b52..0000000 --- a/nginx.vhost.production +++ /dev/null @@ -1,49 +0,0 @@ -# example for a production vhost for szurubooru. -# ideally, use ssl termination + cdn with a provider such as cloudflare. -# modify as needed! - -# rate limiting zone -# poor man's ddos protection, essentially -limit_req_zone $binary_remote_addr zone=throttle:10m rate=25r/s; - -# www -> non-www -server { - listen 80; - listen [::]:80; - server_tokens off; - server_name www.example.com - return 301 http://example.com$request_uri; -} - -server { - server_name example.com; - client_max_body_size 100M; - client_body_timeout 30s; - server_tokens off; - location / { - limit_req zone=throttle burst=5 delay=3; - proxy_http_version 1.1; - proxy_pass http://127.0.0.1:8080; - proxy_set_header Host $http_host; - proxy_set_header Upgrade $http_upgrade; - proxy_set_header Connection "upgrade"; - proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - proxy_set_header X-Scheme $scheme; - proxy_set_header X-Real-IP $remote_addr; - proxy_set_header X-Forwarded-Proto $scheme; - proxy_set_header X-Script-Name /szuru; - error_page 500 501 502 504 505 506 507 508 509 510 511 @err; - error_page 503 @throttle; - } - - location @err { - return 500 "server error. please try again later."; - default_type text/plain; - } - location @throttle { - return 503 "we've detected abuse on your ip. please wait and try again later."; - default_type text/plain; - } - listen 80; - listen [::]:80; -} diff --git a/server/.dockerignore b/server/.dockerignore index a20ffa9..cdfeafc 100644 --- a/server/.dockerignore +++ b/server/.dockerignore @@ -1,8 +1,15 @@ -szurubooru/tests/* -setup.cfg -.pylintrc -mypi.ini +# Linter configs +pyproject.toml +.flake8 +# Python requirements files +requirements.txt +dev-requirements.txt + +# Docker build files Dockerfile .dockerignore -**/.gitignore +hooks/ + +# User configured config file +config.yaml diff --git a/server/.flake8 b/server/.flake8 new file mode 100644 index 0000000..023ae12 --- /dev/null +++ b/server/.flake8 @@ -0,0 +1,5 @@ +[flake8] +filename = szurubooru/ +exclude = __pycache__ +ignore = F401, W503, W504, E203, E231 +max-line-length = 79 diff --git a/server/.pylintrc b/server/.pylintrc deleted file mode 100644 index 846bac6..0000000 --- a/server/.pylintrc +++ /dev/null @@ -1,37 +0,0 @@ -[basic] -function-rgx=^_?[a-z_][a-z0-9_]{2,}$|^test_ -method-rgx=^[a-z_][a-z0-9_]{2,}$|^test_ -const-rgx=^[A-Z_]+$|^_[a-zA-Z_]*$ -good-names=ex,_,logger,i - -[variables] -dummy-variables-rgx=_|dummy - -[format] -max-line-length=79 - -[messages control] -reports=no -disable= - # we're not java - missing-docstring, - broad-except, - - # covered better by pycodestyle - bad-continuation, - - # we're adults - redefined-builtin, - duplicate-code, - too-many-return-statements, - too-many-arguments, - - # plain stupid - no-self-use, - too-few-public-methods - -[typecheck] -generated-members=add|add_all - -[similarities] -min-similarity-lines=5 diff --git a/server/Dockerfile b/server/Dockerfile index dd5bd5a..9a597a1 100644 --- a/server/Dockerfile +++ b/server/Dockerfile @@ -1,46 +1,90 @@ -FROM scratch as approot +ARG ALPINE_VERSION=3.12 + + +FROM alpine:$ALPINE_VERSION as prereqs WORKDIR /opt/app -COPY alembic.ini wait-for-es generate-thumb ./ -COPY szurubooru/ ./szurubooru/ -COPY config.yaml.dist ./ +RUN apk --no-cache add \ + python3 \ + ffmpeg \ + py3-pip \ + # from requirements.txt: + py3-yaml \ + py3-psycopg2 \ + py3-sqlalchemy \ + py3-certifi \ + py3-numpy \ + py3-pillow \ + py3-pynacl \ + py3-tz \ + py3-pyrfc3339 \ + && pip3 install --no-cache-dir --disable-pip-version-check \ + alembic \ + "coloredlogs==5.0" \ + youtube-dl \ + && apk --no-cache del py3-pip + +COPY ./ /opt/app/ +RUN rm -rf /opt/app/szurubooru/tests + + +FROM prereqs as testing +WORKDIR /opt/app + +RUN apk --no-cache add \ + py3-pip \ + py3-pytest \ + py3-pytest-cov \ + postgresql \ + && pip3 install --no-cache-dir --disable-pip-version-check \ + pytest-pgsql \ + freezegun \ + && apk --no-cache del py3-pip \ + && addgroup app \ + && adduser -SDH -h /opt/app -g '' -G app app \ + && chown app:app /opt/app + +COPY --chown=app:app ./szurubooru/tests /opt/app/szurubooru/tests/ + +ENV TEST_ENVIRONMENT="true" +USER app +ENTRYPOINT ["pytest", "--tb=short"] +CMD ["szurubooru/"] -FROM python:3.6-slim +FROM prereqs as release WORKDIR /opt/app ARG PUID=1000 ARG PGID=1000 -ARG PORT=6666 -RUN \ - # Set users - mkdir -p /opt/app /data && \ - groupadd -g ${PGID} app && \ - useradd -d /opt/app -M -c '' -g app -r -u ${PUID} app && \ - chown -R app:app /opt/app /data && \ - # Create init file - echo "#!/bin/sh" >> /init && \ - echo "set -e" >> /init && \ - echo "cd /opt/app" >> /init && \ - echo "./wait-for-es" >> /init && \ - echo "alembic upgrade head" >> /init && \ - echo "exec waitress-serve --port ${PORT} szurubooru.facade:app" \ - >> /init && \ - chmod a+x /init && \ - # Install ffmpeg - apt-get -yqq update && \ - apt-get -yq install --no-install-recommends ffmpeg && \ - rm -rf /var/lib/apt/lists/* && \ - # Install waitress - pip3 install --no-cache-dir waitress -COPY --chown=app:app requirements.txt ./requirements.txt -RUN pip3 install --no-cache-dir -r ./requirements.txt +RUN apk --no-cache add \ + dumb-init \ + py3-setuptools \ + py3-waitress \ + && mkdir -p /opt/app /data \ + && addgroup -g ${PGID} app \ + && adduser -SDH -h /opt/app -g '' -G app -u ${PUID} app \ + && chown -R app:app /opt/app /data -# done to minimize number of layers in final image -COPY --chown=app:app --from=approot / / +USER app +CMD ["/opt/app/docker-start.sh"] -VOLUME ["/data/"] +ARG PORT=6666 +ENV PORT=${PORT} EXPOSE ${PORT} -USER app -CMD ["/init"] + +VOLUME ["/data/"] + +ARG DOCKER_REPO +ARG BUILD_DATE +ARG SOURCE_COMMIT +LABEL \ + maintainer="" \ + org.opencontainers.image.title="${DOCKER_REPO}" \ + org.opencontainers.image.url="https://github.com/rr-/szurubooru" \ + org.opencontainers.image.documentation="https://github.com/rr-/szurubooru/blob/${SOURCE_COMMIT}/doc/INSTALL.md" \ + org.opencontainers.image.created="${BUILD_DATE}" \ + org.opencontainers.image.source="https://github.com/rr-/szurubooru" \ + org.opencontainers.image.revision="${SOURCE_COMMIT}" \ + org.opencontainers.image.licenses="GPL-3.0" diff --git a/server/config.yaml.dist b/server/config.yaml.dist index 7440777..3de5568 100644 --- a/server/config.yaml.dist +++ b/server/config.yaml.dist @@ -3,12 +3,10 @@ # shown in the website title and on the front page name: szurubooru -# user agent name used to download files from the web on behalf of the api users -user_agent: -# used to salt the users' password hashes +# full url to the homepage of this szurubooru site, with no trailing slash +domain: # example: http://example.com +# used to salt the users' password hashes and generate filenames for static content secret: change -# required for running the test suite -test_database: 'sqlite:///:memory:' # Delete thumbnails and source files on post delete # Original functionality is no, to mitigate the impacts of admins going @@ -21,18 +19,29 @@ thumbnails: post_width: 300 post_height: 300 +# settings used to download files from the web on behalf of the api users +user_agent: +max_dl_filesize: 25.0E+6 # maximum filesize limit in bytes + +# automatically convert animated GIF uploads to video formats convert: gif: to_webm: false to_mp4: false +# allow posts to be uploaded even if some image processing errors occur +allow_broken_uploads: false + # used to send password reset e-mails smtp: host: # example: localhost port: # example: 25 user: # example: bot pass: # example: groovy123 - # host can be left empty, in which case it is recommended to fill contactEmail. + from: # example: noreply@example.com + # if host is left empty the password reset feature will be disabled, + # in which case it is recommended to fill contactEmail so that users + # know who to contact when they want to reset their password contact_email: # example: bob@example.com. Meant for manual password reset procedures @@ -41,12 +50,21 @@ enable_safety: yes tag_name_regex: ^\S+$ tag_category_name_regex: ^[^\s%+#/]+$ +pool_name_regex: ^\S+$ +pool_category_name_regex: ^[^\s%+#/]+$ + # don't make these more restrictive unless you want to annoy people; if you do # customize them, make sure to update the instructions in the registration form # template as well. password_regex: '^.{5,}$' user_name_regex: '^[a-zA-Z0-9_-]{1,32}$' +# webhooks to call when events occur (such as post/tag/user/etc. changes) +# the listed urls will be called with a HTTP POST request with a payload +# containing a snapshot resource as JSON. See doc/API.md for details +webhooks: + # - https://api.example.com/webhooks/ + default_rank: regular privileges: @@ -112,6 +130,7 @@ privileges: 'tag_categories:create': moderator 'tag_categories:edit:name': moderator 'tag_categories:edit:color': moderator + 'tag_categories:edit:order': moderator 'tag_categories:list': anonymous 'tag_categories:view': anonymous 'tag_categories:delete': moderator @@ -123,6 +142,24 @@ privileges: 'metrics:list': regular 'metrics:delete': moderator + 'pools:create': regular + 'pools:edit:names': power + 'pools:edit:category': power + 'pools:edit:description': power + 'pools:edit:posts': power + 'pools:list': regular + 'pools:view': anonymous + 'pools:merge': moderator + 'pools:delete': moderator + + 'pool_categories:create': moderator + 'pool_categories:edit:name': moderator + 'pool_categories:edit:color': moderator + 'pool_categories:list': anonymous + 'pool_categories:view': anonymous + 'pool_categories:delete': moderator + 'pool_categories:set_default': moderator + 'comments:create': regular 'comments:delete:any': moderator 'comments:delete:own': regular @@ -135,6 +172,7 @@ privileges: 'snapshots:list': power 'uploads:create': regular + 'uploads:use_downloader': power ## ONLY SET THESE IF DEPLOYING OUTSIDE OF DOCKER #debug: 0 # generate server logs? @@ -144,7 +182,3 @@ privileges: ## usage: schema://user:password@host:port/database_name ## example: postgres://szuru:dog@localhost:5432/szuru_test #database: -#elasticsearch: # used for reverse image search -# host: localhost -# port: 9200 -# index: szurubooru diff --git a/server/dev-requirements.txt b/server/dev-requirements.txt index c9dc234..04788a3 100644 --- a/server/dev-requirements.txt +++ b/server/dev-requirements.txt @@ -1,4 +1,5 @@ pytest>=2.9.1 pytest-cov>=2.2.1 +pytest-pgsql>=1.1.1 freezegun>=0.3.6 pycodestyle>=2.0.0 diff --git a/server/docker-start.sh b/server/docker-start.sh new file mode 100755 index 0000000..34a0e49 --- /dev/null +++ b/server/docker-start.sh @@ -0,0 +1,8 @@ +#!/usr/bin/dumb-init /bin/sh +set -e +cd /opt/app + +alembic upgrade head + +echo "Starting szurubooru API on port ${PORT}" +exec waitress-serve-3 --port ${PORT} szurubooru.facade:app diff --git a/server/generate-thumb b/server/generate-thumb deleted file mode 100755 index 87d7e02..0000000 --- a/server/generate-thumb +++ /dev/null @@ -1,27 +0,0 @@ -#!/usr/bin/env python3 - -''' -Generates thumbnails for posts from CLI. Useful for testing changes to -thumbnail generators, and for weird inputs. -''' - -import argparse -import os.path -import sys -from szurubooru.func import posts - -def main(): - parser = argparse.ArgumentParser('Starts szurubooru using waitress.') - parser.add_argument('post_id', metavar='POST', help='post to generate thumbnail for') - args = parser.parse_args() - - try: - post = posts.get_post_by_id(args.post_id) - posts.generate_post_thumbnail(post) - except posts.PostNotFoundError: - pass - except: - raise - -if __name__ == '__main__': - main() diff --git a/server/hooks/build b/server/hooks/build new file mode 100755 index 0000000..b5e914b --- /dev/null +++ b/server/hooks/build @@ -0,0 +1,7 @@ +#!/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 new file mode 100755 index 0000000..1b1e0ad --- /dev/null +++ b/server/hooks/post_push @@ -0,0 +1,19 @@ +#!/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 new file mode 100755 index 0000000..b325186 --- /dev/null +++ b/server/hooks/test @@ -0,0 +1,8 @@ +#!/bin/sh +set -e + +docker run --rm \ + -t $(docker build --target testing -q .) \ + --color=no szurubooru/ + +exit $? diff --git a/server/mypy.ini b/server/mypy.ini deleted file mode 100644 index a0300b7..0000000 --- a/server/mypy.ini +++ /dev/null @@ -1,14 +0,0 @@ -[mypy] -ignore_missing_imports = True -follow_imports = skip -disallow_untyped_calls = True -disallow_untyped_defs = True -check_untyped_defs = True -disallow_subclassing_any = False -warn_redundant_casts = True -warn_unused_ignores = True -strict_optional = True -strict_boolean = False - -[mypy-szurubooru.tests.*] -ignore_errors=True diff --git a/server/pyproject.toml b/server/pyproject.toml new file mode 100644 index 0000000..ccf47fc --- /dev/null +++ b/server/pyproject.toml @@ -0,0 +1,10 @@ +[tool.black] +line-length = 79 + +[tool.isort] +known_first_party = ["szurubooru"] +known_third_party = ["PIL", "alembic", "coloredlogs", "freezegun", "nacl", "numpy", "pyrfc3339", "pytest", "pytz", "sqlalchemy", "yaml", "youtube_dl"] +multi_line_output = 3 +include_trailing_comma = true +force_grid_wrap = 0 +use_parentheses = true diff --git a/server/requirements.txt b/server/requirements.txt index a142493..d80ec06 100644 --- a/server/requirements.txt +++ b/server/requirements.txt @@ -3,10 +3,10 @@ pyyaml>=3.11 psycopg2-binary>=2.6.1 SQLAlchemy>=1.0.12 coloredlogs==5.0 -elasticsearch>=5.0.0,<7.0.0 -elasticsearch-dsl>=5.0.0,<7.0.0 +certifi>=2017.11.5 numpy>=1.8.2 pillow>=4.3.0 -pynacl==1.2.1 +pynacl>=1.2.1 pytz>=2018.3 pyRFC3339>=1.0 +youtube-dl diff --git a/server/setup.cfg b/server/setup.cfg deleted file mode 100644 index 7e835b4..0000000 --- a/server/setup.cfg +++ /dev/null @@ -1,3 +0,0 @@ -[tool:pytest] -testpaths=szurubooru -addopts=--cov-report=term-missing --cov=szurubooru --tb=short diff --git a/server/szuru-admin b/server/szuru-admin new file mode 100755 index 0000000..004a751 --- /dev/null +++ b/server/szuru-admin @@ -0,0 +1,131 @@ +#!/usr/bin/env python3 + +""" +Collection of CLI commands for an administrator to use +""" + +import logging +import os +import re +import time +from argparse import ArgumentParser +from getpass import getpass +from sys import stderr + +from szurubooru import config, db, errors, model +from szurubooru.func import files, images +from szurubooru.func import posts as postfuncs +from szurubooru.func import users as userfuncs + + +def reset_password(username: str) -> None: + user = userfuncs.get_user_by_name_or_email(username) + + new_password = getpass("Enter new password for '%s': " % user.name) + check_password = getpass("Re-enter password: ") + + if check_password != new_password: + raise errors.ValidationError("Passwords do not match") + + userfuncs.update_user_password(user, new_password) + db.get_session().commit() + print("Sucessfully changed password for '%s'" % user.name) + + +def check_audio() -> None: + post_list = ( + db.session.query(model.Post) + .filter(model.Post.type == model.Post.TYPE_VIDEO) + .order_by(model.Post.post_id) + .all() + ) + + for post in post_list: + print("Checking post %d ..." % post.post_id, end="\r", file=stderr) + content = files.get(postfuncs.get_post_content_path(post)) + + has_existing_flag = model.Post.FLAG_SOUND in post.flags + try: + has_sound_data = images.Image(content).check_for_sound() + except errors.ProcessingError: + print( + "Post %d caused an error when checking for sound" + % post.post_id + ) + + if has_sound_data and not has_existing_flag: + print("Post %d has sound data but is not flagged" % post.post_id) + if not has_sound_data and has_existing_flag: + print("Post %d has no sound data but is flagged" % post.post_id) + + +def reset_filenames() -> None: + regex = re.compile(r"(\d+)_[0-9a-f]{16}\.(\S+)") + + def convert_to_new_filename(old_name: str) -> str: + matches = regex.match(old_name) + if not matches: + return None + post_id = int(matches.group(1)) + post_ext = matches.group(2) + return "%d_%s.%s" % ( + post_id, + postfuncs.get_post_security_hash(post_id), + post_ext, + ) + + def rename_in_dir(dir: str) -> None: + for old_path in os.listdir(config.config["data_dir"] + dir): + new_path = convert_to_new_filename(old_path) + if not new_path: + continue + if old_path != new_path: + print("%s -> %s" % (dir + old_path, dir + new_path)) + os.rename( + config.config["data_dir"] + dir + old_path, + config.config["data_dir"] + dir + new_path, + ) + + rename_in_dir("posts/") + rename_in_dir("generated-thumbnails/") + rename_in_dir("posts/custom-thumbnails/") + + +def main() -> None: + parser_top = ArgumentParser( + description="Collection of CLI commands for an administrator to use", + epilog="Look at README.md for more info", + ) + parser = parser_top.add_mutually_exclusive_group(required=True) + parser.add_argument( + "--change-password", + metavar="<username>", + help="change the password of specified user", + ) + parser.add_argument( + "--check-all-audio", + action="store_true", + help="check the audio flags of all posts, " + "noting discrepancies, without modifying posts", + ) + parser.add_argument( + "--reset-filenames", + action="store_true", + help="reset and rename the content and thumbnail " + "filenames in case of a lost/changed secret key", + ) + command = parser_top.parse_args() + + try: + if command.change_password: + reset_password(command.change_password) + elif command.check_all_audio: + check_audio() + elif command.reset_filenames: + reset_filenames() + except errors.BaseError as e: + print(e, file=stderr) + + +if __name__ == "__main__": + main() diff --git a/server/szurubooru/api/__init__.py b/server/szurubooru/api/__init__.py index a888a19..99c9524 100644 --- a/server/szurubooru/api/__init__.py +++ b/server/szurubooru/api/__init__.py @@ -1,11 +1,13 @@ +import szurubooru.api.comment_api import szurubooru.api.info_api -import szurubooru.api.user_api -import szurubooru.api.user_token_api +import szurubooru.api.metric_api +import szurubooru.api.password_reset_api +import szurubooru.api.pool_api +import szurubooru.api.pool_category_api import szurubooru.api.post_api +import szurubooru.api.snapshot_api import szurubooru.api.tag_api import szurubooru.api.tag_category_api -import szurubooru.api.comment_api -import szurubooru.api.password_reset_api -import szurubooru.api.snapshot_api import szurubooru.api.upload_api -import szurubooru.api.metric_api +import szurubooru.api.user_api +import szurubooru.api.user_token_api diff --git a/server/szurubooru/api/comment_api.py b/server/szurubooru/api/comment_api.py index cc8350a..d60d23e 100644 --- a/server/szurubooru/api/comment_api.py +++ b/server/szurubooru/api/comment_api.py @@ -1,44 +1,52 @@ -from typing import Dict from datetime import datetime -from szurubooru import search, rest, model -from szurubooru.func import ( - auth, comments, posts, scores, versions, serialization) +from typing import Dict +from szurubooru import model, rest, search +from szurubooru.func import ( + auth, + comments, + posts, + scores, + serialization, + versions, +) _search_executor = search.Executor(search.configs.CommentSearchConfig()) def _get_comment(params: Dict[str, str]) -> model.Comment: try: - comment_id = int(params['comment_id']) + comment_id = int(params["comment_id"]) except TypeError: raise comments.InvalidCommentIdError( - 'Invalid comment ID: %r.' % params['comment_id']) + "Invalid comment ID: %r." % params["comment_id"] + ) return comments.get_comment_by_id(comment_id) -def _serialize( - ctx: rest.Context, comment: model.Comment) -> rest.Response: +def _serialize(ctx: rest.Context, comment: model.Comment) -> rest.Response: return comments.serialize_comment( - comment, - ctx.user, - options=serialization.get_serialization_options(ctx)) + comment, ctx.user, options=serialization.get_serialization_options(ctx) + ) -@rest.routes.get('/comments/?') +@rest.routes.get("/comments/?") def get_comments( - ctx: rest.Context, _params: Dict[str, str] = {}) -> rest.Response: - auth.verify_privilege(ctx.user, 'comments:list') + ctx: rest.Context, _params: Dict[str, str] = {} +) -> rest.Response: + auth.verify_privilege(ctx.user, "comments:list") return _search_executor.execute_and_serialize( - ctx, lambda comment: _serialize(ctx, comment)) + ctx, lambda comment: _serialize(ctx, comment) + ) -@rest.routes.post('/comments/?') +@rest.routes.post("/comments/?") def create_comment( - ctx: rest.Context, _params: Dict[str, str] = {}) -> rest.Response: - auth.verify_privilege(ctx.user, 'comments:create') - text = ctx.get_param_as_string('text') - post_id = ctx.get_param_as_int('postId') + ctx: rest.Context, _params: Dict[str, str] = {} +) -> rest.Response: + auth.verify_privilege(ctx.user, "comments:create") + text = ctx.get_param_as_string("text") + post_id = ctx.get_param_as_int("postId") post = posts.get_post_by_id(post_id) comment = comments.create_comment(ctx.user, post, text) ctx.session.add(comment) @@ -46,53 +54,55 @@ def create_comment( return _serialize(ctx, comment) -@rest.routes.get('/comment/(?P<comment_id>[^/]+)/?') +@rest.routes.get("/comment/(?P<comment_id>[^/]+)/?") def get_comment(ctx: rest.Context, params: Dict[str, str]) -> rest.Response: - auth.verify_privilege(ctx.user, 'comments:view') + auth.verify_privilege(ctx.user, "comments:view") comment = _get_comment(params) return _serialize(ctx, comment) -@rest.routes.put('/comment/(?P<comment_id>[^/]+)/?') +@rest.routes.put("/comment/(?P<comment_id>[^/]+)/?") def update_comment(ctx: rest.Context, params: Dict[str, str]) -> rest.Response: comment = _get_comment(params) versions.verify_version(comment, ctx) versions.bump_version(comment) - infix = 'own' if ctx.user.user_id == comment.user_id else 'any' - text = ctx.get_param_as_string('text') - auth.verify_privilege(ctx.user, 'comments:edit:%s' % infix) + infix = "own" if ctx.user.user_id == comment.user_id else "any" + text = ctx.get_param_as_string("text") + auth.verify_privilege(ctx.user, "comments:edit:%s" % infix) comments.update_comment_text(comment, text) comment.last_edit_time = datetime.utcnow() ctx.session.commit() return _serialize(ctx, comment) -@rest.routes.delete('/comment/(?P<comment_id>[^/]+)/?') +@rest.routes.delete("/comment/(?P<comment_id>[^/]+)/?") def delete_comment(ctx: rest.Context, params: Dict[str, str]) -> rest.Response: comment = _get_comment(params) versions.verify_version(comment, ctx) - infix = 'own' if ctx.user.user_id == comment.user_id else 'any' - auth.verify_privilege(ctx.user, 'comments:delete:%s' % infix) + infix = "own" if ctx.user.user_id == comment.user_id else "any" + auth.verify_privilege(ctx.user, "comments:delete:%s" % infix) ctx.session.delete(comment) ctx.session.commit() return {} -@rest.routes.put('/comment/(?P<comment_id>[^/]+)/score/?') +@rest.routes.put("/comment/(?P<comment_id>[^/]+)/score/?") def set_comment_score( - ctx: rest.Context, params: Dict[str, str]) -> rest.Response: - auth.verify_privilege(ctx.user, 'comments:score') - score = ctx.get_param_as_int('score') + ctx: rest.Context, params: Dict[str, str] +) -> rest.Response: + auth.verify_privilege(ctx.user, "comments:score") + score = ctx.get_param_as_int("score") comment = _get_comment(params) scores.set_score(comment, ctx.user, score) ctx.session.commit() return _serialize(ctx, comment) -@rest.routes.delete('/comment/(?P<comment_id>[^/]+)/score/?') +@rest.routes.delete("/comment/(?P<comment_id>[^/]+)/score/?") def delete_comment_score( - ctx: rest.Context, params: Dict[str, str]) -> rest.Response: - auth.verify_privilege(ctx.user, 'comments:score') + ctx: rest.Context, params: Dict[str, str] +) -> rest.Response: + auth.verify_privilege(ctx.user, "comments:score") comment = _get_comment(params) scores.delete_score(comment, ctx.user) ctx.session.commit() diff --git a/server/szurubooru/api/info_api.py b/server/szurubooru/api/info_api.py index 19b2be7..757b09c 100644 --- a/server/szurubooru/api/info_api.py +++ b/server/szurubooru/api/info_api.py @@ -1,60 +1,66 @@ import os -from typing import Optional, Dict from datetime import datetime, timedelta +from typing import Dict, Optional + from szurubooru import config, rest from szurubooru.func import auth, posts, users, util - _cache_time = None # type: Optional[datetime] _cache_result = None # type: Optional[int] def _get_disk_usage() -> int: - global _cache_time, _cache_result # pylint: disable=global-statement + global _cache_time, _cache_result threshold = timedelta(hours=48) now = datetime.utcnow() if _cache_time and _cache_time > now - threshold: assert _cache_result is not None return _cache_result total_size = 0 - for dir_path, _, file_names in os.walk(config.config['data_dir']): + for dir_path, _, file_names in os.walk(config.config["data_dir"]): for file_name in file_names: file_path = os.path.join(dir_path, file_name) - total_size += os.path.getsize(file_path) + try: + total_size += os.path.getsize(file_path) + except FileNotFoundError: + pass _cache_time = now _cache_result = total_size return total_size -@rest.routes.get('/info/?') -def get_info( - ctx: rest.Context, _params: Dict[str, str] = {}) -> rest.Response: +@rest.routes.get("/info/?") +def get_info(ctx: rest.Context, _params: Dict[str, str] = {}) -> rest.Response: post_feature = posts.try_get_current_post_feature() ret = { - 'postCount': posts.get_post_count(), - 'diskUsage': _get_disk_usage(), - 'serverTime': datetime.utcnow(), - 'config': { - 'name': config.config['name'], - 'userNameRegex': config.config['user_name_regex'], - 'passwordRegex': config.config['password_regex'], - 'tagNameRegex': config.config['tag_name_regex'], - 'tagCategoryNameRegex': config.config['tag_category_name_regex'], - 'defaultUserRank': config.config['default_rank'], - 'enableSafety': config.config['enable_safety'], - 'contactEmail': config.config['contact_email'], - 'canSendMails': bool(config.config['smtp']['host']), - 'privileges': - util.snake_case_to_lower_camel_case_keys( - config.config['privileges']), + "postCount": posts.get_post_count(), + "diskUsage": _get_disk_usage(), + "serverTime": datetime.utcnow(), + "config": { + "name": config.config["name"], + "userNameRegex": config.config["user_name_regex"], + "passwordRegex": config.config["password_regex"], + "tagNameRegex": config.config["tag_name_regex"], + "tagCategoryNameRegex": config.config["tag_category_name_regex"], + "defaultUserRank": config.config["default_rank"], + "enableSafety": config.config["enable_safety"], + "contactEmail": config.config["contact_email"], + "canSendMails": bool(config.config["smtp"]["host"]), + "privileges": util.snake_case_to_lower_camel_case_keys( + config.config["privileges"] + ), }, } - if auth.has_privilege(ctx.user, 'posts:view:featured'): - ret['featuredPost'] = ( + if auth.has_privilege(ctx.user, "posts:view:featured"): + ret["featuredPost"] = ( posts.serialize_post(post_feature.post, ctx.user) - if post_feature else None) - ret['featuringUser'] = ( + if post_feature + else None + ) + ret["featuringUser"] = ( users.serialize_user(post_feature.user, ctx.user) - if post_feature else None) - ret['featuringTime'] = post_feature.time if post_feature else None + if post_feature + else None + ) + ret["featuringTime"] = post_feature.time if post_feature else None return ret diff --git a/server/szurubooru/api/password_reset_api.py b/server/szurubooru/api/password_reset_api.py index 887d2f0..e0e31b7 100644 --- a/server/szurubooru/api/password_reset_api.py +++ b/server/szurubooru/api/password_reset_api.py @@ -1,56 +1,65 @@ +from hashlib import md5 from typing import Dict + from szurubooru import config, errors, rest from szurubooru.func import auth, mailer, users, versions -from hashlib import md5 - -MAIL_SUBJECT = 'Password reset for {name}' +MAIL_SUBJECT = "Password reset for {name}" MAIL_BODY = ( - 'You (or someone else) requested to reset your password on {name}.\n' - 'If you wish to proceed, click this link: {url}\n' - 'Otherwise, please ignore this email.') + "You (or someone else) requested to reset your password on {name}.\n" + "If you wish to proceed, click this link: {url}\n" + "Otherwise, please ignore this email." +) -@rest.routes.get('/password-reset/(?P<user_name>[^/]+)/?') +@rest.routes.get("/password-reset/(?P<user_name>[^/]+)/?") def start_password_reset( - ctx: rest.Context, params: Dict[str, str]) -> rest.Response: - user_name = params['user_name'] + ctx: rest.Context, params: Dict[str, str] +) -> rest.Response: + user_name = params["user_name"] user = users.get_user_by_name_or_email(user_name) if not user.email: raise errors.ValidationError( - 'User %r hasn\'t supplied email. Cannot reset password.' % ( - user_name)) + "User %r hasn't supplied email. Cannot reset password." + % (user_name) + ) token = auth.generate_authentication_token(user) - if 'HTTP_ORIGIN' in ctx.env: - url = ctx.env['HTTP_ORIGIN'].rstrip('/') + if config.config["domain"]: + url = config.config["domain"] + elif "HTTP_ORIGIN" in ctx.env: + url = ctx.env["HTTP_ORIGIN"].rstrip("/") + elif "HTTP_REFERER" in ctx.env: + url = ctx.env["HTTP_REFERER"].rstrip("/") else: - url = '' - url += '/password-reset/%s:%s' % (user.name, token) + url = "" + url += "/password-reset/%s:%s" % (user.name, token) mailer.send_mail( - 'noreply@%s' % config.config['name'], + config.config["smtp"]["from"], user.email, - MAIL_SUBJECT.format(name=config.config['name']), - MAIL_BODY.format(name=config.config['name'], url=url)) + MAIL_SUBJECT.format(name=config.config["name"]), + MAIL_BODY.format(name=config.config["name"], url=url), + ) return {} def _hash(token: str) -> str: - return md5(token.encode('utf-8')).hexdigest() + return md5(token.encode("utf-8")).hexdigest() -@rest.routes.post('/password-reset/(?P<user_name>[^/]+)/?') +@rest.routes.post("/password-reset/(?P<user_name>[^/]+)/?") def finish_password_reset( - ctx: rest.Context, params: Dict[str, str]) -> rest.Response: - user_name = params['user_name'] + ctx: rest.Context, params: Dict[str, str] +) -> rest.Response: + user_name = params["user_name"] user = users.get_user_by_name_or_email(user_name) good_token = auth.generate_authentication_token(user) - token = ctx.get_param_as_string('token') + token = ctx.get_param_as_string("token") if _hash(token) != _hash(good_token): - raise errors.ValidationError('Invalid password reset token.') + raise errors.ValidationError("Invalid password reset token.") new_password = users.reset_user_password(user) versions.bump_version(user) ctx.session.commit() - return {'password': new_password} + return {"password": new_password} diff --git a/server/szurubooru/api/pool_api.py b/server/szurubooru/api/pool_api.py new file mode 100644 index 0000000..a2fb716 --- /dev/null +++ b/server/szurubooru/api/pool_api.py @@ -0,0 +1,113 @@ +from datetime import datetime +from typing import Dict, List, Optional + +from szurubooru import db, model, rest, search +from szurubooru.func import auth, pools, serialization, snapshots, versions + +_search_executor = search.Executor(search.configs.PoolSearchConfig()) + + +def _serialize(ctx: rest.Context, pool: model.Pool) -> rest.Response: + return pools.serialize_pool( + pool, options=serialization.get_serialization_options(ctx) + ) + + +def _get_pool(params: Dict[str, str]) -> model.Pool: + return pools.get_pool_by_id(params["pool_id"]) + + +@rest.routes.get("/pools/?") +def get_pools( + ctx: rest.Context, _params: Dict[str, str] = {} +) -> rest.Response: + auth.verify_privilege(ctx.user, "pools:list") + return _search_executor.execute_and_serialize( + ctx, lambda pool: _serialize(ctx, pool) + ) + + +@rest.routes.post("/pool/?") +def create_pool( + ctx: rest.Context, _params: Dict[str, str] = {} +) -> rest.Response: + auth.verify_privilege(ctx.user, "pools:create") + + names = ctx.get_param_as_string_list("names") + category = ctx.get_param_as_string("category") + description = ctx.get_param_as_string("description", default="") + posts = ctx.get_param_as_int_list("posts", default=[]) + + pool = pools.create_pool(names, category, posts) + pool.last_edit_time = datetime.utcnow() + pools.update_pool_description(pool, description) + ctx.session.add(pool) + ctx.session.flush() + snapshots.create(pool, ctx.user) + ctx.session.commit() + return _serialize(ctx, pool) + + +@rest.routes.get("/pool/(?P<pool_id>[^/]+)/?") +def get_pool(ctx: rest.Context, params: Dict[str, str]) -> rest.Response: + auth.verify_privilege(ctx.user, "pools:view") + pool = _get_pool(params) + return _serialize(ctx, pool) + + +@rest.routes.put("/pool/(?P<pool_id>[^/]+)/?") +def update_pool(ctx: rest.Context, params: Dict[str, str]) -> rest.Response: + pool = _get_pool(params) + versions.verify_version(pool, ctx) + versions.bump_version(pool) + if ctx.has_param("names"): + auth.verify_privilege(ctx.user, "pools:edit:names") + pools.update_pool_names(pool, ctx.get_param_as_string_list("names")) + if ctx.has_param("category"): + auth.verify_privilege(ctx.user, "pools:edit:category") + pools.update_pool_category_name( + pool, ctx.get_param_as_string("category") + ) + if ctx.has_param("description"): + auth.verify_privilege(ctx.user, "pools:edit:description") + pools.update_pool_description( + pool, ctx.get_param_as_string("description") + ) + if ctx.has_param("posts"): + auth.verify_privilege(ctx.user, "pools:edit:posts") + posts = ctx.get_param_as_int_list("posts") + pools.update_pool_posts(pool, posts) + pool.last_edit_time = datetime.utcnow() + ctx.session.flush() + snapshots.modify(pool, ctx.user) + ctx.session.commit() + return _serialize(ctx, pool) + + +@rest.routes.delete("/pool/(?P<pool_id>[^/]+)/?") +def delete_pool(ctx: rest.Context, params: Dict[str, str]) -> rest.Response: + pool = _get_pool(params) + versions.verify_version(pool, ctx) + auth.verify_privilege(ctx.user, "pools:delete") + snapshots.delete(pool, ctx.user) + pools.delete(pool) + ctx.session.commit() + return {} + + +@rest.routes.post("/pool-merge/?") +def merge_pools( + ctx: rest.Context, _params: Dict[str, str] = {} +) -> rest.Response: + source_pool_id = ctx.get_param_as_string("remove") + target_pool_id = ctx.get_param_as_string("mergeTo") + source_pool = pools.get_pool_by_id(source_pool_id) + target_pool = pools.get_pool_by_id(target_pool_id) + versions.verify_version(source_pool, ctx, "removeVersion") + versions.verify_version(target_pool, ctx, "mergeToVersion") + versions.bump_version(target_pool) + auth.verify_privilege(ctx.user, "pools:merge") + pools.merge_pools(source_pool, target_pool) + snapshots.merge(source_pool, target_pool, ctx.user) + ctx.session.commit() + return _serialize(ctx, target_pool) diff --git a/server/szurubooru/api/pool_category_api.py b/server/szurubooru/api/pool_category_api.py new file mode 100644 index 0000000..9af41d4 --- /dev/null +++ b/server/szurubooru/api/pool_category_api.py @@ -0,0 +1,109 @@ +from typing import Dict + +from szurubooru import model, rest +from szurubooru.func import ( + auth, + pool_categories, + pools, + serialization, + snapshots, + versions, +) + + +def _serialize( + ctx: rest.Context, category: model.PoolCategory +) -> rest.Response: + return pool_categories.serialize_category( + category, options=serialization.get_serialization_options(ctx) + ) + + +@rest.routes.get("/pool-categories/?") +def get_pool_categories( + ctx: rest.Context, _params: Dict[str, str] = {} +) -> rest.Response: + auth.verify_privilege(ctx.user, "pool_categories:list") + categories = pool_categories.get_all_categories() + return { + "results": [_serialize(ctx, category) for category in categories], + } + + +@rest.routes.post("/pool-categories/?") +def create_pool_category( + ctx: rest.Context, _params: Dict[str, str] = {} +) -> rest.Response: + auth.verify_privilege(ctx.user, "pool_categories:create") + name = ctx.get_param_as_string("name") + color = ctx.get_param_as_string("color") + category = pool_categories.create_category(name, color) + ctx.session.add(category) + ctx.session.flush() + snapshots.create(category, ctx.user) + ctx.session.commit() + return _serialize(ctx, category) + + +@rest.routes.get("/pool-category/(?P<category_name>[^/]+)/?") +def get_pool_category( + ctx: rest.Context, params: Dict[str, str] +) -> rest.Response: + auth.verify_privilege(ctx.user, "pool_categories:view") + category = pool_categories.get_category_by_name(params["category_name"]) + return _serialize(ctx, category) + + +@rest.routes.put("/pool-category/(?P<category_name>[^/]+)/?") +def update_pool_category( + ctx: rest.Context, params: Dict[str, str] +) -> rest.Response: + category = pool_categories.get_category_by_name( + params["category_name"], lock=True + ) + versions.verify_version(category, ctx) + versions.bump_version(category) + if ctx.has_param("name"): + auth.verify_privilege(ctx.user, "pool_categories:edit:name") + pool_categories.update_category_name( + category, ctx.get_param_as_string("name") + ) + if ctx.has_param("color"): + auth.verify_privilege(ctx.user, "pool_categories:edit:color") + pool_categories.update_category_color( + category, ctx.get_param_as_string("color") + ) + ctx.session.flush() + snapshots.modify(category, ctx.user) + ctx.session.commit() + return _serialize(ctx, category) + + +@rest.routes.delete("/pool-category/(?P<category_name>[^/]+)/?") +def delete_pool_category( + ctx: rest.Context, params: Dict[str, str] +) -> rest.Response: + category = pool_categories.get_category_by_name( + params["category_name"], lock=True + ) + versions.verify_version(category, ctx) + auth.verify_privilege(ctx.user, "pool_categories:delete") + pool_categories.delete_category(category) + snapshots.delete(category, ctx.user) + ctx.session.commit() + return {} + + +@rest.routes.put("/pool-category/(?P<category_name>[^/]+)/default/?") +def set_pool_category_as_default( + ctx: rest.Context, params: Dict[str, str] +) -> rest.Response: + auth.verify_privilege(ctx.user, "pool_categories:set_default") + category = pool_categories.get_category_by_name( + params["category_name"], lock=True + ) + pool_categories.set_default_category(category) + ctx.session.flush() + snapshots.modify(category, ctx.user) + ctx.session.commit() + return _serialize(ctx, category) diff --git a/server/szurubooru/api/post_api.py b/server/szurubooru/api/post_api.py index ffbf34d..a83ccc0 100644 --- a/server/szurubooru/api/post_api.py +++ b/server/szurubooru/api/post_api.py @@ -1,10 +1,20 @@ from math import ceil -from typing import Optional, Dict, List from datetime import datetime -from szurubooru import db, model, errors, rest, search -from szurubooru.func import ( - auth, tags, posts, snapshots, favorites, scores, serialization, versions, metrics) +from typing import Dict, List, Optional +from szurubooru import db, errors, model, rest, search +from szurubooru.func import ( + auth, + favorites, + metrics, + mime, + posts, + scores, + serialization, + snapshots, + tags, + versions, +) _search_executor_config = search.configs.PostSearchConfig() _search_executor = search.Executor(_search_executor_config) @@ -12,10 +22,11 @@ _search_executor = search.Executor(_search_executor_config) def _get_post_id(params: Dict[str, str]) -> int: try: - return int(params['post_id']) + return int(params["post_id"]) except TypeError: raise posts.InvalidPostIdError( - 'Invalid post ID: %r.' % params['post_id']) + "Invalid post ID: %r." % params["post_id"] + ) def _get_post(params: Dict[str, str]) -> model.Post: @@ -23,52 +34,62 @@ def _get_post(params: Dict[str, str]) -> model.Post: def _serialize_post( - ctx: rest.Context, post: Optional[model.Post]) -> rest.Response: + ctx: rest.Context, post: Optional[model.Post] +) -> rest.Response: return posts.serialize_post( - post, - ctx.user, - options=serialization.get_serialization_options(ctx)) + post, ctx.user, options=serialization.get_serialization_options(ctx) + ) -@rest.routes.get('/posts/?') +@rest.routes.get("/posts/?") def get_posts( - ctx: rest.Context, _params: Dict[str, str] = {}) -> rest.Response: - auth.verify_privilege(ctx.user, 'posts:list') + ctx: rest.Context, _params: Dict[str, str] = {} +) -> rest.Response: + auth.verify_privilege(ctx.user, "posts:list") _search_executor_config.user = ctx.user return _search_executor.execute_and_serialize( - ctx, lambda post: _serialize_post(ctx, post)) + ctx, lambda post: _serialize_post(ctx, post) + ) -@rest.routes.post('/posts/?') +@rest.routes.post("/posts/?") def create_post( - ctx: rest.Context, _params: Dict[str, str] = {}) -> rest.Response: - anonymous = ctx.get_param_as_bool('anonymous', default=False) + ctx: rest.Context, _params: Dict[str, str] = {} +) -> rest.Response: + anonymous = ctx.get_param_as_bool("anonymous", default=False) if anonymous: - auth.verify_privilege(ctx.user, 'posts:create:anonymous') + auth.verify_privilege(ctx.user, "posts:create:anonymous") else: - auth.verify_privilege(ctx.user, 'posts:create:identified') - content = ctx.get_file('content') - tag_names = ctx.get_param_as_string_list('tags', default=[]) - safety = ctx.get_param_as_string('safety') - source = ctx.get_param_as_string('source', default='') - if ctx.has_param('contentUrl') and not source: - source = ctx.get_param_as_string('contentUrl', default='') - relations = ctx.get_param_as_int_list('relations', default=[]) - notes = ctx.get_param_as_list('notes', default=[]) - flags = ctx.get_param_as_string_list('flags', default=[]) + auth.verify_privilege(ctx.user, "posts:create:identified") + content = ctx.get_file( + "content", + use_video_downloader=auth.has_privilege( + ctx.user, "uploads:use_downloader" + ), + ) + tag_names = ctx.get_param_as_string_list("tags", default=[]) + safety = ctx.get_param_as_string("safety") + source = ctx.get_param_as_string("source", default="") + if ctx.has_param("contentUrl") and not source: + source = ctx.get_param_as_string("contentUrl", default="") + relations = ctx.get_param_as_int_list("relations", default=[]) + notes = ctx.get_param_as_list("notes", default=[]) + flags = ctx.get_param_as_string_list( + "flags", default=posts.get_default_flags(content) + ) post, new_tags = posts.create_post( - content, tag_names, None if anonymous else ctx.user) + content, tag_names, None if anonymous else ctx.user + ) if len(new_tags): - auth.verify_privilege(ctx.user, 'tags:create') + auth.verify_privilege(ctx.user, "tags:create") posts.update_post_safety(post, safety) posts.update_post_source(post, source) posts.update_post_relations(post, relations) posts.update_post_notes(post, notes) posts.update_post_flags(post, flags) - posts.test_sound(post, content) - if ctx.has_file('thumbnail'): - posts.update_post_thumbnail(post, ctx.get_file('thumbnail')) + if ctx.has_file("thumbnail"): + posts.update_post_thumbnail(post, ctx.get_file("thumbnail")) ctx.session.add(post) ctx.session.flush() create_snapshots_for_post(post, new_tags, None if anonymous else ctx.user) @@ -77,74 +98,83 @@ def create_post( create_snapshots_for_post( alternate_post, alternate_post_new_tags, - None if anonymous else ctx.user) + None if anonymous else ctx.user, + ) ctx.session.commit() return _serialize_post(ctx, post) def create_snapshots_for_post( - post: model.Post, - new_tags: List[model.Tag], - user: Optional[model.User]): + post: model.Post, new_tags: List[model.Tag], user: Optional[model.User] +): snapshots.create(post, user) for tag in new_tags: snapshots.create(tag, user) -@rest.routes.get('/post/(?P<post_id>[^/]+)/?') +@rest.routes.get("/post/(?P<post_id>[^/]+)/?") def get_post(ctx: rest.Context, params: Dict[str, str]) -> rest.Response: - auth.verify_privilege(ctx.user, 'posts:view') + auth.verify_privilege(ctx.user, "posts:view") post = _get_post(params) return _serialize_post(ctx, post) -@rest.routes.put('/post/(?P<post_id>[^/]+)/?') +@rest.routes.put("/post/(?P<post_id>[^/]+)/?") def update_post(ctx: rest.Context, params: Dict[str, str]) -> rest.Response: post = _get_post(params) versions.verify_version(post, ctx) versions.bump_version(post) - if ctx.has_file('content'): - auth.verify_privilege(ctx.user, 'posts:edit:content') - posts.update_post_content(post, ctx.get_file('content')) - if ctx.has_param('tags'): - auth.verify_privilege(ctx.user, 'posts:edit:tags') + if ctx.has_file("content"): + auth.verify_privilege(ctx.user, "posts:edit:content") + posts.update_post_content( + post, + ctx.get_file( + "content", + use_video_downloader=auth.has_privilege( + ctx.user, "uploads:use_downloader" + ), + ), + ) + if ctx.has_param("tags"): + auth.verify_privilege(ctx.user, "posts:edit:tags") new_tags = posts.update_post_tags( - post, ctx.get_param_as_string_list('tags')) + post, ctx.get_param_as_string_list("tags") + ) if len(new_tags): - auth.verify_privilege(ctx.user, 'tags:create') + auth.verify_privilege(ctx.user, "tags:create") db.session.flush() for tag in new_tags: snapshots.create(tag, ctx.user) - if ctx.has_param('safety'): - auth.verify_privilege(ctx.user, 'posts:edit:safety') - posts.update_post_safety(post, ctx.get_param_as_string('safety')) - if ctx.has_param('source'): - auth.verify_privilege(ctx.user, 'posts:edit:source') - posts.update_post_source(post, ctx.get_param_as_string('source')) - elif ctx.has_param('contentUrl'): - posts.update_post_source(post, ctx.get_param_as_string('contentUrl')) - if ctx.has_param('relations'): - auth.verify_privilege(ctx.user, 'posts:edit:relations') + if ctx.has_param("safety"): + auth.verify_privilege(ctx.user, "posts:edit:safety") + posts.update_post_safety(post, ctx.get_param_as_string("safety")) + if ctx.has_param("source"): + auth.verify_privilege(ctx.user, "posts:edit:source") + posts.update_post_source(post, ctx.get_param_as_string("source")) + elif ctx.has_param("contentUrl"): + posts.update_post_source(post, ctx.get_param_as_string("contentUrl")) + if ctx.has_param("relations"): + auth.verify_privilege(ctx.user, "posts:edit:relations") posts.update_post_relations( - post, ctx.get_param_as_int_list('relations')) - if ctx.has_param('notes'): - auth.verify_privilege(ctx.user, 'posts:edit:notes') - posts.update_post_notes(post, ctx.get_param_as_list('notes')) - if ctx.has_param('flags'): - auth.verify_privilege(ctx.user, 'posts:edit:flags') - posts.update_post_flags(post, ctx.get_param_as_string_list('flags')) - if ctx.has_file('thumbnail'): - auth.verify_privilege(ctx.user, 'posts:edit:thumbnail') - posts.update_post_thumbnail(post, ctx.get_file('thumbnail')) - if ctx.has_param('metrics'): - auth.verify_privilege(ctx.user, 'metrics:edit:posts') + post, ctx.get_param_as_int_list("relations") + ) + if ctx.has_param("notes"): + auth.verify_privilege(ctx.user, "posts:edit:notes") + posts.update_post_notes(post, ctx.get_param_as_list("notes")) + if ctx.has_param("flags"): + auth.verify_privilege(ctx.user, "posts:edit:flags") + posts.update_post_flags(post, ctx.get_param_as_string_list("flags")) + if ctx.has_file("thumbnail"): + auth.verify_privilege(ctx.user, "posts:edit:thumbnail") + posts.update_post_thumbnail(post, ctx.get_file("thumbnail")) + if ctx.has_param("metrics"): + auth.verify_privilege(ctx.user, "metrics:edit:posts") metrics.update_or_create_post_metrics( - post, ctx.get_param_as_list('metrics')) - if ctx.has_param('metricRanges'): - auth.verify_privilege(ctx.user, 'metrics:edit:posts') + post, ctx.get_param_as_list("metrics")) + if ctx.has_param("metricRanges"): + auth.verify_privilege(ctx.user, "metrics:edit:posts") metrics.update_or_create_post_metric_ranges( - post, ctx.get_param_as_list('metricRanges')) - + post, ctx.get_param_as_list("metricRanges")) post.last_edit_time = datetime.utcnow() ctx.session.flush() snapshots.modify(post, ctx.user) @@ -152,9 +182,9 @@ def update_post(ctx: rest.Context, params: Dict[str, str]) -> rest.Response: return _serialize_post(ctx, post) -@rest.routes.delete('/post/(?P<post_id>[^/]+)/?') +@rest.routes.delete("/post/(?P<post_id>[^/]+)/?") def delete_post(ctx: rest.Context, params: Dict[str, str]) -> rest.Response: - auth.verify_privilege(ctx.user, 'posts:delete') + auth.verify_privilege(ctx.user, "posts:delete") post = _get_post(params) versions.verify_version(post, ctx) snapshots.delete(post, ctx.user) @@ -163,103 +193,113 @@ def delete_post(ctx: rest.Context, params: Dict[str, str]) -> rest.Response: return {} -@rest.routes.post('/post-merge/?') +@rest.routes.post("/post-merge/?") def merge_posts( - ctx: rest.Context, _params: Dict[str, str] = {}) -> rest.Response: - source_post_id = ctx.get_param_as_int('remove') - target_post_id = ctx.get_param_as_int('mergeTo') + ctx: rest.Context, _params: Dict[str, str] = {} +) -> rest.Response: + source_post_id = ctx.get_param_as_int("remove") + target_post_id = ctx.get_param_as_int("mergeTo") source_post = posts.get_post_by_id(source_post_id) target_post = posts.get_post_by_id(target_post_id) - replace_content = ctx.get_param_as_bool('replaceContent') - versions.verify_version(source_post, ctx, 'removeVersion') - versions.verify_version(target_post, ctx, 'mergeToVersion') + replace_content = ctx.get_param_as_bool("replaceContent") + versions.verify_version(source_post, ctx, "removeVersion") + versions.verify_version(target_post, ctx, "mergeToVersion") versions.bump_version(target_post) - auth.verify_privilege(ctx.user, 'posts:merge') + auth.verify_privilege(ctx.user, "posts:merge") posts.merge_posts(source_post, target_post, replace_content) snapshots.merge(source_post, target_post, ctx.user) ctx.session.commit() return _serialize_post(ctx, target_post) -@rest.routes.get('/featured-post/?') +@rest.routes.get("/featured-post/?") def get_featured_post( - ctx: rest.Context, _params: Dict[str, str] = {}) -> rest.Response: - auth.verify_privilege(ctx.user, 'posts:view:featured') + ctx: rest.Context, _params: Dict[str, str] = {} +) -> rest.Response: + auth.verify_privilege(ctx.user, "posts:view:featured") post = posts.try_get_featured_post() return _serialize_post(ctx, post) -@rest.routes.post('/featured-post/?') +@rest.routes.post("/featured-post/?") def set_featured_post( - ctx: rest.Context, _params: Dict[str, str] = {}) -> rest.Response: - auth.verify_privilege(ctx.user, 'posts:feature') - post_id = ctx.get_param_as_int('id') + ctx: rest.Context, _params: Dict[str, str] = {} +) -> rest.Response: + auth.verify_privilege(ctx.user, "posts:feature") + post_id = ctx.get_param_as_int("id") post = posts.get_post_by_id(post_id) featured_post = posts.try_get_featured_post() if featured_post and featured_post.post_id == post.post_id: raise posts.PostAlreadyFeaturedError( - 'Post %r is already featured.' % post_id) + "Post %r is already featured." % post_id + ) posts.feature_post(post, ctx.user) snapshots.modify(post, ctx.user) ctx.session.commit() return _serialize_post(ctx, post) -@rest.routes.put('/post/(?P<post_id>[^/]+)/score/?') +@rest.routes.put("/post/(?P<post_id>[^/]+)/score/?") def set_post_score(ctx: rest.Context, params: Dict[str, str]) -> rest.Response: - auth.verify_privilege(ctx.user, 'posts:score') + auth.verify_privilege(ctx.user, "posts:score") post = _get_post(params) - score = ctx.get_param_as_int('score') + score = ctx.get_param_as_int("score") scores.set_score(post, ctx.user, score) ctx.session.commit() return _serialize_post(ctx, post) -@rest.routes.delete('/post/(?P<post_id>[^/]+)/score/?') +@rest.routes.delete("/post/(?P<post_id>[^/]+)/score/?") def delete_post_score( - ctx: rest.Context, params: Dict[str, str]) -> rest.Response: - auth.verify_privilege(ctx.user, 'posts:score') + ctx: rest.Context, params: Dict[str, str] +) -> rest.Response: + auth.verify_privilege(ctx.user, "posts:score") post = _get_post(params) scores.delete_score(post, ctx.user) ctx.session.commit() return _serialize_post(ctx, post) -@rest.routes.post('/post/(?P<post_id>[^/]+)/favorite/?') +@rest.routes.post("/post/(?P<post_id>[^/]+)/favorite/?") def add_post_to_favorites( - ctx: rest.Context, params: Dict[str, str]) -> rest.Response: - auth.verify_privilege(ctx.user, 'posts:favorite') + ctx: rest.Context, params: Dict[str, str] +) -> rest.Response: + auth.verify_privilege(ctx.user, "posts:favorite") post = _get_post(params) favorites.set_favorite(post, ctx.user) ctx.session.commit() return _serialize_post(ctx, post) -@rest.routes.delete('/post/(?P<post_id>[^/]+)/favorite/?') +@rest.routes.delete("/post/(?P<post_id>[^/]+)/favorite/?") def delete_post_from_favorites( - ctx: rest.Context, params: Dict[str, str]) -> rest.Response: - auth.verify_privilege(ctx.user, 'posts:favorite') + ctx: rest.Context, params: Dict[str, str] +) -> rest.Response: + auth.verify_privilege(ctx.user, "posts:favorite") post = _get_post(params) favorites.unset_favorite(post, ctx.user) ctx.session.commit() return _serialize_post(ctx, post) -@rest.routes.get('/post/(?P<post_id>[^/]+)/around/?') +@rest.routes.get("/post/(?P<post_id>[^/]+)/around/?") def get_posts_around( - ctx: rest.Context, params: Dict[str, str]) -> rest.Response: - auth.verify_privilege(ctx.user, 'posts:list') + ctx: rest.Context, params: Dict[str, str] +) -> rest.Response: + auth.verify_privilege(ctx.user, "posts:list") _search_executor_config.user = ctx.user post_id = _get_post_id(params) return _search_executor.get_around_and_serialize( - ctx, post_id, lambda post: _serialize_post(ctx, post)) + ctx, post_id, lambda post: _serialize_post(ctx, post) + ) -@rest.routes.post('/posts/reverse-search/?') +@rest.routes.post("/posts/reverse-search/?") def get_posts_by_image( - ctx: rest.Context, _params: Dict[str, str] = {}) -> rest.Response: - auth.verify_privilege(ctx.user, 'posts:reverse_search') - content = ctx.get_file('content') + ctx: rest.Context, _params: Dict[str, str] = {} +) -> rest.Response: + auth.verify_privilege(ctx.user, "posts:reverse_search") + content = ctx.get_file("content") try: lookalikes = posts.search_by_image(content) @@ -267,32 +307,32 @@ def get_posts_by_image( lookalikes = [] return { - 'exactPost': - _serialize_post(ctx, posts.search_by_image_exact(content)), - 'similarPosts': - [ - { - 'distance': lookalike.distance, - 'post': _serialize_post(ctx, lookalike.post), - } - for lookalike in lookalikes - ], + "exactPost": _serialize_post( + ctx, posts.search_by_image_exact(content) + ), + "similarPosts": [ + { + "distance": distance, + "post": _serialize_post(ctx, post), + } + for distance, post in lookalikes + ], } -@rest.routes.get('/posts/median/?') +@rest.routes.get("/posts/median/?") def get_posts_median( ctx: rest.Context, _params: Dict[str, str] = {}) -> rest.Response: - auth.verify_privilege(ctx.user, 'posts:list') + auth.verify_privilege(ctx.user, "posts:list") _search_executor_config.user = ctx.user - query_text = ctx.get_param_as_string('query', default='') + query_text = ctx.get_param_as_string("query", default="") total_count = _search_executor.count(query_text) offset = ceil(total_count / 2) - 1 _, results = _search_executor.execute(query_text, offset, 1) return { - 'query': query_text, - 'offset': offset, - 'limit': 1, - 'total': len(results), - 'results': list([_serialize_post(ctx, post) for post in results]) + "query": query_text, + "offset": offset, + "limit": 1, + "total": len(results), + "results": list([_serialize_post(ctx, post) for post in results]) } diff --git a/server/szurubooru/api/snapshot_api.py b/server/szurubooru/api/snapshot_api.py index 469be7f..87012a2 100644 --- a/server/szurubooru/api/snapshot_api.py +++ b/server/szurubooru/api/snapshot_api.py @@ -1,14 +1,16 @@ from typing import Dict -from szurubooru import search, rest -from szurubooru.func import auth, snapshots +from szurubooru import rest, search +from szurubooru.func import auth, snapshots _search_executor = search.Executor(search.configs.SnapshotSearchConfig()) -@rest.routes.get('/snapshots/?') +@rest.routes.get("/snapshots/?") def get_snapshots( - ctx: rest.Context, _params: Dict[str, str] = {}) -> rest.Response: - auth.verify_privilege(ctx.user, 'snapshots:list') + ctx: rest.Context, _params: Dict[str, str] = {} +) -> rest.Response: + auth.verify_privilege(ctx.user, "snapshots:list") return _search_executor.execute_and_serialize( - ctx, lambda snapshot: snapshots.serialize_snapshot(snapshot, ctx.user)) + ctx, lambda snapshot: snapshots.serialize_snapshot(snapshot, ctx.user) + ) diff --git a/server/szurubooru/api/tag_api.py b/server/szurubooru/api/tag_api.py index 6b370af..5f54324 100644 --- a/server/szurubooru/api/tag_api.py +++ b/server/szurubooru/api/tag_api.py @@ -1,19 +1,27 @@ -from typing import Optional, List, Dict from datetime import datetime -from szurubooru import db, model, search, rest -from szurubooru.func import auth, tags, metrics, snapshots, serialization, versions +from typing import Dict, List, Optional +from szurubooru import db, model, rest, search +from szurubooru.func import ( + auth, + metrics, + serialization, + snapshots, + tags, + versions +) _search_executor = search.Executor(search.configs.TagSearchConfig()) def _serialize(ctx: rest.Context, tag: model.Tag) -> rest.Response: return tags.serialize_tag( - tag, options=serialization.get_serialization_options(ctx)) + tag, options=serialization.get_serialization_options(ctx) + ) def _get_tag(params: Dict[str, str]) -> model.Tag: - return tags.get_tag_by_name(params['tag_name']) + return tags.get_tag_by_name(params["tag_name"]) def _create_if_needed(tag_names: List[str], user: model.User) -> None: @@ -21,29 +29,31 @@ def _create_if_needed(tag_names: List[str], user: model.User) -> None: return _existing_tags, new_tags = tags.get_or_create_tags_by_names(tag_names) if len(new_tags): - auth.verify_privilege(user, 'tags:create') + auth.verify_privilege(user, "tags:create") db.session.flush() for tag in new_tags: snapshots.create(tag, user) -@rest.routes.get('/tags/?') +@rest.routes.get("/tags/?") def get_tags(ctx: rest.Context, _params: Dict[str, str] = {}) -> rest.Response: - auth.verify_privilege(ctx.user, 'tags:list') + auth.verify_privilege(ctx.user, "tags:list") return _search_executor.execute_and_serialize( - ctx, lambda tag: _serialize(ctx, tag)) + ctx, lambda tag: _serialize(ctx, tag) + ) -@rest.routes.post('/tags/?') +@rest.routes.post("/tags/?") def create_tag( - ctx: rest.Context, _params: Dict[str, str] = {}) -> rest.Response: - auth.verify_privilege(ctx.user, 'tags:create') + ctx: rest.Context, _params: Dict[str, str] = {} +) -> rest.Response: + auth.verify_privilege(ctx.user, "tags:create") - names = ctx.get_param_as_string_list('names') - category = ctx.get_param_as_string('category') - description = ctx.get_param_as_string('description', default='') - suggestions = ctx.get_param_as_string_list('suggestions', default=[]) - implications = ctx.get_param_as_string_list('implications', default=[]) + names = ctx.get_param_as_string_list("names") + category = ctx.get_param_as_string("category") + description = ctx.get_param_as_string("description", default="") + suggestions = ctx.get_param_as_string_list("suggestions", default=[]) + implications = ctx.get_param_as_string_list("implications", default=[]) _create_if_needed(suggestions, ctx.user) _create_if_needed(implications, ctx.user) @@ -57,44 +67,44 @@ def create_tag( return _serialize(ctx, tag) -@rest.routes.get('/tag/(?P<tag_name>.+)') +@rest.routes.get("/tag/(?P<tag_name>.+)") def get_tag(ctx: rest.Context, params: Dict[str, str]) -> rest.Response: - auth.verify_privilege(ctx.user, 'tags:view') + auth.verify_privilege(ctx.user, "tags:view") tag = _get_tag(params) return _serialize(ctx, tag) -@rest.routes.put('/tag/(?P<tag_name>.+)') +@rest.routes.put("/tag/(?P<tag_name>.+)") def update_tag(ctx: rest.Context, params: Dict[str, str]) -> rest.Response: tag = _get_tag(params) versions.verify_version(tag, ctx) versions.bump_version(tag) - if ctx.has_param('names'): - auth.verify_privilege(ctx.user, 'tags:edit:names') - tags.update_tag_names(tag, ctx.get_param_as_string_list('names')) - if ctx.has_param('category'): - auth.verify_privilege(ctx.user, 'tags:edit:category') - tags.update_tag_category_name( - tag, ctx.get_param_as_string('category')) - if ctx.has_param('description'): - auth.verify_privilege(ctx.user, 'tags:edit:description') + if ctx.has_param("names"): + auth.verify_privilege(ctx.user, "tags:edit:names") + tags.update_tag_names(tag, ctx.get_param_as_string_list("names")) + if ctx.has_param("category"): + auth.verify_privilege(ctx.user, "tags:edit:category") + tags.update_tag_category_name(tag, ctx.get_param_as_string("category")) + if ctx.has_param("description"): + auth.verify_privilege(ctx.user, "tags:edit:description") tags.update_tag_description( - tag, ctx.get_param_as_string('description')) - if ctx.has_param('suggestions'): - auth.verify_privilege(ctx.user, 'tags:edit:suggestions') - suggestions = ctx.get_param_as_string_list('suggestions') + tag, ctx.get_param_as_string("description") + ) + if ctx.has_param("suggestions"): + auth.verify_privilege(ctx.user, "tags:edit:suggestions") + suggestions = ctx.get_param_as_string_list("suggestions") _create_if_needed(suggestions, ctx.user) tags.update_tag_suggestions(tag, suggestions) - if ctx.has_param('implications'): - auth.verify_privilege(ctx.user, 'tags:edit:implications') - implications = ctx.get_param_as_string_list('implications') + if ctx.has_param("implications"): + auth.verify_privilege(ctx.user, "tags:edit:implications") + implications = ctx.get_param_as_string_list("implications") _create_if_needed(implications, ctx.user) tags.update_tag_implications(tag, implications) - if ctx.has_param('metric'): - auth.verify_privilege(ctx.user, 'metrics:edit:bounds') - new_metric = metrics.update_or_create_metric(tag, ctx.get_param('metric')) + if ctx.has_param("metric"): + auth.verify_privilege(ctx.user, "metrics:edit:bounds") + new_metric = metrics.update_or_create_metric(tag, ctx.get_param("metric")) if new_metric is not None: - auth.verify_privilege(ctx.user, 'metrics:create') + auth.verify_privilege(ctx.user, "metrics:create") db.session.flush() # snapshots.create(new_metric, ctx.user) @@ -105,44 +115,45 @@ def update_tag(ctx: rest.Context, params: Dict[str, str]) -> rest.Response: return _serialize(ctx, tag) -@rest.routes.delete('/tag/(?P<tag_name>.+)') +@rest.routes.delete("/tag/(?P<tag_name>.+)") def delete_tag(ctx: rest.Context, params: Dict[str, str]) -> rest.Response: tag = _get_tag(params) versions.verify_version(tag, ctx) - auth.verify_privilege(ctx.user, 'tags:delete') + auth.verify_privilege(ctx.user, "tags:delete") snapshots.delete(tag, ctx.user) tags.delete(tag) ctx.session.commit() return {} -@rest.routes.post('/tag-merge/?') +@rest.routes.post("/tag-merge/?") def merge_tags( - ctx: rest.Context, _params: Dict[str, str] = {}) -> rest.Response: - source_tag_name = ctx.get_param_as_string('remove') - target_tag_name = ctx.get_param_as_string('mergeTo') + ctx: rest.Context, _params: Dict[str, str] = {} +) -> rest.Response: + source_tag_name = ctx.get_param_as_string("remove") + target_tag_name = ctx.get_param_as_string("mergeTo") source_tag = tags.get_tag_by_name(source_tag_name) target_tag = tags.get_tag_by_name(target_tag_name) - versions.verify_version(source_tag, ctx, 'removeVersion') - versions.verify_version(target_tag, ctx, 'mergeToVersion') + versions.verify_version(source_tag, ctx, "removeVersion") + versions.verify_version(target_tag, ctx, "mergeToVersion") versions.bump_version(target_tag) - auth.verify_privilege(ctx.user, 'tags:merge') + auth.verify_privilege(ctx.user, "tags:merge") tags.merge_tags(source_tag, target_tag) snapshots.merge(source_tag, target_tag, ctx.user) ctx.session.commit() return _serialize(ctx, target_tag) -@rest.routes.get('/tag-siblings/(?P<tag_name>.+)') +@rest.routes.get("/tag-siblings/(?P<tag_name>.+)") def get_tag_siblings( - ctx: rest.Context, params: Dict[str, str]) -> rest.Response: - auth.verify_privilege(ctx.user, 'tags:view') + ctx: rest.Context, params: Dict[str, str] +) -> rest.Response: + auth.verify_privilege(ctx.user, "tags:view") tag = _get_tag(params) result = tags.get_tag_siblings(tag) serialized_siblings = [] for sibling, occurrences in result: - serialized_siblings.append({ - 'tag': _serialize(ctx, sibling), - 'occurrences': occurrences - }) - return {'results': serialized_siblings} + serialized_siblings.append( + {"tag": _serialize(ctx, sibling), "occurrences": occurrences} + ) + return {"results": serialized_siblings} diff --git a/server/szurubooru/api/tag_category_api.py b/server/szurubooru/api/tag_category_api.py index 07da999..95d0ed8 100644 --- a/server/szurubooru/api/tag_category_api.py +++ b/server/szurubooru/api/tag_category_api.py @@ -1,32 +1,44 @@ from typing import Dict + from szurubooru import model, rest from szurubooru.func import ( - auth, tags, tag_categories, snapshots, serialization, versions) + auth, + serialization, + snapshots, + tag_categories, + tags, + versions, +) def _serialize( - ctx: rest.Context, category: model.TagCategory) -> rest.Response: + ctx: rest.Context, category: model.TagCategory +) -> rest.Response: return tag_categories.serialize_category( - category, options=serialization.get_serialization_options(ctx)) + category, options=serialization.get_serialization_options(ctx) + ) -@rest.routes.get('/tag-categories/?') +@rest.routes.get("/tag-categories/?") def get_tag_categories( - ctx: rest.Context, _params: Dict[str, str] = {}) -> rest.Response: - auth.verify_privilege(ctx.user, 'tag_categories:list') + ctx: rest.Context, _params: Dict[str, str] = {} +) -> rest.Response: + auth.verify_privilege(ctx.user, "tag_categories:list") categories = tag_categories.get_all_categories() return { - 'results': [_serialize(ctx, category) for category in categories], + "results": [_serialize(ctx, category) for category in categories], } -@rest.routes.post('/tag-categories/?') +@rest.routes.post("/tag-categories/?") def create_tag_category( - ctx: rest.Context, _params: Dict[str, str] = {}) -> rest.Response: - auth.verify_privilege(ctx.user, 'tag_categories:create') - name = ctx.get_param_as_string('name') - color = ctx.get_param_as_string('color') - category = tag_categories.create_category(name, color) + ctx: rest.Context, _params: Dict[str, str] = {} +) -> rest.Response: + auth.verify_privilege(ctx.user, "tag_categories:create") + name = ctx.get_param_as_string("name") + color = ctx.get_param_as_string("color") + order = ctx.get_param_as_int("order") + category = tag_categories.create_category(name, color, order) ctx.session.add(category) ctx.session.flush() snapshots.create(category, ctx.user) @@ -34,54 +46,68 @@ def create_tag_category( return _serialize(ctx, category) -@rest.routes.get('/tag-category/(?P<category_name>[^/]+)/?') +@rest.routes.get("/tag-category/(?P<category_name>[^/]+)/?") def get_tag_category( - ctx: rest.Context, params: Dict[str, str]) -> rest.Response: - auth.verify_privilege(ctx.user, 'tag_categories:view') - category = tag_categories.get_category_by_name(params['category_name']) + ctx: rest.Context, params: Dict[str, str] +) -> rest.Response: + auth.verify_privilege(ctx.user, "tag_categories:view") + category = tag_categories.get_category_by_name(params["category_name"]) return _serialize(ctx, category) -@rest.routes.put('/tag-category/(?P<category_name>[^/]+)/?') +@rest.routes.put("/tag-category/(?P<category_name>[^/]+)/?") def update_tag_category( - ctx: rest.Context, params: Dict[str, str]) -> rest.Response: + ctx: rest.Context, params: Dict[str, str] +) -> rest.Response: category = tag_categories.get_category_by_name( - params['category_name'], lock=True) + params["category_name"], lock=True + ) versions.verify_version(category, ctx) versions.bump_version(category) - if ctx.has_param('name'): - auth.verify_privilege(ctx.user, 'tag_categories:edit:name') + if ctx.has_param("name"): + auth.verify_privilege(ctx.user, "tag_categories:edit:name") tag_categories.update_category_name( - category, ctx.get_param_as_string('name')) - if ctx.has_param('color'): - auth.verify_privilege(ctx.user, 'tag_categories:edit:color') + category, ctx.get_param_as_string("name") + ) + if ctx.has_param("color"): + auth.verify_privilege(ctx.user, "tag_categories:edit:color") tag_categories.update_category_color( - category, ctx.get_param_as_string('color')) + category, ctx.get_param_as_string("color") + ) + if ctx.has_param("order"): + auth.verify_privilege(ctx.user, "tag_categories:edit:order") + tag_categories.update_category_order( + category, ctx.get_param_as_int("order") + ) ctx.session.flush() snapshots.modify(category, ctx.user) ctx.session.commit() return _serialize(ctx, category) -@rest.routes.delete('/tag-category/(?P<category_name>[^/]+)/?') +@rest.routes.delete("/tag-category/(?P<category_name>[^/]+)/?") def delete_tag_category( - ctx: rest.Context, params: Dict[str, str]) -> rest.Response: + ctx: rest.Context, params: Dict[str, str] +) -> rest.Response: category = tag_categories.get_category_by_name( - params['category_name'], lock=True) + params["category_name"], lock=True + ) versions.verify_version(category, ctx) - auth.verify_privilege(ctx.user, 'tag_categories:delete') + auth.verify_privilege(ctx.user, "tag_categories:delete") tag_categories.delete_category(category) snapshots.delete(category, ctx.user) ctx.session.commit() return {} -@rest.routes.put('/tag-category/(?P<category_name>[^/]+)/default/?') +@rest.routes.put("/tag-category/(?P<category_name>[^/]+)/default/?") def set_tag_category_as_default( - ctx: rest.Context, params: Dict[str, str]) -> rest.Response: - auth.verify_privilege(ctx.user, 'tag_categories:set_default') + ctx: rest.Context, params: Dict[str, str] +) -> rest.Response: + auth.verify_privilege(ctx.user, "tag_categories:set_default") category = tag_categories.get_category_by_name( - params['category_name'], lock=True) + params["category_name"], lock=True + ) tag_categories.set_default_category(category) ctx.session.flush() snapshots.modify(category, ctx.user) diff --git a/server/szurubooru/api/upload_api.py b/server/szurubooru/api/upload_api.py index 6a6ecfe..3b7bca8 100644 --- a/server/szurubooru/api/upload_api.py +++ b/server/szurubooru/api/upload_api.py @@ -1,12 +1,20 @@ from typing import Dict + from szurubooru import rest from szurubooru.func import auth, file_uploads -@rest.routes.post('/uploads/?') +@rest.routes.post("/uploads/?") def create_temporary_file( - ctx: rest.Context, _params: Dict[str, str] = {}) -> rest.Response: - auth.verify_privilege(ctx.user, 'uploads:create') - content = ctx.get_file('content', allow_tokens=False) + ctx: rest.Context, _params: Dict[str, str] = {} +) -> rest.Response: + auth.verify_privilege(ctx.user, "uploads:create") + content = ctx.get_file( + "content", + allow_tokens=False, + use_video_downloader=auth.has_privilege( + ctx.user, "uploads:use_downloader" + ), + ) token = file_uploads.save(content) - return {'token': token} + return {"token": token} diff --git a/server/szurubooru/api/user_api.py b/server/szurubooru/api/user_api.py index 5e14fab..a6196cb 100644 --- a/server/szurubooru/api/user_api.py +++ b/server/szurubooru/api/user_api.py @@ -1,97 +1,102 @@ from typing import Any, Dict -from szurubooru import model, search, rest -from szurubooru.func import auth, users, serialization, versions +from szurubooru import model, rest, search +from szurubooru.func import auth, serialization, users, versions _search_executor = search.Executor(search.configs.UserSearchConfig()) def _serialize( - ctx: rest.Context, user: model.User, **kwargs: Any) -> rest.Response: + ctx: rest.Context, user: model.User, **kwargs: Any +) -> rest.Response: return users.serialize_user( user, ctx.user, options=serialization.get_serialization_options(ctx), - **kwargs) + **kwargs + ) -@rest.routes.get('/users/?') +@rest.routes.get("/users/?") def get_users( - ctx: rest.Context, _params: Dict[str, str] = {}) -> rest.Response: - auth.verify_privilege(ctx.user, 'users:list') + ctx: rest.Context, _params: Dict[str, str] = {} +) -> rest.Response: + auth.verify_privilege(ctx.user, "users:list") return _search_executor.execute_and_serialize( - ctx, lambda user: _serialize(ctx, user)) + ctx, lambda user: _serialize(ctx, user) + ) -@rest.routes.post('/users/?') +@rest.routes.post("/users/?") def create_user( - ctx: rest.Context, _params: Dict[str, str] = {}) -> rest.Response: + ctx: rest.Context, _params: Dict[str, str] = {} +) -> rest.Response: if ctx.user.user_id is None: - auth.verify_privilege(ctx.user, 'users:create:self') + auth.verify_privilege(ctx.user, "users:create:self") else: - auth.verify_privilege(ctx.user, 'users:create:any') + auth.verify_privilege(ctx.user, "users:create:any") - name = ctx.get_param_as_string('name') - password = ctx.get_param_as_string('password') - email = ctx.get_param_as_string('email', default='') + name = ctx.get_param_as_string("name") + password = ctx.get_param_as_string("password") + email = ctx.get_param_as_string("email", default="") user = users.create_user(name, password, email) - if ctx.has_param('rank'): - users.update_user_rank(user, ctx.get_param_as_string('rank'), ctx.user) - if ctx.has_param('avatarStyle'): + if ctx.has_param("rank"): + users.update_user_rank(user, ctx.get_param_as_string("rank"), ctx.user) + if ctx.has_param("avatarStyle"): users.update_user_avatar( user, - ctx.get_param_as_string('avatarStyle'), - ctx.get_file('avatar', default=b'')) + ctx.get_param_as_string("avatarStyle"), + ctx.get_file("avatar", default=b""), + ) ctx.session.add(user) ctx.session.commit() return _serialize(ctx, user, force_show_email=True) -@rest.routes.get('/user/(?P<user_name>[^/]+)/?') +@rest.routes.get("/user/(?P<user_name>[^/]+)/?") def get_user(ctx: rest.Context, params: Dict[str, str]) -> rest.Response: - user = users.get_user_by_name(params['user_name']) + user = users.get_user_by_name(params["user_name"]) if ctx.user.user_id != user.user_id: - auth.verify_privilege(ctx.user, 'users:view') + auth.verify_privilege(ctx.user, "users:view") return _serialize(ctx, user) -@rest.routes.put('/user/(?P<user_name>[^/]+)/?') +@rest.routes.put("/user/(?P<user_name>[^/]+)/?") def update_user(ctx: rest.Context, params: Dict[str, str]) -> rest.Response: - user = users.get_user_by_name(params['user_name']) + user = users.get_user_by_name(params["user_name"]) versions.verify_version(user, ctx) versions.bump_version(user) - infix = 'self' if ctx.user.user_id == user.user_id else 'any' - if ctx.has_param('name'): - auth.verify_privilege(ctx.user, 'users:edit:%s:name' % infix) - users.update_user_name(user, ctx.get_param_as_string('name')) - if ctx.has_param('password'): - auth.verify_privilege(ctx.user, 'users:edit:%s:pass' % infix) - users.update_user_password( - user, ctx.get_param_as_string('password')) - if ctx.has_param('email'): - auth.verify_privilege(ctx.user, 'users:edit:%s:email' % infix) - users.update_user_email(user, ctx.get_param_as_string('email')) - if ctx.has_param('rank'): - auth.verify_privilege(ctx.user, 'users:edit:%s:rank' % infix) - users.update_user_rank( - user, ctx.get_param_as_string('rank'), ctx.user) - if ctx.has_param('avatarStyle'): - auth.verify_privilege(ctx.user, 'users:edit:%s:avatar' % infix) + infix = "self" if ctx.user.user_id == user.user_id else "any" + if ctx.has_param("name"): + auth.verify_privilege(ctx.user, "users:edit:%s:name" % infix) + users.update_user_name(user, ctx.get_param_as_string("name")) + if ctx.has_param("password"): + auth.verify_privilege(ctx.user, "users:edit:%s:pass" % infix) + users.update_user_password(user, ctx.get_param_as_string("password")) + if ctx.has_param("email"): + auth.verify_privilege(ctx.user, "users:edit:%s:email" % infix) + users.update_user_email(user, ctx.get_param_as_string("email")) + if ctx.has_param("rank"): + auth.verify_privilege(ctx.user, "users:edit:%s:rank" % infix) + users.update_user_rank(user, ctx.get_param_as_string("rank"), ctx.user) + if ctx.has_param("avatarStyle"): + auth.verify_privilege(ctx.user, "users:edit:%s:avatar" % infix) users.update_user_avatar( user, - ctx.get_param_as_string('avatarStyle'), - ctx.get_file('avatar', default=b'')) + ctx.get_param_as_string("avatarStyle"), + ctx.get_file("avatar", default=b""), + ) ctx.session.commit() return _serialize(ctx, user) -@rest.routes.delete('/user/(?P<user_name>[^/]+)/?') +@rest.routes.delete("/user/(?P<user_name>[^/]+)/?") def delete_user(ctx: rest.Context, params: Dict[str, str]) -> rest.Response: - user = users.get_user_by_name(params['user_name']) + user = users.get_user_by_name(params["user_name"]) versions.verify_version(user, ctx) - infix = 'self' if ctx.user.user_id == user.user_id else 'any' - auth.verify_privilege(ctx.user, 'users:delete:%s' % infix) + infix = "self" if ctx.user.user_id == user.user_id else "any" + auth.verify_privilege(ctx.user, "users:delete:%s" % infix) ctx.session.delete(user) ctx.session.commit() return {} diff --git a/server/szurubooru/api/user_token_api.py b/server/szurubooru/api/user_token_api.py index 7739823..772f97a 100644 --- a/server/szurubooru/api/user_token_api.py +++ b/server/szurubooru/api/user_token_api.py @@ -1,82 +1,90 @@ from typing import Dict + from szurubooru import model, rest -from szurubooru.func import auth, users, user_tokens, serialization, versions +from szurubooru.func import auth, serialization, user_tokens, users, versions def _serialize( - ctx: rest.Context, user_token: model.UserToken) -> rest.Response: + ctx: rest.Context, user_token: model.UserToken +) -> rest.Response: return user_tokens.serialize_user_token( user_token, ctx.user, - options=serialization.get_serialization_options(ctx)) + options=serialization.get_serialization_options(ctx), + ) -@rest.routes.get('/user-tokens/(?P<user_name>[^/]+)/?') +@rest.routes.get("/user-tokens/(?P<user_name>[^/]+)/?") def get_user_tokens( - ctx: rest.Context, params: Dict[str, str] = {}) -> rest.Response: - user = users.get_user_by_name(params['user_name']) - infix = 'self' if ctx.user.user_id == user.user_id else 'any' - auth.verify_privilege(ctx.user, 'user_tokens:list:%s' % infix) + ctx: rest.Context, params: Dict[str, str] = {} +) -> rest.Response: + user = users.get_user_by_name(params["user_name"]) + infix = "self" if ctx.user.user_id == user.user_id else "any" + auth.verify_privilege(ctx.user, "user_tokens:list:%s" % infix) user_token_list = user_tokens.get_user_tokens(user) - return { - 'results': [_serialize(ctx, token) for token in user_token_list] - } + return {"results": [_serialize(ctx, token) for token in user_token_list]} -@rest.routes.post('/user-token/(?P<user_name>[^/]+)/?') +@rest.routes.post("/user-token/(?P<user_name>[^/]+)/?") def create_user_token( - ctx: rest.Context, params: Dict[str, str] = {}) -> rest.Response: - user = users.get_user_by_name(params['user_name']) - infix = 'self' if ctx.user.user_id == user.user_id else 'any' - auth.verify_privilege(ctx.user, 'user_tokens:create:%s' % infix) - enabled = ctx.get_param_as_bool('enabled', True) + ctx: rest.Context, params: Dict[str, str] = {} +) -> rest.Response: + user = users.get_user_by_name(params["user_name"]) + infix = "self" if ctx.user.user_id == user.user_id else "any" + auth.verify_privilege(ctx.user, "user_tokens:create:%s" % infix) + enabled = ctx.get_param_as_bool("enabled", True) user_token = user_tokens.create_user_token(user, enabled) - if ctx.has_param('note'): - note = ctx.get_param_as_string('note') + if ctx.has_param("note"): + note = ctx.get_param_as_string("note") user_tokens.update_user_token_note(user_token, note) - if ctx.has_param('expirationTime'): - expiration_time = ctx.get_param_as_string('expirationTime') + if ctx.has_param("expirationTime"): + expiration_time = ctx.get_param_as_string("expirationTime") user_tokens.update_user_token_expiration_time( - user_token, expiration_time) + user_token, expiration_time + ) ctx.session.add(user_token) ctx.session.commit() return _serialize(ctx, user_token) -@rest.routes.put('/user-token/(?P<user_name>[^/]+)/(?P<user_token>[^/]+)/?') +@rest.routes.put("/user-token/(?P<user_name>[^/]+)/(?P<user_token>[^/]+)/?") def update_user_token( - ctx: rest.Context, params: Dict[str, str] = {}) -> rest.Response: - user = users.get_user_by_name(params['user_name']) - infix = 'self' if ctx.user.user_id == user.user_id else 'any' - auth.verify_privilege(ctx.user, 'user_tokens:edit:%s' % infix) - user_token = user_tokens.get_by_user_and_token(user, params['user_token']) + ctx: rest.Context, params: Dict[str, str] = {} +) -> rest.Response: + user = users.get_user_by_name(params["user_name"]) + infix = "self" if ctx.user.user_id == user.user_id else "any" + auth.verify_privilege(ctx.user, "user_tokens:edit:%s" % infix) + user_token = user_tokens.get_by_user_and_token(user, params["user_token"]) versions.verify_version(user_token, ctx) versions.bump_version(user_token) - if ctx.has_param('enabled'): - auth.verify_privilege(ctx.user, 'user_tokens:edit:%s' % infix) + if ctx.has_param("enabled"): + auth.verify_privilege(ctx.user, "user_tokens:edit:%s" % infix) user_tokens.update_user_token_enabled( - user_token, ctx.get_param_as_bool('enabled')) - if ctx.has_param('note'): - auth.verify_privilege(ctx.user, 'user_tokens:edit:%s' % infix) - note = ctx.get_param_as_string('note') + user_token, ctx.get_param_as_bool("enabled") + ) + if ctx.has_param("note"): + auth.verify_privilege(ctx.user, "user_tokens:edit:%s" % infix) + note = ctx.get_param_as_string("note") user_tokens.update_user_token_note(user_token, note) - if ctx.has_param('expirationTime'): - auth.verify_privilege(ctx.user, 'user_tokens:edit:%s' % infix) - expiration_time = ctx.get_param_as_string('expirationTime') + if ctx.has_param("expirationTime"): + auth.verify_privilege(ctx.user, "user_tokens:edit:%s" % infix) + expiration_time = ctx.get_param_as_string("expirationTime") user_tokens.update_user_token_expiration_time( - user_token, expiration_time) + user_token, expiration_time + ) user_tokens.update_user_token_edit_time(user_token) ctx.session.commit() return _serialize(ctx, user_token) -@rest.routes.delete('/user-token/(?P<user_name>[^/]+)/(?P<user_token>[^/]+)/?') +@rest.routes.delete("/user-token/(?P<user_name>[^/]+)/(?P<user_token>[^/]+)/?") def delete_user_token( - ctx: rest.Context, params: Dict[str, str]) -> rest.Response: - user = users.get_user_by_name(params['user_name']) - infix = 'self' if ctx.user.user_id == user.user_id else 'any' - auth.verify_privilege(ctx.user, 'user_tokens:delete:%s' % infix) - user_token = user_tokens.get_by_user_and_token(user, params['user_token']) + ctx: rest.Context, params: Dict[str, str] +) -> rest.Response: + user = users.get_user_by_name(params["user_name"]) + infix = "self" if ctx.user.user_id == user.user_id else "any" + auth.verify_privilege(ctx.user, "user_tokens:delete:%s" % infix) + user_token = user_tokens.get_by_user_and_token(user, params["user_token"]) if user_token is not None: ctx.session.delete(user_token) ctx.session.commit() diff --git a/server/szurubooru/config.py b/server/szurubooru/config.py index f9c745c..1515a54 100644 --- a/server/szurubooru/config.py +++ b/server/szurubooru/config.py @@ -1,14 +1,19 @@ -from typing import Dict +import logging import os +from typing import Dict + import yaml + from szurubooru import errors +logger = logging.getLogger(__name__) -def merge(left: Dict, right: Dict) -> Dict: + +def _merge(left: Dict, right: Dict) -> Dict: for key in right: if key in left: if isinstance(left[key], dict) and isinstance(right[key], dict): - merge(left[key], right[key]) + _merge(left[key], right[key]) elif left[key] != right[key]: left[key] = right[key] else: @@ -16,44 +21,45 @@ def merge(left: Dict, right: Dict) -> Dict: return left -def docker_config() -> Dict: - for key in [ - 'POSTGRES_USER', - 'POSTGRES_PASSWORD', - 'POSTGRES_HOST', - 'ESEARCH_HOST' - ]: - if not os.getenv(key, False): - raise errors.ConfigError(f'Environment variable "{key}" not set') +def _docker_config() -> Dict: + if "TEST_ENVIRONMENT" not in os.environ: + for key in ["POSTGRES_USER", "POSTGRES_PASSWORD", "POSTGRES_HOST"]: + if key not in os.environ: + raise errors.ConfigError( + f'Environment variable "{key}" not set' + ) return { - 'debug': True, - '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' % { - 'user': os.getenv('POSTGRES_USER'), - 'pass': os.getenv('POSTGRES_PASSWORD'), - 'host': os.getenv('POSTGRES_HOST'), - 'port': int(os.getenv('POSTGRES_PORT', 5432)), - 'db': os.getenv('POSTGRES_DB', os.getenv('POSTGRES_USER')) + "debug": True, + "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" + % { + "user": os.getenv("POSTGRES_USER"), + "pass": os.getenv("POSTGRES_PASSWORD"), + "host": os.getenv("POSTGRES_HOST"), + "port": int(os.getenv("POSTGRES_PORT", 5432)), + "db": os.getenv("POSTGRES_DB", os.getenv("POSTGRES_USER")), }, - 'elasticsearch': { - 'host': os.getenv('ESEARCH_HOST'), - 'port': int(os.getenv('ESEARCH_PORT', 9200)), - 'index': os.getenv('ESEARCH_INDEX', 'szurubooru') - } } -def read_config() -> Dict: - with open('config.yaml.dist') as handle: - ret = yaml.load(handle.read()) - if os.path.exists('config.yaml'): - with open('config.yaml') as handle: - ret = merge(ret, yaml.load(handle.read())) - if os.path.exists('/.dockerenv'): - ret = merge(ret, docker_config()) - return ret +def _file_config(filename: str) -> Dict: + with open(filename) as handle: + return yaml.load(handle.read(), Loader=yaml.SafeLoader) or {} + + +def _read_config() -> Dict: + ret = _file_config("config.yaml.dist") + if os.path.isfile("config.yaml"): + ret = _merge(ret, _file_config("config.yaml")) + elif os.path.isdir("config.yaml"): + logger.warning( + "'config.yaml' should be a file, not a directory, skipping" + ) + if os.path.exists("/.dockerenv"): + ret = _merge(ret, _docker_config()) + return ret -config = read_config() # pylint: disable=invalid-name +config = _read_config() diff --git a/server/szurubooru/db.py b/server/szurubooru/db.py index f90bfaf..ed59a70 100644 --- a/server/szurubooru/db.py +++ b/server/szurubooru/db.py @@ -1,14 +1,15 @@ -from typing import Any import threading +from typing import Any + import sqlalchemy as sa import sqlalchemy.orm + from szurubooru import config -# pylint: disable=invalid-name _data = threading.local() -_engine = sa.create_engine(config.config['database']) # type: Any -sessionmaker = sa.orm.sessionmaker(bind=_engine, autoflush=False) # type: Any -session = sa.orm.scoped_session(sessionmaker) # type: Any +_engine = sa.create_engine(config.config["database"]) # type: Any +_sessionmaker = sa.orm.sessionmaker(bind=_engine, autoflush=False) # type: Any +session = sa.orm.scoped_session(_sessionmaker) # type: Any def get_session() -> Any: @@ -30,7 +31,7 @@ def get_query_count() -> int: def _bump_query_count() -> None: - _data.query_count = getattr(_data, 'query_count', 0) + 1 + _data.query_count = getattr(_data, "query_count", 0) + 1 -sa.event.listen(_engine, 'after_execute', lambda *args: _bump_query_count()) +sa.event.listen(_engine, "after_execute", lambda *args: _bump_query_count()) diff --git a/server/szurubooru/errors.py b/server/szurubooru/errors.py index beeb469..9106f04 100644 --- a/server/szurubooru/errors.py +++ b/server/szurubooru/errors.py @@ -3,9 +3,10 @@ from typing import Dict class BaseError(RuntimeError): def __init__( - self, - message: str = 'Unknown error', - extra_fields: Dict[str, str] = None) -> None: + self, + message: str = "Unknown error", + extra_fields: Dict[str, str] = None, + ) -> None: super().__init__(message) self.extra_fields = extra_fields diff --git a/server/szurubooru/facade.py b/server/szurubooru/facade.py index 90709f9..ecf34c7 100644 --- a/server/szurubooru/facade.py +++ b/server/szurubooru/facade.py @@ -1,125 +1,148 @@ -import os -import time import logging +import os import threading -from typing import Callable, Any, Type +import time +from typing import Any, Callable, Type import coloredlogs import sqlalchemy as sa import sqlalchemy.orm.exc -from szurubooru import config, db, errors, rest -from szurubooru.func import posts, file_uploads -# pylint: disable=unused-import -from szurubooru import api, middleware + +from szurubooru import api, config, db, errors, middleware, rest +from szurubooru.func.file_uploads import purge_old_uploads +from szurubooru.func.posts import update_all_post_signatures def _map_error( - ex: Exception, - target_class: Type[rest.errors.BaseHttpError], - title: str) -> rest.errors.BaseHttpError: + ex: Exception, target_class: Type[rest.errors.BaseHttpError], title: str +) -> rest.errors.BaseHttpError: return target_class( name=type(ex).__name__, title=title, description=str(ex), - extra_fields=getattr(ex, 'extra_fields', {})) + extra_fields=getattr(ex, "extra_fields", {}), + ) def _on_auth_error(ex: Exception) -> None: - raise _map_error(ex, rest.errors.HttpForbidden, 'Authentication error') + raise _map_error(ex, rest.errors.HttpForbidden, "Authentication error") def _on_validation_error(ex: Exception) -> None: - raise _map_error(ex, rest.errors.HttpBadRequest, 'Validation error') + raise _map_error(ex, rest.errors.HttpBadRequest, "Validation error") def _on_search_error(ex: Exception) -> None: - raise _map_error(ex, rest.errors.HttpBadRequest, 'Search error') + raise _map_error(ex, rest.errors.HttpBadRequest, "Search error") def _on_integrity_error(ex: Exception) -> None: - raise _map_error(ex, rest.errors.HttpConflict, 'Integrity violation') + raise _map_error(ex, rest.errors.HttpConflict, "Integrity violation") def _on_not_found_error(ex: Exception) -> None: - raise _map_error(ex, rest.errors.HttpNotFound, 'Not found') + raise _map_error(ex, rest.errors.HttpNotFound, "Not found") def _on_processing_error(ex: Exception) -> None: - raise _map_error(ex, rest.errors.HttpBadRequest, 'Processing error') + raise _map_error(ex, rest.errors.HttpBadRequest, "Processing error") def _on_third_party_error(ex: Exception) -> None: raise _map_error( - ex, - rest.errors.HttpInternalServerError, - 'Server configuration error') + ex, rest.errors.HttpInternalServerError, "Server configuration error" + ) def _on_stale_data_error(_ex: Exception) -> None: raise rest.errors.HttpConflict( - name='IntegrityError', - title='Integrity violation', + name="IntegrityError", + title="Integrity violation", description=( - 'Someone else modified this in the meantime. ' - 'Please try again.')) + "Someone else modified this in the meantime. " "Please try again." + ), + ) def validate_config() -> None: - ''' + """ Check whether config doesn't contain errors that might prove lethal at runtime. - ''' + """ from szurubooru.func.auth import RANK_MAP - for privilege, rank in config.config['privileges'].items(): + + for privilege, rank in config.config["privileges"].items(): if rank not in RANK_MAP.values(): raise errors.ConfigError( - 'Rank %r for privilege %r is missing' % (rank, privilege)) - if config.config['default_rank'] not in RANK_MAP.values(): + "Rank %r for privilege %r is missing" % (rank, privilege) + ) + if config.config["default_rank"] not in RANK_MAP.values(): raise errors.ConfigError( - 'Default rank %r is not on the list of known ranks' % ( - config.config['default_rank'])) + "Default rank %r is not on the list of known ranks" + % (config.config["default_rank"]) + ) - for key in ['data_url', 'data_dir']: + for key in ["data_url", "data_dir"]: if not config.config[key]: raise errors.ConfigError( - 'Service is not configured: %r is missing' % key) + "Service is not configured: %r is missing" % key + ) - if not os.path.isabs(config.config['data_dir']): - raise errors.ConfigError( - 'data_dir must be an absolute path') + if not os.path.isabs(config.config["data_dir"]): + raise errors.ConfigError("data_dir must be an absolute path") - if not config.config['database']: - raise errors.ConfigError('Database is not configured') + if not config.config["database"]: + raise errors.ConfigError("Database is not configured") + if config.config["webhooks"] and not isinstance( + config.config["webhooks"], list + ): + raise errors.ConfigError("Webhooks must be provided as a list of URLs") -def purge_old_uploads() -> None: + if config.config["smtp"]["host"]: + if not config.config["smtp"]["port"]: + raise errors.ConfigError("SMTP host is set but port is not set") + if not config.config["smtp"]["user"]: + raise errors.ConfigError( + "SMTP host is set but username is not set" + ) + if not config.config["smtp"]["pass"]: + raise errors.ConfigError( + "SMTP host is set but password is not set" + ) + if not config.config["smtp"]["from"]: + raise errors.ConfigError( + "From address must be set to use mail-based password reset" + ) + + +def purge_old_uploads_daemon() -> None: while True: try: - file_uploads.purge_old_uploads() + purge_old_uploads() except Exception as ex: logging.exception(ex) time.sleep(60 * 5) 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') - logging.getLogger('elasticsearch').disabled = True - if config.config['debug']: - logging.getLogger('szurubooru').setLevel(logging.INFO) - if config.config['show_sql']: - logging.getLogger('sqlalchemy.engine').setLevel(logging.INFO) + coloredlogs.install(fmt="[%(asctime)-15s] %(name)s %(message)s") + if config.config["debug"]: + logging.getLogger("szurubooru").setLevel(logging.INFO) + if config.config["show_sql"]: + logging.getLogger("sqlalchemy.engine").setLevel(logging.INFO) - purge_thread = threading.Thread(target=purge_old_uploads) + purge_thread = threading.Thread(target=purge_old_uploads_daemon) purge_thread.daemon = True purge_thread.start() - try: - posts.populate_reverse_search() - db.session.commit() - except errors.ThirdPartyError: - pass + hashing_thread = threading.Thread(target=update_all_post_signatures) + hashing_thread.daemon = False + hashing_thread.start() + + db.session.commit() rest.errors.handle(errors.AuthError, _on_auth_error) rest.errors.handle(errors.ValidationError, _on_validation_error) @@ -133,4 +156,4 @@ def create_app() -> Callable[[Any, Any], Any]: return rest.application -app = create_app() # pylint: disable=invalid-name +app = create_app() diff --git a/server/szurubooru/func/auth.py b/server/szurubooru/func/auth.py index 65be79a..d013775 100644 --- a/server/szurubooru/func/auth.py +++ b/server/szurubooru/func/auth.py @@ -1,60 +1,67 @@ -from typing import Tuple, Optional import hashlib import random import uuid from collections import OrderedDict from datetime import datetime +from typing import Optional, Tuple + from nacl import pwhash from nacl.exceptions import InvalidkeyError -from szurubooru import config, db, model, errors -from szurubooru.func import util +from szurubooru import config, db, errors, model +from szurubooru.func import util -RANK_MAP = OrderedDict([ - (model.User.RANK_ANONYMOUS, 'anonymous'), - (model.User.RANK_RESTRICTED, 'restricted'), - (model.User.RANK_REGULAR, 'regular'), - (model.User.RANK_POWER, 'power'), - (model.User.RANK_MODERATOR, 'moderator'), - (model.User.RANK_ADMINISTRATOR, 'administrator'), - (model.User.RANK_NOBODY, 'nobody'), -]) +RANK_MAP = OrderedDict( + [ + (model.User.RANK_ANONYMOUS, "anonymous"), + (model.User.RANK_RESTRICTED, "restricted"), + (model.User.RANK_REGULAR, "regular"), + (model.User.RANK_POWER, "power"), + (model.User.RANK_MODERATOR, "moderator"), + (model.User.RANK_ADMINISTRATOR, "administrator"), + (model.User.RANK_NOBODY, "nobody"), + ] +) def get_password_hash(salt: str, password: str) -> Tuple[str, int]: - ''' Retrieve argon2id password hash. ''' - return pwhash.argon2id.str( - (config.config['secret'] + salt + password).encode('utf8') - ).decode('utf8'), 3 + """ Retrieve argon2id password hash. """ + return ( + pwhash.argon2id.str( + (config.config["secret"] + salt + password).encode("utf8") + ).decode("utf8"), + 3, + ) def get_sha256_legacy_password_hash( - salt: str, password: str) -> Tuple[str, int]: - ''' Retrieve old-style sha256 password hash. ''' + salt: str, password: str +) -> Tuple[str, int]: + """ Retrieve old-style sha256 password hash. """ digest = hashlib.sha256() - digest.update(config.config['secret'].encode('utf8')) - digest.update(salt.encode('utf8')) - digest.update(password.encode('utf8')) + digest.update(config.config["secret"].encode("utf8")) + digest.update(salt.encode("utf8")) + digest.update(password.encode("utf8")) return digest.hexdigest(), 2 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')) - digest.update(password.encode('utf8')) + digest.update(b"1A2/$_4xVa") + digest.update(salt.encode("utf8")) + digest.update(password.encode("utf8")) return digest.hexdigest(), 1 def create_password() -> str: alphabet = { - 'c': list('bcdfghijklmnpqrstvwxyz'), - 'v': list('aeiou'), - 'n': list('0123456789'), + "c": list("bcdfghijklmnpqrstvwxyz"), + "v": list("aeiou"), + "n": list("0123456789"), } - pattern = 'cvcvnncvcv' - return ''.join(random.choice(alphabet[l]) for l in list(pattern)) + pattern = "cvcvnncvcv" + return "".join(random.choice(alphabet[type]) for type in list(pattern)) def is_valid_password(user: model.User, password: str) -> bool: @@ -63,12 +70,13 @@ def is_valid_password(user: model.User, password: str) -> bool: try: return pwhash.verify( - user.password_hash.encode('utf8'), - (config.config['secret'] + salt + password).encode('utf8')) + user.password_hash.encode("utf8"), + (config.config["secret"] + salt + password).encode("utf8"), + ) except InvalidkeyError: possible_hashes = [ get_sha256_legacy_password_hash(salt, password)[0], - get_sha1_legacy_password_hash(salt, password)[0] + get_sha1_legacy_password_hash(salt, password)[0], ] if valid_hash in possible_hashes: # Convert the user password hash to the new hash @@ -82,16 +90,18 @@ def is_valid_password(user: model.User, password: str) -> bool: def is_valid_token(user_token: Optional[model.UserToken]) -> bool: - ''' + """ Token must be enabled and if it has an expiration, it must be greater than now. - ''' + """ if user_token is None: return False if not user_token.enabled: return False - if (user_token.expiration_time is not None - and user_token.expiration_time < datetime.utcnow()): + if ( + user_token.expiration_time is not None + and user_token.expiration_time < datetime.utcnow() + ): return False return True @@ -99,26 +109,27 @@ def is_valid_token(user_token: Optional[model.UserToken]) -> bool: def has_privilege(user: model.User, privilege_name: str) -> bool: assert user all_ranks = list(RANK_MAP.keys()) - assert privilege_name in config.config['privileges'] + assert privilege_name in config.config["privileges"] assert user.rank in all_ranks minimal_rank = util.flip(RANK_MAP)[ - config.config['privileges'][privilege_name]] - good_ranks = all_ranks[all_ranks.index(minimal_rank):] + config.config["privileges"][privilege_name] + ] + good_ranks = all_ranks[all_ranks.index(minimal_rank) :] return user.rank in good_ranks def verify_privilege(user: model.User, privilege_name: str) -> None: assert user if not has_privilege(user, privilege_name): - raise errors.AuthError('Insufficient privileges to do this.') + raise errors.AuthError("Insufficient privileges to do this.") 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')) - digest.update(user.password_salt.encode('utf8')) + digest.update(config.config["secret"].encode("utf8")) + digest.update(user.password_salt.encode("utf8")) return digest.hexdigest() diff --git a/server/szurubooru/func/cache.py b/server/szurubooru/func/cache.py index 01e4659..65e99e1 100644 --- a/server/szurubooru/func/cache.py +++ b/server/szurubooru/func/cache.py @@ -1,5 +1,5 @@ -from typing import Any, List, Dict from datetime import datetime +from typing import Any, Dict, List class LruCacheItem: @@ -18,12 +18,11 @@ class LruCache: def insert_item(self, item: LruCacheItem) -> None: if item.key in self.hash: item_index = next( - i - for i, v in enumerate(self.item_list) - if v.key == item.key) + i for i, v in enumerate(self.item_list) if v.key == item.key + ) self.item_list[:] = ( - self.item_list[:item_index] + - self.item_list[item_index + 1:]) + self.item_list[:item_index] + self.item_list[item_index + 1 :] + ) self.item_list.insert(0, item) else: if len(self.item_list) > self.length: diff --git a/server/szurubooru/func/comments.py b/server/szurubooru/func/comments.py index 9f88283..5eb7c8e 100644 --- a/server/szurubooru/func/comments.py +++ b/server/szurubooru/func/comments.py @@ -1,7 +1,8 @@ from datetime import datetime -from typing import Any, Optional, List, Dict, Callable -from szurubooru import db, model, errors, rest -from szurubooru.func import users, scores, serialization +from typing import Any, Callable, Dict, List, Optional + +from szurubooru import db, errors, model, rest +from szurubooru.func import scores, serialization, users class InvalidCommentIdError(errors.ValidationError): @@ -23,15 +24,15 @@ class CommentSerializer(serialization.BaseSerializer): def _serializers(self) -> Dict[str, Callable[[], Any]]: return { - 'id': self.serialize_id, - 'user': self.serialize_user, - 'postId': self.serialize_post_id, - 'version': self.serialize_version, - 'text': self.serialize_text, - 'creationTime': self.serialize_creation_time, - 'lastEditTime': self.serialize_last_edit_time, - 'score': self.serialize_score, - 'ownScore': self.serialize_own_score, + "id": self.serialize_id, + "user": self.serialize_user, + "postId": self.serialize_post_id, + "version": self.serialize_version, + "text": self.serialize_text, + "creationTime": self.serialize_creation_time, + "lastEditTime": self.serialize_last_edit_time, + "score": self.serialize_score, + "ownScore": self.serialize_own_score, } def serialize_id(self) -> Any: @@ -63,9 +64,8 @@ class CommentSerializer(serialization.BaseSerializer): def serialize_comment( - comment: model.Comment, - auth_user: model.User, - options: List[str] = []) -> rest.Response: + comment: model.Comment, auth_user: model.User, options: List[str] = [] +) -> rest.Response: if comment is None: return None return CommentSerializer(comment, auth_user).serialize(options) @@ -74,21 +74,22 @@ def serialize_comment( def try_get_comment_by_id(comment_id: int) -> Optional[model.Comment]: comment_id = int(comment_id) return ( - db.session - .query(model.Comment) + db.session.query(model.Comment) .filter(model.Comment.comment_id == comment_id) - .one_or_none()) + .one_or_none() + ) def get_comment_by_id(comment_id: int) -> model.Comment: comment = try_get_comment_by_id(comment_id) if comment: return comment - raise CommentNotFoundError('Comment %r not found.' % comment_id) + raise CommentNotFoundError("Comment %r not found." % comment_id) def create_comment( - user: model.User, post: model.Post, text: str) -> model.Comment: + user: model.User, post: model.Post, text: str +) -> model.Comment: comment = model.Comment() comment.user = user comment.post = post @@ -100,5 +101,5 @@ def create_comment( def update_comment_text(comment: model.Comment, text: str) -> None: assert comment if not text: - raise EmptyCommentTextError('Comment text cannot be empty.') + raise EmptyCommentTextError("Comment text cannot be empty.") comment.text = text diff --git a/server/szurubooru/func/diff.py b/server/szurubooru/func/diff.py index 90014f7..3282ebb 100644 --- a/server/szurubooru/func/diff.py +++ b/server/szurubooru/func/diff.py @@ -1,4 +1,4 @@ -from typing import List, Dict, Any +from typing import Any, Dict, List def get_list_diff(old: List[Any], new: List[Any]) -> Any: @@ -16,8 +16,11 @@ def get_list_diff(old: List[Any], new: List[Any]) -> Any: equal = False added.append(item) - return None if equal else { - 'type': 'list change', 'added': added, 'removed': removed} + return ( + None + if equal + else {"type": "list change", "added": added, "removed": removed} + ) def get_dict_diff(old: Dict[str, Any], new: Dict[str, Any]) -> Any: @@ -40,23 +43,20 @@ def get_dict_diff(old: Dict[str, Any], new: Dict[str, Any]) -> Any: else: equal = False value[key] = { - 'type': 'primitive change', - 'old-value': old[key], - 'new-value': new[key], + "type": "primitive change", + "old-value": old[key], + "new-value": new[key], } else: equal = False - value[key] = { - 'type': 'deleted property', - 'value': old[key] - } + value[key] = {"type": "deleted property", "value": old[key]} for key in new.keys(): if key not in old: equal = False value[key] = { - 'type': 'added property', - 'value': new[key], + "type": "added property", + "value": new[key], } - return None if equal else {'type': 'object change', 'value': value} + return None if equal else {"type": "object change", "value": value} diff --git a/server/szurubooru/func/favorites.py b/server/szurubooru/func/favorites.py index f567bfa..a012537 100644 --- a/server/szurubooru/func/favorites.py +++ b/server/szurubooru/func/favorites.py @@ -1,6 +1,7 @@ -from typing import Any, Optional, Callable, Tuple from datetime import datetime -from szurubooru import db, model, errors +from typing import Any, Callable, Optional, Tuple + +from szurubooru import db, errors, model class InvalidFavoriteTargetError(errors.ValidationError): @@ -8,10 +9,11 @@ class InvalidFavoriteTargetError(errors.ValidationError): def _get_table_info( - entity: model.Base) -> Tuple[model.Base, Callable[[model.Base], Any]]: + entity: model.Base, +) -> Tuple[model.Base, Callable[[model.Base], Any]]: assert entity resource_type, _, _ = model.util.get_resource_info(entity) - if resource_type == 'post': + if resource_type == "post": return model.PostFavorite, lambda table: table.post_id raise InvalidFavoriteTargetError() @@ -38,6 +40,7 @@ def unset_favorite(entity: model.Base, user: Optional[model.User]) -> None: def set_favorite(entity: model.Base, user: Optional[model.User]) -> None: from szurubooru.func import scores + assert entity assert user try: diff --git a/server/szurubooru/func/file_uploads.py b/server/szurubooru/func/file_uploads.py index e7f93d8..800397d 100644 --- a/server/szurubooru/func/file_uploads.py +++ b/server/szurubooru/func/file_uploads.py @@ -1,25 +1,25 @@ -from typing import Optional from datetime import datetime, timedelta -from szurubooru.func import files, util +from typing import Optional +from szurubooru.func import files, util MAX_MINUTES = 60 def _get_path(checksum: str) -> str: - return 'temporary-uploads/%s.dat' % checksum + return "temporary-uploads/%s.dat" % checksum def purge_old_uploads() -> None: now = datetime.now() - for file in files.scan('temporary-uploads'): + for file in files.scan("temporary-uploads"): file_time = datetime.fromtimestamp(file.stat().st_ctime) if now - file_time > timedelta(minutes=MAX_MINUTES): - files.delete('temporary-uploads/%s' % file.name) + files.delete("temporary-uploads/%s" % file.name) def get(checksum: str) -> Optional[bytes]: - return files.get('temporary-uploads/%s.dat' % checksum) + return files.get("temporary-uploads/%s.dat" % checksum) def save(content: bytes) -> str: diff --git a/server/szurubooru/func/files.py b/server/szurubooru/func/files.py index fa9f36f..6a89826 100644 --- a/server/szurubooru/func/files.py +++ b/server/szurubooru/func/files.py @@ -1,10 +1,11 @@ -from typing import Any, Optional, List import os +from typing import Any, List, Optional + from szurubooru import config def _get_full_path(path: str) -> str: - return os.path.join(config.config['data_dir'], path) + return os.path.join(config.config["data_dir"], path) def delete(path: str) -> None: @@ -31,12 +32,12 @@ def get(path: str) -> Optional[bytes]: full_path = _get_full_path(path) if not os.path.exists(full_path): return None - with open(full_path, 'rb') as handle: + with open(full_path, "rb") as handle: return handle.read() def save(path: str, content: bytes) -> None: full_path = _get_full_path(path) os.makedirs(os.path.dirname(full_path), exist_ok=True) - with open(full_path, 'wb') as handle: + with open(full_path, "wb") as handle: handle.write(content) diff --git a/server/szurubooru/func/image_hash.py b/server/szurubooru/func/image_hash.py index b89b218..fc7d141 100644 --- a/server/szurubooru/func/image_hash.py +++ b/server/szurubooru/func/image_hash.py @@ -1,14 +1,14 @@ import logging -from io import BytesIO +import math from datetime import datetime -from typing import Any, Optional, Tuple, Set, List, Callable -import elasticsearch -import elasticsearch_dsl +from io import BytesIO +from typing import Any, Callable, List, Optional, Set, Tuple + import numpy as np from PIL import Image + from szurubooru import config, errors -# pylint: disable=invalid-name logger = logging.getLogger(__name__) # Math based on paper from H. Chi Wong, Marshall Bern and David Goldberg @@ -17,87 +17,96 @@ logger = logging.getLogger(__name__) LOWER_PERCENTILE = 5 UPPER_PERCENTILE = 95 -IDENTICAL_TOLERANCE = 2 / 255. +IDENTICAL_TOLERANCE = 2 / 255.0 DISTANCE_CUTOFF = 0.45 N_LEVELS = 2 N = 9 P = None SAMPLE_WORDS = 16 MAX_WORDS = 63 -ES_DOC_TYPE = 'image' -ES_MAX_RESULTS = 100 - -Window = Tuple[Tuple[float, float], Tuple[float, float]] -NpMatrix = Any +SIG_CHUNK_BITS = 32 +SIG_NUMS = 8 * N * N +SIG_BASE = 2 * N_LEVELS + 2 +SIG_CHUNK_WIDTH = int(SIG_CHUNK_BITS / math.log2(SIG_BASE)) +SIG_CHUNK_NUMS = SIG_NUMS / SIG_CHUNK_WIDTH +assert SIG_NUMS % SIG_CHUNK_WIDTH == 0 -def _get_session() -> elasticsearch.Elasticsearch: - return elasticsearch.Elasticsearch([{ - 'host': config.config['elasticsearch']['host'], - 'port': config.config['elasticsearch']['port'], - }]) +Window = Tuple[Tuple[float, float], Tuple[float, float]] +NpMatrix = np.ndarray def _preprocess_image(content: bytes) -> NpMatrix: - img = Image.open(BytesIO(content)) - return np.asarray(img.convert('L'), dtype=np.uint8) + try: + img = Image.open(BytesIO(content)) + return np.asarray(img.convert("L"), dtype=np.uint8) + except IOError: + raise errors.ProcessingError( + "Unable to generate a signature hash " "for this image." + ) def _crop_image( - image: NpMatrix, - lower_percentile: float, - upper_percentile: float) -> Window: + image: NpMatrix, lower_percentile: float, upper_percentile: float +) -> Window: rw = np.cumsum(np.sum(np.abs(np.diff(image, axis=1)), axis=1)) cw = np.cumsum(np.sum(np.abs(np.diff(image, axis=0)), axis=0)) upper_column_limit = np.searchsorted( - cw, np.percentile(cw, upper_percentile), side='left') + cw, np.percentile(cw, upper_percentile), side="left" + ) lower_column_limit = np.searchsorted( - cw, np.percentile(cw, lower_percentile), side='right') + cw, np.percentile(cw, lower_percentile), side="right" + ) upper_row_limit = np.searchsorted( - rw, np.percentile(rw, upper_percentile), side='left') + rw, np.percentile(rw, upper_percentile), side="left" + ) lower_row_limit = np.searchsorted( - rw, np.percentile(rw, lower_percentile), side='right') + rw, np.percentile(rw, lower_percentile), side="right" + ) if lower_row_limit > upper_row_limit: - lower_row_limit = int(lower_percentile / 100. * image.shape[0]) - upper_row_limit = int(upper_percentile / 100. * image.shape[0]) + lower_row_limit = int(lower_percentile / 100.0 * image.shape[0]) + upper_row_limit = int(upper_percentile / 100.0 * image.shape[0]) if lower_column_limit > upper_column_limit: - lower_column_limit = int(lower_percentile / 100. * image.shape[1]) - upper_column_limit = int(upper_percentile / 100. * image.shape[1]) + lower_column_limit = int(lower_percentile / 100.0 * image.shape[1]) + upper_column_limit = int(upper_percentile / 100.0 * image.shape[1]) return ( (lower_row_limit, upper_row_limit), - (lower_column_limit, upper_column_limit)) + (lower_column_limit, upper_column_limit), + ) def _normalize_and_threshold( - diff_array: NpMatrix, - identical_tolerance: float, - n_levels: int) -> None: + diff_array: NpMatrix, identical_tolerance: float, n_levels: int +) -> None: mask = np.abs(diff_array) < identical_tolerance - diff_array[mask] = 0. + diff_array[mask] = 0.0 if np.all(mask): return positive_cutoffs = np.percentile( - diff_array[diff_array > 0.], np.linspace(0, 100, n_levels + 1)) + diff_array[diff_array > 0.0], np.linspace(0, 100, n_levels + 1) + ) negative_cutoffs = np.percentile( - diff_array[diff_array < 0.], np.linspace(100, 0, n_levels + 1)) + diff_array[diff_array < 0.0], np.linspace(100, 0, n_levels + 1) + ) for level, interval in enumerate( - positive_cutoffs[i:i + 2] - for i in range(positive_cutoffs.shape[0] - 1)): + positive_cutoffs[i : i + 2] + for i in range(positive_cutoffs.shape[0] - 1) + ): diff_array[ - (diff_array >= interval[0]) & (diff_array <= interval[1])] = \ - level + 1 + (diff_array >= interval[0]) & (diff_array <= interval[1]) + ] = (level + 1) for level, interval in enumerate( - negative_cutoffs[i:i + 2] - for i in range(negative_cutoffs.shape[0] - 1)): + negative_cutoffs[i : i + 2] + for i in range(negative_cutoffs.shape[0] - 1) + ): diff_array[ - (diff_array <= interval[0]) & (diff_array >= interval[1])] = \ - -(level + 1) + (diff_array <= interval[0]) & (diff_array >= interval[1]) + ] = -(level + 1) def _compute_grid_points( - image: NpMatrix, - n: float, - window: Window = None) -> Tuple[NpMatrix, NpMatrix]: + image: NpMatrix, n: float, window: Window = None +) -> Tuple[NpMatrix, NpMatrix]: if window is None: window = ((0, image.shape[0]), (0, image.shape[1])) x_coords = np.linspace(window[0][0], window[0][1], n + 2, dtype=int)[1:-1] @@ -106,12 +115,10 @@ def _compute_grid_points( def _compute_mean_level( - image: NpMatrix, - x_coords: NpMatrix, - y_coords: NpMatrix, - p: Optional[float]) -> NpMatrix: + image: NpMatrix, x_coords: NpMatrix, y_coords: NpMatrix, p: Optional[float] +) -> NpMatrix: if p is None: - p = max([2.0, int(0.5 + min(image.shape) / 20.)]) + p = max([2.0, int(0.5 + min(image.shape) / 20.0)]) avg_grey = np.zeros((x_coords.shape[0], y_coords.shape[0])) for i, x in enumerate(x_coords): lower_x_lim = int(max([x - p / 2, 0])) @@ -120,7 +127,8 @@ def _compute_mean_level( lower_y_lim = int(max([y - p / 2, 0])) upper_y_lim = int(min([lower_y_lim + p, image.shape[1]])) avg_grey[i, j] = np.mean( - image[lower_x_lim:upper_x_lim, lower_y_lim:upper_y_lim]) + image[lower_x_lim:upper_x_lim, lower_y_lim:upper_y_lim] + ) return avg_grey @@ -130,95 +138,117 @@ def _compute_differentials(grey_level_matrix: NpMatrix) -> NpMatrix: ( np.diff(grey_level_matrix), ( - np.zeros(grey_level_matrix.shape[0]) - .reshape((grey_level_matrix.shape[0], 1)) - ) - ), axis=1) + np.zeros(grey_level_matrix.shape[0]).reshape( + (grey_level_matrix.shape[0], 1) + ) + ), + ), + axis=1, + ) down_neighbors = -np.concatenate( ( np.diff(grey_level_matrix, axis=0), ( - np.zeros(grey_level_matrix.shape[1]) - .reshape((1, grey_level_matrix.shape[1])) - ) - )) + np.zeros(grey_level_matrix.shape[1]).reshape( + (1, grey_level_matrix.shape[1]) + ) + ), + ) + ) left_neighbors = -np.concatenate( - (right_neighbors[:, -1:], right_neighbors[:, :-1]), axis=1) + (right_neighbors[:, -1:], right_neighbors[:, :-1]), axis=1 + ) up_neighbors = -np.concatenate((down_neighbors[-1:], down_neighbors[:-1])) diagonals = np.arange( - -grey_level_matrix.shape[0] + 1, grey_level_matrix.shape[0]) - upper_left_neighbors = sum([ - np.diagflat(np.insert(np.diff(np.diag(grey_level_matrix, i)), 0, 0), i) - for i in diagonals]) - upper_right_neighbors = sum([ - np.diagflat(np.insert(np.diff(np.diag(flipped, i)), 0, 0), i) - for i in diagonals]) + -grey_level_matrix.shape[0] + 1, grey_level_matrix.shape[0] + ) + upper_left_neighbors = sum( + [ + np.diagflat( + np.insert(np.diff(np.diag(grey_level_matrix, i)), 0, 0), i + ) + for i in diagonals + ] + ) + upper_right_neighbors = sum( + [ + np.diagflat(np.insert(np.diff(np.diag(flipped, i)), 0, 0), i) + for i in diagonals + ] + ) lower_right_neighbors = -np.pad( - upper_left_neighbors[1:, 1:], (0, 1), mode='constant') + upper_left_neighbors[1:, 1:], (0, 1), mode="constant" + ) lower_left_neighbors = -np.pad( - upper_right_neighbors[1:, 1:], (0, 1), mode='constant') - return np.dstack(np.array([ - upper_left_neighbors, - up_neighbors, - np.fliplr(upper_right_neighbors), - left_neighbors, - right_neighbors, - np.fliplr(lower_left_neighbors), - down_neighbors, - lower_right_neighbors])) + upper_right_neighbors[1:, 1:], (0, 1), mode="constant" + ) + return np.dstack( + np.array( + [ + upper_left_neighbors, + up_neighbors, + np.fliplr(upper_right_neighbors), + left_neighbors, + right_neighbors, + np.fliplr(lower_left_neighbors), + down_neighbors, + lower_right_neighbors, + ] + ) + ) -def _generate_signature(content: bytes) -> NpMatrix: - im_array = _preprocess_image(content) - image_limits = _crop_image( - im_array, - lower_percentile=LOWER_PERCENTILE, - upper_percentile=UPPER_PERCENTILE) - x_coords, y_coords = _compute_grid_points( - im_array, n=N, window=image_limits) - avg_grey = _compute_mean_level(im_array, x_coords, y_coords, p=P) - diff_matrix = _compute_differentials(avg_grey) - _normalize_and_threshold( - diff_matrix, - identical_tolerance=IDENTICAL_TOLERANCE, - n_levels=N_LEVELS) - return np.ravel(diff_matrix).astype('int8') +def _words_to_int(word_array: NpMatrix) -> List[int]: + width = word_array.shape[1] + coding_vector = 3 ** np.arange(width) + return np.dot(word_array + 1, coding_vector).astype(int).tolist() def _get_words(array: NpMatrix, k: int, n: int) -> NpMatrix: - word_positions = np.linspace( - 0, array.shape[0], n, endpoint=False).astype('int') + word_positions = np.linspace(0, array.shape[0], n, endpoint=False).astype( + "int" + ) assert k <= array.shape[0] assert word_positions.shape[0] <= array.shape[0] - words = np.zeros((n, k)).astype('int8') + words = np.zeros((n, k)).astype("int8") for i, pos in enumerate(word_positions): if pos + k <= array.shape[0]: - words[i] = array[pos:pos + k] + words[i] = array[pos : pos + k] else: temp = array[pos:].copy() - temp.resize(k) + temp.resize(k, refcheck=False) words[i] = temp - _max_contrast(words) - words = _words_to_int(words) + words[words > 0] = 1 + words[words < 0] = -1 return words -def _words_to_int(word_array: NpMatrix) -> NpMatrix: - width = word_array.shape[1] - coding_vector = 3**np.arange(width) - return np.dot(word_array + 1, coding_vector) +def generate_signature(content: bytes) -> NpMatrix: + im_array = _preprocess_image(content) + image_limits = _crop_image( + im_array, + lower_percentile=LOWER_PERCENTILE, + upper_percentile=UPPER_PERCENTILE, + ) + x_coords, y_coords = _compute_grid_points( + im_array, n=N, window=image_limits + ) + avg_grey = _compute_mean_level(im_array, x_coords, y_coords, p=P) + diff_matrix = _compute_differentials(avg_grey) + _normalize_and_threshold( + diff_matrix, identical_tolerance=IDENTICAL_TOLERANCE, n_levels=N_LEVELS + ) + return np.ravel(diff_matrix).astype("int8") -def _max_contrast(array: NpMatrix) -> None: - array[array > 0] = 1 - array[array < 0] = -1 +def generate_words(signature: NpMatrix) -> List[int]: + return _words_to_int(_get_words(signature, k=SAMPLE_WORDS, n=MAX_WORDS)) -def _normalized_distance( - target_array: NpMatrix, - vec: NpMatrix, - nan_value: float = 1.0) -> List[float]: - target_array = target_array.astype(int) +def normalized_distance( + target_array: Any, vec: NpMatrix, nan_value: float = 1.0 +) -> List[float]: + target_array = np.array(target_array).astype(int) vec = vec.astype(int) topvec = np.linalg.norm(vec - target_array, axis=1) norm1 = np.linalg.norm(vec, axis=0) @@ -228,124 +258,50 @@ def _normalized_distance( return finvec -def _safety_blanket(default_param_factory: Callable[[], Any]) -> Callable: - def wrapper_outer(target_function: Callable) -> Callable: - def wrapper_inner(*args: Any, **kwargs: Any) -> Any: - try: - return target_function(*args, **kwargs) - except elasticsearch.exceptions.NotFoundError: - # index not yet created, will be created dynamically by - # add_image() - return default_param_factory() - except elasticsearch.exceptions.ElasticsearchException as ex: - logger.warning('Problem with elastic search: %s', ex) - raise errors.ThirdPartyError( - 'Error connecting to elastic search.') - except IOError: - raise errors.ProcessingError('Not an image.') - except Exception as ex: - raise errors.ThirdPartyError('Unknown error (%s).' % ex) - return wrapper_inner - return wrapper_outer - - -class Lookalike: - def __init__(self, score: int, distance: float, path: Any) -> None: - self.score = score - self.distance = distance - self.path = path - +def pack_signature(signature: NpMatrix) -> bytes: + """ + Serializes the signature vector for efficient storage in a database. -@_safety_blanket(lambda: None) -def add_image(path: str, image_content: bytes) -> None: - assert path - assert image_content - signature = _generate_signature(image_content) - words = _get_words(signature, k=SAMPLE_WORDS, n=MAX_WORDS) + Shifts the range of the signature vector from [-N_LEVELS,+N_LEVELS] + to [0, base] - record = { - 'signature': signature.tolist(), - 'path': path, - 'timestamp': datetime.now(), - } - for i in range(MAX_WORDS): - record['simple_word_' + str(i)] = words[i].tolist() - - _get_session().index( - index=config.config['elasticsearch']['index'], - doc_type=ES_DOC_TYPE, - body=record, - refresh=True) - - -@_safety_blanket(lambda: None) -def delete_image(path: str) -> None: - assert path - _get_session().delete_by_query( - index=config.config['elasticsearch']['index'], - doc_type=ES_DOC_TYPE, - body={'query': {'term': {'path': path}}}) - - -@_safety_blanket(lambda: []) -def search_by_image(image_content: bytes) -> List[Lookalike]: - signature = _generate_signature(image_content) - words = _get_words(signature, k=SAMPLE_WORDS, n=MAX_WORDS) - - res = _get_session().search( - index=config.config['elasticsearch']['index'], - doc_type=ES_DOC_TYPE, - body={ - 'query': - { - 'bool': - { - 'should': - [ - {'term': {'simple_word_%d' % i: word.tolist()}} - for i, word in enumerate(words) - ] - } - }, - '_source': {'excludes': ['simple_word_*']}}, - size=ES_MAX_RESULTS, - timeout='10s')['hits']['hits'] - - if len(res) == 0: - return [] - - sigs = np.array([x['_source']['signature'] for x in res]) - dists = _normalized_distance(sigs, np.array(signature)) - - ids = set() # type: Set[int] - ret = [] - for item, dist in zip(res, dists): - id = item['_id'] - score = item['_score'] - path = item['_source']['path'] - if id in ids: - continue - ids.add(id) - if dist < DISTANCE_CUTOFF: - ret.append(Lookalike(score=score, distance=dist, path=path)) - return ret + The vector can then be broken up into chunks, with each chunk + consisting of SIG_CHUNK_WIDTH digits of radix `base`. + This is then converted into a more packed array consisting of + uint32 elements (for SIG_CHUNK_BITS = 32). + """ + coding_vector = np.flipud(SIG_BASE ** np.arange(SIG_CHUNK_WIDTH)) + return ( + np.array( + [ + np.dot(x, coding_vector) + for x in np.reshape( + signature + N_LEVELS, (-1, SIG_CHUNK_WIDTH) + ) + ] + ) + .astype(f"uint{SIG_CHUNK_BITS}") + .tobytes() + ) -@_safety_blanket(lambda: None) -def purge() -> None: - _get_session().delete_by_query( - index=config.config['elasticsearch']['index'], - doc_type=ES_DOC_TYPE, - body={'query': {'match_all': {}}}, - refresh=True) +def unpack_signature(packed: bytes) -> NpMatrix: + """ + Deserializes the signature vector once recieved from the database. -@_safety_blanket(lambda: set()) -def get_all_paths() -> Set[str]: - search = ( - elasticsearch_dsl.Search( - using=_get_session(), - index=config.config['elasticsearch']['index'], - doc_type=ES_DOC_TYPE) - .source(['path'])) - return set(h.path for h in search.scan()) + Functions as an inverse transformation of pack_signature() + """ + return np.ravel( + np.array( + [ + [ + int(digit) - N_LEVELS + for digit in np.base_repr(e, base=SIG_BASE).zfill( + SIG_CHUNK_WIDTH + ) + ] + for e in np.frombuffer(packed, dtype=f"uint{SIG_CHUNK_BITS}") + ] + ).astype("int8") + ) diff --git a/server/szurubooru/func/images.py b/server/szurubooru/func/images.py index 6e88ab4..6413ac8 100644 --- a/server/szurubooru/func/images.py +++ b/server/szurubooru/func/images.py @@ -1,14 +1,14 @@ -from typing import List -import logging import json -import shlex -import subprocess +import logging import math import re +import shlex +import subprocess +from typing import List + from szurubooru import errors from szurubooru.func import mime, util - logger = logging.getLogger(__name__) @@ -19,97 +19,142 @@ class Image: @property def width(self) -> int: - return self.info['streams'][0]['width'] + return self.info["streams"][0]["width"] @property def height(self) -> int: - return self.info['streams'][0]['height'] + return self.info["streams"][0]["height"] @property def frames(self) -> int: - return self.info['streams'][0]['nb_read_frames'] + return self.info["streams"][0]["nb_read_frames"] def resize_fill(self, width: int, height: int) -> None: width_greater = self.width > self.height width, height = (-1, height) if width_greater else (width, -1) cli = [ - '-i', '{path}', - '-f', 'image2', - '-filter:v', "scale='{width}:{height}'".format( - width=width, height=height), - '-map', '0:v:0', - '-vframes', '1', - '-vcodec', 'png', - '-', + "-i", + "{path}", + "-f", + "image2", + "-filter:v", + "scale='{width}:{height}'".format(width=width, height=height), + "-map", + "0:v:0", + "-vframes", + "1", + "-vcodec", + "png", + "-", ] - if 'duration' in self.info['format'] \ - and self.info['format']['format_name'] != 'swf': - duration = float(self.info['format']['duration']) + if ( + "duration" in self.info["format"] + and self.info["format"]["format_name"] != "swf" + ): + duration = float(self.info["format"]["duration"]) if duration > 3: cli = [ - '-ss', - '%d' % math.floor(duration * 0.3), + "-ss", + "%d" % math.floor(duration * 0.3), ] + cli content = self._execute(cli, ignore_error_if_data=True) if not content: - raise errors.ProcessingError('Error while resizing image.') + raise errors.ProcessingError("Error while resizing image.") self.content = content self._reload_info() def to_png(self) -> bytes: - return self._execute([ - '-i', '{path}', - '-f', 'image2', - '-map', '0:v:0', - '-vframes', '1', - '-vcodec', 'png', - '-', - ]) + return self._execute( + [ + "-i", + "{path}", + "-f", + "image2", + "-map", + "0:v:0", + "-vframes", + "1", + "-vcodec", + "png", + "-", + ] + ) def to_jpeg(self) -> bytes: - return self._execute([ - '-f', 'lavfi', - '-i', 'color=white:s=%dx%d' % (self.width, self.height), - '-i', '{path}', - '-f', 'image2', - '-filter_complex', 'overlay', - '-map', '0:v:0', - '-vframes', '1', - '-vcodec', 'mjpeg', - '-', - ]) + return self._execute( + [ + "-f", + "lavfi", + "-i", + "color=white:s=%dx%d" % (self.width, self.height), + "-i", + "{path}", + "-f", + "image2", + "-filter_complex", + "overlay", + "-map", + "0:v:0", + "-vframes", + "1", + "-vcodec", + "mjpeg", + "-", + ] + ) def to_webm(self) -> bytes: - with util.create_temp_file_path(suffix='.log') as phase_log_path: + with util.create_temp_file_path(suffix=".log") as phase_log_path: # Pass 1 - self._execute([ - '-i', '{path}', - '-pass', '1', - '-passlogfile', phase_log_path, - '-vcodec', 'libvpx-vp9', - '-crf', '4', - '-b:v', '2500K', - '-acodec', 'libvorbis', - '-f', 'webm', - '-y', '/dev/null' - ]) + self._execute( + [ + "-i", + "{path}", + "-pass", + "1", + "-passlogfile", + phase_log_path, + "-vcodec", + "libvpx-vp9", + "-crf", + "4", + "-b:v", + "2500K", + "-acodec", + "libvorbis", + "-f", + "webm", + "-y", + "/dev/null", + ] + ) # Pass 2 - return self._execute([ - '-i', '{path}', - '-pass', '2', - '-passlogfile', phase_log_path, - '-vcodec', 'libvpx-vp9', - '-crf', '4', - '-b:v', '2500K', - '-acodec', 'libvorbis', - '-f', 'webm', - '-' - ]) + return self._execute( + [ + "-i", + "{path}", + "-pass", + "2", + "-passlogfile", + phase_log_path, + "-vcodec", + "libvpx-vp9", + "-crf", + "4", + "-b:v", + "2500K", + "-acodec", + "libvorbis", + "-f", + "webm", + "-", + ] + ) def to_mp4(self) -> bytes: - with util.create_temp_file_path(suffix='.dat') as mp4_temp_path: + with util.create_temp_file_path(suffix=".dat") as mp4_temp_path: width = self.width height = self.height altered_dimensions = False @@ -123,96 +168,138 @@ class Image: altered_dimensions = True args = [ - '-i', '{path}', - '-vcodec', 'libx264', - '-preset', 'slow', - '-crf', '22', - '-b:v', '200K', - '-profile:v', 'main', - '-pix_fmt', 'yuv420p', - '-acodec', 'aac', - '-f', 'mp4' + "-i", + "{path}", + "-vcodec", + "libx264", + "-preset", + "slow", + "-crf", + "22", + "-b:v", + "200K", + "-profile:v", + "main", + "-pix_fmt", + "yuv420p", + "-acodec", + "aac", + "-f", + "mp4", ] if altered_dimensions: - args += ['-filter:v', 'scale=\'%d:%d\'' % (width, height)] + args += ["-filter:v", "scale='%d:%d'" % (width, height)] - self._execute(args + ['-y', mp4_temp_path]) + self._execute(args + ["-y", mp4_temp_path]) - with open(mp4_temp_path, 'rb') as mp4_temp: + with open(mp4_temp_path, "rb") as mp4_temp: return mp4_temp.read() def check_for_sound(self) -> bool: - audioinfo = json.loads(self._execute([ - '-i', '{path}', - '-of', 'json', - '-select_streams', 'a', - '-show_streams', - ], program='ffprobe').decode('utf-8')) - assert 'streams' in audioinfo - if len(audioinfo['streams']) < 1: + audioinfo = json.loads( + self._execute( + [ + "-i", + "{path}", + "-of", + "json", + "-select_streams", + "a", + "-show_streams", + ], + program="ffprobe", + ).decode("utf-8") + ) + assert "streams" in audioinfo + if len(audioinfo["streams"]) < 1: return False - log = self._execute([ - '-hide_banner', - '-progress', '-', - '-i', '{path}', - '-af', 'volumedetect', - '-max_muxing_queue_size', '99999', - '-vn', '-sn', - '-f', 'null', - '-y', '/dev/null', - ], get_logs=True).decode('utf-8', errors='replace') - log_match = re.search(r'.*volumedetect.*mean_volume: (.*) dB', log) - assert log_match - assert log_match.groups() + log = self._execute( + [ + "-hide_banner", + "-progress", + "-", + "-i", + "{path}", + "-af", + "volumedetect", + "-max_muxing_queue_size", + "99999", + "-vn", + "-sn", + "-f", + "null", + "-y", + "/dev/null", + ], + get_logs=True, + ).decode("utf-8", errors="replace") + log_match = re.search(r".*volumedetect.*mean_volume: (.*) dB", log) + if not log_match or not log_match.groups(): + raise errors.ProcessingError( + "A problem occured when trying to check for audio" + ) meanvol = float(log_match.groups()[0]) # -91.0 dB is the minimum for 16-bit audio, assume sound if > -80.0 dB return meanvol > -80.0 def _execute( - self, - cli: List[str], - program: str = 'ffmpeg', - ignore_error_if_data: bool = False, - get_logs: bool = False) -> bytes: + self, + cli: List[str], + program: str = "ffmpeg", + ignore_error_if_data: bool = False, + get_logs: bool = False, + ) -> bytes: extension = mime.get_extension(mime.get_mime_type(self.content)) assert extension - with util.create_temp_file(suffix='.' + extension) as handle: + with util.create_temp_file(suffix="." + extension) as handle: handle.write(self.content) handle.flush() - cli = [program, '-loglevel', '32' if get_logs else '24'] + cli + cli = [program, "-loglevel", "32" if get_logs else "24"] + cli cli = [part.format(path=handle.name) for part in cli] proc = subprocess.Popen( cli, stdout=subprocess.PIPE, stdin=subprocess.PIPE, - stderr=subprocess.PIPE) + stderr=subprocess.PIPE, + ) out, err = proc.communicate(input=self.content) if proc.returncode != 0: logger.warning( - 'Failed to execute ffmpeg command (cli=%r, err=%r)', - ' '.join(shlex.quote(arg) for arg in cli), - err) - if ((len(out) > 0 and not ignore_error_if_data) - or len(out) == 0): + "Failed to execute ffmpeg command (cli=%r, err=%r)", + " ".join(shlex.quote(arg) for arg in cli), + err, + ) + if (len(out) > 0 and not ignore_error_if_data) or len( + out + ) == 0: raise errors.ProcessingError( - 'Error while processing image.\n' - + err.decode('utf-8')) + "Error while processing image.\n" + err.decode("utf-8") + ) return err if get_logs else out def _reload_info(self) -> None: - self.info = json.loads(self._execute([ - '-i', '{path}', - '-of', 'json', - '-select_streams', 'v', - '-show_format', - '-show_streams', - ], program='ffprobe').decode('utf-8')) - assert 'format' in self.info - assert 'streams' in self.info - if len(self.info['streams']) < 1: - logger.warning('The video contains no video streams.') + self.info = json.loads( + self._execute( + [ + "-i", + "{path}", + "-of", + "json", + "-select_streams", + "v", + "-show_format", + "-show_streams", + ], + program="ffprobe", + ).decode("utf-8") + ) + assert "format" in self.info + assert "streams" in self.info + if len(self.info["streams"]) < 1: + logger.warning("The video contains no video streams.") raise errors.ProcessingError( - 'The video contains no video streams.') + "The video contains no video streams." + ) diff --git a/server/szurubooru/func/mailer.py b/server/szurubooru/func/mailer.py index 76682f1..c4cf9db 100644 --- a/server/szurubooru/func/mailer.py +++ b/server/szurubooru/func/mailer.py @@ -1,16 +1,18 @@ -import smtplib import email.mime.text +import smtplib + from szurubooru import config def send_mail(sender: str, recipient: str, subject: str, body: str) -> None: msg = email.mime.text.MIMEText(body) - msg['Subject'] = subject - msg['From'] = sender - msg['To'] = recipient + msg["Subject"] = subject + msg["From"] = sender + msg["To"] = recipient smtp = smtplib.SMTP( - config.config['smtp']['host'], int(config.config['smtp']['port'])) - smtp.login(config.config['smtp']['user'], config.config['smtp']['pass']) + config.config["smtp"]["host"], int(config.config["smtp"]["port"]) + ) + smtp.login(config.config["smtp"]["user"], config.config["smtp"]["pass"]) smtp.send_message(msg) smtp.quit() diff --git a/server/szurubooru/func/mime.py b/server/szurubooru/func/mime.py index c83f744..5f6279b 100644 --- a/server/szurubooru/func/mime.py +++ b/server/szurubooru/func/mime.py @@ -4,55 +4,66 @@ from typing import Optional def get_mime_type(content: bytes) -> str: if not content: - return 'application/octet-stream' + return "application/octet-stream" - if content[0:3] in (b'CWS', b'FWS', b'ZWS'): - return 'application/x-shockwave-flash' + if content[0:3] in (b"CWS", b"FWS", b"ZWS"): + return "application/x-shockwave-flash" - if content[0:3] == b'\xFF\xD8\xFF': - return 'image/jpeg' + if content[0:3] == b"\xFF\xD8\xFF": + return "image/jpeg" - if content[0:6] == b'\x89PNG\x0D\x0A': - return 'image/png' + if content[0:6] == b"\x89PNG\x0D\x0A": + return "image/png" - if content[0:6] in (b'GIF87a', b'GIF89a'): - return 'image/gif' + if content[0:6] in (b"GIF87a", b"GIF89a"): + return "image/gif" - if content[0:4] == b'\x1A\x45\xDF\xA3': - return 'video/webm' + if content[8:12] == b"WEBP": + return "image/webp" - if content[4:12] in (b'ftypisom', b'ftypmp42'): - return 'video/mp4' + if content[0:4] == b"\x1A\x45\xDF\xA3": + return "video/webm" - return 'application/octet-stream' + if content[4:12] in (b"ftypisom", b"ftypiso5", b"ftypmp42"): + return "video/mp4" + + return "application/octet-stream" def get_extension(mime_type: str) -> Optional[str]: extension_map = { - 'application/x-shockwave-flash': 'swf', - 'image/gif': 'gif', - 'image/jpeg': 'jpg', - 'image/png': 'png', - 'video/mp4': 'mp4', - 'video/webm': 'webm', - 'application/octet-stream': 'dat', + "application/x-shockwave-flash": "swf", + "image/gif": "gif", + "image/jpeg": "jpg", + "image/png": "png", + "image/webp": "webp", + "video/mp4": "mp4", + "video/webm": "webm", + "application/octet-stream": "dat", } - return extension_map.get((mime_type or '').strip().lower(), None) + return extension_map.get((mime_type or "").strip().lower(), None) def is_flash(mime_type: str) -> bool: - return mime_type.lower() == 'application/x-shockwave-flash' + return mime_type.lower() == "application/x-shockwave-flash" 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/webm") def is_image(mime_type: str) -> bool: - return mime_type.lower() in ('image/jpeg', 'image/png', 'image/gif') + return mime_type.lower() in ( + "image/jpeg", + "image/png", + "image/gif", + "image/webp", + ) def is_animated_gif(content: bytes) -> bool: - pattern = b'\x21\xF9\x04[\x00-\xFF]{4}\x00[\x2C\x21]' - return get_mime_type(content) == 'image/gif' \ + pattern = b"\x21\xF9\x04[\x00-\xFF]{4}\x00[\x2C\x21]" + return ( + get_mime_type(content) == "image/gif" and len(re.findall(pattern, content)) > 1 + ) diff --git a/server/szurubooru/func/net.py b/server/szurubooru/func/net.py index e6326c0..4e4c222 100644 --- a/server/szurubooru/func/net.py +++ b/server/szurubooru/func/net.py @@ -1,16 +1,94 @@ +import json +import logging +import os +import urllib.error import urllib.request -from szurubooru import config -from szurubooru import errors +from tempfile import NamedTemporaryFile +from threading import Thread +from typing import Any, Dict, List +from youtube_dl import YoutubeDL +from youtube_dl.utils import YoutubeDLError -def download(url: str) -> bytes: +from szurubooru import config, errors +from szurubooru.func import mime, util + +logger = logging.getLogger(__name__) + + +def download(url: str, use_video_downloader: bool = False) -> bytes: assert url request = urllib.request.Request(url) - if config.config['user_agent']: - request.add_header('User-Agent', config.config['user_agent']) - request.add_header('Referer', url) + if config.config["user_agent"]: + request.add_header("User-Agent", config.config["user_agent"]) + request.add_header("Referer", url) try: with urllib.request.urlopen(request) as handle: - return handle.read() + content = handle.read() except Exception as ex: - raise errors.ProcessingError('Error downloading %s (%s)' % (url, ex)) + raise errors.ProcessingError("Error downloading %s (%s)" % (url, ex)) + if ( + use_video_downloader + and mime.get_mime_type(content) == "application/octet-stream" + ): + return _youtube_dl_wrapper(url) + return content + + +def _youtube_dl_wrapper(url: str) -> bytes: + outpath = os.path.join( + config.config["data_dir"], + "temporary-uploads", + "youtubedl-" + util.get_sha1(url)[0:8] + ".dat", + ) + options = { + "ignoreerrors": False, + "format": "best[ext=webm]/best[ext=mp4]/best[ext=flv]", + "logger": logger, + "max_filesize": config.config["max_dl_filesize"], + "max_downloads": 1, + "outtmpl": outpath, + } + try: + with YoutubeDL(options) as ydl: + ydl.extract_info(url, download=True) + with open(outpath, "rb") as f: + return f.read() + except YoutubeDLError as ex: + raise errors.ThirdPartyError( + "Error downloading video %s (%s)" % (url, ex) + ) + except FileNotFoundError: + raise errors.ThirdPartyError( + "Error downloading video %s (file could not be saved)" % (url) + ) + + +def post_to_webhooks(payload: Dict[str, Any]) -> List[Thread]: + threads = [ + Thread(target=_post_to_webhook, args=(webhook, payload)) + for webhook in (config.config["webhooks"] or []) + ] + for thread in threads: + thread.daemon = False + thread.start() + return threads + + +def _post_to_webhook(webhook: str, payload: Dict[str, Any]) -> None: + req = urllib.request.Request(webhook) + req.data = json.dumps( + payload, + default=lambda x: x.isoformat("T") + "Z", + ).encode("utf-8") + req.add_header("Content-Type", "application/json") + try: + res = urllib.request.urlopen(req) + if not 200 <= res.status <= 299: + logger.warning( + f"Webhook {webhook} returned {res.status} {res.reason}" + ) + return res.status + except urllib.error.URLError as e: + logger.warning(f"Unable to call webhook {webhook}: {str(e)}") + return 400 diff --git a/server/szurubooru/func/pool_categories.py b/server/szurubooru/func/pool_categories.py new file mode 100644 index 0000000..c13857c --- /dev/null +++ b/server/szurubooru/func/pool_categories.py @@ -0,0 +1,207 @@ +import re +from typing import Any, Callable, Dict, List, Optional + +import sqlalchemy as sa + +from szurubooru import config, db, errors, model, rest +from szurubooru.func import cache, serialization, util + +DEFAULT_CATEGORY_NAME_CACHE_KEY = "default-pool-category" + + +class PoolCategoryNotFoundError(errors.NotFoundError): + pass + + +class PoolCategoryAlreadyExistsError(errors.ValidationError): + pass + + +class PoolCategoryIsInUseError(errors.ValidationError): + pass + + +class InvalidPoolCategoryNameError(errors.ValidationError): + pass + + +class InvalidPoolCategoryColorError(errors.ValidationError): + pass + + +def _verify_name_validity(name: str) -> None: + name_regex = config.config["pool_category_name_regex"] + if not re.match(name_regex, name): + raise InvalidPoolCategoryNameError( + "Name must satisfy regex %r." % name_regex + ) + + +class PoolCategorySerializer(serialization.BaseSerializer): + def __init__(self, category: model.PoolCategory) -> None: + self.category = category + + def _serializers(self) -> Dict[str, Callable[[], Any]]: + return { + "name": self.serialize_name, + "version": self.serialize_version, + "color": self.serialize_color, + "usages": self.serialize_usages, + "default": self.serialize_default, + } + + def serialize_name(self) -> Any: + return self.category.name + + def serialize_version(self) -> Any: + return self.category.version + + def serialize_color(self) -> Any: + return self.category.color + + def serialize_usages(self) -> Any: + return self.category.pool_count + + def serialize_default(self) -> Any: + return self.category.default + + +def serialize_category( + category: Optional[model.PoolCategory], options: List[str] = [] +) -> Optional[rest.Response]: + if not category: + return None + return PoolCategorySerializer(category).serialize(options) + + +def create_category(name: str, color: str) -> model.PoolCategory: + category = model.PoolCategory() + update_category_name(category, name) + update_category_color(category, color) + if not get_all_categories(): + category.default = True + return category + + +def update_category_name(category: model.PoolCategory, name: str) -> None: + assert category + if not name: + raise InvalidPoolCategoryNameError("Name cannot be empty.") + expr = sa.func.lower(model.PoolCategory.name) == name.lower() + if category.pool_category_id: + expr = expr & ( + model.PoolCategory.pool_category_id != category.pool_category_id + ) + already_exists = ( + db.session.query(model.PoolCategory).filter(expr).count() > 0 + ) + if already_exists: + raise PoolCategoryAlreadyExistsError( + "A category with this name already exists." + ) + if util.value_exceeds_column_size(name, model.PoolCategory.name): + raise InvalidPoolCategoryNameError("Name is too long.") + _verify_name_validity(name) + category.name = name + cache.remove(DEFAULT_CATEGORY_NAME_CACHE_KEY) + + +def update_category_color(category: model.PoolCategory, color: str) -> None: + assert category + if not color: + raise InvalidPoolCategoryColorError("Color cannot be empty.") + if not re.match(r"^#?[0-9a-z]+$", color): + raise InvalidPoolCategoryColorError("Invalid color.") + if util.value_exceeds_column_size(color, model.PoolCategory.color): + raise InvalidPoolCategoryColorError("Color is too long.") + category.color = color + + +def try_get_category_by_name( + name: str, lock: bool = False +) -> Optional[model.PoolCategory]: + query = db.session.query(model.PoolCategory).filter( + sa.func.lower(model.PoolCategory.name) == name.lower() + ) + if lock: + query = query.with_for_update() + return query.one_or_none() + + +def get_category_by_name(name: str, lock: bool = False) -> model.PoolCategory: + category = try_get_category_by_name(name, lock) + if not category: + raise PoolCategoryNotFoundError("Pool category %r not found." % name) + return category + + +def get_all_category_names() -> List[str]: + return [cat.name for cat in get_all_categories()] + + +def get_all_categories() -> List[model.PoolCategory]: + return ( + db.session.query(model.PoolCategory) + .order_by(model.PoolCategory.name.asc()) + .all() + ) + + +def try_get_default_category( + lock: bool = False, +) -> Optional[model.PoolCategory]: + query = db.session.query(model.PoolCategory).filter( + model.PoolCategory.default + ) + if lock: + query = query.with_for_update() + category = query.first() + # if for some reason (e.g. as a result of migration) there's no default + # category, get the first record available. + if not category: + query = db.session.query(model.PoolCategory).order_by( + model.PoolCategory.pool_category_id.asc() + ) + if lock: + query = query.with_for_update() + category = query.first() + return category + + +def get_default_category(lock: bool = False) -> model.PoolCategory: + category = try_get_default_category(lock) + if not category: + raise PoolCategoryNotFoundError("No pool category created yet.") + return category + + +def get_default_category_name() -> str: + if cache.has(DEFAULT_CATEGORY_NAME_CACHE_KEY): + return cache.get(DEFAULT_CATEGORY_NAME_CACHE_KEY) + default_category = get_default_category() + default_category_name = default_category.name + cache.put(DEFAULT_CATEGORY_NAME_CACHE_KEY, default_category_name) + return default_category_name + + +def set_default_category(category: model.PoolCategory) -> None: + assert category + old_category = try_get_default_category(lock=True) + if old_category: + db.session.refresh(old_category) + old_category.default = False + db.session.refresh(category) + category.default = True + cache.remove(DEFAULT_CATEGORY_NAME_CACHE_KEY) + + +def delete_category(category: model.PoolCategory) -> None: + assert category + if len(get_all_category_names()) == 1: + raise PoolCategoryIsInUseError("Cannot delete the last category.") + if (category.pool_count or 0) > 0: + raise PoolCategoryIsInUseError( + "Pool category has some usages and cannot be deleted. " + + "Please remove this category from relevant pools first." + ) + db.session.delete(category) diff --git a/server/szurubooru/func/pools.py b/server/szurubooru/func/pools.py new file mode 100644 index 0000000..c3ea9f0 --- /dev/null +++ b/server/szurubooru/func/pools.py @@ -0,0 +1,337 @@ +import re +from datetime import datetime +from typing import Any, Callable, Dict, List, Optional, Tuple + +import sqlalchemy as sa + +from szurubooru import config, db, errors, model, rest +from szurubooru.func import pool_categories, posts, serialization, util + + +class PoolNotFoundError(errors.NotFoundError): + pass + + +class PoolAlreadyExistsError(errors.ValidationError): + pass + + +class PoolIsInUseError(errors.ValidationError): + pass + + +class InvalidPoolNameError(errors.ValidationError): + pass + + +class InvalidPoolDuplicateError(errors.ValidationError): + pass + + +class InvalidPoolCategoryError(errors.ValidationError): + pass + + +class InvalidPoolDescriptionError(errors.ValidationError): + pass + + +class InvalidPoolRelationError(errors.ValidationError): + pass + + +class InvalidPoolNonexistentPostError(errors.ValidationError): + pass + + +def _verify_name_validity(name: str) -> None: + if util.value_exceeds_column_size(name, model.PoolName.name): + raise InvalidPoolNameError("Name is too long.") + name_regex = config.config["pool_name_regex"] + if not re.match(name_regex, name): + raise InvalidPoolNameError("Name must satisfy regex %r." % name_regex) + + +def _get_names(pool: model.Pool) -> List[str]: + assert pool + return [pool_name.name for pool_name in pool.names] + + +def _lower_list(names: List[str]) -> List[str]: + return [name.lower() for name in names] + + +def _check_name_intersection( + names1: List[str], names2: List[str], case_sensitive: bool +) -> bool: + if not case_sensitive: + names1 = _lower_list(names1) + names2 = _lower_list(names2) + return len(set(names1).intersection(names2)) > 0 + + +def _duplicates(a: List[int]) -> List[int]: + seen = set() + dupes = [] + for x in a: + if x not in seen: + seen.add(x) + else: + dupes.append(x) + return dupes + + +def sort_pools(pools: List[model.Pool]) -> List[model.Pool]: + default_category_name = pool_categories.get_default_category_name() + return sorted( + pools, + key=lambda pool: ( + default_category_name == pool.category.name, + pool.category.name, + pool.names[0].name, + ), + ) + + +class PoolSerializer(serialization.BaseSerializer): + def __init__(self, pool: model.Pool) -> None: + self.pool = pool + + def _serializers(self) -> Dict[str, Callable[[], Any]]: + return { + "id": self.serialize_id, + "names": self.serialize_names, + "category": self.serialize_category, + "version": self.serialize_version, + "description": self.serialize_description, + "creationTime": self.serialize_creation_time, + "lastEditTime": self.serialize_last_edit_time, + "postCount": self.serialize_post_count, + "posts": self.serialize_posts, + } + + def serialize_id(self) -> Any: + return self.pool.pool_id + + def serialize_names(self) -> Any: + return [pool_name.name for pool_name in self.pool.names] + + def serialize_category(self) -> Any: + return self.pool.category.name + + def serialize_version(self) -> Any: + return self.pool.version + + def serialize_description(self) -> Any: + return self.pool.description + + def serialize_creation_time(self) -> Any: + return self.pool.creation_time + + def serialize_last_edit_time(self) -> Any: + return self.pool.last_edit_time + + def serialize_post_count(self) -> Any: + return self.pool.post_count + + def serialize_posts(self) -> Any: + return [ + post + for post in [ + posts.serialize_micro_post(rel, None) + for rel in self.pool.posts + ] + ] + + +def serialize_pool( + pool: model.Pool, options: List[str] = [] +) -> Optional[rest.Response]: + if not pool: + return None + return PoolSerializer(pool).serialize(options) + + +def serialize_micro_pool(pool: model.Pool) -> Optional[rest.Response]: + return serialize_pool( + pool, options=["id", "names", "category", "description", "postCount"] + ) + + +def try_get_pool_by_id(pool_id: int) -> Optional[model.Pool]: + return ( + db.session.query(model.Pool) + .filter(model.Pool.pool_id == pool_id) + .one_or_none() + ) + + +def get_pool_by_id(pool_id: int) -> model.Pool: + pool = try_get_pool_by_id(pool_id) + if not pool: + raise PoolNotFoundError("Pool %r not found." % pool_id) + return pool + + +def try_get_pool_by_name(name: str) -> Optional[model.Pool]: + return ( + db.session.query(model.Pool) + .join(model.PoolName) + .filter(sa.func.lower(model.PoolName.name) == name.lower()) + .one_or_none() + ) + + +def get_pool_by_name(name: str) -> model.Pool: + pool = try_get_pool_by_name(name) + if not pool: + raise PoolNotFoundError("Pool %r not found." % name) + return pool + + +def get_pools_by_names(names: List[str]) -> List[model.Pool]: + names = util.icase_unique(names) + if len(names) == 0: + return [] + return ( + db.session.query(model.Pool) + .join(model.PoolName) + .filter( + sa.sql.or_( + sa.func.lower(model.PoolName.name) == name.lower() + for name in names + ) + ) + .all() + ) + + +def get_or_create_pools_by_names( + names: List[str], +) -> Tuple[List[model.Pool], List[model.Pool]]: + names = util.icase_unique(names) + existing_pools = get_pools_by_names(names) + new_pools = [] + pool_category_name = pool_categories.get_default_category_name() + for name in names: + found = False + for existing_pool in existing_pools: + if _check_name_intersection( + _get_names(existing_pool), [name], False + ): + found = True + break + if not found: + new_pool = create_pool( + names=[name], category_name=pool_category_name, post_ids=[] + ) + db.session.add(new_pool) + new_pools.append(new_pool) + return existing_pools, new_pools + + +def delete(source_pool: model.Pool) -> None: + assert source_pool + db.session.delete(source_pool) + + +def merge_pools(source_pool: model.Pool, target_pool: model.Pool) -> None: + assert source_pool + assert target_pool + if source_pool.pool_id == target_pool.pool_id: + raise InvalidPoolRelationError("Cannot merge pool with itself.") + + def merge_pool_posts(source_pool_id: int, target_pool_id: int) -> None: + alias1 = model.PoolPost + alias2 = sa.orm.util.aliased(model.PoolPost) + update_stmt = sa.sql.expression.update(alias1).where( + alias1.pool_id == source_pool_id + ) + update_stmt = update_stmt.where( + ~sa.exists() + .where(alias1.post_id == alias2.post_id) + .where(alias2.pool_id == target_pool_id) + ) + update_stmt = update_stmt.values(pool_id=target_pool_id) + db.session.execute(update_stmt) + + merge_pool_posts(source_pool.pool_id, target_pool.pool_id) + delete(source_pool) + + +def create_pool( + names: List[str], category_name: str, post_ids: List[int] +) -> model.Pool: + pool = model.Pool() + pool.creation_time = datetime.utcnow() + update_pool_names(pool, names) + update_pool_category_name(pool, category_name) + update_pool_posts(pool, post_ids) + return pool + + +def update_pool_category_name(pool: model.Pool, category_name: str) -> None: + assert pool + pool.category = pool_categories.get_category_by_name(category_name) + + +def update_pool_names(pool: model.Pool, names: List[str]) -> None: + # sanitize + assert pool + names = util.icase_unique([name for name in names if name]) + if not len(names): + raise InvalidPoolNameError("At least one name must be specified.") + for name in names: + _verify_name_validity(name) + + # check for existing pools + expr = sa.sql.false() + for name in names: + expr = expr | (sa.func.lower(model.PoolName.name) == name.lower()) + if pool.pool_id: + expr = expr & (model.PoolName.pool_id != pool.pool_id) + existing_pools = db.session.query(model.PoolName).filter(expr).all() + if len(existing_pools): + raise PoolAlreadyExistsError( + "One of names is already used by another pool." + ) + + # remove unwanted items + for pool_name in pool.names[:]: + if not _check_name_intersection([pool_name.name], names, True): + pool.names.remove(pool_name) + # add wanted items + for name in names: + if not _check_name_intersection(_get_names(pool), [name], True): + pool.names.append(model.PoolName(name, -1)) + + # set alias order to match the request + for i, name in enumerate(names): + for pool_name in pool.names: + if pool_name.name.lower() == name.lower(): + pool_name.order = i + + +def update_pool_description(pool: model.Pool, description: str) -> None: + assert pool + if util.value_exceeds_column_size(description, model.Pool.description): + raise InvalidPoolDescriptionError("Description is too long.") + pool.description = description or None + + +def update_pool_posts(pool: model.Pool, post_ids: List[int]) -> None: + assert pool + dupes = _duplicates(post_ids) + if len(dupes) > 0: + dupes = ", ".join(list(str(x) for x in dupes)) + raise InvalidPoolDuplicateError("Duplicate post(s) in pool: " + dupes) + ret = posts.get_posts_by_ids(post_ids) + if len(post_ids) != len(ret): + missing = set(post_ids) - set(post.post_id for post in ret) + missing = ", ".join(list(str(x) for x in missing)) + raise InvalidPoolNonexistentPostError( + "The following posts do not exist: " + missing + ) + pool.posts.clear() + for post in ret: + pool.posts.append(post) diff --git a/server/szurubooru/func/posts.py b/server/szurubooru/func/posts.py index b6816b0..ee7c31a 100644 --- a/server/szurubooru/func/posts.py +++ b/server/szurubooru/func/posts.py @@ -1,17 +1,35 @@ import hmac -from typing import Any, Optional, Tuple, List, Dict, Callable +import logging from datetime import datetime +from typing import Any, Callable, Dict, List, Optional, Tuple + import sqlalchemy as sa -from szurubooru import config, db, model, errors, rest + +from szurubooru import config, db, errors, model, rest from szurubooru.func import ( - users, scores, comments, tags, metrics, util, - mime, images, files, image_hash, serialization, snapshots) + comments, + files, + image_hash, + images, + metrics, + mime, + pools, + scores, + serialization, + snapshots, + tags, + users, + util, +) + +logger = logging.getLogger(__name__) EMPTY_PIXEL = ( - b'\x47\x49\x46\x38\x39\x61\x01\x00\x01\x00\x80\x01\x00\x00\x00\x00' - b'\xff\xff\xff\x21\xf9\x04\x01\x00\x00\x01\x00\x2c\x00\x00\x00\x00' - b'\x01\x00\x01\x00\x00\x02\x02\x4c\x01\x00\x3b') + b"\x47\x49\x46\x38\x39\x61\x01\x00\x01\x00\x80\x01\x00\x00\x00\x00" + b"\xff\xff\xff\x21\xf9\x04\x01\x00\x00\x01\x00\x2c\x00\x00\x00\x00" + b"\x01\x00\x01\x00\x00\x02\x02\x4c\x01\x00\x3b" +) class PostNotFoundError(errors.NotFoundError): @@ -25,11 +43,12 @@ class PostAlreadyFeaturedError(errors.ValidationError): class PostAlreadyUploadedError(errors.ValidationError): def __init__(self, other_post: model.Post) -> None: super().__init__( - 'Post already uploaded (%d)' % other_post.post_id, + "Post already uploaded (%d)" % other_post.post_id, { - 'otherPostUrl': get_post_content_url(other_post), - 'otherPostId': other_post.post_id, - }) + "otherPostUrl": get_post_content_url(other_post), + "otherPostId": other_post.post_id, + }, + ) class InvalidPostIdError(errors.ValidationError): @@ -60,81 +79,83 @@ class InvalidPostFlagError(errors.ValidationError): pass -class PostLookalike(image_hash.Lookalike): - def __init__(self, score: int, distance: float, post: model.Post) -> None: - super().__init__(score, distance, post.post_id) - self.post = post - - SAFETY_MAP = { - model.Post.SAFETY_SAFE: 'safe', - model.Post.SAFETY_SKETCHY: 'sketchy', - model.Post.SAFETY_UNSAFE: 'unsafe', + model.Post.SAFETY_SAFE: "safe", + model.Post.SAFETY_SKETCHY: "sketchy", + model.Post.SAFETY_UNSAFE: "unsafe", } TYPE_MAP = { - model.Post.TYPE_IMAGE: 'image', - model.Post.TYPE_ANIMATION: 'animation', - model.Post.TYPE_VIDEO: 'video', - model.Post.TYPE_FLASH: 'flash', + model.Post.TYPE_IMAGE: "image", + model.Post.TYPE_ANIMATION: "animation", + model.Post.TYPE_VIDEO: "video", + model.Post.TYPE_FLASH: "flash", } FLAG_MAP = { - model.Post.FLAG_LOOP: 'loop', - model.Post.FLAG_SOUND: 'sound', + model.Post.FLAG_LOOP: "loop", + model.Post.FLAG_SOUND: "sound", } def get_post_security_hash(id: int) -> str: return hmac.new( - config.config['secret'].encode('utf8'), - str(id).encode('utf-8')).hexdigest()[0:16] + config.config["secret"].encode("utf8"), + msg=str(id).encode("utf-8"), + digestmod="md5", + ).hexdigest()[0:16] def get_post_content_url(post: model.Post) -> str: assert post - return '%s/posts/%d_%s.%s' % ( - config.config['data_url'].rstrip('/'), + return "%s/posts/%d_%s.%s" % ( + config.config["data_url"].rstrip("/"), post.post_id, get_post_security_hash(post.post_id), - mime.get_extension(post.mime_type) or 'dat') + mime.get_extension(post.mime_type) or "dat", + ) def get_post_thumbnail_url(post: model.Post) -> str: assert post - return '%s/generated-thumbnails/%d_%s.jpg' % ( - config.config['data_url'].rstrip('/'), + return "%s/generated-thumbnails/%d_%s.jpg" % ( + config.config["data_url"].rstrip("/"), post.post_id, - get_post_security_hash(post.post_id)) + get_post_security_hash(post.post_id), + ) def get_post_content_path(post: model.Post) -> str: assert post assert post.post_id - return 'posts/%d_%s.%s' % ( + return "posts/%d_%s.%s" % ( post.post_id, get_post_security_hash(post.post_id), - mime.get_extension(post.mime_type) or 'dat') + mime.get_extension(post.mime_type) or "dat", + ) def get_post_thumbnail_path(post: model.Post) -> str: assert post - return 'generated-thumbnails/%d_%s.jpg' % ( + return "generated-thumbnails/%d_%s.jpg" % ( post.post_id, - get_post_security_hash(post.post_id)) + get_post_security_hash(post.post_id), + ) def get_post_thumbnail_backup_path(post: model.Post) -> str: assert post - return 'posts/custom-thumbnails/%d_%s.dat' % ( - post.post_id, get_post_security_hash(post.post_id)) + return "posts/custom-thumbnails/%d_%s.dat" % ( + post.post_id, + get_post_security_hash(post.post_id), + ) def serialize_note(note: model.PostNote) -> rest.Response: assert note return { - 'polygon': note.polygon, - 'text': note.text, + "polygon": note.polygon, + "text": note.text, } @@ -145,40 +166,41 @@ class PostSerializer(serialization.BaseSerializer): def _serializers(self) -> Dict[str, Callable[[], Any]]: return { - 'id': self.serialize_id, - 'version': self.serialize_version, - 'creationTime': self.serialize_creation_time, - 'lastEditTime': self.serialize_last_edit_time, - 'safety': self.serialize_safety, - 'source': self.serialize_source, - 'type': self.serialize_type, - 'mimeType': self.serialize_mime, - 'checksum': self.serialize_checksum, - 'fileSize': self.serialize_file_size, - 'canvasWidth': self.serialize_canvas_width, - 'canvasHeight': self.serialize_canvas_height, - 'contentUrl': self.serialize_content_url, - 'thumbnailUrl': self.serialize_thumbnail_url, - 'flags': self.serialize_flags, - 'tags': self.serialize_tags, - 'relations': self.serialize_relations, - 'user': self.serialize_user, - 'score': self.serialize_score, - 'ownScore': self.serialize_own_score, - 'ownFavorite': self.serialize_own_favorite, - 'tagCount': self.serialize_tag_count, - 'favoriteCount': self.serialize_favorite_count, - 'commentCount': self.serialize_comment_count, - 'noteCount': self.serialize_note_count, - 'relationCount': self.serialize_relation_count, - 'featureCount': self.serialize_feature_count, - 'lastFeatureTime': self.serialize_last_feature_time, - 'favoritedBy': self.serialize_favorited_by, - 'hasCustomThumbnail': self.serialize_has_custom_thumbnail, - 'notes': self.serialize_notes, - 'comments': self.serialize_comments, - 'metrics': self.serialize_metrics, - 'metricRanges': self.serialize_metric_ranges, + "id": self.serialize_id, + "version": self.serialize_version, + "creationTime": self.serialize_creation_time, + "lastEditTime": self.serialize_last_edit_time, + "safety": self.serialize_safety, + "source": self.serialize_source, + "type": self.serialize_type, + "mimeType": self.serialize_mime, + "checksum": self.serialize_checksum, + "fileSize": self.serialize_file_size, + "canvasWidth": self.serialize_canvas_width, + "canvasHeight": self.serialize_canvas_height, + "contentUrl": self.serialize_content_url, + "thumbnailUrl": self.serialize_thumbnail_url, + "flags": self.serialize_flags, + "tags": self.serialize_tags, + "relations": self.serialize_relations, + "user": self.serialize_user, + "score": self.serialize_score, + "ownScore": self.serialize_own_score, + "ownFavorite": self.serialize_own_favorite, + "tagCount": self.serialize_tag_count, + "favoriteCount": self.serialize_favorite_count, + "commentCount": self.serialize_comment_count, + "noteCount": self.serialize_note_count, + "relationCount": self.serialize_relation_count, + "featureCount": self.serialize_feature_count, + "lastFeatureTime": self.serialize_last_feature_time, + "favoritedBy": self.serialize_favorited_by, + "hasCustomThumbnail": self.serialize_has_custom_thumbnail, + "notes": self.serialize_notes, + "comments": self.serialize_comments, + "metrics": self.serialize_metrics, + "metricRanges": self.serialize_metric_ranges, + "pools": self.serialize_pools, } def serialize_id(self) -> Any: @@ -229,25 +251,28 @@ class PostSerializer(serialization.BaseSerializer): def serialize_tags(self) -> Any: return [ { - 'names': [name.name for name in tag.names], - 'category': tag.category.name, - 'usages': tag.post_count, - 'metric': { - 'min': tag.metric.min, - 'max': tag.metric.max + "names": [name.name for name in tag.names], + "category": tag.category.name, + "usages": tag.post_count, + "metric": { + "min": tag.metric.min, + "max": tag.metric.max } if tag.metric else None, } - for tag in tags.sort_tags(self.post.tags)] + for tag in tags.sort_tags(self.post.tags) + ] def serialize_relations(self) -> Any: return sorted( { - post['id']: post + post["id"]: post for post in [ serialize_micro_post(rel, self.auth_user) - for rel in self.post.relations] + for rel in self.post.relations + ] }.values(), - key=lambda post: post['id']) + key=lambda post: post["id"], + ) def serialize_user(self) -> Any: return users.serialize_micro_user(self.post.user, self.auth_user) @@ -259,10 +284,16 @@ class PostSerializer(serialization.BaseSerializer): return scores.get_score(self.post, self.auth_user) def serialize_own_favorite(self) -> Any: - return len([ - user for user in self.post.favorited_by - if user.user_id == self.auth_user.user_id] - ) > 0 + return ( + len( + [ + user + for user in self.post.favorited_by + if user.user_id == self.auth_user.user_id + ] + ) + > 0 + ) def serialize_tag_count(self) -> Any: return self.post.tag_count @@ -297,43 +328,58 @@ class PostSerializer(serialization.BaseSerializer): def serialize_notes(self) -> Any: return sorted( [serialize_note(note) for note in self.post.notes], - key=lambda x: x['polygon']) + key=lambda x: x["polygon"], + ) def serialize_comments(self) -> Any: return [ comments.serialize_comment(comment, self.auth_user) for comment in sorted( - self.post.comments, - key=lambda comment: comment.creation_time)] + self.post.comments, key=lambda comment: comment.creation_time + ) + ] + + def serialize_pools(self) -> List[Any]: + return [ + pools.serialize_micro_pool(pool) + for pool in sorted( + self.post.pools, key=lambda pool: pool.creation_time + ) + ] def serialize_metrics(self) -> Any: return [ metrics.serialize_post_metric(metric) for metric in sorted( self.post.metrics, - key=lambda metric: metric.metric.tag_name)] + key=lambda metric: metric.metric.tag_name + ) + ] def serialize_metric_ranges(self) -> Any: return [ metrics.serialize_post_metric_range(metric_range) for metric_range in sorted( self.post.metric_ranges, - key=lambda metric_range: metric_range.metric.tag_name)] + key=lambda metric_range: metric_range.metric.tag_name + ) + ] def serialize_post( - post: Optional[model.Post], - auth_user: model.User, - options: List[str] = []) -> Optional[rest.Response]: + post: Optional[model.Post], auth_user: model.User, options: List[str] = [] +) -> Optional[rest.Response]: if not post: return None return PostSerializer(post, auth_user).serialize(options) def serialize_micro_post( - post: model.Post, auth_user: model.User) -> Optional[rest.Response]: + post: model.Post, auth_user: model.User +) -> Optional[rest.Response]: return serialize_post( - post, auth_user=auth_user, options=['id', 'thumbnailUrl']) + post, auth_user=auth_user, options=["id", "thumbnailUrl"] + ) def get_post_count() -> int: @@ -342,25 +388,37 @@ def get_post_count() -> int: def try_get_post_by_id(post_id: int) -> Optional[model.Post]: return ( - db.session - .query(model.Post) + db.session.query(model.Post) .filter(model.Post.post_id == post_id) - .one_or_none()) + .one_or_none() + ) def get_post_by_id(post_id: int) -> model.Post: post = try_get_post_by_id(post_id) if not post: - raise PostNotFoundError('Post %r not found.' % post_id) + raise PostNotFoundError("Post %r not found." % post_id) return post +def get_posts_by_ids(ids: List[int]) -> List[model.Post]: + if len(ids) == 0: + return [] + posts = ( + db.session.query(model.Post) + .filter(sa.sql.or_(model.Post.post_id == post_id for post_id in ids)) + .all() + ) + id_order = {v: k for k, v in enumerate(ids)} + return sorted(posts, key=lambda post: id_order.get(post.post_id)) + + def try_get_current_post_feature() -> Optional[model.PostFeature]: return ( - db.session - .query(model.PostFeature) + db.session.query(model.PostFeature) .order_by(model.PostFeature.time.desc()) - .first()) + .first() + ) def try_get_featured_post() -> Optional[model.Post]: @@ -369,22 +427,22 @@ def try_get_featured_post() -> Optional[model.Post]: def create_post( - content: bytes, - tag_names: List[str], - user: Optional[model.User]) -> Tuple[model.Post, List[model.Tag]]: + content: bytes, tag_names: List[str], user: Optional[model.User] +) -> Tuple[model.Post, List[model.Tag]]: post = model.Post() post.safety = model.Post.SAFETY_SAFE post.user = user post.creation_time = datetime.utcnow() post.flags = [] - post.type = '' - post.checksum = '' - post.mime_type = '' - db.session.add(post) + post.type = "" + post.checksum = "" + post.mime_type = "" update_post_content(post, content) new_tags = update_post_tags(post, tag_names) + + db.session.add(post) return post, new_tags @@ -393,35 +451,38 @@ def update_post_safety(post: model.Post, safety: str) -> None: safety = util.flip(SAFETY_MAP).get(safety, None) if not safety: raise InvalidPostSafetyError( - 'Safety can be either of %r.' % list(SAFETY_MAP.values())) + "Safety can be either of %r." % list(SAFETY_MAP.values()) + ) post.safety = safety def update_post_source(post: model.Post, source: Optional[str]) -> None: assert post if util.value_exceeds_column_size(source, model.Post.source): - raise InvalidPostSourceError('Source is too long.') + raise InvalidPostSourceError("Source is too long.") post.source = source or None -@sa.events.event.listens_for(model.Post, 'after_insert') +@sa.events.event.listens_for(model.Post, "after_insert") def _after_post_insert( - _mapper: Any, _connection: Any, post: model.Post) -> None: + _mapper: Any, _connection: Any, post: model.Post +) -> None: _sync_post_content(post) -@sa.events.event.listens_for(model.Post, 'after_update') +@sa.events.event.listens_for(model.Post, "after_update") def _after_post_update( - _mapper: Any, _connection: Any, post: model.Post) -> None: + _mapper: Any, _connection: Any, post: model.Post +) -> None: _sync_post_content(post) -@sa.events.event.listens_for(model.Post, 'before_delete') +@sa.events.event.listens_for(model.Post, "before_delete") def _before_post_delete( - _mapper: Any, _connection: Any, post: model.Post) -> None: + _mapper: Any, _connection: Any, post: model.Post +) -> None: if post.post_id: - image_hash.delete_image(post.post_id) - if config.config['delete_source_files']: + if config.config["delete_source_files"]: files.delete(get_post_content_path(post)) files.delete(get_post_thumbnail_path(post)) @@ -429,54 +490,50 @@ def _before_post_delete( def _sync_post_content(post: model.Post) -> None: regenerate_thumb = False - if hasattr(post, '__content'): - content = getattr(post, '__content') + if hasattr(post, "__content"): + content = getattr(post, "__content") files.save(get_post_content_path(post), content) - delattr(post, '__content') + delattr(post, "__content") regenerate_thumb = True - if post.post_id and post.type in ( - model.Post.TYPE_IMAGE, model.Post.TYPE_ANIMATION): - image_hash.delete_image(post.post_id) - image_hash.add_image(post.post_id, content) - if hasattr(post, '__thumbnail'): - if getattr(post, '__thumbnail'): + if hasattr(post, "__thumbnail"): + if getattr(post, "__thumbnail"): files.save( get_post_thumbnail_backup_path(post), - getattr(post, '__thumbnail')) + getattr(post, "__thumbnail"), + ) else: files.delete(get_post_thumbnail_backup_path(post)) - delattr(post, '__thumbnail') + delattr(post, "__thumbnail") regenerate_thumb = True if regenerate_thumb: generate_post_thumbnail(post) -def generate_alternate_formats(post: model.Post, content: bytes) \ - -> List[Tuple[model.Post, List[model.Tag]]]: +def generate_alternate_formats( + post: model.Post, content: bytes +) -> List[Tuple[model.Post, List[model.Tag]]]: assert post assert content new_posts = [] if mime.is_animated_gif(content): tag_names = [tag.first_name for tag in post.tags] - if config.config['convert']['gif']['to_mp4']: + if config.config["convert"]["gif"]["to_mp4"]: mp4_post, new_tags = create_post( - images.Image(content).to_mp4(), - tag_names, - post.user) - update_post_flags(mp4_post, ['loop']) + images.Image(content).to_mp4(), tag_names, post.user + ) + update_post_flags(mp4_post, ["loop"]) update_post_safety(mp4_post, post.safety) update_post_source(mp4_post, post.source) new_posts += [(mp4_post, new_tags)] - if config.config['convert']['gif']['to_webm']: + if config.config["convert"]["gif"]["to_webm"]: webm_post, new_tags = create_post( - images.Image(content).to_webm(), - tag_names, - post.user) - update_post_flags(webm_post, ['loop']) + images.Image(content).to_webm(), tag_names, post.user + ) + update_post_flags(webm_post, ["loop"]) update_post_safety(webm_post, post.safety) update_post_source(webm_post, post.source) new_posts += [(webm_post, new_tags)] @@ -492,25 +549,75 @@ def generate_alternate_formats(post: model.Post, content: bytes) \ return new_posts -def test_sound(post: model.Post, content: bytes) -> None: - assert post +def get_default_flags(content: bytes) -> List[str]: assert content + ret = [] if mime.is_video(mime.get_mime_type(content)): + ret.append(model.Post.FLAG_LOOP) if images.Image(content).check_for_sound(): - flags = post.flags - if model.Post.FLAG_SOUND not in flags: - flags.append(model.Post.FLAG_SOUND) - update_post_flags(post, flags) + ret.append(model.Post.FLAG_SOUND) + return ret + + +def purge_post_signature(post: model.Post) -> None: + ( + db.session.query(model.PostSignature) + .filter(model.PostSignature.post_id == post.post_id) + .delete() + ) + + +def generate_post_signature(post: model.Post, content: bytes) -> None: + try: + unpacked_signature = image_hash.generate_signature(content) + packed_signature = image_hash.pack_signature(unpacked_signature) + words = image_hash.generate_words(unpacked_signature) + + db.session.add( + model.PostSignature( + post=post, signature=packed_signature, words=words + ) + ) + except errors.ProcessingError: + if not config.config["allow_broken_uploads"]: + raise InvalidPostContentError( + "Unable to generate image hash data." + ) + + +def update_all_post_signatures() -> None: + posts_to_hash = ( + db.session.query(model.Post) + .filter( + (model.Post.type == model.Post.TYPE_IMAGE) + | (model.Post.type == model.Post.TYPE_ANIMATION) + ) + .filter(model.Post.signature == None) # noqa: E711 + .order_by(model.Post.post_id.asc()) + .all() + ) + for post in posts_to_hash: + try: + generate_post_signature( + post, files.get(get_post_content_path(post)) + ) + db.session.commit() + logger.info("Hashed Post %d", post.post_id) + except Exception as ex: + logger.exception(ex) def update_post_content(post: model.Post, content: Optional[bytes]) -> None: assert post if not content: - raise InvalidPostContentError('Post content missing.') + raise InvalidPostContentError("Post content missing.") + + update_signature = False post.mime_type = mime.get_mime_type(content) if mime.is_flash(post.mime_type): post.type = model.Post.TYPE_FLASH elif mime.is_image(post.mime_type): + update_signature = True if mime.is_animated_gif(content): post.type = model.Post.TYPE_ANIMATION else: @@ -519,39 +626,56 @@ def update_post_content(post: model.Post, content: Optional[bytes]) -> None: post.type = model.Post.TYPE_VIDEO else: raise InvalidPostContentError( - 'Unhandled file type: %r' % post.mime_type) + "Unhandled file type: %r" % post.mime_type + ) post.checksum = util.get_sha1(content) other_post = ( - db.session - .query(model.Post) + db.session.query(model.Post) .filter(model.Post.checksum == post.checksum) .filter(model.Post.post_id != post.post_id) - .one_or_none()) - if other_post \ - and other_post.post_id \ - and other_post.post_id != post.post_id: + .one_or_none() + ) + if ( + other_post + and other_post.post_id + and other_post.post_id != post.post_id + ): raise PostAlreadyUploadedError(other_post) + if update_signature: + purge_post_signature(post) + post.signature = generate_post_signature(post, content) + post.file_size = len(content) try: image = images.Image(content) post.canvas_width = image.width post.canvas_height = image.height except errors.ProcessingError: - post.canvas_width = None - post.canvas_height = None - if (post.canvas_width is not None and post.canvas_width <= 0) \ - or (post.canvas_height is not None and post.canvas_height <= 0): - post.canvas_width = None - post.canvas_height = None - setattr(post, '__content', content) + if not config.config["allow_broken_uploads"]: + raise InvalidPostContentError("Unable to process image metadata") + else: + post.canvas_width = None + post.canvas_height = None + if (post.canvas_width is not None and post.canvas_width <= 0) or ( + post.canvas_height is not None and post.canvas_height <= 0 + ): + if not config.config["allow_broken_uploads"]: + raise InvalidPostContentError( + "Invalid image dimensions returned during processing" + ) + else: + post.canvas_width = None + post.canvas_height = None + setattr(post, "__content", content) def update_post_thumbnail( - post: model.Post, content: Optional[bytes] = None) -> None: + post: model.Post, content: Optional[bytes] = None +) -> None: assert post - setattr(post, '__thumbnail', content) + setattr(post, "__thumbnail", content) def generate_post_thumbnail(post: model.Post) -> None: @@ -564,15 +688,17 @@ def generate_post_thumbnail(post: model.Post) -> None: assert content image = images.Image(content) image.resize_fill( - int(config.config['thumbnails']['post_width']), - int(config.config['thumbnails']['post_height'])) + int(config.config["thumbnails"]["post_width"]), + int(config.config["thumbnails"]["post_height"]), + ) files.save(get_post_thumbnail_path(post), image.to_jpeg()) except errors.ProcessingError: files.save(get_post_thumbnail_path(post), EMPTY_PIXEL) def update_post_tags( - post: model.Post, tag_names: List[str]) -> List[model.Tag]: + post: model.Post, tag_names: List[str] +) -> List[model.Tag]: assert post existing_tags, new_tags = tags.get_or_create_tags_by_names(tag_names) post.tags = existing_tags + new_tags @@ -584,22 +710,21 @@ def update_post_relations(post: model.Post, new_post_ids: List[int]) -> None: try: new_post_ids = [int(id) for id in new_post_ids] except ValueError: - raise InvalidPostRelationError( - 'A relation must be numeric post ID.') + raise InvalidPostRelationError("A relation must be numeric post ID.") old_posts = post.relations old_post_ids = [int(p.post_id) for p in old_posts] if new_post_ids: new_posts = ( - db.session - .query(model.Post) + db.session.query(model.Post) .filter(model.Post.post_id.in_(new_post_ids)) - .all()) + .all() + ) else: new_posts = [] if len(new_posts) != len(new_post_ids): - raise InvalidPostRelationError('One of relations does not exist.') + raise InvalidPostRelationError("One of relations does not exist.") if post.post_id in new_post_ids: - raise InvalidPostRelationError('Post cannot relate to itself.') + raise InvalidPostRelationError("Post cannot relate to itself.") relations_to_del = [p for p in old_posts if p.post_id not in new_post_ids] relations_to_add = [p for p in new_posts if p.post_id not in old_post_ids] @@ -615,37 +740,44 @@ def update_post_notes(post: model.Post, notes: Any) -> None: assert post post.notes = [] for note in notes: - for field in ('polygon', 'text'): + for field in ("polygon", "text"): if field not in note: - raise InvalidPostNoteError('Note is missing %r field.' % field) - if not note['text']: - raise InvalidPostNoteError('A note\'s text cannot be empty.') - if not isinstance(note['polygon'], (list, tuple)): + raise InvalidPostNoteError("Note is missing %r field." % field) + if not note["text"]: + raise InvalidPostNoteError("A note's text cannot be empty.") + if not isinstance(note["polygon"], (list, tuple)): raise InvalidPostNoteError( - 'A note\'s polygon must be a list of points.') - if len(note['polygon']) < 3: + "A note's polygon must be a list of points." + ) + if len(note["polygon"]) < 3: raise InvalidPostNoteError( - 'A note\'s polygon must have at least 3 points.') - for point in note['polygon']: + "A note's polygon must have at least 3 points." + ) + for point in note["polygon"]: if not isinstance(point, (list, tuple)): raise InvalidPostNoteError( - 'A note\'s polygon point must be a list of length 2.') + "A note's polygon point must be a list of length 2." + ) if len(point) != 2: raise InvalidPostNoteError( - 'A point in note\'s polygon must have two coordinates.') + "A point in note's polygon must have two coordinates." + ) try: pos_x = float(point[0]) pos_y = float(point[1]) if not 0 <= pos_x <= 1 or not 0 <= pos_y <= 1: raise InvalidPostNoteError( - 'All points must fit in the image (0..1 range).') + "All points must fit in the image (0..1 range)." + ) except ValueError: raise InvalidPostNoteError( - 'A point in note\'s polygon must be numeric.') - if util.value_exceeds_column_size(note['text'], model.PostNote.text): - raise InvalidPostNoteError('Note text is too long.') + "A point in note's polygon must be numeric." + ) + if util.value_exceeds_column_size(note["text"], model.PostNote.text): + raise InvalidPostNoteError("Note text is too long.") post.notes.append( - model.PostNote(polygon=note['polygon'], text=str(note['text']))) + model.PostNote(polygon=note["polygon"], text=str(note["text"])) + ) def update_post_flags(post: model.Post, flags: List[str]) -> None: @@ -655,7 +787,8 @@ def update_post_flags(post: model.Post, flags: List[str]) -> None: flag = util.flip(FLAG_MAP).get(flag, None) if not flag: raise InvalidPostFlagError( - 'Flag must be one of %r.' % list(FLAG_MAP.values())) + "Flag must be one of %r." % list(FLAG_MAP.values()) + ) target_flags.append(flag) post.flags = target_flags @@ -675,32 +808,31 @@ def delete(post: model.Post) -> None: def merge_posts( - source_post: model.Post, - target_post: model.Post, - replace_content: bool) -> None: + source_post: model.Post, target_post: model.Post, replace_content: bool +) -> None: assert source_post assert target_post if source_post.post_id == target_post.post_id: - raise InvalidPostRelationError('Cannot merge post with itself.') + raise InvalidPostRelationError("Cannot merge post with itself.") def merge_tables( - table: model.Base, - anti_dup_func: Optional[Callable[[model.Base, model.Base], bool]], - source_post_id: int, - target_post_id: int) -> None: + table: model.Base, + anti_dup_func: Optional[Callable[[model.Base, model.Base], bool]], + source_post_id: int, + target_post_id: int, + ) -> None: alias1 = table alias2 = sa.orm.util.aliased(table) - update_stmt = ( - sa.sql.expression.update(alias1) - .where(alias1.post_id == source_post_id)) + update_stmt = sa.sql.expression.update(alias1).where( + alias1.post_id == source_post_id + ) if anti_dup_func is not None: - update_stmt = ( - update_stmt - .where( - ~sa.exists() - .where(anti_dup_func(alias1, alias2)) - .where(alias2.post_id == target_post_id))) + update_stmt = update_stmt.where( + ~sa.exists() + .where(anti_dup_func(alias1, alias2)) + .where(alias2.post_id == target_post_id) + ) update_stmt = update_stmt.values(post_id=target_post_id) db.session.execute(update_stmt) @@ -710,21 +842,24 @@ def merge_posts( model.PostTag, lambda alias1, alias2: alias1.tag_id == alias2.tag_id, source_post_id, - target_post_id) + target_post_id, + ) def merge_scores(source_post_id: int, target_post_id: int) -> None: merge_tables( model.PostScore, lambda alias1, alias2: alias1.user_id == alias2.user_id, source_post_id, - target_post_id) + target_post_id, + ) def merge_favorites(source_post_id: int, target_post_id: int) -> None: merge_tables( model.PostFavorite, lambda alias1, alias2: alias1.user_id == alias2.user_id, source_post_id, - target_post_id) + target_post_id, + ) def merge_comments(source_post_id: int, target_post_id: int) -> None: merge_tables(model.Comment, None, source_post_id, target_post_id) @@ -739,8 +874,10 @@ def merge_posts( .where( ~sa.exists() .where(alias2.child_id == alias1.child_id) - .where(alias2.parent_id == target_post_id)) - .values(parent_id=target_post_id)) + .where(alias2.parent_id == target_post_id) + ) + .values(parent_id=target_post_id) + ) db.session.execute(update_stmt) update_stmt = ( @@ -750,26 +887,31 @@ def merge_posts( .where( ~sa.exists() .where(alias2.parent_id == alias1.parent_id) - .where(alias2.child_id == target_post_id)) - .values(child_id=target_post_id)) + .where(alias2.child_id == target_post_id) + ) + .values(child_id=target_post_id) + ) db.session.execute(update_stmt) - def transfer_flags(source_post_id: int, target_post_id: int) -> None: - target = get_post_by_id(target_post_id) - source = get_post_by_id(source_post_id) - target.flags = source.flags - merge_tags(source_post.post_id, target_post.post_id) merge_comments(source_post.post_id, target_post.post_id) merge_scores(source_post.post_id, target_post.post_id) merge_favorites(source_post.post_id, target_post.post_id) merge_relations(source_post.post_id, target_post.post_id) + def transfer_flags(source_post_id: int, target_post_id: int) -> None: + target = get_post_by_id(target_post_id) + source = get_post_by_id(source_post_id) + target.flags = source.flags + db.session.flush() + content = None if replace_content: content = files.get(get_post_content_path(source_post)) transfer_flags(source_post.post_id, target_post.post_id) + # fixes unknown issue with SA's cascade deletions + purge_post_signature(source_post) delete(source_post) db.session.flush() @@ -780,44 +922,50 @@ def merge_posts( def search_by_image_exact(image_content: bytes) -> Optional[model.Post]: checksum = util.get_sha1(image_content) return ( - db.session - .query(model.Post) + db.session.query(model.Post) .filter(model.Post.checksum == checksum) - .one_or_none()) + .one_or_none() + ) -def search_by_image(image_content: bytes) -> List[PostLookalike]: - ret = [] - for result in image_hash.search_by_image(image_content): - post = try_get_post_by_id(result.path) - if post: - ret.append(PostLookalike( - score=result.score, - distance=result.distance, - post=post)) - return ret +def search_by_image(image_content: bytes) -> List[Tuple[float, model.Post]]: + query_signature = image_hash.generate_signature(image_content) + query_words = image_hash.generate_words(query_signature) + """ + The unnest function is used here to expand one row containing the 'words' + array into multiple rows each containing a singular word. -def populate_reverse_search() -> None: - excluded_post_ids = image_hash.get_all_paths() + Documentation of the unnest function can be found here: + https://www.postgresql.org/docs/9.2/functions-array.html + """ - post_ids_to_hash = ( - db.session - .query(model.Post.post_id) - .filter( - (model.Post.type == model.Post.TYPE_IMAGE) | - (model.Post.type == model.Post.TYPE_ANIMATION)) - .filter(~model.Post.post_id.in_(excluded_post_ids)) - .order_by(model.Post.post_id.asc()) - .all()) + dbquery = """ + SELECT s.post_id, s.signature, count(a.query) AS score + FROM post_signature AS s, unnest(s.words, :q) AS a(word, query) + WHERE a.word = a.query + GROUP BY s.post_id + ORDER BY score DESC LIMIT 100; + """ - for post_ids_chunk in util.chunks(post_ids_to_hash, 100): - posts_chunk = ( - db.session - .query(model.Post) - .filter(model.Post.post_id.in_(post_ids_chunk)) - .all()) - for post in posts_chunk: - content_path = get_post_content_path(post) - if files.has(content_path): - image_hash.add_image(post.post_id, files.get(content_path)) + candidates = db.session.execute(dbquery, {"q": query_words}) + data = tuple( + zip( + *[ + (post_id, image_hash.unpack_signature(packedsig)) + for post_id, packedsig, score in candidates + ] + ) + ) + if data: + candidate_post_ids, sigarray = data + distances = image_hash.normalized_distance(sigarray, query_signature) + return [ + (distance, try_get_post_by_id(candidate_post_id)) + for candidate_post_id, distance in zip( + candidate_post_ids, distances + ) + if distance < image_hash.DISTANCE_CUTOFF + ] + else: + return [] diff --git a/server/szurubooru/func/scores.py b/server/szurubooru/func/scores.py index 615fd98..b095f48 100644 --- a/server/szurubooru/func/scores.py +++ b/server/szurubooru/func/scores.py @@ -1,6 +1,7 @@ import datetime -from typing import Any, Tuple, Callable -from szurubooru import db, model, errors +from typing import Any, Callable, Tuple + +from szurubooru import db, errors, model class InvalidScoreTargetError(errors.ValidationError): @@ -12,12 +13,13 @@ class InvalidScoreValueError(errors.ValidationError): def _get_table_info( - entity: model.Base) -> Tuple[model.Base, Callable[[model.Base], Any]]: + entity: model.Base, +) -> Tuple[model.Base, Callable[[model.Base], Any]]: assert entity resource_type, _, _ = model.util.get_resource_info(entity) - if resource_type == 'post': + if resource_type == "post": return model.PostScore, lambda table: table.post_id - elif resource_type == 'comment': + elif resource_type == "comment": return model.CommentScore, lambda table: table.comment_id raise InvalidScoreTargetError() @@ -40,16 +42,17 @@ def get_score(entity: model.Base, user: model.User) -> int: assert user table, get_column = _get_table_info(entity) row = ( - db.session - .query(table.score) + db.session.query(table.score) .filter(get_column(table) == get_column(entity)) .filter(table.user_id == user.user_id) - .one_or_none()) + .one_or_none() + ) return row[0] if row else 0 def set_score(entity: model.Base, user: model.User, score: int) -> None: from szurubooru.func import favorites + assert entity assert user if not score: @@ -61,7 +64,8 @@ def set_score(entity: model.Base, user: model.User, score: int) -> None: return if score not in (-1, 1): raise InvalidScoreValueError( - 'Score %r is invalid. Valid scores: %r.' % (score, (-1, 1))) + "Score %r is invalid. Valid scores: %r." % (score, (-1, 1)) + ) score_entity = _get_score_entity(entity, user) if score_entity: score_entity.score = score diff --git a/server/szurubooru/func/serialization.py b/server/szurubooru/func/serialization.py index 699fb47..d2fadc0 100644 --- a/server/szurubooru/func/serialization.py +++ b/server/szurubooru/func/serialization.py @@ -1,9 +1,10 @@ -from typing import Any, List, Dict, Callable -from szurubooru import model, rest, errors +from typing import Any, Callable, Dict, List + +from szurubooru import errors, model, rest def get_serialization_options(ctx: rest.Context) -> List[str]: - return ctx.get_param_as_list('fields', default=[]) + return ctx.get_param_as_list("fields", default=[]) class BaseSerializer: @@ -17,8 +18,9 @@ class BaseSerializer: for key in options: if key not in field_factories: raise errors.ValidationError( - 'Invalid key: %r. Valid keys: %r.' % ( - key, list(sorted(field_factories.keys())))) + "Invalid key: %r. Valid keys: %r." + % (key, list(sorted(field_factories.keys()))) + ) factory = field_factories[key] ret[key] = factory() return ret diff --git a/server/szurubooru/func/snapshots.py b/server/szurubooru/func/snapshots.py index 240c3bc..afb26ea 100644 --- a/server/szurubooru/func/snapshots.py +++ b/server/szurubooru/func/snapshots.py @@ -1,73 +1,111 @@ -from typing import Any, Optional, Dict, Callable from datetime import datetime +from typing import Any, Callable, Dict, Optional + +import sqlalchemy as sa + from szurubooru import db, model -from szurubooru.func import diff, users +from szurubooru.func import diff, net, users def get_tag_category_snapshot(category: model.TagCategory) -> Dict[str, Any]: assert category return { - 'name': category.name, - 'color': category.color, - 'default': True if category.default else False, + "name": category.name, + "color": category.color, + "default": True if category.default else False, } def get_tag_snapshot(tag: model.Tag) -> Dict[str, Any]: assert tag return { - 'names': [tag_name.name for tag_name in tag.names], - 'category': tag.category.name, - 'suggestions': sorted(rel.first_name for rel in tag.suggestions), - 'implications': sorted(rel.first_name for rel in tag.implications), + "names": [tag_name.name for tag_name in tag.names], + "category": tag.category.name, + "suggestions": sorted(rel.first_name for rel in tag.suggestions), + "implications": sorted(rel.first_name for rel in tag.implications), + } + + +def get_pool_category_snapshot(category: model.PoolCategory) -> Dict[str, Any]: + assert category + return { + "name": category.name, + "color": category.color, + "default": True if category.default else False, + } + + +def get_pool_snapshot(pool: model.Pool) -> Dict[str, Any]: + assert pool + return { + "names": [pool_name.name for pool_name in pool.names], + "category": pool.category.name, + "posts": [post.post_id for post in pool.posts], } def get_post_snapshot(post: model.Post) -> Dict[str, Any]: assert post return { - 'source': post.source, - 'safety': post.safety, - 'checksum': post.checksum, - 'flags': post.flags, - 'featured': post.is_featured, - 'tags': sorted([tag.first_name for tag in post.tags]), - 'relations': sorted([rel.post_id for rel in post.relations]), - 'notes': sorted([{ - 'polygon': [[point[0], point[1]] for point in note.polygon], - 'text': note.text, - } for note in post.notes], key=lambda x: x['polygon']), + "source": post.source, + "safety": post.safety, + "checksum": post.checksum, + "flags": post.flags, + "featured": post.is_featured, + "tags": sorted([tag.first_name for tag in post.tags]), + "relations": sorted([rel.post_id for rel in post.relations]), + "notes": sorted( + [ + { + "polygon": [ + [point[0], point[1]] for point in note.polygon + ], + "text": note.text, + } + for note in post.notes + ], + key=lambda x: x["polygon"], + ), } _snapshot_factories = { # lambdas allow mocking target functions in the tests - # pylint: disable=unnecessary-lambda - 'tag_category': lambda entity: get_tag_category_snapshot(entity), - 'tag': lambda entity: get_tag_snapshot(entity), - 'post': lambda entity: get_post_snapshot(entity), + "tag_category": lambda entity: get_tag_category_snapshot(entity), + "tag": lambda entity: get_tag_snapshot(entity), + "post": lambda entity: get_post_snapshot(entity), + "pool_category": lambda entity: get_pool_category_snapshot(entity), + "pool": lambda entity: get_pool_snapshot(entity), } # type: Dict[model.Base, Callable[[model.Base], Dict[str ,Any]]] def serialize_snapshot( - snapshot: model.Snapshot, auth_user: model.User) -> Dict[str, Any]: + snapshot: model.Snapshot, auth_user: model.User +) -> Dict[str, Any]: assert snapshot return { - 'operation': snapshot.operation, - 'type': snapshot.resource_type, - 'id': snapshot.resource_name, - 'user': users.serialize_micro_user(snapshot.user, auth_user), - 'data': snapshot.data, - 'time': snapshot.creation_time, + "operation": snapshot.operation, + "type": snapshot.resource_type, + "id": snapshot.resource_name, + "user": users.serialize_micro_user(snapshot.user, auth_user), + "data": snapshot.data, + "time": snapshot.creation_time, } +def _post_to_webhooks(snapshot: model.Snapshot) -> None: + webhook_user = model.User() + webhook_user.name = None + webhook_user.rank = "anonymous" + net.post_to_webhooks(serialize_snapshot(snapshot, webhook_user)) + + def _create( - operation: str, - entity: model.Base, - auth_user: Optional[model.User]) -> model.Snapshot: - resource_type, resource_pkey, resource_name = ( - model.util.get_resource_info(entity)) + operation: str, entity: model.Base, auth_user: Optional[model.User] +) -> model.Snapshot: + resource_type, resource_pkey, resource_name = model.util.get_resource_info( + entity + ) snapshot = model.Snapshot() snapshot.creation_time = datetime.utcnow() @@ -85,9 +123,9 @@ def create(entity: model.Base, auth_user: Optional[model.User]) -> None: snapshot_factory = _snapshot_factories[snapshot.resource_type] snapshot.data = snapshot_factory(entity) db.session.add(snapshot) + _post_to_webhooks(snapshot) -# pylint: disable=protected-access def modify(entity: model.Base, auth_user: Optional[model.User]) -> None: assert entity @@ -95,18 +133,19 @@ def modify(entity: model.Base, auth_user: Optional[model.User]) -> None: ( cls for cls in model.Base._decl_class_registry.values() - if hasattr(cls, '__table__') + if hasattr(cls, "__table__") and cls.__table__.fullname == entity.__table__.fullname ), - None) + None, + ) assert table snapshot = _create(model.Snapshot.OPERATION_MODIFIED, entity, auth_user) snapshot_factory = _snapshot_factories[snapshot.resource_type] - detached_session = db.sessionmaker() + detached_session = sa.orm.sessionmaker(bind=db.session.get_bind())() detached_entity = detached_session.query(table).get(snapshot.resource_pkey) - assert detached_entity, 'Entity not found in DB, have you committed it?' + assert detached_entity, "Entity not found in DB, have you committed it?" detached_snapshot = snapshot_factory(detached_entity) detached_session.close() @@ -116,6 +155,7 @@ def modify(entity: model.Base, auth_user: Optional[model.User]) -> None: if not snapshot.data: return db.session.add(snapshot) + _post_to_webhooks(snapshot) def delete(entity: model.Base, auth_user: Optional[model.User]) -> None: @@ -124,17 +164,24 @@ def delete(entity: model.Base, auth_user: Optional[model.User]) -> None: snapshot_factory = _snapshot_factories[snapshot.resource_type] snapshot.data = snapshot_factory(entity) db.session.add(snapshot) + _post_to_webhooks(snapshot) def merge( - source_entity: model.Base, - target_entity: model.Base, - auth_user: Optional[model.User]) -> None: + source_entity: model.Base, + target_entity: model.Base, + auth_user: Optional[model.User], +) -> None: assert source_entity assert target_entity snapshot = _create( - model.Snapshot.OPERATION_MERGED, source_entity, auth_user) - resource_type, _resource_pkey, resource_name = ( - model.util.get_resource_info(target_entity)) + model.Snapshot.OPERATION_MERGED, source_entity, auth_user + ) + ( + resource_type, + _resource_pkey, + resource_name, + ) = model.util.get_resource_info(target_entity) snapshot.data = [resource_type, resource_name] db.session.add(snapshot) + _post_to_webhooks(snapshot) diff --git a/server/szurubooru/func/tag_categories.py b/server/szurubooru/func/tag_categories.py index bec2f0d..d2c6021 100644 --- a/server/szurubooru/func/tag_categories.py +++ b/server/szurubooru/func/tag_categories.py @@ -1,11 +1,12 @@ import re -from typing import Any, Optional, Dict, List, Callable +from typing import Any, Callable, Dict, List, Optional + import sqlalchemy as sa -from szurubooru import config, db, model, errors, rest -from szurubooru.func import util, serialization, cache +from szurubooru import config, db, errors, model, rest +from szurubooru.func import cache, serialization, util -DEFAULT_CATEGORY_NAME_CACHE_KEY = 'default-tag-category' +DEFAULT_CATEGORY_NAME_CACHE_KEY = "default-tag-category" class TagCategoryNotFoundError(errors.NotFoundError): @@ -29,10 +30,11 @@ class InvalidTagCategoryColorError(errors.ValidationError): def _verify_name_validity(name: str) -> None: - name_regex = config.config['tag_category_name_regex'] + name_regex = config.config["tag_category_name_regex"] if not re.match(name_regex, name): raise InvalidTagCategoryNameError( - 'Name must satisfy regex %r.' % name_regex) + "Name must satisfy regex %r." % name_regex + ) class TagCategorySerializer(serialization.BaseSerializer): @@ -41,11 +43,12 @@ class TagCategorySerializer(serialization.BaseSerializer): def _serializers(self) -> Dict[str, Callable[[], Any]]: return { - 'name': self.serialize_name, - 'version': self.serialize_version, - 'color': self.serialize_color, - 'usages': self.serialize_usages, - 'default': self.serialize_default, + "name": self.serialize_name, + "version": self.serialize_version, + "color": self.serialize_color, + "usages": self.serialize_usages, + "default": self.serialize_default, + "order": self.serialize_order, } def serialize_name(self) -> Any: @@ -63,19 +66,23 @@ class TagCategorySerializer(serialization.BaseSerializer): def serialize_default(self) -> Any: return self.category.default + def serialize_order(self) -> Any: + return self.category.order + def serialize_category( - category: Optional[model.TagCategory], - options: List[str] = []) -> Optional[rest.Response]: + category: Optional[model.TagCategory], options: List[str] = [] +) -> Optional[rest.Response]: if not category: return None return TagCategorySerializer(category).serialize(options) -def create_category(name: str, color: str) -> model.TagCategory: +def create_category(name: str, color: str, order: int) -> model.TagCategory: category = model.TagCategory() update_category_name(category, name) update_category_color(category, color) + update_category_order(category, order) if not get_all_categories(): category.default = True return category @@ -84,18 +91,21 @@ def create_category(name: str, color: str) -> model.TagCategory: def update_category_name(category: model.TagCategory, name: str) -> None: assert category if not name: - raise InvalidTagCategoryNameError('Name cannot be empty.') + raise InvalidTagCategoryNameError("Name cannot be empty.") expr = sa.func.lower(model.TagCategory.name) == name.lower() if category.tag_category_id: expr = expr & ( - model.TagCategory.tag_category_id != category.tag_category_id) + model.TagCategory.tag_category_id != category.tag_category_id + ) already_exists = ( - db.session.query(model.TagCategory).filter(expr).count() > 0) + db.session.query(model.TagCategory).filter(expr).count() > 0 + ) if already_exists: raise TagCategoryAlreadyExistsError( - 'A category with this name already exists.') + "A category with this name already exists." + ) if util.value_exceeds_column_size(name, model.TagCategory.name): - raise InvalidTagCategoryNameError('Name is too long.') + raise InvalidTagCategoryNameError("Name is too long.") _verify_name_validity(name) category.name = name cache.remove(DEFAULT_CATEGORY_NAME_CACHE_KEY) @@ -104,58 +114,66 @@ def update_category_name(category: model.TagCategory, name: str) -> None: def update_category_color(category: model.TagCategory, color: str) -> None: assert category if not color: - raise InvalidTagCategoryColorError('Color cannot be empty.') - if not re.match(r'^#?[0-9a-z]+$', color): - raise InvalidTagCategoryColorError('Invalid color.') + raise InvalidTagCategoryColorError("Color cannot be empty.") + if not re.match(r"^#?[0-9a-z]+$", color): + raise InvalidTagCategoryColorError("Invalid color.") if util.value_exceeds_column_size(color, model.TagCategory.color): - raise InvalidTagCategoryColorError('Color is too long.') + raise InvalidTagCategoryColorError("Color is too long.") category.color = color +def update_category_order(category: model.TagCategory, order: int) -> None: + assert category + category.order = order + + def try_get_category_by_name( - name: str, lock: bool = False) -> Optional[model.TagCategory]: - query = ( - db.session - .query(model.TagCategory) - .filter(sa.func.lower(model.TagCategory.name) == name.lower())) + name: str, lock: bool = False +) -> Optional[model.TagCategory]: + query = db.session.query(model.TagCategory).filter( + sa.func.lower(model.TagCategory.name) == name.lower() + ) if lock: - query = query.with_lockmode('update') + query = query.with_for_update() return query.one_or_none() def get_category_by_name(name: str, lock: bool = False) -> model.TagCategory: category = try_get_category_by_name(name, lock) if not category: - raise TagCategoryNotFoundError('Tag category %r not found.' % name) + raise TagCategoryNotFoundError("Tag category %r not found." % name) return category def get_all_category_names() -> List[str]: - return [row[0] for row in db.session.query(model.TagCategory.name).all()] + return [cat.name for cat in get_all_categories()] def get_all_categories() -> List[model.TagCategory]: - return db.session.query(model.TagCategory).all() + return ( + db.session.query(model.TagCategory) + .order_by(model.TagCategory.order.asc(), model.TagCategory.name.asc()) + .all() + ) def try_get_default_category( - lock: bool = False) -> Optional[model.TagCategory]: - query = ( - db.session - .query(model.TagCategory) - .filter(model.TagCategory.default)) + lock: bool = False, +) -> Optional[model.TagCategory]: + query = db.session.query(model.TagCategory).filter( + model.TagCategory.default + ) if lock: - query = query.with_lockmode('update') + query = query.with_for_update() category = query.first() # if for some reason (e.g. as a result of migration) there's no default # category, get the first record available. if not category: - query = ( - db.session - .query(model.TagCategory) - .order_by(model.TagCategory.tag_category_id.asc())) + query = db.session.query(model.TagCategory).order_by( + model.TagCategory.tag_category_id.asc() + ) if lock: - query = query.with_lockmode('update') + query = query.with_for_update() category = query.first() return category @@ -163,7 +181,7 @@ def try_get_default_category( def get_default_category(lock: bool = False) -> model.TagCategory: category = try_get_default_category(lock) if not category: - raise TagCategoryNotFoundError('No tag category created yet.') + raise TagCategoryNotFoundError("No tag category created yet.") return category @@ -190,9 +208,10 @@ def set_default_category(category: model.TagCategory) -> None: def delete_category(category: model.TagCategory) -> None: assert category if len(get_all_category_names()) == 1: - raise TagCategoryIsInUseError('Cannot delete the last category.') + raise TagCategoryIsInUseError("Cannot delete the last category.") if (category.tag_count or 0) > 0: raise TagCategoryIsInUseError( - 'Tag category has some usages and cannot be deleted. ' + - 'Please remove this category from relevant tags first..') + "Tag category has some usages and cannot be deleted. " + + "Please remove this category from relevant tags first.." + ) db.session.delete(category) diff --git a/server/szurubooru/func/tags.py b/server/szurubooru/func/tags.py index 3384b4e..4981c5d 100644 --- a/server/szurubooru/func/tags.py +++ b/server/szurubooru/func/tags.py @@ -1,11 +1,11 @@ -import json -import os import re -from typing import Any, Optional, Tuple, List, Dict, Callable from datetime import datetime +from typing import Any, Callable, Dict, List, Optional, Tuple + import sqlalchemy as sa -from szurubooru import config, db, model, errors, rest -from szurubooru.func import util, tag_categories, serialization + +from szurubooru import config, db, errors, model, rest +from szurubooru.func import serialization, tag_categories, util class TagNotFoundError(errors.NotFoundError): @@ -38,10 +38,10 @@ class InvalidTagDescriptionError(errors.ValidationError): def _verify_name_validity(name: str) -> None: if util.value_exceeds_column_size(name, model.TagName.name): - raise InvalidTagNameError('Name is too long.') - name_regex = config.config['tag_name_regex'] + raise InvalidTagNameError("Name is too long.") + name_regex = config.config["tag_name_regex"] if not re.match(name_regex, name): - raise InvalidTagNameError('Name must satisfy regex %r.' % name_regex) + raise InvalidTagNameError("Name must satisfy regex %r." % name_regex) def _get_names(tag: model.Tag) -> List[str]: @@ -54,7 +54,8 @@ def _lower_list(names: List[str]) -> List[str]: def _check_name_intersection( - names1: List[str], names2: List[str], case_sensitive: bool) -> bool: + names1: List[str], names2: List[str], case_sensitive: bool +) -> bool: if not case_sensitive: names1 = _lower_list(names1) names2 = _lower_list(names2) @@ -66,17 +67,19 @@ def sort_tags(tags: List[model.Tag]) -> List[model.Tag]: return sorted( tags, key=lambda tag: ( + tag.category.order, default_category_name == tag.category.name, tag.category.name, - tag.names[0].name) + tag.names[0].name, + ), ) def serialize_relation(tag): return { - 'names': [tag_name.name for tag_name in tag.names], - 'category': tag.category.name, - 'usages': tag.post_count, + "names": [tag_name.name for tag_name in tag.names], + "category": tag.category.name, + "usages": tag.post_count, } @@ -86,16 +89,16 @@ class TagSerializer(serialization.BaseSerializer): def _serializers(self) -> Dict[str, Callable[[], Any]]: return { - 'names': self.serialize_names, - 'category': self.serialize_category, - 'version': self.serialize_version, - 'description': self.serialize_description, - 'creationTime': self.serialize_creation_time, - 'lastEditTime': self.serialize_last_edit_time, - 'usages': self.serialize_usages, - 'suggestions': self.serialize_suggestions, - 'implications': self.serialize_implications, - 'metric': self.serialize_metric, + "names": self.serialize_names, + "category": self.serialize_category, + "version": self.serialize_version, + "description": self.serialize_description, + "creationTime": self.serialize_creation_time, + "lastEditTime": self.serialize_last_edit_time, + "usages": self.serialize_usages, + "suggestions": self.serialize_suggestions, + "implications": self.serialize_implications, + "metric": self.serialize_metric, } def serialize_names(self) -> Any: @@ -122,26 +125,29 @@ class TagSerializer(serialization.BaseSerializer): def serialize_suggestions(self) -> Any: return [ serialize_relation(relation) - for relation in sort_tags(self.tag.suggestions)] + for relation in sort_tags(self.tag.suggestions) + ] def serialize_implications(self) -> Any: return [ serialize_relation(relation) - for relation in sort_tags(self.tag.implications)] + for relation in sort_tags(self.tag.implications) + ] def serialize_metric(self) -> Any: if not self.tag.metric: return None else: return { - 'version': self.tag.metric.version, - 'min': self.tag.metric.min, - 'max': self.tag.metric.max, + "version": self.tag.metric.version, + "min": self.tag.metric.min, + "max": self.tag.metric.max, } def serialize_tag( - tag: model.Tag, options: List[str] = []) -> Optional[rest.Response]: + tag: model.Tag, options: List[str] = [] +) -> Optional[rest.Response]: if not tag: return None return TagSerializer(tag).serialize(options) @@ -149,17 +155,17 @@ def serialize_tag( def try_get_tag_by_name(name: str) -> Optional[model.Tag]: return ( - db.session - .query(model.Tag) + db.session.query(model.Tag) .join(model.TagName) .filter(sa.func.lower(model.TagName.name) == name.lower()) - .one_or_none()) + .one_or_none() + ) def get_tag_by_name(name: str) -> model.Tag: tag = try_get_tag_by_name(name) if not tag: - raise TagNotFoundError('Tag %r not found.' % name) + raise TagNotFoundError("Tag %r not found." % name) return tag @@ -173,12 +179,16 @@ def get_tags_by_names(names: List[str]) -> List[model.Tag]: .filter( sa.sql.or_( sa.func.lower(model.TagName.name) == name.lower() - for name in names)) - .all()) + for name in names + ) + ) + .all() + ) def get_or_create_tags_by_names( - names: List[str]) -> Tuple[List[model.Tag], List[model.Tag]]: + names: List[str], +) -> Tuple[List[model.Tag], List[model.Tag]]: names = util.icase_unique(names) existing_tags = get_tags_by_names(names) new_tags = [] @@ -187,7 +197,8 @@ def get_or_create_tags_by_names( found = False for existing_tag in existing_tags: if _check_name_intersection( - _get_names(existing_tag), [name], False): + _get_names(existing_tag), [name], False + ): found = True break if not found: @@ -195,7 +206,8 @@ def get_or_create_tags_by_names( names=[name], category_name=tag_category_name, suggestions=[], - implications=[]) + implications=[], + ) db.session.add(new_tag) new_tags.append(new_tag) return existing_tags, new_tags @@ -207,8 +219,7 @@ def get_tag_siblings(tag: model.Tag) -> List[model.Tag]: pt_alias1 = sa.orm.aliased(model.PostTag) pt_alias2 = sa.orm.aliased(model.PostTag) result = ( - db.session - .query(tag_alias, sa.func.count(pt_alias2.post_id)) + db.session.query(tag_alias, sa.func.count(pt_alias2.post_id)) .join(pt_alias1, pt_alias1.tag_id == tag_alias.tag_id) .join(pt_alias2, pt_alias2.post_id == pt_alias1.post_id) .filter(pt_alias2.tag_id == tag.tag_id) @@ -216,18 +227,23 @@ def get_tag_siblings(tag: model.Tag) -> List[model.Tag]: .group_by(tag_alias.tag_id) .order_by(sa.func.count(pt_alias2.post_id).desc()) .order_by(tag_alias.first_name) - .limit(50)) + .limit(50) + ) return result def delete(source_tag: model.Tag) -> None: assert source_tag db.session.execute( - sa.sql.expression.delete(model.TagSuggestion) - .where(model.TagSuggestion.child_id == source_tag.tag_id)) + sa.sql.expression.delete(model.TagSuggestion).where( + model.TagSuggestion.child_id == source_tag.tag_id + ) + ) db.session.execute( - sa.sql.expression.delete(model.TagImplication) - .where(model.TagImplication.child_id == source_tag.tag_id)) + sa.sql.expression.delete(model.TagImplication).where( + model.TagImplication.child_id == source_tag.tag_id + ) + ) db.session.delete(source_tag) @@ -235,27 +251,27 @@ def merge_tags(source_tag: model.Tag, target_tag: model.Tag) -> None: assert source_tag assert target_tag if source_tag.tag_id == target_tag.tag_id: - raise InvalidTagRelationError('Cannot merge tag with itself.') + raise InvalidTagRelationError("Cannot merge tag with itself.") if source_tag.metric or target_tag.metric: - raise InvalidTagRelationError('Cannot merge tags with metrics.') + raise InvalidTagRelationError("Cannot merge tags with metrics.") def merge_posts(source_tag_id: int, target_tag_id: int) -> None: alias1 = model.PostTag alias2 = sa.orm.util.aliased(model.PostTag) - update_stmt = ( - sa.sql.expression.update(alias1) - .where(alias1.tag_id == source_tag_id)) - update_stmt = ( - update_stmt - .where( - ~sa.exists() - .where(alias1.post_id == alias2.post_id) - .where(alias2.tag_id == target_tag_id))) + update_stmt = sa.sql.expression.update(alias1).where( + alias1.tag_id == source_tag_id + ) + update_stmt = update_stmt.where( + ~sa.exists() + .where(alias1.post_id == alias2.post_id) + .where(alias2.tag_id == target_tag_id) + ) update_stmt = update_stmt.values(tag_id=target_tag_id) db.session.execute(update_stmt) def merge_relations( - table: model.Base, source_tag_id: int, target_tag_id: int) -> None: + table: model.Base, source_tag_id: int, target_tag_id: int + ) -> None: alias1 = table alias2 = sa.orm.util.aliased(table) update_stmt = ( @@ -265,8 +281,10 @@ def merge_tags(source_tag: model.Tag, target_tag: model.Tag) -> None: .where( ~sa.exists() .where(alias2.child_id == alias1.child_id) - .where(alias2.parent_id == target_tag_id)) - .values(parent_id=target_tag_id)) + .where(alias2.parent_id == target_tag_id) + ) + .values(parent_id=target_tag_id) + ) db.session.execute(update_stmt) update_stmt = ( @@ -276,8 +294,10 @@ def merge_tags(source_tag: model.Tag, target_tag: model.Tag) -> None: .where( ~sa.exists() .where(alias2.parent_id == alias1.parent_id) - .where(alias2.child_id == target_tag_id)) - .values(child_id=target_tag_id)) + .where(alias2.child_id == target_tag_id) + ) + .values(child_id=target_tag_id) + ) db.session.execute(update_stmt) def merge_suggestions(source_tag_id: int, target_tag_id: int) -> None: @@ -293,10 +313,11 @@ def merge_tags(source_tag: model.Tag, target_tag: model.Tag) -> None: def create_tag( - names: List[str], - category_name: str, - suggestions: List[str], - implications: List[str]) -> model.Tag: + names: List[str], + category_name: str, + suggestions: List[str], + implications: List[str], +) -> model.Tag: tag = model.Tag() tag.creation_time = datetime.utcnow() update_tag_names(tag, names) @@ -316,7 +337,7 @@ def update_tag_names(tag: model.Tag, names: List[str]) -> None: assert tag names = util.icase_unique([name for name in names if name]) if not len(names): - raise InvalidTagNameError('At least one name must be specified.') + raise InvalidTagNameError("At least one name must be specified.") for name in names: _verify_name_validity(name) @@ -329,7 +350,8 @@ def update_tag_names(tag: model.Tag, names: List[str]) -> None: existing_tags = db.session.query(model.TagName).filter(expr).all() if len(existing_tags): raise TagAlreadyExistsError( - 'One of names is already used by another tag.') + "One of names is already used by another tag." + ) # remove unwanted items for tag_name in tag.names[:]: @@ -351,7 +373,7 @@ def update_tag_names(tag: model.Tag, names: List[str]) -> None: def update_tag_implications(tag: model.Tag, relations: List[str]) -> None: assert tag if _check_name_intersection(_get_names(tag), relations, False): - raise InvalidTagRelationError('Tag cannot imply itself.') + raise InvalidTagRelationError("Tag cannot imply itself.") tag.implications = get_tags_by_names(relations) @@ -359,12 +381,12 @@ def update_tag_implications(tag: model.Tag, relations: List[str]) -> None: def update_tag_suggestions(tag: model.Tag, relations: List[str]) -> None: assert tag if _check_name_intersection(_get_names(tag), relations, False): - raise InvalidTagRelationError('Tag cannot suggest itself.') + raise InvalidTagRelationError("Tag cannot suggest itself.") tag.suggestions = get_tags_by_names(relations) def update_tag_description(tag: model.Tag, description: str) -> None: assert tag if util.value_exceeds_column_size(description, model.Tag.description): - raise InvalidTagDescriptionError('Description is too long.') + raise InvalidTagDescriptionError("Description is too long.") tag.description = description or None diff --git a/server/szurubooru/func/user_tokens.py b/server/szurubooru/func/user_tokens.py index c0f4bad..8d977e4 100644 --- a/server/szurubooru/func/user_tokens.py +++ b/server/szurubooru/func/user_tokens.py @@ -1,8 +1,10 @@ from datetime import datetime -from typing import Any, Optional, List, Dict, Callable -from pyrfc3339 import parser as rfc3339_parser +from typing import Any, Callable, Dict, List, Optional + import pytz -from szurubooru import db, model, rest, errors +from pyrfc3339 import parser as rfc3339_parser + +from szurubooru import db, errors, model, rest from szurubooru.func import auth, serialization, users, util @@ -16,23 +18,22 @@ class InvalidNoteError(errors.ValidationError): class UserTokenSerializer(serialization.BaseSerializer): def __init__( - self, - user_token: model.UserToken, - auth_user: model.User) -> None: + self, user_token: model.UserToken, auth_user: model.User + ) -> None: self.user_token = user_token self.auth_user = auth_user def _serializers(self) -> Dict[str, Callable[[], Any]]: return { - 'user': self.serialize_user, - 'token': self.serialize_token, - 'note': self.serialize_note, - 'enabled': self.serialize_enabled, - 'expirationTime': self.serialize_expiration_time, - 'creationTime': self.serialize_creation_time, - 'lastEditTime': self.serialize_last_edit_time, - 'lastUsageTime': self.serialize_last_usage_time, - 'version': self.serialize_version, + "user": self.serialize_user, + "token": self.serialize_token, + "note": self.serialize_note, + "enabled": self.serialize_enabled, + "expirationTime": self.serialize_expiration_time, + "creationTime": self.serialize_creation_time, + "lastEditTime": self.serialize_last_edit_time, + "lastUsageTime": self.serialize_last_usage_time, + "version": self.serialize_version, } def serialize_user(self) -> Any: @@ -64,31 +65,31 @@ class UserTokenSerializer(serialization.BaseSerializer): def serialize_user_token( - user_token: Optional[model.UserToken], - auth_user: model.User, - options: List[str] = []) -> Optional[rest.Response]: + user_token: Optional[model.UserToken], + auth_user: model.User, + options: List[str] = [], +) -> Optional[rest.Response]: if not user_token: return None return UserTokenSerializer(user_token, auth_user).serialize(options) -def get_by_user_and_token( - user: model.User, token: str) -> model.UserToken: +def get_by_user_and_token(user: model.User, token: str) -> model.UserToken: return ( - db.session - .query(model.UserToken) + db.session.query(model.UserToken) .filter(model.UserToken.user_id == user.user_id) .filter(model.UserToken.token == token) - .one_or_none()) + .one_or_none() + ) def get_user_tokens(user: model.User) -> List[model.UserToken]: assert user return ( - db.session - .query(model.UserToken) + db.session.query(model.UserToken) .filter(model.UserToken.user_id == user.user_id) - .all()) + .all() + ) def create_user_token(user: model.User, enabled: bool) -> model.UserToken: @@ -103,7 +104,8 @@ def create_user_token(user: model.User, enabled: bool) -> model.UserToken: def update_user_token_enabled( - user_token: model.UserToken, enabled: bool) -> None: + user_token: model.UserToken, enabled: bool +) -> None: assert user_token user_token.enabled = enabled update_user_token_edit_time(user_token) @@ -115,28 +117,30 @@ def update_user_token_edit_time(user_token: model.UserToken) -> None: def update_user_token_expiration_time( - user_token: model.UserToken, expiration_time_str: str) -> None: + user_token: model.UserToken, expiration_time_str: str +) -> None: assert user_token try: expiration_time = rfc3339_parser.parse(expiration_time_str, utc=True) expiration_time = expiration_time.astimezone(pytz.UTC) if expiration_time < datetime.utcnow().replace(tzinfo=pytz.UTC): raise InvalidExpirationError( - 'Expiration cannot happen in the past') + "Expiration cannot happen in the past" + ) user_token.expiration_time = expiration_time update_user_token_edit_time(user_token) except ValueError: raise InvalidExpirationError( - 'Expiration is in an invalid format {}'.format( - expiration_time_str)) + "Expiration is in an invalid format {}".format(expiration_time_str) + ) def update_user_token_note(user_token: model.UserToken, note: str) -> None: assert user_token - note = note.strip() if note is not None else '' + note = note.strip() if note is not None else "" note = None if len(note) == 0 else note if util.value_exceeds_column_size(note, model.UserToken.note): - raise InvalidNoteError('Note is too long.') + raise InvalidNoteError("Note is too long.") user_token.note = note update_user_token_edit_time(user_token) diff --git a/server/szurubooru/func/users.py b/server/szurubooru/func/users.py index e5946dc..5cbe3cc 100644 --- a/server/szurubooru/func/users.py +++ b/server/szurubooru/func/users.py @@ -1,9 +1,11 @@ -from datetime import datetime -from typing import Any, Optional, Union, List, Dict, Callable import re +from datetime import datetime +from typing import Any, Callable, Dict, List, Optional, Union + import sqlalchemy as sa -from szurubooru import config, db, model, errors, rest -from szurubooru.func import auth, util, serialization, files, images + +from szurubooru import config, db, errors, model, rest +from szurubooru.func import auth, files, images, serialization, util class UserNotFoundError(errors.NotFoundError): @@ -35,36 +37,41 @@ class InvalidAvatarError(errors.ValidationError): def get_avatar_path(user_name: str) -> str: - return 'avatars/' + user_name.lower() + '.png' + return "avatars/" + user_name.lower() + ".png" def get_avatar_url(user: model.User) -> str: assert user if user.avatar_style == user.AVATAR_GRAVATAR: assert user.email or user.name - return 'https://gravatar.com/avatar/%s?d=retro&s=%d' % ( + return "https://gravatar.com/avatar/%s?d=retro&s=%d" % ( util.get_md5((user.email or user.name).lower()), - config.config['thumbnails']['avatar_width']) + config.config["thumbnails"]["avatar_width"], + ) assert user.name - return '%s/avatars/%s.png' % ( - config.config['data_url'].rstrip('/'), user.name.lower()) + return "%s/avatars/%s.png" % ( + config.config["data_url"].rstrip("/"), + user.name.lower(), + ) def get_email( - user: model.User, - auth_user: model.User, - force_show_email: bool) -> Union[bool, str]: + user: model.User, auth_user: model.User, force_show_email: bool +) -> Union[bool, str]: assert user assert auth_user - if not force_show_email \ - and auth_user.user_id != user.user_id \ - and not auth.has_privilege(auth_user, 'users:edit:any:email'): + if ( + not force_show_email + and auth_user.user_id != user.user_id + and not auth.has_privilege(auth_user, "users:edit:any:email") + ): return False return user.email def get_liked_post_count( - user: model.User, auth_user: model.User) -> Union[bool, int]: + user: model.User, auth_user: model.User +) -> Union[bool, int]: assert user assert auth_user if auth_user.user_id != user.user_id: @@ -73,7 +80,8 @@ def get_liked_post_count( def get_disliked_post_count( - user: model.User, auth_user: model.User) -> Union[bool, int]: + user: model.User, auth_user: model.User +) -> Union[bool, int]: assert user assert auth_user if auth_user.user_id != user.user_id: @@ -83,29 +91,30 @@ def get_disliked_post_count( class UserSerializer(serialization.BaseSerializer): def __init__( - self, - user: model.User, - auth_user: model.User, - force_show_email: bool = False) -> None: + self, + user: model.User, + auth_user: model.User, + force_show_email: bool = False, + ) -> None: self.user = user self.auth_user = auth_user self.force_show_email = force_show_email def _serializers(self) -> Dict[str, Callable[[], Any]]: return { - 'name': self.serialize_name, - 'creationTime': self.serialize_creation_time, - 'lastLoginTime': self.serialize_last_login_time, - 'version': self.serialize_version, - 'rank': self.serialize_rank, - 'avatarStyle': self.serialize_avatar_style, - 'avatarUrl': self.serialize_avatar_url, - 'commentCount': self.serialize_comment_count, - 'uploadedPostCount': self.serialize_uploaded_post_count, - 'favoritePostCount': self.serialize_favorite_post_count, - 'likedPostCount': self.serialize_liked_post_count, - 'dislikedPostCount': self.serialize_disliked_post_count, - 'email': self.serialize_email, + "name": self.serialize_name, + "creationTime": self.serialize_creation_time, + "lastLoginTime": self.serialize_last_login_time, + "version": self.serialize_version, + "rank": self.serialize_rank, + "avatarStyle": self.serialize_avatar_style, + "avatarUrl": self.serialize_avatar_url, + "commentCount": self.serialize_comment_count, + "uploadedPostCount": self.serialize_uploaded_post_count, + "favoritePostCount": self.serialize_favorite_post_count, + "likedPostCount": self.serialize_liked_post_count, + "dislikedPostCount": self.serialize_disliked_post_count, + "email": self.serialize_email, } def serialize_name(self) -> Any: @@ -149,20 +158,22 @@ class UserSerializer(serialization.BaseSerializer): def serialize_user( - user: Optional[model.User], - auth_user: model.User, - options: List[str] = [], - force_show_email: bool = False) -> Optional[rest.Response]: + user: Optional[model.User], + auth_user: model.User, + options: List[str] = [], + force_show_email: bool = False, +) -> Optional[rest.Response]: if not user: return None return UserSerializer(user, auth_user, force_show_email).serialize(options) def serialize_micro_user( - user: Optional[model.User], - auth_user: model.User) -> Optional[rest.Response]: + user: Optional[model.User], auth_user: model.User +) -> Optional[rest.Response]: return serialize_user( - user, auth_user=auth_user, options=['name', 'avatarUrl']) + user, auth_user=auth_user, options=["name", "avatarUrl"] + ) def get_user_count() -> int: @@ -171,33 +182,34 @@ def get_user_count() -> int: def try_get_user_by_name(name: str) -> Optional[model.User]: return ( - db.session - .query(model.User) + db.session.query(model.User) .filter(sa.func.lower(model.User.name) == sa.func.lower(name)) - .one_or_none()) + .one_or_none() + ) def get_user_by_name(name: str) -> model.User: user = try_get_user_by_name(name) if not user: - raise UserNotFoundError('User %r not found.' % name) + raise UserNotFoundError("User %r not found." % name) return user def try_get_user_by_name_or_email(name_or_email: str) -> Optional[model.User]: return ( - db.session - .query(model.User) + db.session.query(model.User) .filter( - (sa.func.lower(model.User.name) == sa.func.lower(name_or_email)) | - (sa.func.lower(model.User.email) == sa.func.lower(name_or_email))) - .one_or_none()) + (sa.func.lower(model.User.name) == sa.func.lower(name_or_email)) + | (sa.func.lower(model.User.email) == sa.func.lower(name_or_email)) + ) + .one_or_none() + ) def get_user_by_name_or_email(name_or_email: str) -> model.User: user = try_get_user_by_name_or_email(name_or_email) if not user: - raise UserNotFoundError('User %r not found.' % name_or_email) + raise UserNotFoundError("User %r not found." % name_or_email) return user @@ -207,7 +219,7 @@ def create_user(name: str, password: str, email: str) -> model.User: update_user_password(user, password) update_user_email(user, email) if get_user_count() > 0: - user.rank = util.flip(auth.RANK_MAP)[config.config['default_rank']] + user.rank = util.flip(auth.RANK_MAP)[config.config["default_rank"]] else: user.rank = model.User.RANK_ADMINISTRATOR user.creation_time = datetime.utcnow() @@ -218,17 +230,18 @@ def create_user(name: str, password: str, email: str) -> model.User: def update_user_name(user: model.User, name: str) -> None: assert user if not name: - raise InvalidUserNameError('Name cannot be empty.') + raise InvalidUserNameError("Name cannot be empty.") if util.value_exceeds_column_size(name, model.User.name): - raise InvalidUserNameError('User name is too long.') + raise InvalidUserNameError("User name is too long.") name = name.strip() - name_regex = config.config['user_name_regex'] + name_regex = config.config["user_name_regex"] if not re.match(name_regex, name): raise InvalidUserNameError( - 'User name %r must satisfy regex %r.' % (name, name_regex)) + "User name %r must satisfy regex %r." % (name, name_regex) + ) other_user = try_get_user_by_name(name) if other_user and other_user.user_id != user.user_id: - raise UserAlreadyExistsError('User %r already exists.' % name) + raise UserAlreadyExistsError("User %r already exists." % name) if user.name and files.has(get_avatar_path(user.name)): files.move(get_avatar_path(user.name), get_avatar_path(name)) user.name = name @@ -237,14 +250,16 @@ def update_user_name(user: model.User, name: str) -> None: def update_user_password(user: model.User, password: str) -> None: assert user if not password: - raise InvalidPasswordError('Password cannot be empty.') - password_regex = config.config['password_regex'] + raise InvalidPasswordError("Password cannot be empty.") + password_regex = config.config["password_regex"] if not re.match(password_regex, password): raise InvalidPasswordError( - 'Password must satisfy regex %r.' % password_regex) + "Password must satisfy regex %r." % password_regex + ) user.password_salt = auth.create_password() password_hash, revision = auth.get_password_hash( - user.password_salt, password) + user.password_salt, password + ) user.password_hash = password_hash user.password_revision = revision @@ -253,53 +268,56 @@ def update_user_email(user: model.User, email: str) -> None: assert user email = email.strip() if util.value_exceeds_column_size(email, model.User.email): - raise InvalidEmailError('Email is too long.') + raise InvalidEmailError("Email is too long.") if not util.is_valid_email(email): - raise InvalidEmailError('E-mail is invalid.') + raise InvalidEmailError("E-mail is invalid.") user.email = email or None def update_user_rank( - user: model.User, rank: str, auth_user: model.User) -> None: + user: model.User, rank: str, auth_user: model.User +) -> None: assert user if not rank: - raise InvalidRankError('Rank cannot be empty.') + raise InvalidRankError("Rank cannot be empty.") rank = util.flip(auth.RANK_MAP).get(rank.strip(), None) all_ranks = list(auth.RANK_MAP.values()) if not rank: - raise InvalidRankError( - 'Rank can be either of %r.' % all_ranks) + raise InvalidRankError("Rank can be either of %r." % all_ranks) if rank in (model.User.RANK_ANONYMOUS, model.User.RANK_NOBODY): - raise InvalidRankError('Rank %r cannot be used.' % auth.RANK_MAP[rank]) - if all_ranks.index(auth_user.rank) \ - < all_ranks.index(rank) and get_user_count() > 0: - raise errors.AuthError('Trying to set higher rank than your own.') + raise InvalidRankError("Rank %r cannot be used." % auth.RANK_MAP[rank]) + if ( + all_ranks.index(auth_user.rank) < all_ranks.index(rank) + and get_user_count() > 0 + ): + raise errors.AuthError("Trying to set higher rank than your own.") user.rank = rank def update_user_avatar( - user: model.User, - avatar_style: str, - avatar_content: Optional[bytes] = None) -> None: + user: model.User, avatar_style: str, avatar_content: Optional[bytes] = None +) -> None: assert user - if avatar_style == 'gravatar': + if avatar_style == "gravatar": user.avatar_style = user.AVATAR_GRAVATAR - elif avatar_style == 'manual': + elif avatar_style == "manual": user.avatar_style = user.AVATAR_MANUAL - avatar_path = 'avatars/' + user.name.lower() + '.png' + avatar_path = "avatars/" + user.name.lower() + ".png" if not avatar_content: if files.has(avatar_path): return - raise InvalidAvatarError('Avatar content missing.') + raise InvalidAvatarError("Avatar content missing.") image = images.Image(avatar_content) image.resize_fill( - int(config.config['thumbnails']['avatar_width']), - int(config.config['thumbnails']['avatar_height'])) + int(config.config["thumbnails"]["avatar_width"]), + int(config.config["thumbnails"]["avatar_height"]), + ) files.save(avatar_path, image.to_png()) else: raise InvalidAvatarError( - 'Avatar style %r is invalid. Valid avatar styles: %r.' % ( - avatar_style, ['gravatar', 'manual'])) + "Avatar style %r is invalid. Valid avatar styles: %r." + % (avatar_style, ["gravatar", "manual"]) + ) def bump_user_login_time(user: model.User) -> None: @@ -312,7 +330,8 @@ def reset_user_password(user: model.User) -> str: password = auth.create_password() user.password_salt = auth.create_password() password_hash, revision = auth.get_password_hash( - user.password_salt, password) + user.password_salt, password + ) user.password_hash = password_hash user.password_revision = revision return password diff --git a/server/szurubooru/func/util.py b/server/szurubooru/func/util.py index 4638d4b..eacdc2a 100644 --- a/server/szurubooru/func/util.py +++ b/server/szurubooru/func/util.py @@ -1,29 +1,32 @@ -import os import hashlib +import os import re import tempfile -from typing import Any, Optional, Union, Tuple, List, Dict, Generator, TypeVar -from datetime import datetime, timedelta from contextlib import contextmanager -from szurubooru import errors +from datetime import datetime, timedelta +from typing import Any, Dict, Generator, List, Optional, Tuple, TypeVar, Union +from szurubooru import errors -T = TypeVar('T') +T = TypeVar("T") def snake_case_to_lower_camel_case(text: str) -> str: - components = text.split('_') - return components[0].lower() + \ - ''.join(word[0].upper() + word[1:].lower() for word in components[1:]) + components = text.split("_") + return components[0].lower() + "".join( + word[0].upper() + word[1:].lower() for word in components[1:] + ) def snake_case_to_upper_train_case(text: str) -> str: - return '-'.join( - word[0].upper() + word[1:].lower() for word in text.split('_')) + return "-".join( + word[0].upper() + word[1:].lower() for word in text.split("_") + ) def snake_case_to_lower_camel_case_keys( - source: Dict[str, Any]) -> Dict[str, Any]: + source: Dict[str, Any] +) -> Dict[str, Any]: target = {} for key, value in source.items(): target[snake_case_to_lower_camel_case(key)] = value @@ -35,7 +38,7 @@ def create_temp_file(**kwargs: Any) -> Generator: (descriptor, path) = tempfile.mkstemp(**kwargs) os.close(descriptor) try: - with open(path, 'r+b') as handle: + with open(path, "r+b") as handle: yield handle finally: os.remove(path) @@ -65,7 +68,7 @@ def flatten_list(source: List[List[T]]) -> List[T]: def get_md5(source: Union[str, bytes]) -> str: if not isinstance(source, bytes): - source = source.encode('utf-8') + source = source.encode("utf-8") md5 = hashlib.md5() md5.update(source) return md5.hexdigest() @@ -73,7 +76,7 @@ def get_md5(source: Union[str, bytes]) -> str: def get_sha1(source: Union[str, bytes]) -> str: if not isinstance(source, bytes): - source = source.encode('utf-8') + source = source.encode("utf-8") sha1 = hashlib.sha1() sha1.update(source) return sha1.hexdigest() @@ -84,12 +87,13 @@ 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 not email or re.match(r'^[^@]*@[^@]*\.[^@]*$', email) is not None + """ 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. """ -class dotdict(dict): # pylint: disable=invalid-name - ''' dot.notation access to dictionary attributes. ''' def __getattr__(self, attr: str) -> Any: return self.get(attr) @@ -98,51 +102,54 @@ class dotdict(dict): # pylint: disable=invalid-name 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 value = value.lower() if not value: - raise errors.ValidationError('Empty date format.') + raise errors.ValidationError("Empty date format.") - if value == 'today': + if value == "today": now = datetime.utcnow() return ( datetime(now.year, now.month, now.day, 0, 0, 0), - datetime(now.year, now.month, now.day, 0, 0, 0) + almost_one_day + datetime(now.year, now.month, now.day, 0, 0, 0) + almost_one_day, ) - if value == 'yesterday': + if value == "yesterday": now = datetime.utcnow() return ( datetime(now.year, now.month, now.day, 0, 0, 0) - one_day, - datetime(now.year, now.month, now.day, 0, 0, 0) - one_second) + datetime(now.year, now.month, now.day, 0, 0, 0) - one_second, + ) - match = re.match(r'^(\d{4})$', value) + match = re.match(r"^(\d{4})$", value) if match: year = int(match.group(1)) return (datetime(year, 1, 1), datetime(year + 1, 1, 1) - one_second) - match = re.match(r'^(\d{4})-(\d{1,2})$', value) + match = re.match(r"^(\d{4})-(\d{1,2})$", value) if match: year = int(match.group(1)) month = int(match.group(2)) return ( datetime(year, month, 1), - datetime(year, month + 1, 1) - one_second) + datetime(year, month + 1, 1) - one_second, + ) - match = re.match(r'^(\d{4})-(\d{1,2})-(\d{1,2})$', value) + match = re.match(r"^(\d{4})-(\d{1,2})-(\d{1,2})$", value) if match: year = int(match.group(1)) month = int(match.group(2)) day = int(match.group(3)) return ( datetime(year, month, day), - datetime(year, month, day + 1) - one_second) + datetime(year, month, day + 1) - one_second, + ) - raise errors.ValidationError('Invalid date format: %r.' % value) + raise errors.ValidationError("Invalid date format: %r." % value) def icase_unique(source: List[str]) -> List[str]: @@ -172,4 +179,4 @@ def get_column_size(column: Any) -> Optional[int]: def chunks(source_list: List[Any], part_size: int) -> Generator: for i in range(0, len(source_list), part_size): - yield source_list[i:i + part_size] + yield source_list[i : i + part_size] diff --git a/server/szurubooru/func/versions.py b/server/szurubooru/func/versions.py index 6e5a367..790b05b 100644 --- a/server/szurubooru/func/versions.py +++ b/server/szurubooru/func/versions.py @@ -1,16 +1,16 @@ -from szurubooru import errors, rest, model +from szurubooru import errors, model, rest def verify_version( - entity: model.Base, - context: rest.Context, - field_name: str = 'version') -> None: + entity: model.Base, context: rest.Context, field_name: str = "version" +) -> None: actual_version = context.get_param_as_int(field_name) expected_version = entity.version if actual_version != expected_version: raise errors.IntegrityError( - 'Someone else modified this in the meantime. ' + - 'Please try again.') + "Someone else modified this in the meantime. " + + "Please try again." + ) def bump_version(entity: model.Base) -> None: diff --git a/server/szurubooru/middleware/__init__.py b/server/szurubooru/middleware/__init__.py index c5a90d8..7177564 100644 --- a/server/szurubooru/middleware/__init__.py +++ b/server/szurubooru/middleware/__init__.py @@ -1,4 +1,4 @@ -''' Various hooks that get executed for each request. ''' +""" Various hooks that get executed for each request. """ import szurubooru.middleware.authenticator import szurubooru.middleware.cache_purger diff --git a/server/szurubooru/middleware/authenticator.py b/server/szurubooru/middleware/authenticator.py index 4340ec9..e73b235 100644 --- a/server/szurubooru/middleware/authenticator.py +++ b/server/szurubooru/middleware/authenticator.py @@ -1,55 +1,66 @@ import base64 from typing import Optional, Tuple -from szurubooru import model, errors, rest -from szurubooru.func import auth, users, user_tokens + +from szurubooru import errors, model, rest +from szurubooru.func import auth, user_tokens, users 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.') + raise errors.AuthError("Invalid password.") return user def _authenticate_token( - username: str, token: str) -> Tuple[model.User, model.UserToken]: - ''' Try to authenticate user. Throw AuthError for invalid users. ''' + username: str, token: str +) -> Tuple[model.User, model.UserToken]: + """ 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): - raise errors.AuthError('Invalid token.') + raise errors.AuthError("Invalid token.") return user, user_token def _get_user(ctx: rest.Context, bump_login: bool) -> Optional[model.User]: - if not ctx.has_header('Authorization'): + if not ctx.has_header("Authorization"): return None auth_token = None try: - auth_type, credentials = ctx.get_header('Authorization').split(' ', 1) - if auth_type.lower() == 'basic': - username, password = base64.decodebytes( - credentials.encode('ascii')).decode('utf8').split(':', 1) + auth_type, credentials = ctx.get_header("Authorization").split(" ", 1) + if auth_type.lower() == "basic": + username, password = ( + base64.decodebytes(credentials.encode("ascii")) + .decode("utf8") + .split(":", 1) + ) auth_user = _authenticate_basic_auth(username, password) - elif auth_type.lower() == 'token': - username, token = base64.decodebytes( - credentials.encode('ascii')).decode('utf8').split(':', 1) + elif auth_type.lower() == "token": + username, token = ( + base64.decodebytes(credentials.encode("ascii")) + .decode("utf8") + .split(":", 1) + ) auth_user, auth_token = _authenticate_token(username, token) else: raise HttpBadRequest( - 'ValidationError', - 'Only basic or token HTTP authentication is supported.') + "ValidationError", + "Only basic or token HTTP authentication is supported.", + ) except ValueError as err: msg = ( - 'Authorization header values are not properly formed. ' - 'Supplied header {0}. Got error: {1}') + "Authorization header values are not properly formed. " + "Supplied header {0}. Got error: {1}" + ) raise HttpBadRequest( - 'ValidationError', - msg.format(ctx.get_header('Authorization'), str(err))) + "ValidationError", + msg.format(ctx.get_header("Authorization"), str(err)), + ) if bump_login and auth_user.user_id: users.bump_user_login_time(auth_user) @@ -61,8 +72,8 @@ 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. ''' - bump_login = ctx.get_param_as_bool('bump-login', default=False) + """ 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: ctx.user = auth_user diff --git a/server/szurubooru/middleware/cache_purger.py b/server/szurubooru/middleware/cache_purger.py index d83fb84..e3d05a7 100644 --- a/server/szurubooru/middleware/cache_purger.py +++ b/server/szurubooru/middleware/cache_purger.py @@ -5,5 +5,5 @@ from szurubooru.rest import middleware @middleware.pre_hook def process_request(ctx: rest.Context) -> None: - if ctx.method != 'GET': + if ctx.method != "GET": cache.purge() diff --git a/server/szurubooru/middleware/request_logger.py b/server/szurubooru/middleware/request_logger.py index 54e40e4..79fffbd 100644 --- a/server/szurubooru/middleware/request_logger.py +++ b/server/szurubooru/middleware/request_logger.py @@ -1,8 +1,8 @@ import logging + from szurubooru import db, rest from szurubooru.rest import middleware - logger = logging.getLogger(__name__) @@ -14,8 +14,9 @@ def process_request(_ctx: rest.Context) -> None: @middleware.post_hook def process_response(ctx: rest.Context) -> None: logger.info( - '%s %s (user=%s, queries=%d)', + "%s %s (user=%s, queries=%d)", ctx.method, ctx.url, ctx.user.name, - db.get_query_count()) + db.get_query_count(), + ) diff --git a/server/szurubooru/migrations/env.py b/server/szurubooru/migrations/env.py index 59d031f..cd4f6ad 100644 --- a/server/szurubooru/migrations/env.py +++ b/server/szurubooru/migrations/env.py @@ -1,28 +1,40 @@ +""" +Alembic setup and configuration script + +isort:skip_file +""" + + +import logging.config import os import sys +from time import sleep import alembic import sqlalchemy as sa -import logging.config + +# fmt: off # make szurubooru module importable dir_to_self = os.path.dirname(os.path.realpath(__file__)) sys.path.append(os.path.join(dir_to_self, *[os.pardir] * 2)) -import szurubooru.model.base -import szurubooru.config +import szurubooru.config # noqa: E402 +import szurubooru.model.base # noqa: E402 +# fmt: on + alembic_config = alembic.context.config logging.config.fileConfig(alembic_config.config_file_name) szuru_config = szurubooru.config.config -alembic_config.set_main_option('sqlalchemy.url', szuru_config['database']) +alembic_config.set_main_option("sqlalchemy.url", szuru_config["database"]) target_metadata = szurubooru.model.Base.metadata def run_migrations_offline(): - ''' + """ Run migrations in 'offline' mode. This configures the context with just a URL @@ -32,35 +44,47 @@ def run_migrations_offline(): Calls to context.execute() here emit the given string to the script output. - ''' - url = alembic_config.get_main_option('sqlalchemy.url') + """ + url = alembic_config.get_main_option("sqlalchemy.url") alembic.context.configure( url=url, target_metadata=target_metadata, literal_binds=True, - compare_type=True) + compare_type=True, + ) with alembic.context.begin_transaction(): alembic.context.run_migrations() def run_migrations_online(): - ''' + """ Run migrations in 'online' mode. In this scenario we need to create an Engine and associate a connection with the context. - ''' + """ connectable = sa.engine_from_config( alembic_config.get_section(alembic_config.config_ini_section), - prefix='sqlalchemy.', - poolclass=sa.pool.NullPool) + prefix="sqlalchemy.", + poolclass=sa.pool.NullPool, + ) + + def connect_with_timeout(connectable, timeout=45): + dt = 5 + for _ in range(int(timeout / dt)): + try: + return connectable.connect() + except sa.exc.OperationalError: + sleep(dt) + return connectable.connect() - with connectable.connect() as connection: + with connect_with_timeout(connectable) as connection: alembic.context.configure( connection=connection, target_metadata=target_metadata, - compare_type=True) + compare_type=True, + ) with alembic.context.begin_transaction(): alembic.context.run_migrations() diff --git a/server/szurubooru/migrations/script.py.mako b/server/szurubooru/migrations/script.py.mako index 13adc51..f065447 100644 --- a/server/szurubooru/migrations/script.py.mako +++ b/server/szurubooru/migrations/script.py.mako @@ -7,6 +7,7 @@ Created at: ${create_date} import sqlalchemy as sa from alembic import op + ${imports if imports else ""} revision = ${repr(up_revision)} diff --git a/server/szurubooru/migrations/versions/00cb3a2734db_create_tag_tables.py b/server/szurubooru/migrations/versions/00cb3a2734db_create_tag_tables.py index 77d7641..a817044 100644 --- a/server/szurubooru/migrations/versions/00cb3a2734db_create_tag_tables.py +++ b/server/szurubooru/migrations/versions/00cb3a2734db_create_tag_tables.py @@ -1,65 +1,70 @@ -''' +""" Create tag tables Revision ID: 00cb3a2734db Created at: 2016-04-15 23:15:36.255429 -''' +""" import sqlalchemy as sa from alembic import op -revision = '00cb3a2734db' -down_revision = 'e5c1216a8503' +revision = "00cb3a2734db" +down_revision = "e5c1216a8503" branch_labels = None depends_on = None def upgrade(): op.create_table( - 'tag_category', - sa.Column('id', sa.Integer(), nullable=False), - sa.Column('name', sa.Unicode(length=32), nullable=False), - sa.Column('color', sa.Unicode(length=32), nullable=False), - sa.PrimaryKeyConstraint('id')) + "tag_category", + sa.Column("id", sa.Integer(), nullable=False), + sa.Column("name", sa.Unicode(length=32), nullable=False), + sa.Column("color", sa.Unicode(length=32), nullable=False), + sa.PrimaryKeyConstraint("id"), + ) op.create_table( - 'tag', - sa.Column('id', sa.Integer(), nullable=False), - sa.Column('category_id', sa.Integer(), nullable=False), - sa.Column('creation_time', sa.DateTime(), nullable=False), - sa.Column('last_edit_time', sa.DateTime(), nullable=True), - sa.ForeignKeyConstraint(['category_id'], ['tag_category.id']), - sa.PrimaryKeyConstraint('id')) + "tag", + sa.Column("id", sa.Integer(), nullable=False), + sa.Column("category_id", sa.Integer(), nullable=False), + sa.Column("creation_time", sa.DateTime(), nullable=False), + sa.Column("last_edit_time", sa.DateTime(), nullable=True), + sa.ForeignKeyConstraint(["category_id"], ["tag_category.id"]), + sa.PrimaryKeyConstraint("id"), + ) op.create_table( - 'tag_name', - sa.Column('tag_name_id', sa.Integer(), nullable=False), - sa.Column('tag_id', sa.Integer(), nullable=False), - sa.Column('name', sa.Unicode(length=64), nullable=False), - sa.ForeignKeyConstraint(['tag_id'], ['tag.id']), - sa.PrimaryKeyConstraint('tag_name_id'), - sa.UniqueConstraint('name')) + "tag_name", + sa.Column("tag_name_id", sa.Integer(), nullable=False), + sa.Column("tag_id", sa.Integer(), nullable=False), + sa.Column("name", sa.Unicode(length=64), nullable=False), + sa.ForeignKeyConstraint(["tag_id"], ["tag.id"]), + sa.PrimaryKeyConstraint("tag_name_id"), + sa.UniqueConstraint("name"), + ) op.create_table( - 'tag_implication', - sa.Column('parent_id', sa.Integer(), nullable=False), - sa.Column('child_id', sa.Integer(), nullable=False), - sa.ForeignKeyConstraint(['parent_id'], ['tag.id']), - sa.ForeignKeyConstraint(['child_id'], ['tag.id']), - sa.PrimaryKeyConstraint('parent_id', 'child_id')) + "tag_implication", + sa.Column("parent_id", sa.Integer(), nullable=False), + sa.Column("child_id", sa.Integer(), nullable=False), + sa.ForeignKeyConstraint(["parent_id"], ["tag.id"]), + sa.ForeignKeyConstraint(["child_id"], ["tag.id"]), + sa.PrimaryKeyConstraint("parent_id", "child_id"), + ) op.create_table( - 'tag_suggestion', - sa.Column('parent_id', sa.Integer(), nullable=False), - sa.Column('child_id', sa.Integer(), nullable=False), - sa.ForeignKeyConstraint(['parent_id'], ['tag.id']), - sa.ForeignKeyConstraint(['child_id'], ['tag.id']), - sa.PrimaryKeyConstraint('parent_id', 'child_id')) + "tag_suggestion", + sa.Column("parent_id", sa.Integer(), nullable=False), + sa.Column("child_id", sa.Integer(), nullable=False), + sa.ForeignKeyConstraint(["parent_id"], ["tag.id"]), + sa.ForeignKeyConstraint(["child_id"], ["tag.id"]), + sa.PrimaryKeyConstraint("parent_id", "child_id"), + ) def downgrade(): - op.drop_table('tag_suggestion') - op.drop_table('tag_implication') - op.drop_table('tag_name') - op.drop_table('tag') - op.drop_table('tag_category') + op.drop_table("tag_suggestion") + op.drop_table("tag_implication") + op.drop_table("tag_name") + op.drop_table("tag") + op.drop_table("tag_category") diff --git a/server/szurubooru/migrations/versions/02ef5f73f4ab_add_hashes_to_post_file_names.py b/server/szurubooru/migrations/versions/02ef5f73f4ab_add_hashes_to_post_file_names.py index c7e5d01..fb4f09c 100644 --- a/server/szurubooru/migrations/versions/02ef5f73f4ab_add_hashes_to_post_file_names.py +++ b/server/szurubooru/migrations/versions/02ef5f73f4ab_add_hashes_to_post_file_names.py @@ -1,43 +1,45 @@ -''' +""" Add hashes to post file names Revision ID: 02ef5f73f4ab Created at: 2017-08-24 13:30:46.766928 -''' +""" import os import re + from szurubooru.func import files, posts -revision = '02ef5f73f4ab' -down_revision = '5f00af3004a4' +revision = "02ef5f73f4ab" +down_revision = "5f00af3004a4" branch_labels = None depends_on = None def upgrade(): - for name in ['posts', 'posts/custom-thumbnails', 'generated-thumbnails']: + for name in ["posts", "posts/custom-thumbnails", "generated-thumbnails"]: for entry in list(files.scan(name)): - match = re.match(r'^(?P<name>\d+)\.(?P<ext>\w+)$', entry.name) + match = re.match(r"^(?P<name>\d+)\.(?P<ext>\w+)$", entry.name) if match: - post_id = int(match.group('name')) + post_id = int(match.group("name")) security_hash = posts.get_post_security_hash(post_id) - ext = match.group('ext') - new_name = '%s_%s.%s' % (post_id, security_hash, ext) + ext = match.group("ext") + new_name = "%s_%s.%s" % (post_id, security_hash, ext) new_path = os.path.join(os.path.dirname(entry.path), new_name) os.rename(entry.path, new_path) def downgrade(): - for name in ['posts', 'posts/custom-thumbnails', 'generated-thumbnails']: + for name in ["posts", "posts/custom-thumbnails", "generated-thumbnails"]: for entry in list(files.scan(name)): match = re.match( - r'^(?P<name>\d+)_(?P<hash>[0-9A-Fa-f]+)\.(?P<ext>\w+)$', - entry.name) + r"^(?P<name>\d+)_(?P<hash>[0-9A-Fa-f]+)\.(?P<ext>\w+)$", + entry.name, + ) if match: - post_id = int(match.group('name')) - security_hash = match.group('hash') - ext = match.group('ext') - new_name = '%s.%s' % (post_id, ext) + post_id = int(match.group("name")) + security_hash = match.group("hash") # noqa: F841 + ext = match.group("ext") + new_name = "%s.%s" % (post_id, ext) new_path = os.path.join(os.path.dirname(entry.path), new_name) os.rename(entry.path, new_path) diff --git a/server/szurubooru/migrations/versions/055d0e048fb3_add_default_column_to_tag_categories.py b/server/szurubooru/migrations/versions/055d0e048fb3_add_default_column_to_tag_categories.py index 1ced159..0b4efb8 100644 --- a/server/szurubooru/migrations/versions/055d0e048fb3_add_default_column_to_tag_categories.py +++ b/server/szurubooru/migrations/versions/055d0e048fb3_add_default_column_to_tag_categories.py @@ -1,28 +1,30 @@ -''' +""" Add default column to tag categories Revision ID: 055d0e048fb3 Created at: 2016-05-22 18:12:58.149678 -''' +""" import sqlalchemy as sa from alembic import op -revision = '055d0e048fb3' -down_revision = '49ab4e1139ef' +revision = "055d0e048fb3" +down_revision = "49ab4e1139ef" branch_labels = None depends_on = None def upgrade(): op.add_column( - 'tag_category', sa.Column('default', sa.Boolean(), nullable=True)) + "tag_category", sa.Column("default", sa.Boolean(), nullable=True) + ) op.execute( - sa.table('tag_category', sa.column('default')) + sa.table("tag_category", sa.column("default")) .update() - .values(default=False)) - op.alter_column('tag_category', 'default', nullable=False) + .values(default=False) + ) + op.alter_column("tag_category", "default", nullable=False) def downgrade(): - op.drop_column('tag_category', 'default') + op.drop_column("tag_category", "default") diff --git a/server/szurubooru/migrations/versions/1cd4c7b22846_change_flags_column_to_string.py b/server/szurubooru/migrations/versions/1cd4c7b22846_change_flags_column_to_string.py index b450b1d..ef9c5f3 100644 --- a/server/szurubooru/migrations/versions/1cd4c7b22846_change_flags_column_to_string.py +++ b/server/szurubooru/migrations/versions/1cd4c7b22846_change_flags_column_to_string.py @@ -1,63 +1,54 @@ -''' +""" Change flags column to string Revision ID: 1cd4c7b22846 Created at: 2018-09-21 19:37:27.686568 -''' +""" import sqlalchemy as sa from alembic import op -revision = '1cd4c7b22846' -down_revision = 'a39c7f98a7fa' +revision = "1cd4c7b22846" +down_revision = "a39c7f98a7fa" branch_labels = None depends_on = None def upgrade(): conn = op.get_bind() - op.alter_column('post', 'flags', new_column_name='oldflags') - op.add_column('post', sa.Column( - 'flags', sa.Unicode(200), default='', nullable=True)) + op.alter_column("post", "flags", new_column_name="oldflags") + op.add_column( + "post", sa.Column("flags", sa.Unicode(200), default="", nullable=True) + ) posts = sa.Table( - 'post', + "post", sa.MetaData(), - sa.Column('id', sa.Integer, primary_key=True), - sa.Column('flags', sa.Unicode(200), default='', nullable=True), - sa.Column('oldflags', sa.PickleType(), nullable=True), + sa.Column("id", sa.Integer, primary_key=True), + sa.Column("flags", sa.Unicode(200), default="", nullable=True), + sa.Column("oldflags", sa.PickleType(), nullable=True), ) for row in conn.execute(posts.select()): - newflag = ','.join(row.oldflags) if row.oldflags else '' + newflag = ",".join(row.oldflags) if row.oldflags else "" conn.execute( - # pylint: disable=no-value-for-parameter - posts.update().where( - posts.c.id == row.id - ).values( - flags=newflag - ) + posts.update().where(posts.c.id == row.id).values(flags=newflag) ) - op.drop_column('post', 'oldflags') + op.drop_column("post", "oldflags") def downgrade(): conn = op.get_bind() - op.alter_column('post', 'flags', new_column_name='oldflags') - op.add_column('post', sa.Column('flags', sa.PickleType(), nullable=True)) + op.alter_column("post", "flags", new_column_name="oldflags") + op.add_column("post", sa.Column("flags", sa.PickleType(), nullable=True)) posts = sa.Table( - 'post', + "post", sa.MetaData(), - sa.Column('id', sa.Integer, primary_key=True), - sa.Column('flags', sa.PickleType(), nullable=True), - sa.Column('oldflags', sa.Unicode(200), default='', nullable=True), + sa.Column("id", sa.Integer, primary_key=True), + sa.Column("flags", sa.PickleType(), nullable=True), + sa.Column("oldflags", sa.Unicode(200), default="", nullable=True), ) for row in conn.execute(posts.select()): - newflag = [x for x in row.oldflags.split(',') if x] + newflag = [x for x in row.oldflags.split(",") if x] conn.execute( - # pylint: disable=no-value-for-parameter - posts.update().where( - posts.c.id == row.id - ).values( - flags=newflag - ) + posts.update().where(posts.c.id == row.id).values(flags=newflag) ) - op.drop_column('post', 'oldflags') + op.drop_column("post", "oldflags") diff --git a/server/szurubooru/migrations/versions/1e280b5d5df1_longer_tag_names.py b/server/szurubooru/migrations/versions/1e280b5d5df1_longer_tag_names.py new file mode 100644 index 0000000..5a79103 --- /dev/null +++ b/server/szurubooru/migrations/versions/1e280b5d5df1_longer_tag_names.py @@ -0,0 +1,50 @@ +""" +Longer tag names + +Revision ID: 1e280b5d5df1 +Created at: 2020-03-15 18:57:12.901148 +""" + +import sqlalchemy as sa +from alembic import op + +revision = "1e280b5d5df1" +down_revision = "52d6ea6584b8" +branch_labels = None +depends_on = None + + +def upgrade(): + op.alter_column( + "tag_name", + "name", + type_=sa.Unicode(128), + existing_type=sa.Unicode(64), + existing_nullable=False, + ) + + op.alter_column( + "snapshot", + "resource_name", + type_=sa.Unicode(128), + existing_type=sa.Unicode(64), + existing_nullable=False, + ) + + +def downgrade(): + op.alter_column( + "tag_name", + "name", + type_=sa.Unicode(64), + existing_type=sa.Unicode(128), + existing_nullable=False, + ) + + op.alter_column( + "snapshot", + "resource_name", + type_=sa.Unicode(64), + existing_type=sa.Unicode(128), + existing_nullable=False, + ) diff --git a/server/szurubooru/migrations/versions/23abaf4a0a4b_add_mime_type_to_posts.py b/server/szurubooru/migrations/versions/23abaf4a0a4b_add_mime_type_to_posts.py index e18119b..c8c4031 100644 --- a/server/szurubooru/migrations/versions/23abaf4a0a4b_add_mime_type_to_posts.py +++ b/server/szurubooru/migrations/versions/23abaf4a0a4b_add_mime_type_to_posts.py @@ -1,23 +1,24 @@ -''' +""" Add mime type to posts Revision ID: 23abaf4a0a4b Created at: 2016-05-02 00:02:33.024885 -''' +""" import sqlalchemy as sa from alembic import op -revision = '23abaf4a0a4b' -down_revision = 'ed6dd16a30f3' +revision = "23abaf4a0a4b" +down_revision = "ed6dd16a30f3" branch_labels = None depends_on = None def upgrade(): op.add_column( - 'post', sa.Column('mime-type', sa.Unicode(length=32), nullable=False)) + "post", sa.Column("mime-type", sa.Unicode(length=32), nullable=False) + ) def downgrade(): - op.drop_column('post', 'mime-type') + op.drop_column("post", "mime-type") diff --git a/server/szurubooru/migrations/versions/336a76ec1338_create_post_tables.py b/server/szurubooru/migrations/versions/336a76ec1338_create_post_tables.py index b767c98..aea6cc7 100644 --- a/server/szurubooru/migrations/versions/336a76ec1338_create_post_tables.py +++ b/server/szurubooru/migrations/versions/336a76ec1338_create_post_tables.py @@ -1,64 +1,67 @@ -''' +""" Create post tables Revision ID: 336a76ec1338 Created at: 2016-04-19 12:06:08.649503 -''' +""" import sqlalchemy as sa from alembic import op -revision = '336a76ec1338' -down_revision = '00cb3a2734db' +revision = "336a76ec1338" +down_revision = "00cb3a2734db" branch_labels = None depends_on = None def upgrade(): op.create_table( - 'post', - sa.Column('id', sa.Integer(), nullable=False), - sa.Column('user_id', sa.Integer(), nullable=True), - sa.Column('creation_time', sa.DateTime(), nullable=False), - sa.Column('last_edit_time', sa.DateTime(), nullable=True), - sa.Column('safety', sa.Unicode(length=32), nullable=False), - sa.Column('type', sa.Unicode(length=32), nullable=False), - sa.Column('checksum', sa.Unicode(length=64), nullable=False), - sa.Column('source', sa.Unicode(length=200), nullable=True), - sa.Column('file_size', sa.Integer(), nullable=True), - sa.Column('image_width', sa.Integer(), nullable=True), - sa.Column('image_height', sa.Integer(), nullable=True), - sa.Column('flags', sa.Integer(), nullable=False), - sa.Column('auto_fav_count', sa.Integer(), nullable=False), - sa.Column('auto_score', sa.Integer(), nullable=False), - sa.Column('auto_feature_count', sa.Integer(), nullable=False), - sa.Column('auto_comment_count', sa.Integer(), nullable=False), - sa.Column('auto_note_count', sa.Integer(), nullable=False), - sa.Column('auto_fav_time', sa.Integer(), nullable=False), - sa.Column('auto_feature_time', sa.Integer(), nullable=False), - sa.Column('auto_comment_creation_time', sa.Integer(), nullable=False), - sa.Column('auto_comment_edit_time', sa.Integer(), nullable=False), - sa.ForeignKeyConstraint(['user_id'], ['user.id']), - sa.PrimaryKeyConstraint('id')) + "post", + sa.Column("id", sa.Integer(), nullable=False), + sa.Column("user_id", sa.Integer(), nullable=True), + sa.Column("creation_time", sa.DateTime(), nullable=False), + sa.Column("last_edit_time", sa.DateTime(), nullable=True), + sa.Column("safety", sa.Unicode(length=32), nullable=False), + sa.Column("type", sa.Unicode(length=32), nullable=False), + sa.Column("checksum", sa.Unicode(length=64), nullable=False), + sa.Column("source", sa.Unicode(length=200), nullable=True), + sa.Column("file_size", sa.Integer(), nullable=True), + sa.Column("image_width", sa.Integer(), nullable=True), + sa.Column("image_height", sa.Integer(), nullable=True), + sa.Column("flags", sa.Integer(), nullable=False), + sa.Column("auto_fav_count", sa.Integer(), nullable=False), + sa.Column("auto_score", sa.Integer(), nullable=False), + sa.Column("auto_feature_count", sa.Integer(), nullable=False), + sa.Column("auto_comment_count", sa.Integer(), nullable=False), + sa.Column("auto_note_count", sa.Integer(), nullable=False), + sa.Column("auto_fav_time", sa.Integer(), nullable=False), + sa.Column("auto_feature_time", sa.Integer(), nullable=False), + sa.Column("auto_comment_creation_time", sa.Integer(), nullable=False), + sa.Column("auto_comment_edit_time", sa.Integer(), nullable=False), + sa.ForeignKeyConstraint(["user_id"], ["user.id"]), + sa.PrimaryKeyConstraint("id"), + ) op.create_table( - 'post_relation', - sa.Column('parent_id', sa.Integer(), nullable=False), - sa.Column('child_id', sa.Integer(), nullable=False), - sa.ForeignKeyConstraint(['child_id'], ['post.id']), - sa.ForeignKeyConstraint(['parent_id'], ['post.id']), - sa.PrimaryKeyConstraint('parent_id', 'child_id')) + "post_relation", + sa.Column("parent_id", sa.Integer(), nullable=False), + sa.Column("child_id", sa.Integer(), nullable=False), + sa.ForeignKeyConstraint(["child_id"], ["post.id"]), + sa.ForeignKeyConstraint(["parent_id"], ["post.id"]), + sa.PrimaryKeyConstraint("parent_id", "child_id"), + ) op.create_table( - 'post_tag', - sa.Column('post_id', sa.Integer(), nullable=False), - sa.Column('tag_id', sa.Integer(), nullable=False), - sa.ForeignKeyConstraint(['post_id'], ['post.id']), - sa.ForeignKeyConstraint(['tag_id'], ['tag.id']), - sa.PrimaryKeyConstraint('post_id', 'tag_id')) + "post_tag", + sa.Column("post_id", sa.Integer(), nullable=False), + sa.Column("tag_id", sa.Integer(), nullable=False), + sa.ForeignKeyConstraint(["post_id"], ["post.id"]), + sa.ForeignKeyConstraint(["tag_id"], ["tag.id"]), + sa.PrimaryKeyConstraint("post_id", "tag_id"), + ) def downgrade(): - op.drop_table('post_tag') - op.drop_table('post_relation') - op.drop_table('post') + op.drop_table("post_tag") + op.drop_table("post_relation") + op.drop_table("post") diff --git a/server/szurubooru/migrations/versions/3c1f0316fa7f_resize_post_columns.py b/server/szurubooru/migrations/versions/3c1f0316fa7f_resize_post_columns.py new file mode 100644 index 0000000..17e30d5 --- /dev/null +++ b/server/szurubooru/migrations/versions/3c1f0316fa7f_resize_post_columns.py @@ -0,0 +1,34 @@ +""" +resize post columns + +Revision ID: 3c1f0316fa7f +Created at: 2019-07-27 22:29:33.874837 +""" + +import sqlalchemy as sa +from alembic import op + +revision = "3c1f0316fa7f" +down_revision = "1cd4c7b22846" +branch_labels = None +depends_on = None + + +def upgrade(): + op.alter_column( + "post", "flags", type_=sa.Unicode(32), existing_type=sa.Unicode(200) + ) + + op.alter_column( + "post", "source", type_=sa.Unicode(2048), existing_type=sa.Unicode(200) + ) + + +def downgrade(): + op.alter_column( + "post", "flags", type_=sa.Unicode(200), existing_type=sa.Unicode(32) + ) + + op.alter_column( + "post", "source", type_=sa.Unicode(200), existing_type=sa.Unicode(2048) + ) diff --git a/server/szurubooru/migrations/versions/46cd5229839b_add_snapshot_resource_repr.py b/server/szurubooru/migrations/versions/46cd5229839b_add_snapshot_resource_repr.py index 0a46fbc..7620e90 100644 --- a/server/szurubooru/migrations/versions/46cd5229839b_add_snapshot_resource_repr.py +++ b/server/szurubooru/migrations/versions/46cd5229839b_add_snapshot_resource_repr.py @@ -1,24 +1,25 @@ -''' +""" Add snapshot resource_repr column Revision ID: 46cd5229839b Created at: 2016-04-21 19:00:48.087069 -''' +""" import sqlalchemy as sa from alembic import op -revision = '46cd5229839b' -down_revision = '565e01e3cf6d' +revision = "46cd5229839b" +down_revision = "565e01e3cf6d" branch_labels = None depends_on = None def upgrade(): op.add_column( - 'snapshot', - sa.Column('resource_repr', sa.Unicode(length=64), nullable=False)) + "snapshot", + sa.Column("resource_repr", sa.Unicode(length=64), nullable=False), + ) def downgrade(): - op.drop_column('snapshot', 'resource_repr') + op.drop_column("snapshot", "resource_repr") diff --git a/server/szurubooru/migrations/versions/46df355634dc_add_comment_tables.py b/server/szurubooru/migrations/versions/46df355634dc_add_comment_tables.py index 49971fe..c2a0092 100644 --- a/server/szurubooru/migrations/versions/46df355634dc_add_comment_tables.py +++ b/server/szurubooru/migrations/versions/46df355634dc_add_comment_tables.py @@ -1,43 +1,45 @@ -''' +""" Add comment tables Revision ID: 46df355634dc Created at: 2016-04-24 09:02:05.008648 -''' +""" import sqlalchemy as sa from alembic import op -revision = '46df355634dc' -down_revision = '84bd402f15f0' +revision = "46df355634dc" +down_revision = "84bd402f15f0" branch_labels = None depends_on = None def upgrade(): op.create_table( - 'comment', - sa.Column('id', sa.Integer(), nullable=False), - sa.Column('user_id', sa.Integer(), nullable=True), - sa.Column('post_id', sa.Integer(), nullable=False), - sa.Column('creation_time', sa.DateTime(), nullable=False), - sa.Column('last_edit_time', sa.DateTime(), nullable=True), - sa.Column('text', sa.UnicodeText(), nullable=True), - sa.ForeignKeyConstraint(['user_id'], ['user.id']), - sa.ForeignKeyConstraint(['post_id'], ['post.id']), - sa.PrimaryKeyConstraint('id')) + "comment", + sa.Column("id", sa.Integer(), nullable=False), + sa.Column("user_id", sa.Integer(), nullable=True), + sa.Column("post_id", sa.Integer(), nullable=False), + sa.Column("creation_time", sa.DateTime(), nullable=False), + sa.Column("last_edit_time", sa.DateTime(), nullable=True), + sa.Column("text", sa.UnicodeText(), nullable=True), + sa.ForeignKeyConstraint(["user_id"], ["user.id"]), + sa.ForeignKeyConstraint(["post_id"], ["post.id"]), + sa.PrimaryKeyConstraint("id"), + ) op.create_table( - 'comment_score', - sa.Column('comment_id', sa.Integer(), nullable=False), - sa.Column('user_id', sa.Integer(), nullable=False), - sa.Column('time', sa.DateTime(), nullable=False), - sa.Column('score', sa.Integer(), nullable=False), - sa.ForeignKeyConstraint(['comment_id'], ['comment.id']), - sa.ForeignKeyConstraint(['user_id'], ['user.id']), - sa.PrimaryKeyConstraint('comment_id', 'user_id')) + "comment_score", + sa.Column("comment_id", sa.Integer(), nullable=False), + sa.Column("user_id", sa.Integer(), nullable=False), + sa.Column("time", sa.DateTime(), nullable=False), + sa.Column("score", sa.Integer(), nullable=False), + sa.ForeignKeyConstraint(["comment_id"], ["comment.id"]), + sa.ForeignKeyConstraint(["user_id"], ["user.id"]), + sa.PrimaryKeyConstraint("comment_id", "user_id"), + ) def downgrade(): - op.drop_table('comment_score') - op.drop_table('comment') + op.drop_table("comment_score") + op.drop_table("comment") diff --git a/server/szurubooru/migrations/versions/49ab4e1139ef_create_indexes.py b/server/szurubooru/migrations/versions/49ab4e1139ef_create_indexes.py index b18e410..73a0ad1 100644 --- a/server/szurubooru/migrations/versions/49ab4e1139ef_create_indexes.py +++ b/server/szurubooru/migrations/versions/49ab4e1139ef_create_indexes.py @@ -1,71 +1,74 @@ -''' +""" Create indexes Revision ID: 49ab4e1139ef Created at: 2016-05-09 09:38:28.078936 -''' +""" import sqlalchemy as sa from alembic import op -revision = '49ab4e1139ef' -down_revision = '23abaf4a0a4b' +revision = "49ab4e1139ef" +down_revision = "23abaf4a0a4b" branch_labels = None depends_on = None def upgrade(): for index_name, table_name, column_name in [ - ('ix_comment_post_id', 'comment', 'post_id'), - ('ix_comment_user_id', 'comment', 'user_id'), - ('ix_comment_score_user_id', 'comment_score', 'user_id'), - ('ix_post_user_id', 'post', 'user_id'), - ('ix_post_favorite_post_id', 'post_favorite', 'post_id'), - ('ix_post_favorite_user_id', 'post_favorite', 'user_id'), - ('ix_post_feature_post_id', 'post_feature', 'post_id'), - ('ix_post_feature_user_id', 'post_feature', 'user_id'), - ('ix_post_note_post_id', 'post_note', 'post_id'), - ('ix_post_relation_child_id', 'post_relation', 'child_id'), - ('ix_post_relation_parent_id', 'post_relation', 'parent_id'), - ('ix_post_score_post_id', 'post_score', 'post_id'), - ('ix_post_score_user_id', 'post_score', 'user_id'), - ('ix_post_tag_post_id', 'post_tag', 'post_id'), - ('ix_post_tag_tag_id', 'post_tag', 'tag_id'), - ('ix_snapshot_resource_id', 'snapshot', 'resource_id'), - ('ix_snapshot_resource_type', 'snapshot', 'resource_type'), - ('ix_tag_category_id', 'tag', 'category_id'), - ('ix_tag_implication_child_id', 'tag_implication', 'child_id'), - ('ix_tag_implication_parent_id', 'tag_implication', 'parent_id'), - ('ix_tag_name_tag_id', 'tag_name', 'tag_id'), - ('ix_tag_suggestion_child_id', 'tag_suggestion', 'child_id'), - ('ix_tag_suggestion_parent_id', 'tag_suggestion', 'parent_id')]: + ("ix_comment_post_id", "comment", "post_id"), + ("ix_comment_user_id", "comment", "user_id"), + ("ix_comment_score_user_id", "comment_score", "user_id"), + ("ix_post_user_id", "post", "user_id"), + ("ix_post_favorite_post_id", "post_favorite", "post_id"), + ("ix_post_favorite_user_id", "post_favorite", "user_id"), + ("ix_post_feature_post_id", "post_feature", "post_id"), + ("ix_post_feature_user_id", "post_feature", "user_id"), + ("ix_post_note_post_id", "post_note", "post_id"), + ("ix_post_relation_child_id", "post_relation", "child_id"), + ("ix_post_relation_parent_id", "post_relation", "parent_id"), + ("ix_post_score_post_id", "post_score", "post_id"), + ("ix_post_score_user_id", "post_score", "user_id"), + ("ix_post_tag_post_id", "post_tag", "post_id"), + ("ix_post_tag_tag_id", "post_tag", "tag_id"), + ("ix_snapshot_resource_id", "snapshot", "resource_id"), + ("ix_snapshot_resource_type", "snapshot", "resource_type"), + ("ix_tag_category_id", "tag", "category_id"), + ("ix_tag_implication_child_id", "tag_implication", "child_id"), + ("ix_tag_implication_parent_id", "tag_implication", "parent_id"), + ("ix_tag_name_tag_id", "tag_name", "tag_id"), + ("ix_tag_suggestion_child_id", "tag_suggestion", "child_id"), + ("ix_tag_suggestion_parent_id", "tag_suggestion", "parent_id"), + ]: op.create_index( - op.f(index_name), table_name, [column_name], unique=False) + op.f(index_name), table_name, [column_name], unique=False + ) def downgrade(): for index_name, table_name in [ - ('ix_tag_suggestion_parent_id', 'tag_suggestion'), - ('ix_tag_suggestion_child_id', 'tag_suggestion'), - ('ix_tag_name_tag_id', 'tag_name'), - ('ix_tag_implication_parent_id', 'tag_implication'), - ('ix_tag_implication_child_id', 'tag_implication'), - ('ix_tag_category_id', 'tag'), - ('ix_snapshot_resource_type', 'snapshot'), - ('ix_snapshot_resource_id', 'snapshot'), - ('ix_post_tag_tag_id', 'post_tag'), - ('ix_post_tag_post_id', 'post_tag'), - ('ix_post_score_user_id', 'post_score'), - ('ix_post_score_post_id', 'post_score'), - ('ix_post_relation_parent_id', 'post_relation'), - ('ix_post_relation_child_id', 'post_relation'), - ('ix_post_note_post_id', 'post_note'), - ('ix_post_feature_user_id', 'post_feature'), - ('ix_post_feature_post_id', 'post_feature'), - ('ix_post_favorite_user_id', 'post_favorite'), - ('ix_post_favorite_post_id', 'post_favorite'), - ('ix_post_user_id', 'post'), - ('ix_comment_score_user_id', 'comment_score'), - ('ix_comment_user_id', 'comment'), - ('ix_comment_post_id', 'comment')]: + ("ix_tag_suggestion_parent_id", "tag_suggestion"), + ("ix_tag_suggestion_child_id", "tag_suggestion"), + ("ix_tag_name_tag_id", "tag_name"), + ("ix_tag_implication_parent_id", "tag_implication"), + ("ix_tag_implication_child_id", "tag_implication"), + ("ix_tag_category_id", "tag"), + ("ix_snapshot_resource_type", "snapshot"), + ("ix_snapshot_resource_id", "snapshot"), + ("ix_post_tag_tag_id", "post_tag"), + ("ix_post_tag_post_id", "post_tag"), + ("ix_post_score_user_id", "post_score"), + ("ix_post_score_post_id", "post_score"), + ("ix_post_relation_parent_id", "post_relation"), + ("ix_post_relation_child_id", "post_relation"), + ("ix_post_note_post_id", "post_note"), + ("ix_post_feature_user_id", "post_feature"), + ("ix_post_feature_post_id", "post_feature"), + ("ix_post_favorite_user_id", "post_favorite"), + ("ix_post_favorite_post_id", "post_favorite"), + ("ix_post_user_id", "post"), + ("ix_comment_score_user_id", "comment_score"), + ("ix_comment_user_id", "comment"), + ("ix_comment_post_id", "comment"), + ]: op.drop_index(op.f(index_name), table_name=table_name) diff --git a/server/szurubooru/migrations/versions/4a020f1d271a_rename_snapshot_columns.py b/server/szurubooru/migrations/versions/4a020f1d271a_rename_snapshot_columns.py index e1c98ca..957bede 100644 --- a/server/szurubooru/migrations/versions/4a020f1d271a_rename_snapshot_columns.py +++ b/server/szurubooru/migrations/versions/4a020f1d271a_rename_snapshot_columns.py @@ -1,54 +1,57 @@ -''' +""" Rename snapshot columns Revision ID: 4a020f1d271a Created at: 2016-08-16 09:25:38.350861 -''' +""" import sqlalchemy as sa from alembic import op - -revision = '4a020f1d271a' -down_revision = '840b460c5613' +revision = "4a020f1d271a" +down_revision = "840b460c5613" branch_labels = None depends_on = None def upgrade(): op.add_column( - 'snapshot', - sa.Column('resource_name', sa.Unicode(length=64), nullable=False)) + "snapshot", + sa.Column("resource_name", sa.Unicode(length=64), nullable=False), + ) op.add_column( - 'snapshot', - sa.Column('resource_pkey', sa.Integer(), nullable=False)) + "snapshot", sa.Column("resource_pkey", sa.Integer(), nullable=False) + ) op.create_index( - op.f('ix_snapshot_resource_pkey'), - 'snapshot', - ['resource_pkey'], - unique=False) - op.drop_index('ix_snapshot_resource_id', table_name='snapshot') - op.drop_column('snapshot', 'resource_id') - op.drop_column('snapshot', 'resource_repr') + op.f("ix_snapshot_resource_pkey"), + "snapshot", + ["resource_pkey"], + unique=False, + ) + op.drop_index("ix_snapshot_resource_id", table_name="snapshot") + op.drop_column("snapshot", "resource_id") + op.drop_column("snapshot", "resource_repr") def downgrade(): op.add_column( - 'snapshot', + "snapshot", sa.Column( - 'resource_repr', + "resource_repr", sa.VARCHAR(length=64), autoincrement=False, - nullable=False)) + nullable=False, + ), + ) op.add_column( - 'snapshot', + "snapshot", sa.Column( - 'resource_id', - sa.INTEGER(), - autoincrement=False, - nullable=False)) + "resource_id", sa.INTEGER(), autoincrement=False, nullable=False + ), + ) op.create_index( - 'ix_snapshot_resource_id', 'snapshot', ['resource_id'], unique=False) - op.drop_index(op.f('ix_snapshot_resource_pkey'), table_name='snapshot') - op.drop_column('snapshot', 'resource_pkey') - op.drop_column('snapshot', 'resource_name') + "ix_snapshot_resource_id", "snapshot", ["resource_id"], unique=False + ) + op.drop_index(op.f("ix_snapshot_resource_pkey"), table_name="snapshot") + op.drop_column("snapshot", "resource_pkey") + op.drop_column("snapshot", "resource_name") diff --git a/server/szurubooru/migrations/versions/4c526f869323_add_description_to_tags.py b/server/szurubooru/migrations/versions/4c526f869323_add_description_to_tags.py index f53866f..443bc3c 100644 --- a/server/szurubooru/migrations/versions/4c526f869323_add_description_to_tags.py +++ b/server/szurubooru/migrations/versions/4c526f869323_add_description_to_tags.py @@ -1,23 +1,24 @@ -''' +""" Add description to tags Revision ID: 4c526f869323 Created at: 2016-06-21 17:56:34.979741 -''' +""" import sqlalchemy as sa from alembic import op -revision = '4c526f869323' -down_revision = '055d0e048fb3' +revision = "4c526f869323" +down_revision = "055d0e048fb3" branch_labels = None depends_on = None def upgrade(): op.add_column( - 'tag', sa.Column('description', sa.UnicodeText(), nullable=True)) + "tag", sa.Column("description", sa.UnicodeText(), nullable=True) + ) def downgrade(): - op.drop_column('tag', 'description') + op.drop_column("tag", "description") diff --git a/server/szurubooru/migrations/versions/52d6ea6584b8_generate_post_signature_table.py b/server/szurubooru/migrations/versions/52d6ea6584b8_generate_post_signature_table.py new file mode 100644 index 0000000..e544f80 --- /dev/null +++ b/server/szurubooru/migrations/versions/52d6ea6584b8_generate_post_signature_table.py @@ -0,0 +1,30 @@ +""" +Generate post signature table + +Revision ID: 52d6ea6584b8 +Created at: 2020-03-07 17:03:40.193512 +""" + +import sqlalchemy as sa +from alembic import op + +revision = "52d6ea6584b8" +down_revision = "3c1f0316fa7f" +branch_labels = None +depends_on = None + + +def upgrade(): + ArrayType = sa.dialects.postgresql.ARRAY(sa.Integer, dimensions=1) + op.create_table( + "post_signature", + sa.Column("post_id", sa.Integer(), nullable=False), + sa.Column("signature", sa.LargeBinary(), nullable=False), + sa.Column("words", ArrayType, nullable=False), + sa.ForeignKeyConstraint(["post_id"], ["post.id"]), + sa.PrimaryKeyConstraint("post_id"), + ) + + +def downgrade(): + op.drop_table("post_signature") diff --git a/server/szurubooru/migrations/versions/54de8acc6cef_add_default_pool_category.py b/server/szurubooru/migrations/versions/54de8acc6cef_add_default_pool_category.py new file mode 100644 index 0000000..73bf342 --- /dev/null +++ b/server/szurubooru/migrations/versions/54de8acc6cef_add_default_pool_category.py @@ -0,0 +1,60 @@ +""" +add default pool category + +Revision ID: 54de8acc6cef +Created at: 2020-05-03 14:57:46.825766 +""" + +import sqlalchemy as sa +from alembic import op + +revision = "54de8acc6cef" +down_revision = "6a2f424ec9d2" +branch_labels = None +depends_on = None + + +Base = sa.ext.declarative.declarative_base() + + +class PoolCategory(Base): + __tablename__ = "pool_category" + __table_args__ = {"extend_existing": True} + + pool_category_id = sa.Column("id", sa.Integer, primary_key=True) + version = sa.Column("version", sa.Integer, nullable=False) + name = sa.Column("name", sa.Unicode(32), nullable=False) + color = sa.Column("color", sa.Unicode(32), nullable=False) + default = sa.Column("default", sa.Boolean, nullable=False) + + __mapper_args__ = { + "version_id_col": version, + "version_id_generator": False, + } + + +def upgrade(): + session = sa.orm.session.Session(bind=op.get_bind()) + if session.query(PoolCategory).count() == 0: + category = PoolCategory() + category.name = "default" + category.color = "default" + category.version = 1 + category.default = True + session.add(category) + session.commit() + + +def downgrade(): + session = sa.orm.session.Session(bind=op.get_bind()) + default_category = ( + session.query(PoolCategory) + .filter(PoolCategory.name == "default") + .filter(PoolCategory.color == "default") + .filter(PoolCategory.version == 1) + .filter(PoolCategory.default == 1) + .one_or_none() + ) + if default_category: + session.delete(default_category) + session.commit() diff --git a/server/szurubooru/migrations/versions/565e01e3cf6d_create_snapshot_table.py b/server/szurubooru/migrations/versions/565e01e3cf6d_create_snapshot_table.py index 475fe96..a4fb89f 100644 --- a/server/szurubooru/migrations/versions/565e01e3cf6d_create_snapshot_table.py +++ b/server/szurubooru/migrations/versions/565e01e3cf6d_create_snapshot_table.py @@ -1,32 +1,33 @@ -''' +""" Create snapshot table Revision ID: 565e01e3cf6d Created at: 2016-04-19 12:07:58.372426 -''' +""" import sqlalchemy as sa from alembic import op -revision = '565e01e3cf6d' -down_revision = '336a76ec1338' +revision = "565e01e3cf6d" +down_revision = "336a76ec1338" branch_labels = None depends_on = None def upgrade(): op.create_table( - 'snapshot', - sa.Column('id', sa.Integer(), nullable=False), - sa.Column('creation_time', sa.DateTime(), nullable=False), - sa.Column('resource_type', sa.Unicode(length=32), nullable=False), - sa.Column('resource_id', sa.Integer(), nullable=False), - sa.Column('operation', sa.Unicode(length=16), nullable=False), - sa.Column('user_id', sa.Integer(), nullable=True), - sa.Column('data', sa.PickleType(), nullable=True), - sa.ForeignKeyConstraint(['user_id'], ['user.id']), - sa.PrimaryKeyConstraint('id')) + "snapshot", + sa.Column("id", sa.Integer(), nullable=False), + sa.Column("creation_time", sa.DateTime(), nullable=False), + sa.Column("resource_type", sa.Unicode(length=32), nullable=False), + sa.Column("resource_id", sa.Integer(), nullable=False), + sa.Column("operation", sa.Unicode(length=16), nullable=False), + sa.Column("user_id", sa.Integer(), nullable=True), + sa.Column("data", sa.PickleType(), nullable=True), + sa.ForeignKeyConstraint(["user_id"], ["user.id"]), + sa.PrimaryKeyConstraint("id"), + ) def downgrade(): - op.drop_table('snapshot') + op.drop_table("snapshot") diff --git a/server/szurubooru/migrations/versions/5f00af3004a4_add_default_tag_category.py b/server/szurubooru/migrations/versions/5f00af3004a4_add_default_tag_category.py index e097e67..716a04e 100644 --- a/server/szurubooru/migrations/versions/5f00af3004a4_add_default_tag_category.py +++ b/server/szurubooru/migrations/versions/5f00af3004a4_add_default_tag_category.py @@ -1,18 +1,17 @@ -''' +""" Add default tag category Revision ID: 5f00af3004a4 Created at: 2017-02-02 20:06:13.336380 -''' +""" import sqlalchemy as sa -from alembic import op import sqlalchemy.ext.declarative import sqlalchemy.orm.session +from alembic import op - -revision = '5f00af3004a4' -down_revision = '9837fc981ec7' +revision = "5f00af3004a4" +down_revision = "9837fc981ec7" branch_labels = None depends_on = None @@ -21,18 +20,18 @@ Base = sa.ext.declarative.declarative_base() class TagCategory(Base): - __tablename__ = 'tag_category' - __table_args__ = {'extend_existing': True} + __tablename__ = "tag_category" + __table_args__ = {"extend_existing": True} - tag_category_id = sa.Column('id', sa.Integer, primary_key=True) - version = sa.Column('version', sa.Integer, nullable=False) - name = sa.Column('name', sa.Unicode(32), nullable=False) - color = sa.Column('color', sa.Unicode(32), nullable=False) - default = sa.Column('default', sa.Boolean, nullable=False) + tag_category_id = sa.Column("id", sa.Integer, primary_key=True) + version = sa.Column("version", sa.Integer, nullable=False) + name = sa.Column("name", sa.Unicode(32), nullable=False) + color = sa.Column("color", sa.Unicode(32), nullable=False) + default = sa.Column("default", sa.Boolean, nullable=False) __mapper_args__ = { - 'version_id_col': version, - 'version_id_generator': False, + "version_id_col": version, + "version_id_generator": False, } @@ -40,8 +39,8 @@ def upgrade(): session = sa.orm.session.Session(bind=op.get_bind()) if session.query(TagCategory).count() == 0: category = TagCategory() - category.name = 'default' - category.color = 'default' + category.name = "default" + category.color = "default" category.version = 1 category.default = True session.add(category) @@ -51,13 +50,13 @@ def upgrade(): def downgrade(): session = sa.orm.session.Session(bind=op.get_bind()) default_category = ( - session - .query(TagCategory) - .filter(TagCategory.name == 'default') - .filter(TagCategory.color == 'default') + session.query(TagCategory) + .filter(TagCategory.name == "default") + .filter(TagCategory.color == "default") .filter(TagCategory.version == 1) .filter(TagCategory.default == 1) - .one_or_none()) + .one_or_none() + ) if default_category: session.delete(default_category) session.commit() diff --git a/server/szurubooru/migrations/versions/6a2f424ec9d2_create_pool_tables.py b/server/szurubooru/migrations/versions/6a2f424ec9d2_create_pool_tables.py new file mode 100644 index 0000000..197373d --- /dev/null +++ b/server/szurubooru/migrations/versions/6a2f424ec9d2_create_pool_tables.py @@ -0,0 +1,67 @@ +""" +create pool tables + +Revision ID: 6a2f424ec9d2 +Created at: 2020-05-03 14:47:59.136410 +""" + +import sqlalchemy as sa +from alembic import op + +revision = "6a2f424ec9d2" +down_revision = "1e280b5d5df1" +branch_labels = None +depends_on = None + + +def upgrade(): + op.create_table( + "pool_category", + sa.Column("id", sa.Integer(), nullable=False), + sa.Column("version", sa.Integer(), nullable=False, default=1), + sa.Column("name", sa.Unicode(length=32), nullable=False), + sa.Column("color", sa.Unicode(length=32), nullable=False), + sa.Column("default", sa.Boolean(), nullable=False, default=False), + sa.PrimaryKeyConstraint("id"), + ) + + op.create_table( + "pool", + sa.Column("id", sa.Integer(), nullable=False), + sa.Column("version", sa.Integer(), nullable=False, default=1), + sa.Column("description", sa.UnicodeText(), nullable=True), + sa.Column("category_id", sa.Integer(), nullable=False), + sa.Column("creation_time", sa.DateTime(), nullable=False), + sa.Column("last_edit_time", sa.DateTime(), nullable=True), + sa.ForeignKeyConstraint(["category_id"], ["pool_category.id"]), + sa.PrimaryKeyConstraint("id"), + ) + + op.create_table( + "pool_name", + sa.Column("pool_name_id", sa.Integer(), nullable=False), + sa.Column("pool_id", sa.Integer(), nullable=False), + sa.Column("name", sa.Unicode(length=256), nullable=False), + sa.Column("ord", sa.Integer(), nullable=False, index=True), + sa.ForeignKeyConstraint(["pool_id"], ["pool.id"]), + sa.PrimaryKeyConstraint("pool_name_id"), + sa.UniqueConstraint("name"), + ) + + op.create_table( + "pool_post", + sa.Column("pool_id", sa.Integer(), nullable=False), + sa.Column("post_id", sa.Integer(), nullable=False, index=True), + sa.Column("ord", sa.Integer(), nullable=False, index=True), + sa.ForeignKeyConstraint(["pool_id"], ["pool.id"], ondelete="CASCADE"), + sa.ForeignKeyConstraint(["post_id"], ["post.id"], ondelete="CASCADE"), + sa.PrimaryKeyConstraint("pool_id", "post_id"), + ) + + +def downgrade(): + op.drop_index(op.f("ix_pool_name_ord"), table_name="pool_name") + op.drop_table("pool_post") + op.drop_table("pool_name") + op.drop_table("pool") + op.drop_table("pool_category") diff --git a/server/szurubooru/migrations/versions/7f6baf38c27c_add_versions.py b/server/szurubooru/migrations/versions/7f6baf38c27c_add_versions.py index 2236026..24056c0 100644 --- a/server/szurubooru/migrations/versions/7f6baf38c27c_add_versions.py +++ b/server/szurubooru/migrations/versions/7f6baf38c27c_add_versions.py @@ -1,31 +1,30 @@ -''' +""" Add entity versions Revision ID: 7f6baf38c27c Created at: 2016-08-06 22:26:58.111763 -''' +""" import sqlalchemy as sa from alembic import op -revision = '7f6baf38c27c' -down_revision = '4c526f869323' +revision = "7f6baf38c27c" +down_revision = "4c526f869323" branch_labels = None depends_on = None -tables = ['tag_category', 'tag', 'user', 'post', 'comment'] +tables = ["tag_category", "tag", "user", "post", "comment"] def upgrade(): for table in tables: - op.add_column(table, sa.Column('version', sa.Integer(), nullable=True)) + op.add_column(table, sa.Column("version", sa.Integer(), nullable=True)) op.execute( - sa.table(table, sa.column('version')) - .update() - .values(version=1)) - op.alter_column(table, 'version', nullable=False) + sa.table(table, sa.column("version")).update().values(version=1) + ) + op.alter_column(table, "version", nullable=False) def downgrade(): for table in tables: - op.drop_column(table, 'version') + op.drop_column(table, "version") diff --git a/server/szurubooru/migrations/versions/840b460c5613_fix_foreignkeys.py b/server/szurubooru/migrations/versions/840b460c5613_fix_foreignkeys.py index 8821dd8..b2b5df5 100644 --- a/server/szurubooru/migrations/versions/840b460c5613_fix_foreignkeys.py +++ b/server/szurubooru/migrations/versions/840b460c5613_fix_foreignkeys.py @@ -1,33 +1,36 @@ -''' +""" Fix ForeignKey constraint definitions Revision ID: 840b460c5613 Created at: 2016-08-15 18:39:30.909867 -''' +""" import sqlalchemy as sa from alembic import op - -revision = '840b460c5613' -down_revision = '7f6baf38c27c' +revision = "840b460c5613" +down_revision = "7f6baf38c27c" branch_labels = None depends_on = None def upgrade(): - op.drop_constraint('post_user_id_fkey', 'post', type_='foreignkey') - op.drop_constraint('snapshot_user_id_fkey', 'snapshot', type_='foreignkey') + op.drop_constraint("post_user_id_fkey", "post", type_="foreignkey") + op.drop_constraint("snapshot_user_id_fkey", "snapshot", type_="foreignkey") op.create_foreign_key( - None, 'post', 'user', ['user_id'], ['id'], ondelete='SET NULL') + None, "post", "user", ["user_id"], ["id"], ondelete="SET NULL" + ) op.create_foreign_key( - None, 'snapshot', 'user', ['user_id'], ['id'], ondelete='set null') + None, "snapshot", "user", ["user_id"], ["id"], ondelete="set null" + ) def downgrade(): - op.drop_constraint(None, 'snapshot', type_='foreignkey') - op.drop_constraint(None, 'post', type_='foreignkey') + op.drop_constraint(None, "snapshot", type_="foreignkey") + op.drop_constraint(None, "post", type_="foreignkey") op.create_foreign_key( - 'snapshot_user_id_fkey', 'snapshot', 'user', ['user_id'], ['id']) + "snapshot_user_id_fkey", "snapshot", "user", ["user_id"], ["id"] + ) op.create_foreign_key( - 'post_user_id_fkey', 'post', 'user', ['user_id'], ['id']) + "post_user_id_fkey", "post", "user", ["user_id"], ["id"] + ) diff --git a/server/szurubooru/migrations/versions/84bd402f15f0_change_flags_column_type.py b/server/szurubooru/migrations/versions/84bd402f15f0_change_flags_column_type.py index 7236641..d495b82 100644 --- a/server/szurubooru/migrations/versions/84bd402f15f0_change_flags_column_type.py +++ b/server/szurubooru/migrations/versions/84bd402f15f0_change_flags_column_type.py @@ -1,26 +1,27 @@ -''' +""" Change flags column type Revision ID: 84bd402f15f0 Created at: 2016-04-22 20:48:32.386159 -''' +""" import sqlalchemy as sa from alembic import op -revision = '84bd402f15f0' -down_revision = '9587de88a84b' +revision = "84bd402f15f0" +down_revision = "9587de88a84b" branch_labels = None depends_on = None def upgrade(): - op.drop_column('post', 'flags') - op.add_column('post', sa.Column('flags', sa.PickleType(), nullable=True)) + op.drop_column("post", "flags") + op.add_column("post", sa.Column("flags", sa.PickleType(), nullable=True)) def downgrade(): - op.drop_column('post', 'flags') + op.drop_column("post", "flags") op.add_column( - 'post', - sa.Column('flags', sa.Integer(), autoincrement=False, nullable=False)) + "post", + sa.Column("flags", sa.Integer(), autoincrement=False, nullable=False), + ) diff --git a/server/szurubooru/migrations/versions/9587de88a84b_create_aux_post_tables.py b/server/szurubooru/migrations/versions/9587de88a84b_create_aux_post_tables.py index eddec24..46647cf 100644 --- a/server/szurubooru/migrations/versions/9587de88a84b_create_aux_post_tables.py +++ b/server/szurubooru/migrations/versions/9587de88a84b_create_aux_post_tables.py @@ -1,61 +1,65 @@ -''' +""" Create auxilliary post tables Revision ID: 9587de88a84b Created at: 2016-04-22 17:42:57.697229 -''' +""" import sqlalchemy as sa from alembic import op -revision = '9587de88a84b' -down_revision = '46cd5229839b' +revision = "9587de88a84b" +down_revision = "46cd5229839b" branch_labels = None depends_on = None def upgrade(): op.create_table( - 'post_favorite', - sa.Column('post_id', sa.Integer(), nullable=False), - sa.Column('user_id', sa.Integer(), nullable=False), - sa.Column('time', sa.DateTime(), nullable=False), - sa.ForeignKeyConstraint(['post_id'], ['post.id']), - sa.ForeignKeyConstraint(['user_id'], ['user.id']), - sa.PrimaryKeyConstraint('post_id', 'user_id')) + "post_favorite", + sa.Column("post_id", sa.Integer(), nullable=False), + sa.Column("user_id", sa.Integer(), nullable=False), + sa.Column("time", sa.DateTime(), nullable=False), + sa.ForeignKeyConstraint(["post_id"], ["post.id"]), + sa.ForeignKeyConstraint(["user_id"], ["user.id"]), + sa.PrimaryKeyConstraint("post_id", "user_id"), + ) op.create_table( - 'post_feature', - sa.Column('id', sa.Integer(), nullable=False), - sa.Column('post_id', sa.Integer(), nullable=False), - sa.Column('user_id', sa.Integer(), nullable=False), - sa.Column('time', sa.DateTime(), nullable=False), - sa.ForeignKeyConstraint(['post_id'], ['post.id']), - sa.ForeignKeyConstraint(['user_id'], ['user.id']), - sa.PrimaryKeyConstraint('id')) + "post_feature", + sa.Column("id", sa.Integer(), nullable=False), + sa.Column("post_id", sa.Integer(), nullable=False), + sa.Column("user_id", sa.Integer(), nullable=False), + sa.Column("time", sa.DateTime(), nullable=False), + sa.ForeignKeyConstraint(["post_id"], ["post.id"]), + sa.ForeignKeyConstraint(["user_id"], ["user.id"]), + sa.PrimaryKeyConstraint("id"), + ) op.create_table( - 'post_note', - sa.Column('id', sa.Integer(), nullable=False), - sa.Column('post_id', sa.Integer(), nullable=False), - sa.Column('text', sa.UnicodeText(), nullable=False), - sa.Column('polygon', sa.PickleType(), nullable=False), - sa.ForeignKeyConstraint(['post_id'], ['post.id']), - sa.PrimaryKeyConstraint('id')) + "post_note", + sa.Column("id", sa.Integer(), nullable=False), + sa.Column("post_id", sa.Integer(), nullable=False), + sa.Column("text", sa.UnicodeText(), nullable=False), + sa.Column("polygon", sa.PickleType(), nullable=False), + sa.ForeignKeyConstraint(["post_id"], ["post.id"]), + sa.PrimaryKeyConstraint("id"), + ) op.create_table( - 'post_score', - sa.Column('post_id', sa.Integer(), nullable=False), - sa.Column('user_id', sa.Integer(), nullable=False), - sa.Column('time', sa.DateTime(), nullable=False), - sa.Column('score', sa.Integer(), nullable=False), - sa.ForeignKeyConstraint(['post_id'], ['post.id']), - sa.ForeignKeyConstraint(['user_id'], ['user.id']), - sa.PrimaryKeyConstraint('post_id', 'user_id')) + "post_score", + sa.Column("post_id", sa.Integer(), nullable=False), + sa.Column("user_id", sa.Integer(), nullable=False), + sa.Column("time", sa.DateTime(), nullable=False), + sa.Column("score", sa.Integer(), nullable=False), + sa.ForeignKeyConstraint(["post_id"], ["post.id"]), + sa.ForeignKeyConstraint(["user_id"], ["user.id"]), + sa.PrimaryKeyConstraint("post_id", "user_id"), + ) def downgrade(): - op.drop_table('post_score') - op.drop_table('post_note') - op.drop_table('post_feature') - op.drop_table('post_favorite') + op.drop_table("post_score") + op.drop_table("post_note") + op.drop_table("post_feature") + op.drop_table("post_favorite") diff --git a/server/szurubooru/migrations/versions/9837fc981ec7_add_order_to_tag_names.py b/server/szurubooru/migrations/versions/9837fc981ec7_add_order_to_tag_names.py index 39f9edf..d1e74e9 100644 --- a/server/szurubooru/migrations/versions/9837fc981ec7_add_order_to_tag_names.py +++ b/server/szurubooru/migrations/versions/9837fc981ec7_add_order_to_tag_names.py @@ -1,17 +1,16 @@ -''' +""" Add order to tag names Revision ID: 9837fc981ec7 Created at: 2016-08-28 19:03:59.831527 -''' +""" import sqlalchemy as sa -from alembic import op import sqlalchemy.ext.declarative +from alembic import op - -revision = '9837fc981ec7' -down_revision = '4a020f1d271a' +revision = "9837fc981ec7" +down_revision = "4a020f1d271a" branch_labels = None depends_on = None @@ -20,21 +19,20 @@ Base = sa.ext.declarative.declarative_base() class TagName(Base): - __tablename__ = 'tag_name' - __table_args__ = {'extend_existing': True} + __tablename__ = "tag_name" + __table_args__ = {"extend_existing": True} - tag_name_id = sa.Column('tag_name_id', sa.Integer, primary_key=True) - ord = sa.Column('ord', sa.Integer, nullable=False, index=True) + tag_name_id = sa.Column("tag_name_id", sa.Integer, primary_key=True) + ord = sa.Column("ord", sa.Integer, nullable=False, index=True) def upgrade(): - op.add_column('tag_name', sa.Column('ord', sa.Integer(), nullable=True)) + op.add_column("tag_name", sa.Column("ord", sa.Integer(), nullable=True)) op.execute(TagName.__table__.update().values(ord=TagName.tag_name_id)) - op.alter_column('tag_name', 'ord', nullable=False) - op.create_index( - op.f('ix_tag_name_ord'), 'tag_name', ['ord'], unique=False) + op.alter_column("tag_name", "ord", nullable=False) + op.create_index(op.f("ix_tag_name_ord"), "tag_name", ["ord"], unique=False) def downgrade(): - op.drop_index(op.f('ix_tag_name_ord'), table_name='tag_name') - op.drop_column('tag_name', 'ord') + op.drop_index(op.f("ix_tag_name_ord"), table_name="tag_name") + op.drop_column("tag_name", "ord") diff --git a/server/szurubooru/migrations/versions/9ef1a1643c2a_update_user_table_for_hardened_passwords.py b/server/szurubooru/migrations/versions/9ef1a1643c2a_update_user_table_for_hardened_passwords.py index 3805772..b8763a7 100644 --- a/server/szurubooru/migrations/versions/9ef1a1643c2a_update_user_table_for_hardened_passwords.py +++ b/server/szurubooru/migrations/versions/9ef1a1643c2a_update_user_table_for_hardened_passwords.py @@ -1,19 +1,18 @@ -''' +""" Alter the password_hash field to work with larger output. Particularly libsodium output for greater password security. Revision ID: 9ef1a1643c2a Created at: 2018-02-24 23:00:32.848575 -''' +""" import sqlalchemy as sa import sqlalchemy.ext.declarative import sqlalchemy.orm.session from alembic import op - -revision = '9ef1a1643c2a' -down_revision = '02ef5f73f4ab' +revision = "9ef1a1643c2a" +down_revision = "02ef5f73f4ab" branch_labels = None depends_on = None @@ -21,43 +20,46 @@ Base = sa.ext.declarative.declarative_base() class User(Base): - __tablename__ = 'user' + __tablename__ = "user" - AVATAR_GRAVATAR = 'gravatar' + AVATAR_GRAVATAR = "gravatar" - user_id = sa.Column('id', sa.Integer, primary_key=True) - creation_time = sa.Column('creation_time', sa.DateTime, nullable=False) - last_login_time = sa.Column('last_login_time', sa.DateTime) - version = sa.Column('version', sa.Integer, default=1, nullable=False) - name = sa.Column('name', sa.Unicode(50), nullable=False, unique=True) - password_hash = sa.Column('password_hash', sa.Unicode(128), nullable=False) - password_salt = sa.Column('password_salt', sa.Unicode(32)) + user_id = sa.Column("id", sa.Integer, primary_key=True) + creation_time = sa.Column("creation_time", sa.DateTime, nullable=False) + last_login_time = sa.Column("last_login_time", sa.DateTime) + version = sa.Column("version", sa.Integer, default=1, nullable=False) + name = sa.Column("name", sa.Unicode(50), nullable=False, unique=True) + password_hash = sa.Column("password_hash", sa.Unicode(128), nullable=False) + password_salt = sa.Column("password_salt", sa.Unicode(32)) password_revision = sa.Column( - 'password_revision', sa.SmallInteger, default=0, nullable=False) - email = sa.Column('email', sa.Unicode(64), nullable=True) - rank = sa.Column('rank', sa.Unicode(32), nullable=False) + "password_revision", sa.SmallInteger, default=0, nullable=False + ) + email = sa.Column("email", sa.Unicode(64), nullable=True) + rank = sa.Column("rank", sa.Unicode(32), nullable=False) avatar_style = sa.Column( - 'avatar_style', sa.Unicode(32), nullable=False, - default=AVATAR_GRAVATAR) + "avatar_style", sa.Unicode(32), nullable=False, default=AVATAR_GRAVATAR + ) __mapper_args__ = { - 'version_id_col': version, - 'version_id_generator': False, + "version_id_col": version, + "version_id_generator": False, } def upgrade(): op.alter_column( - 'user', - 'password_hash', + "user", + "password_hash", existing_type=sa.VARCHAR(length=64), type_=sa.Unicode(length=128), - existing_nullable=False) - op.add_column('user', sa.Column( - 'password_revision', - sa.SmallInteger(), - nullable=True, - default=0)) + existing_nullable=False, + ) + op.add_column( + "user", + sa.Column( + "password_revision", sa.SmallInteger(), nullable=True, default=0 + ), + ) session = sa.orm.session.Session(bind=op.get_bind()) if session.query(User).count() >= 0: @@ -73,17 +75,16 @@ def upgrade(): session.commit() op.alter_column( - 'user', - 'password_revision', - existing_nullable=True, - nullable=False) + "user", "password_revision", existing_nullable=True, nullable=False + ) def downgrade(): op.alter_column( - 'user', - 'password_hash', + "user", + "password_hash", existing_type=sa.Unicode(length=128), type_=sa.VARCHAR(length=64), - existing_nullable=False) - op.drop_column('user', 'password_revision') + existing_nullable=False, + ) + op.drop_column("user", "password_revision") diff --git a/server/szurubooru/migrations/versions/a39c7f98a7fa_add_user_token_table.py b/server/szurubooru/migrations/versions/a39c7f98a7fa_add_user_token_table.py index 899eaa7..57dda5c 100644 --- a/server/szurubooru/migrations/versions/a39c7f98a7fa_add_user_token_table.py +++ b/server/szurubooru/migrations/versions/a39c7f98a7fa_add_user_token_table.py @@ -1,39 +1,40 @@ -''' +""" Added a user_token table for API authorization Revision ID: a39c7f98a7fa Created at: 2018-02-25 01:31:27.345595 -''' +""" import sqlalchemy as sa from alembic import op - -revision = 'a39c7f98a7fa' -down_revision = '9ef1a1643c2a' +revision = "a39c7f98a7fa" +down_revision = "9ef1a1643c2a" branch_labels = None depends_on = None def upgrade(): op.create_table( - 'user_token', - sa.Column('id', sa.Integer(), nullable=False), - sa.Column('user_id', sa.Integer(), nullable=False), - sa.Column('token', sa.Unicode(length=36), nullable=False), - sa.Column('note', sa.Unicode(length=128), nullable=True), - sa.Column('enabled', sa.Boolean(), nullable=False), - sa.Column('expiration_time', sa.DateTime(), nullable=True), - sa.Column('creation_time', sa.DateTime(), nullable=False), - sa.Column('last_edit_time', sa.DateTime(), nullable=True), - sa.Column('last_usage_time', sa.DateTime(), nullable=True), - sa.Column('version', sa.Integer(), nullable=False), - sa.ForeignKeyConstraint(['user_id'], ['user.id'], ondelete='CASCADE'), - sa.PrimaryKeyConstraint('id')) + "user_token", + sa.Column("id", sa.Integer(), nullable=False), + sa.Column("user_id", sa.Integer(), nullable=False), + sa.Column("token", sa.Unicode(length=36), nullable=False), + sa.Column("note", sa.Unicode(length=128), nullable=True), + sa.Column("enabled", sa.Boolean(), nullable=False), + sa.Column("expiration_time", sa.DateTime(), nullable=True), + sa.Column("creation_time", sa.DateTime(), nullable=False), + sa.Column("last_edit_time", sa.DateTime(), nullable=True), + sa.Column("last_usage_time", sa.DateTime(), nullable=True), + sa.Column("version", sa.Integer(), nullable=False), + sa.ForeignKeyConstraint(["user_id"], ["user.id"], ondelete="CASCADE"), + sa.PrimaryKeyConstraint("id"), + ) op.create_index( - op.f('ix_user_token_user_id'), 'user_token', ['user_id'], unique=False) + op.f("ix_user_token_user_id"), "user_token", ["user_id"], unique=False + ) def downgrade(): - op.drop_index(op.f('ix_user_token_user_id'), table_name='user_token') - op.drop_table('user_token') + op.drop_index(op.f("ix_user_token_user_id"), table_name="user_token") + op.drop_table("user_token") diff --git a/server/szurubooru/migrations/versions/c867abb456b1_support_large_file_uploads.py b/server/szurubooru/migrations/versions/c867abb456b1_support_large_file_uploads.py new file mode 100644 index 0000000..212e196 --- /dev/null +++ b/server/szurubooru/migrations/versions/c867abb456b1_support_large_file_uploads.py @@ -0,0 +1,26 @@ +""" +support large file uploads + +Revision ID: c867abb456b1 +Created at: 2020-10-11 15:37:30.965231 +""" + +import sqlalchemy as sa +from alembic import op + +revision = "c867abb456b1" +down_revision = "c97dc1bf184a" +branch_labels = None +depends_on = None + + +def upgrade(): + op.alter_column( + "post", "file_size", type_=sa.BigInteger, existing_type=sa.Integer + ) + + +def downgrade(): + op.alter_column( + "post", "file_size", type_=sa.Integer, existing_type=sa.BigInteger + ) diff --git a/server/szurubooru/migrations/versions/c97dc1bf184a_add_order_column_to_tag_categories.py b/server/szurubooru/migrations/versions/c97dc1bf184a_add_order_column_to_tag_categories.py new file mode 100644 index 0000000..c5a3124 --- /dev/null +++ b/server/szurubooru/migrations/versions/c97dc1bf184a_add_order_column_to_tag_categories.py @@ -0,0 +1,28 @@ +""" +Add order column to tag categories. + +Revision ID: c97dc1bf184a +Created at: 2020-09-19 17:08:03.225667 +""" + +import sqlalchemy as sa +from alembic import op + +revision = "c97dc1bf184a" +down_revision = "54de8acc6cef" +branch_labels = None +depends_on = None + + +def upgrade(): + op.add_column( + "tag_category", sa.Column("order", sa.Integer, nullable=True) + ) + op.execute( + sa.table("tag_category", sa.column("order")).update().values(order=1) + ) + op.alter_column("tag_category", "order", nullable=False) + + +def downgrade(): + op.drop_column("tag_category", "order") diff --git a/server/szurubooru/migrations/versions/e5c1216a8503_create_user_table.py b/server/szurubooru/migrations/versions/e5c1216a8503_create_user_table.py index a84e31b..672d4eb 100644 --- a/server/szurubooru/migrations/versions/e5c1216a8503_create_user_table.py +++ b/server/szurubooru/migrations/versions/e5c1216a8503_create_user_table.py @@ -1,14 +1,14 @@ -''' +""" Create user table Revision ID: e5c1216a8503 Created at: 2016-03-20 15:53:25.030415 -''' +""" import sqlalchemy as sa from alembic import op -revision = 'e5c1216a8503' +revision = "e5c1216a8503" down_revision = None branch_labels = None depends_on = None @@ -16,19 +16,20 @@ depends_on = None def upgrade(): op.create_table( - 'user', - sa.Column('id', sa.Integer(), nullable=False), - sa.Column('name', sa.Unicode(length=50), nullable=False), - sa.Column('password_hash', sa.Unicode(length=64), nullable=False), - sa.Column('password_salt', sa.Unicode(length=32), nullable=True), - sa.Column('email', sa.Unicode(length=64), nullable=True), - sa.Column('rank', sa.Unicode(length=32), nullable=False), - sa.Column('creation_time', sa.DateTime(), nullable=False), - sa.Column('last_login_time', sa.DateTime()), - sa.Column('avatar_style', sa.Unicode(length=32), nullable=False), - sa.PrimaryKeyConstraint('id')) - op.create_unique_constraint('uq_user_name', 'user', ['name']) + "user", + sa.Column("id", sa.Integer(), nullable=False), + sa.Column("name", sa.Unicode(length=50), nullable=False), + sa.Column("password_hash", sa.Unicode(length=64), nullable=False), + sa.Column("password_salt", sa.Unicode(length=32), nullable=True), + sa.Column("email", sa.Unicode(length=64), nullable=True), + sa.Column("rank", sa.Unicode(length=32), nullable=False), + sa.Column("creation_time", sa.DateTime(), nullable=False), + sa.Column("last_login_time", sa.DateTime()), + sa.Column("avatar_style", sa.Unicode(length=32), nullable=False), + sa.PrimaryKeyConstraint("id"), + ) + op.create_unique_constraint("uq_user_name", "user", ["name"]) def downgrade(): - op.drop_table('user') + op.drop_table("user") diff --git a/server/szurubooru/migrations/versions/ed6dd16a30f3_delete_post_columns.py b/server/szurubooru/migrations/versions/ed6dd16a30f3_delete_post_columns.py index dffc6c0..3b36c6a 100644 --- a/server/szurubooru/migrations/versions/ed6dd16a30f3_delete_post_columns.py +++ b/server/szurubooru/migrations/versions/ed6dd16a30f3_delete_post_columns.py @@ -1,48 +1,49 @@ -''' +""" Delete post columns Revision ID: ed6dd16a30f3 Created at: 2016-04-24 16:29:25.309154 -''' +""" import sqlalchemy as sa from alembic import op -revision = 'ed6dd16a30f3' -down_revision = '46df355634dc' +revision = "ed6dd16a30f3" +down_revision = "46df355634dc" branch_labels = None depends_on = None def upgrade(): for column_name in [ - 'auto_comment_edit_time', - 'auto_fav_count', - 'auto_comment_creation_time', - 'auto_feature_count', - 'auto_comment_count', - 'auto_score', - 'auto_fav_time', - 'auto_feature_time', - 'auto_note_count']: - op.drop_column('post', column_name) + "auto_comment_edit_time", + "auto_fav_count", + "auto_comment_creation_time", + "auto_feature_count", + "auto_comment_count", + "auto_score", + "auto_fav_time", + "auto_feature_time", + "auto_note_count", + ]: + op.drop_column("post", column_name) def downgrade(): for column_name in [ - 'auto_note_count', - 'auto_feature_time', - 'auto_fav_time', - 'auto_score', - 'auto_comment_count', - 'auto_feature_count', - 'auto_comment_creation_time', - 'auto_fav_count', - 'auto_comment_edit_time']: + "auto_note_count", + "auto_feature_time", + "auto_fav_time", + "auto_score", + "auto_comment_count", + "auto_feature_count", + "auto_comment_creation_time", + "auto_fav_count", + "auto_comment_edit_time", + ]: op.add_column( - 'post', + "post", sa.Column( - column_name, - sa.INTEGER(), - autoincrement=False, - nullable=False)) + column_name, sa.INTEGER(), autoincrement=False, nullable=False + ), + ) diff --git a/server/szurubooru/model/__init__.py b/server/szurubooru/model/__init__.py index 202e434..2befe74 100644 --- a/server/szurubooru/model/__init__.py +++ b/server/szurubooru/model/__init__.py @@ -1,16 +1,20 @@ +import szurubooru.model.util from szurubooru.model.base import Base -from szurubooru.model.user import User, UserToken -from szurubooru.model.tag_category import TagCategory -from szurubooru.model.tag import Tag, TagName, TagSuggestion, TagImplication +from szurubooru.model.comment import Comment, CommentScore from szurubooru.model.metric import Metric, PostMetric, PostMetricRange +from szurubooru.model.pool import Pool, PoolName, PoolPost +from szurubooru.model.pool_category import PoolCategory from szurubooru.model.post import ( Post, - PostTag, - PostRelation, PostFavorite, - PostScore, + PostFeature, PostNote, - PostFeature) -from szurubooru.model.comment import Comment, CommentScore + PostRelation, + PostScore, + PostSignature, + PostTag, +) from szurubooru.model.snapshot import Snapshot -import szurubooru.model.util +from szurubooru.model.tag import Tag, TagImplication, TagName, TagSuggestion +from szurubooru.model.tag_category import TagCategory +from szurubooru.model.user import User, UserToken diff --git a/server/szurubooru/model/base.py b/server/szurubooru/model/base.py index e61d35a..860e542 100644 --- a/server/szurubooru/model/base.py +++ b/server/szurubooru/model/base.py @@ -1,4 +1,3 @@ from sqlalchemy.ext.declarative import declarative_base - -Base = declarative_base() # pylint: disable=invalid-name +Base = declarative_base() diff --git a/server/szurubooru/model/comment.py b/server/szurubooru/model/comment.py index 17b76a0..e64961e 100644 --- a/server/szurubooru/model/comment.py +++ b/server/szurubooru/model/comment.py @@ -1,58 +1,65 @@ import sqlalchemy as sa + from szurubooru.db import get_session from szurubooru.model.base import Base class CommentScore(Base): - __tablename__ = 'comment_score' + __tablename__ = "comment_score" comment_id = sa.Column( - 'comment_id', + "comment_id", sa.Integer, - sa.ForeignKey('comment.id'), + sa.ForeignKey("comment.id"), nullable=False, - primary_key=True) + primary_key=True, + ) user_id = sa.Column( - 'user_id', + "user_id", sa.Integer, - sa.ForeignKey('user.id'), + sa.ForeignKey("user.id"), nullable=False, primary_key=True, - index=True) - time = sa.Column('time', sa.DateTime, nullable=False) - score = sa.Column('score', sa.Integer, nullable=False) + index=True, + ) + time = sa.Column("time", sa.DateTime, nullable=False) + score = sa.Column("score", sa.Integer, nullable=False) - comment = sa.orm.relationship('Comment') + comment = sa.orm.relationship("Comment") user = sa.orm.relationship( - 'User', - backref=sa.orm.backref('comment_scores', cascade='all, delete-orphan')) + "User", + backref=sa.orm.backref("comment_scores", cascade="all, delete-orphan"), + ) class Comment(Base): - __tablename__ = 'comment' + __tablename__ = "comment" - comment_id = sa.Column('id', sa.Integer, primary_key=True) + comment_id = sa.Column("id", sa.Integer, primary_key=True) post_id = sa.Column( - 'post_id', + "post_id", sa.Integer, - sa.ForeignKey('post.id'), + sa.ForeignKey("post.id"), nullable=False, - index=True) + index=True, + ) user_id = sa.Column( - 'user_id', + "user_id", sa.Integer, - sa.ForeignKey('user.id'), + sa.ForeignKey("user.id"), nullable=True, - index=True) - version = sa.Column('version', sa.Integer, default=1, nullable=False) - creation_time = sa.Column('creation_time', sa.DateTime, nullable=False) - last_edit_time = sa.Column('last_edit_time', sa.DateTime) - text = sa.Column('text', sa.UnicodeText, default=None) + index=True, + ) + version = sa.Column("version", sa.Integer, default=1, nullable=False) + creation_time = sa.Column("creation_time", sa.DateTime, nullable=False) + last_edit_time = sa.Column("last_edit_time", sa.DateTime) + text = sa.Column("text", sa.UnicodeText, default=None) - user = sa.orm.relationship('User') - post = sa.orm.relationship('Post') + user = sa.orm.relationship("User") + post = sa.orm.relationship("Post") scores = sa.orm.relationship( - 'CommentScore', cascade='all, delete-orphan', lazy='joined') + "CommentScore", cascade="all, delete-orphan", lazy="joined" + ) @property def score(self) -> int: @@ -60,9 +67,11 @@ class Comment(Base): get_session() .query(sa.sql.expression.func.sum(CommentScore.score)) .filter(CommentScore.comment_id == self.comment_id) - .one()[0] or 0) + .one()[0] + or 0 + ) __mapper_args__ = { - 'version_id_col': version, - 'version_id_generator': False, + "version_id_col": version, + "version_id_generator": False, } diff --git a/server/szurubooru/model/pool.py b/server/szurubooru/model/pool.py new file mode 100644 index 0000000..3dcdd35 --- /dev/null +++ b/server/szurubooru/model/pool.py @@ -0,0 +1,113 @@ +import sqlalchemy as sa +from sqlalchemy.ext.associationproxy import association_proxy +from sqlalchemy.ext.orderinglist import ordering_list + +from szurubooru.model.base import Base + + +class PoolName(Base): + __tablename__ = "pool_name" + + pool_name_id = sa.Column("pool_name_id", sa.Integer, primary_key=True) + pool_id = sa.Column( + "pool_id", + sa.Integer, + sa.ForeignKey("pool.id"), + nullable=False, + index=True, + ) + name = sa.Column("name", sa.Unicode(128), nullable=False, unique=True) + order = sa.Column("ord", sa.Integer, nullable=False, index=True) + + def __init__(self, name: str, order: int) -> None: + self.name = name + self.order = order + + +class PoolPost(Base): + __tablename__ = "pool_post" + + pool_id = sa.Column( + "pool_id", + sa.Integer, + sa.ForeignKey("pool.id"), + nullable=False, + primary_key=True, + index=True, + ) + post_id = sa.Column( + "post_id", + sa.Integer, + sa.ForeignKey("post.id"), + nullable=False, + primary_key=True, + index=True, + ) + order = sa.Column("ord", sa.Integer, nullable=False, index=True) + + pool = sa.orm.relationship("Pool", back_populates="_posts") + post = sa.orm.relationship("Post", back_populates="_pools") + + def __init__(self, post) -> None: + self.post_id = post.post_id + + +class Pool(Base): + __tablename__ = "pool" + + pool_id = sa.Column("id", sa.Integer, primary_key=True) + category_id = sa.Column( + "category_id", + sa.Integer, + sa.ForeignKey("pool_category.id"), + nullable=False, + index=True, + ) + version = sa.Column("version", sa.Integer, default=1, nullable=False) + creation_time = sa.Column("creation_time", sa.DateTime, nullable=False) + last_edit_time = sa.Column("last_edit_time", sa.DateTime) + description = sa.Column("description", sa.UnicodeText, default=None) + + category = sa.orm.relationship("PoolCategory", lazy="joined") + names = sa.orm.relationship( + "PoolName", + cascade="all,delete-orphan", + lazy="joined", + order_by="PoolName.order", + ) + _posts = sa.orm.relationship( + "PoolPost", + cascade="all,delete-orphan", + lazy="joined", + back_populates="pool", + order_by="PoolPost.order", + collection_class=ordering_list("order"), + ) + posts = association_proxy("_posts", "post") + + post_count = sa.orm.column_property( + ( + sa.sql.expression.select( + [sa.sql.expression.func.count(PoolPost.post_id)] + ) + .where(PoolPost.pool_id == pool_id) + .as_scalar() + ), + deferred=True, + ) + + first_name = sa.orm.column_property( + ( + sa.sql.expression.select([PoolName.name]) + .where(PoolName.pool_id == pool_id) + .order_by(PoolName.order) + .limit(1) + .as_scalar() + ), + deferred=True, + ) + + __mapper_args__ = { + "version_id_col": version, + "version_id_generator": False, + } diff --git a/server/szurubooru/model/pool_category.py b/server/szurubooru/model/pool_category.py new file mode 100644 index 0000000..f527d2d --- /dev/null +++ b/server/szurubooru/model/pool_category.py @@ -0,0 +1,34 @@ +from typing import Optional + +import sqlalchemy as sa + +from szurubooru.model.base import Base +from szurubooru.model.pool import Pool + + +class PoolCategory(Base): + __tablename__ = "pool_category" + + pool_category_id = sa.Column("id", sa.Integer, primary_key=True) + version = sa.Column("version", sa.Integer, default=1, nullable=False) + name = sa.Column("name", sa.Unicode(32), nullable=False) + color = sa.Column( + "color", sa.Unicode(32), nullable=False, default="#000000" + ) + default = sa.Column("default", sa.Boolean, nullable=False, default=False) + + def __init__(self, name: Optional[str] = None) -> None: + self.name = name + + pool_count = sa.orm.column_property( + sa.sql.expression.select( + [sa.sql.expression.func.count("Pool.pool_id")] + ) + .where(Pool.category_id == pool_category_id) + .correlate_except(sa.table("Pool")) + ) + + __mapper_args__ = { + "version_id_col": version, + "version_id_generator": False, + } diff --git a/server/szurubooru/model/post.py b/server/szurubooru/model/post.py index 987f720..cf68860 100644 --- a/server/szurubooru/model/post.py +++ b/server/szurubooru/model/post.py @@ -1,119 +1,135 @@ from typing import List + import sqlalchemy as sa +from sqlalchemy.ext.associationproxy import association_proxy +from sqlalchemy.ext.hybrid import hybrid_property +from sqlalchemy.ext.orderinglist import ordering_list + from szurubooru.model.base import Base from szurubooru.model.comment import Comment -from sqlalchemy.ext.hybrid import hybrid_property +from szurubooru.model.pool import PoolPost class PostFeature(Base): - __tablename__ = 'post_feature' + __tablename__ = "post_feature" - post_feature_id = sa.Column('id', sa.Integer, primary_key=True) + post_feature_id = sa.Column("id", sa.Integer, primary_key=True) post_id = sa.Column( - 'post_id', + "post_id", sa.Integer, - sa.ForeignKey('post.id'), + sa.ForeignKey("post.id"), nullable=False, - index=True) + index=True, + ) user_id = sa.Column( - 'user_id', + "user_id", sa.Integer, - sa.ForeignKey('user.id'), + sa.ForeignKey("user.id"), nullable=False, - index=True) - time = sa.Column('time', sa.DateTime, nullable=False) + index=True, + ) + time = sa.Column("time", sa.DateTime, nullable=False) - post = sa.orm.relationship('Post') # type: Post + post = sa.orm.relationship("Post") # type: Post user = sa.orm.relationship( - 'User', - backref=sa.orm.backref( - 'post_features', cascade='all, delete-orphan')) + "User", + backref=sa.orm.backref("post_features", cascade="all, delete-orphan"), + ) class PostScore(Base): - __tablename__ = 'post_score' + __tablename__ = "post_score" post_id = sa.Column( - 'post_id', + "post_id", sa.Integer, - sa.ForeignKey('post.id'), + sa.ForeignKey("post.id"), primary_key=True, nullable=False, - index=True) + index=True, + ) user_id = sa.Column( - 'user_id', + "user_id", sa.Integer, - sa.ForeignKey('user.id'), + sa.ForeignKey("user.id"), primary_key=True, nullable=False, - index=True) - time = sa.Column('time', sa.DateTime, nullable=False) - score = sa.Column('score', sa.Integer, nullable=False) + index=True, + ) + time = sa.Column("time", sa.DateTime, nullable=False) + score = sa.Column("score", sa.Integer, nullable=False) - post = sa.orm.relationship('Post') + post = sa.orm.relationship("Post") user = sa.orm.relationship( - 'User', - backref=sa.orm.backref('post_scores', cascade='all, delete-orphan')) + "User", + backref=sa.orm.backref("post_scores", cascade="all, delete-orphan"), + ) class PostFavorite(Base): - __tablename__ = 'post_favorite' + __tablename__ = "post_favorite" post_id = sa.Column( - 'post_id', + "post_id", sa.Integer, - sa.ForeignKey('post.id'), + sa.ForeignKey("post.id"), primary_key=True, nullable=False, - index=True) + index=True, + ) user_id = sa.Column( - 'user_id', + "user_id", sa.Integer, - sa.ForeignKey('user.id'), + sa.ForeignKey("user.id"), primary_key=True, nullable=False, - index=True) - time = sa.Column('time', sa.DateTime, nullable=False) + index=True, + ) + time = sa.Column("time", sa.DateTime, nullable=False) - post = sa.orm.relationship('Post') + post = sa.orm.relationship("Post") user = sa.orm.relationship( - 'User', - backref=sa.orm.backref('post_favorites', cascade='all, delete-orphan')) + "User", + backref=sa.orm.backref("post_favorites", cascade="all, delete-orphan"), + ) class PostNote(Base): - __tablename__ = 'post_note' + __tablename__ = "post_note" - post_note_id = sa.Column('id', sa.Integer, primary_key=True) + post_note_id = sa.Column("id", sa.Integer, primary_key=True) post_id = sa.Column( - 'post_id', + "post_id", sa.Integer, - sa.ForeignKey('post.id'), + sa.ForeignKey("post.id"), nullable=False, - index=True) - polygon = sa.Column('polygon', sa.PickleType, nullable=False) - text = sa.Column('text', sa.UnicodeText, nullable=False) + index=True, + ) + polygon = sa.Column("polygon", sa.PickleType, nullable=False) + text = sa.Column("text", sa.UnicodeText, nullable=False) - post = sa.orm.relationship('Post') + post = sa.orm.relationship("Post") class PostRelation(Base): - __tablename__ = 'post_relation' + __tablename__ = "post_relation" parent_id = sa.Column( - 'parent_id', + "parent_id", sa.Integer, - sa.ForeignKey('post.id'), + sa.ForeignKey("post.id"), primary_key=True, nullable=False, - index=True) + index=True, + ) child_id = sa.Column( - 'child_id', + "child_id", sa.Integer, - sa.ForeignKey('post.id'), + sa.ForeignKey("post.id"), primary_key=True, nullable=False, - index=True) + index=True, + ) def __init__(self, parent_id: int, child_id: int) -> None: self.parent_id = parent_id @@ -121,100 +137,150 @@ class PostRelation(Base): class PostTag(Base): - __tablename__ = 'post_tag' + __tablename__ = "post_tag" post_id = sa.Column( - 'post_id', + "post_id", sa.Integer, - sa.ForeignKey('post.id'), + sa.ForeignKey("post.id"), primary_key=True, nullable=False, - index=True) + index=True, + ) tag_id = sa.Column( - 'tag_id', + "tag_id", sa.Integer, - sa.ForeignKey('tag.id'), + sa.ForeignKey("tag.id"), primary_key=True, nullable=False, - index=True) + index=True, + ) def __init__(self, post_id: int, tag_id: int) -> None: self.post_id = post_id self.tag_id = tag_id +class PostSignature(Base): + __tablename__ = "post_signature" + + post_id = sa.Column( + "post_id", + sa.Integer, + sa.ForeignKey("post.id"), + primary_key=True, + nullable=False, + index=True, + ) + signature = sa.Column("signature", sa.LargeBinary, nullable=False) + words = sa.Column( + "words", + sa.dialects.postgresql.ARRAY(sa.Integer, dimensions=1), + nullable=False, + index=True, + ) + + post = sa.orm.relationship("Post") + + class Post(Base): - __tablename__ = 'post' + __tablename__ = "post" - SAFETY_SAFE = 'safe' - SAFETY_SKETCHY = 'sketchy' - SAFETY_UNSAFE = 'unsafe' + SAFETY_SAFE = "safe" + SAFETY_SKETCHY = "sketchy" + SAFETY_UNSAFE = "unsafe" - TYPE_IMAGE = 'image' - TYPE_ANIMATION = 'animation' - TYPE_VIDEO = 'video' - TYPE_FLASH = 'flash' + TYPE_IMAGE = "image" + TYPE_ANIMATION = "animation" + TYPE_VIDEO = "video" + TYPE_FLASH = "flash" - FLAG_LOOP = 'loop' - FLAG_SOUND = 'sound' + FLAG_LOOP = "loop" + FLAG_SOUND = "sound" # basic meta - post_id = sa.Column('id', sa.Integer, primary_key=True) + post_id = sa.Column("id", sa.Integer, primary_key=True) user_id = sa.Column( - 'user_id', + "user_id", sa.Integer, - sa.ForeignKey('user.id', ondelete='SET NULL'), + sa.ForeignKey("user.id", ondelete="SET NULL"), nullable=True, - index=True) - version = sa.Column('version', sa.Integer, default=1, nullable=False) - creation_time = sa.Column('creation_time', sa.DateTime, nullable=False) - last_edit_time = sa.Column('last_edit_time', sa.DateTime) - safety = sa.Column('safety', sa.Unicode(32), nullable=False) - source = sa.Column('source', sa.Unicode(200)) - flags_string = sa.Column('flags', sa.Unicode(200), default='') + index=True, + ) + version = sa.Column("version", sa.Integer, default=1, nullable=False) + creation_time = sa.Column("creation_time", sa.DateTime, nullable=False) + last_edit_time = sa.Column("last_edit_time", sa.DateTime) + safety = sa.Column("safety", sa.Unicode(32), nullable=False) + source = sa.Column("source", sa.Unicode(2048)) + flags_string = sa.Column("flags", sa.Unicode(32), default="") # content description - type = sa.Column('type', sa.Unicode(32), nullable=False) - checksum = sa.Column('checksum', sa.Unicode(64), nullable=False) - file_size = sa.Column('file_size', sa.Integer) - canvas_width = sa.Column('image_width', sa.Integer) - canvas_height = sa.Column('image_height', sa.Integer) - mime_type = sa.Column('mime-type', sa.Unicode(32), nullable=False) + type = sa.Column("type", sa.Unicode(32), nullable=False) + checksum = sa.Column("checksum", sa.Unicode(64), nullable=False) + file_size = sa.Column("file_size", sa.BigInteger) + canvas_width = sa.Column("image_width", sa.Integer) + canvas_height = sa.Column("image_height", sa.Integer) + mime_type = sa.Column("mime-type", sa.Unicode(32), nullable=False) # foreign tables - user = sa.orm.relationship('User') - tags = sa.orm.relationship('Tag', backref='posts', secondary='post_tag') + user = sa.orm.relationship("User") + tags = sa.orm.relationship("Tag", backref="posts", secondary="post_tag") + signature = sa.orm.relationship( + "PostSignature", + uselist=False, + cascade="all, delete, delete-orphan", + lazy="joined", + ) relations = sa.orm.relationship( - 'Post', - secondary='post_relation', + "Post", + secondary="post_relation", primaryjoin=post_id == PostRelation.parent_id, - secondaryjoin=post_id == PostRelation.child_id, lazy='joined', - backref='related_by') + secondaryjoin=post_id == PostRelation.child_id, + lazy="joined", + backref="related_by", + ) features = sa.orm.relationship( - 'PostFeature', cascade='all, delete-orphan', lazy='joined') + "PostFeature", cascade="all, delete-orphan", lazy="joined" + ) scores = sa.orm.relationship( - 'PostScore', cascade='all, delete-orphan', lazy='joined') + "PostScore", cascade="all, delete-orphan", lazy="joined" + ) favorited_by = sa.orm.relationship( - 'PostFavorite', cascade='all, delete-orphan', lazy='joined') + "PostFavorite", cascade="all, delete-orphan", lazy="joined" + ) notes = sa.orm.relationship( - 'PostNote', cascade='all, delete-orphan', lazy='joined') - comments = sa.orm.relationship('Comment', cascade='all, delete-orphan') + "PostNote", cascade="all, delete-orphan", lazy="joined" + ) + comments = sa.orm.relationship("Comment", cascade="all, delete-orphan") metrics = sa.orm.relationship( - 'PostMetric', cascade='all, delete-orphan', lazy='joined') + "PostMetric", cascade="all, delete-orphan", lazy="joined" + ) metric_ranges = sa.orm.relationship( - 'PostMetricRange', cascade='all, delete-orphan', lazy='joined') + "PostMetricRange", cascade="all, delete-orphan", lazy="joined" + ) + _pools = sa.orm.relationship( + "PoolPost", + cascade="all,delete-orphan", + lazy="select", + order_by="PoolPost.order", + back_populates="post", + ) + pools = association_proxy("_pools", "pool") # dynamic columns tag_count = sa.orm.column_property( sa.sql.expression.select( - [sa.sql.expression.func.count(PostTag.tag_id)]) + [sa.sql.expression.func.count(PostTag.tag_id)] + ) .where(PostTag.post_id == post_id) - .correlate_except(PostTag)) + .correlate_except(PostTag) + ) canvas_area = sa.orm.column_property(canvas_width * canvas_height) canvas_aspect_ratio = sa.orm.column_property( - sa.sql.expression.func.cast(canvas_width, sa.Float) / - sa.sql.expression.func.cast(canvas_height, sa.Float)) + sa.sql.expression.func.cast(canvas_width, sa.Float) + / sa.sql.expression.func.cast(canvas_height, sa.Float) + ) @property def is_featured(self) -> bool: @@ -222,81 +288,106 @@ class Post(Base): sa.orm.object_session(self) .query(PostFeature) .order_by(PostFeature.time.desc()) - .first()) + .first() + ) return featured_post and featured_post.post_id == self.post_id @hybrid_property def flags(self) -> List[str]: - return sorted([x for x in self.flags_string.split(',') if x]) + return sorted([x for x in self.flags_string.split(",") if x]) @flags.setter def flags(self, data: List[str]) -> None: - self.flags_string = ','.join([x for x in data if x]) + self.flags_string = ",".join([x for x in data if x]) score = sa.orm.column_property( sa.sql.expression.select( - [sa.sql.expression.func.coalesce( - sa.sql.expression.func.sum(PostScore.score), 0)]) + [ + sa.sql.expression.func.coalesce( + sa.sql.expression.func.sum(PostScore.score), 0 + ) + ] + ) .where(PostScore.post_id == post_id) - .correlate_except(PostScore)) + .correlate_except(PostScore) + ) favorite_count = sa.orm.column_property( sa.sql.expression.select( - [sa.sql.expression.func.count(PostFavorite.post_id)]) + [sa.sql.expression.func.count(PostFavorite.post_id)] + ) .where(PostFavorite.post_id == post_id) - .correlate_except(PostFavorite)) + .correlate_except(PostFavorite) + ) last_favorite_time = sa.orm.column_property( sa.sql.expression.select( - [sa.sql.expression.func.max(PostFavorite.time)]) + [sa.sql.expression.func.max(PostFavorite.time)] + ) .where(PostFavorite.post_id == post_id) - .correlate_except(PostFavorite)) + .correlate_except(PostFavorite) + ) feature_count = sa.orm.column_property( sa.sql.expression.select( - [sa.sql.expression.func.count(PostFeature.post_id)]) + [sa.sql.expression.func.count(PostFeature.post_id)] + ) .where(PostFeature.post_id == post_id) - .correlate_except(PostFeature)) + .correlate_except(PostFeature) + ) last_feature_time = sa.orm.column_property( sa.sql.expression.select( - [sa.sql.expression.func.max(PostFeature.time)]) + [sa.sql.expression.func.max(PostFeature.time)] + ) .where(PostFeature.post_id == post_id) - .correlate_except(PostFeature)) + .correlate_except(PostFeature) + ) comment_count = sa.orm.column_property( sa.sql.expression.select( - [sa.sql.expression.func.count(Comment.post_id)]) + [sa.sql.expression.func.count(Comment.post_id)] + ) .where(Comment.post_id == post_id) - .correlate_except(Comment)) + .correlate_except(Comment) + ) last_comment_creation_time = sa.orm.column_property( sa.sql.expression.select( - [sa.sql.expression.func.max(Comment.creation_time)]) + [sa.sql.expression.func.max(Comment.creation_time)] + ) .where(Comment.post_id == post_id) - .correlate_except(Comment)) + .correlate_except(Comment) + ) last_comment_edit_time = sa.orm.column_property( sa.sql.expression.select( - [sa.sql.expression.func.max(Comment.last_edit_time)]) + [sa.sql.expression.func.max(Comment.last_edit_time)] + ) .where(Comment.post_id == post_id) - .correlate_except(Comment)) + .correlate_except(Comment) + ) note_count = sa.orm.column_property( sa.sql.expression.select( - [sa.sql.expression.func.count(PostNote.post_id)]) + [sa.sql.expression.func.count(PostNote.post_id)] + ) .where(PostNote.post_id == post_id) - .correlate_except(PostNote)) + .correlate_except(PostNote) + ) relation_count = sa.orm.column_property( sa.sql.expression.select( - [sa.sql.expression.func.count(PostRelation.child_id)]) + [sa.sql.expression.func.count(PostRelation.child_id)] + ) .where( - (PostRelation.parent_id == post_id) | - (PostRelation.child_id == post_id)) - .correlate_except(PostRelation)) + (PostRelation.parent_id == post_id) + | (PostRelation.child_id == post_id) + ) + .correlate_except(PostRelation) + ) __mapper_args__ = { - 'version_id_col': version, - 'version_id_generator': False, + "version_id_col": version, + "version_id_generator": False, } diff --git a/server/szurubooru/model/snapshot.py b/server/szurubooru/model/snapshot.py index 7f8bbdf..d4e3fc8 100644 --- a/server/szurubooru/model/snapshot.py +++ b/server/szurubooru/model/snapshot.py @@ -1,29 +1,32 @@ import sqlalchemy as sa + from szurubooru.model.base import Base class Snapshot(Base): - __tablename__ = 'snapshot' + __tablename__ = "snapshot" - OPERATION_CREATED = 'created' - OPERATION_MODIFIED = 'modified' - OPERATION_DELETED = 'deleted' - OPERATION_MERGED = 'merged' + OPERATION_CREATED = "created" + OPERATION_MODIFIED = "modified" + OPERATION_DELETED = "deleted" + OPERATION_MERGED = "merged" - snapshot_id = sa.Column('id', sa.Integer, primary_key=True) - creation_time = sa.Column('creation_time', sa.DateTime, nullable=False) - operation = sa.Column('operation', sa.Unicode(16), nullable=False) + snapshot_id = sa.Column("id", sa.Integer, primary_key=True) + creation_time = sa.Column("creation_time", sa.DateTime, nullable=False) + operation = sa.Column("operation", sa.Unicode(16), nullable=False) resource_type = sa.Column( - 'resource_type', sa.Unicode(32), nullable=False, index=True) + "resource_type", sa.Unicode(32), nullable=False, index=True + ) resource_pkey = sa.Column( - 'resource_pkey', sa.Integer, nullable=False, index=True) - resource_name = sa.Column( - 'resource_name', sa.Unicode(64), nullable=False) + "resource_pkey", sa.Integer, nullable=False, index=True + ) + resource_name = sa.Column("resource_name", sa.Unicode(128), nullable=False) user_id = sa.Column( - 'user_id', + "user_id", sa.Integer, - sa.ForeignKey('user.id', ondelete='set null'), - nullable=True) - data = sa.Column('data', sa.PickleType) + sa.ForeignKey("user.id", ondelete="set null"), + nullable=True, + ) + data = sa.Column("data", sa.PickleType) - user = sa.orm.relationship('User') + user = sa.orm.relationship("User") diff --git a/server/szurubooru/model/tag.py b/server/szurubooru/model/tag.py index e0d18e6..65dabb1 100644 --- a/server/szurubooru/model/tag.py +++ b/server/szurubooru/model/tag.py @@ -1,25 +1,28 @@ import sqlalchemy as sa + from szurubooru.model.base import Base from szurubooru.model.post import PostTag class TagSuggestion(Base): - __tablename__ = 'tag_suggestion' + __tablename__ = "tag_suggestion" parent_id = sa.Column( - 'parent_id', + "parent_id", sa.Integer, - sa.ForeignKey('tag.id'), + sa.ForeignKey("tag.id"), nullable=False, primary_key=True, - index=True) + index=True, + ) child_id = sa.Column( - 'child_id', + "child_id", sa.Integer, - sa.ForeignKey('tag.id'), + sa.ForeignKey("tag.id"), nullable=False, primary_key=True, - index=True) + index=True, + ) def __init__(self, parent_id: int, child_id: int) -> None: self.parent_id = parent_id @@ -27,22 +30,24 @@ class TagSuggestion(Base): class TagImplication(Base): - __tablename__ = 'tag_implication' + __tablename__ = "tag_implication" parent_id = sa.Column( - 'parent_id', + "parent_id", sa.Integer, - sa.ForeignKey('tag.id'), + sa.ForeignKey("tag.id"), nullable=False, primary_key=True, - index=True) + index=True, + ) child_id = sa.Column( - 'child_id', + "child_id", sa.Integer, - sa.ForeignKey('tag.id'), + sa.ForeignKey("tag.id"), nullable=False, primary_key=True, - index=True) + index=True, + ) def __init__(self, parent_id: int, child_id: int) -> None: self.parent_id = parent_id @@ -50,17 +55,18 @@ class TagImplication(Base): class TagName(Base): - __tablename__ = 'tag_name' + __tablename__ = "tag_name" - tag_name_id = sa.Column('tag_name_id', sa.Integer, primary_key=True) + tag_name_id = sa.Column("tag_name_id", sa.Integer, primary_key=True) tag_id = sa.Column( - 'tag_id', + "tag_id", sa.Integer, - sa.ForeignKey('tag.id'), + sa.ForeignKey("tag.id"), nullable=False, - index=True) - name = sa.Column('name', sa.Unicode(64), nullable=False, unique=True) - order = sa.Column('ord', sa.Integer, nullable=False, index=True) + index=True, + ) + name = sa.Column("name", sa.Unicode(128), nullable=False, unique=True) + order = sa.Column("ord", sa.Integer, nullable=False, index=True) def __init__(self, name: str, order: int) -> None: self.name = name @@ -68,48 +74,55 @@ class TagName(Base): class Tag(Base): - __tablename__ = 'tag' + __tablename__ = "tag" - tag_id = sa.Column('id', sa.Integer, primary_key=True) + tag_id = sa.Column("id", sa.Integer, primary_key=True) category_id = sa.Column( - 'category_id', + "category_id", sa.Integer, - sa.ForeignKey('tag_category.id'), + sa.ForeignKey("tag_category.id"), nullable=False, - index=True) - version = sa.Column('version', sa.Integer, default=1, nullable=False) - creation_time = sa.Column('creation_time', sa.DateTime, nullable=False) - last_edit_time = sa.Column('last_edit_time', sa.DateTime) - description = sa.Column('description', sa.UnicodeText, default=None) + index=True, + ) + version = sa.Column("version", sa.Integer, default=1, nullable=False) + creation_time = sa.Column("creation_time", sa.DateTime, nullable=False) + last_edit_time = sa.Column("last_edit_time", sa.DateTime) + description = sa.Column("description", sa.UnicodeText, default=None) - category = sa.orm.relationship('TagCategory', lazy='joined') + category = sa.orm.relationship("TagCategory", lazy="joined") names = sa.orm.relationship( - 'TagName', - cascade='all,delete-orphan', - lazy='joined', - order_by='TagName.order') + "TagName", + cascade="all,delete-orphan", + lazy="joined", + order_by="TagName.order", + ) suggestions = sa.orm.relationship( - 'Tag', - secondary='tag_suggestion', + "Tag", + secondary="tag_suggestion", primaryjoin=tag_id == TagSuggestion.parent_id, secondaryjoin=tag_id == TagSuggestion.child_id, - lazy='joined') + lazy="joined", + ) implications = sa.orm.relationship( - 'Tag', - secondary='tag_implication', + "Tag", + secondary="tag_implication", primaryjoin=tag_id == TagImplication.parent_id, secondaryjoin=tag_id == TagImplication.child_id, - lazy='joined') + lazy="joined", + ) metric = sa.orm.relationship( - 'Metric', + "Metric", uselist=False, - cascade='all, delete-orphan') + cascade="all, delete-orphan" + ) post_count = sa.orm.column_property( sa.sql.expression.select( - [sa.sql.expression.func.count(PostTag.post_id)]) + [sa.sql.expression.func.count(PostTag.post_id)] + ) .where(PostTag.tag_id == tag_id) - .correlate_except(PostTag)) + .correlate_except(PostTag) + ) first_name = sa.orm.column_property( ( @@ -119,27 +132,32 @@ class Tag(Base): .limit(1) .as_scalar() ), - deferred=True) + deferred=True, + ) suggestion_count = sa.orm.column_property( ( sa.sql.expression.select( - [sa.sql.expression.func.count(TagSuggestion.child_id)]) + [sa.sql.expression.func.count(TagSuggestion.child_id)] + ) .where(TagSuggestion.parent_id == tag_id) .as_scalar() ), - deferred=True) + deferred=True, + ) implication_count = sa.orm.column_property( ( sa.sql.expression.select( - [sa.sql.expression.func.count(TagImplication.child_id)]) + [sa.sql.expression.func.count(TagImplication.child_id)] + ) .where(TagImplication.parent_id == tag_id) .as_scalar() ), - deferred=True) + deferred=True, + ) __mapper_args__ = { - 'version_id_col': version, - 'version_id_generator': False, + "version_id_col": version, + "version_id_generator": False, } diff --git a/server/szurubooru/model/tag_category.py b/server/szurubooru/model/tag_category.py index 2c961ed..a336a21 100644 --- a/server/szurubooru/model/tag_category.py +++ b/server/szurubooru/model/tag_category.py @@ -1,28 +1,33 @@ from typing import Optional + import sqlalchemy as sa + from szurubooru.model.base import Base from szurubooru.model.tag import Tag class TagCategory(Base): - __tablename__ = 'tag_category' + __tablename__ = "tag_category" - tag_category_id = sa.Column('id', sa.Integer, primary_key=True) - version = sa.Column('version', sa.Integer, default=1, nullable=False) - name = sa.Column('name', sa.Unicode(32), nullable=False) + tag_category_id = sa.Column("id", sa.Integer, primary_key=True) + version = sa.Column("version", sa.Integer, default=1, nullable=False) + name = sa.Column("name", sa.Unicode(32), nullable=False) color = sa.Column( - 'color', sa.Unicode(32), nullable=False, default='#000000') - default = sa.Column('default', sa.Boolean, nullable=False, default=False) + "color", sa.Unicode(32), nullable=False, default="#000000" + ) + default = sa.Column("default", sa.Boolean, nullable=False, default=False) + order = sa.Column("order", sa.Integer, nullable=False, default=1) def __init__(self, name: Optional[str] = None) -> None: self.name = name tag_count = sa.orm.column_property( - sa.sql.expression.select([sa.sql.expression.func.count('Tag.tag_id')]) + sa.sql.expression.select([sa.sql.expression.func.count("Tag.tag_id")]) .where(Tag.category_id == tag_category_id) - .correlate_except(sa.table('Tag'))) + .correlate_except(sa.table("Tag")) + ) __mapper_args__ = { - 'version_id_col': version, - 'version_id_generator': False, + "version_id_col": version, + "version_id_generator": False, } diff --git a/server/szurubooru/model/user.py b/server/szurubooru/model/user.py index 2d599e8..41a9b30 100644 --- a/server/szurubooru/model/user.py +++ b/server/szurubooru/model/user.py @@ -1,110 +1,123 @@ import sqlalchemy as sa + from szurubooru.model.base import Base -from szurubooru.model.post import Post, PostScore, PostFavorite from szurubooru.model.comment import Comment +from szurubooru.model.post import Post, PostFavorite, PostScore class User(Base): - __tablename__ = 'user' + __tablename__ = "user" - AVATAR_GRAVATAR = 'gravatar' - AVATAR_MANUAL = 'manual' + AVATAR_GRAVATAR = "gravatar" + AVATAR_MANUAL = "manual" - RANK_ANONYMOUS = 'anonymous' - RANK_RESTRICTED = 'restricted' - RANK_REGULAR = 'regular' - RANK_POWER = 'power' - RANK_MODERATOR = 'moderator' - RANK_ADMINISTRATOR = 'administrator' - RANK_NOBODY = 'nobody' # unattainable, used for privileges + RANK_ANONYMOUS = "anonymous" + RANK_RESTRICTED = "restricted" + RANK_REGULAR = "regular" + RANK_POWER = "power" + RANK_MODERATOR = "moderator" + RANK_ADMINISTRATOR = "administrator" + RANK_NOBODY = "nobody" # unattainable, used for privileges - user_id = sa.Column('id', sa.Integer, primary_key=True) - creation_time = sa.Column('creation_time', sa.DateTime, nullable=False) - last_login_time = sa.Column('last_login_time', sa.DateTime) - version = sa.Column('version', sa.Integer, default=1, nullable=False) - name = sa.Column('name', sa.Unicode(50), nullable=False, unique=True) - password_hash = sa.Column('password_hash', sa.Unicode(128), nullable=False) - password_salt = sa.Column('password_salt', sa.Unicode(32)) + user_id = sa.Column("id", sa.Integer, primary_key=True) + creation_time = sa.Column("creation_time", sa.DateTime, nullable=False) + last_login_time = sa.Column("last_login_time", sa.DateTime) + version = sa.Column("version", sa.Integer, default=1, nullable=False) + name = sa.Column("name", sa.Unicode(50), nullable=False, unique=True) + password_hash = sa.Column("password_hash", sa.Unicode(128), nullable=False) + password_salt = sa.Column("password_salt", sa.Unicode(32)) password_revision = sa.Column( - 'password_revision', sa.SmallInteger, default=0, nullable=False) - email = sa.Column('email', sa.Unicode(64), nullable=True) - rank = sa.Column('rank', sa.Unicode(32), nullable=False) + "password_revision", sa.SmallInteger, default=0, nullable=False + ) + email = sa.Column("email", sa.Unicode(64), nullable=True) + rank = sa.Column("rank", sa.Unicode(32), nullable=False) avatar_style = sa.Column( - 'avatar_style', sa.Unicode(32), nullable=False, - default=AVATAR_GRAVATAR) + "avatar_style", sa.Unicode(32), nullable=False, default=AVATAR_GRAVATAR + ) - comments = sa.orm.relationship('Comment') + comments = sa.orm.relationship("Comment") @property def post_count(self) -> int: from szurubooru.db import session + return ( - session - .query(sa.sql.expression.func.sum(1)) + session.query(sa.sql.expression.func.sum(1)) .filter(Post.user_id == self.user_id) - .one()[0] or 0) + .one()[0] + or 0 + ) @property def comment_count(self) -> int: from szurubooru.db import session + return ( - session - .query(sa.sql.expression.func.sum(1)) + session.query(sa.sql.expression.func.sum(1)) .filter(Comment.user_id == self.user_id) - .one()[0] or 0) + .one()[0] + or 0 + ) @property def favorite_post_count(self) -> int: from szurubooru.db import session + return ( - session - .query(sa.sql.expression.func.sum(1)) + session.query(sa.sql.expression.func.sum(1)) .filter(PostFavorite.user_id == self.user_id) - .one()[0] or 0) + .one()[0] + or 0 + ) @property def liked_post_count(self) -> int: from szurubooru.db import session + return ( - session - .query(sa.sql.expression.func.sum(1)) + session.query(sa.sql.expression.func.sum(1)) .filter(PostScore.user_id == self.user_id) .filter(PostScore.score == 1) - .one()[0] or 0) + .one()[0] + or 0 + ) @property def disliked_post_count(self) -> int: from szurubooru.db import session + return ( - session - .query(sa.sql.expression.func.sum(1)) + session.query(sa.sql.expression.func.sum(1)) .filter(PostScore.user_id == self.user_id) .filter(PostScore.score == -1) - .one()[0] or 0) + .one()[0] + or 0 + ) __mapper_args__ = { - 'version_id_col': version, - 'version_id_generator': False, + "version_id_col": version, + "version_id_generator": False, } class UserToken(Base): - __tablename__ = 'user_token' + __tablename__ = "user_token" - user_token_id = sa.Column('id', sa.Integer, primary_key=True) + user_token_id = sa.Column("id", sa.Integer, primary_key=True) user_id = sa.Column( - 'user_id', + "user_id", sa.Integer, - sa.ForeignKey('user.id', ondelete='CASCADE'), + sa.ForeignKey("user.id", ondelete="CASCADE"), nullable=False, - index=True) - token = sa.Column('token', sa.Unicode(36), nullable=False) - note = sa.Column('note', sa.Unicode(128), nullable=True) - enabled = sa.Column('enabled', sa.Boolean, nullable=False, default=True) - expiration_time = sa.Column('expiration_time', sa.DateTime, nullable=True) - creation_time = sa.Column('creation_time', sa.DateTime, nullable=False) - last_edit_time = sa.Column('last_edit_time', sa.DateTime) - last_usage_time = sa.Column('last_usage_time', sa.DateTime) - version = sa.Column('version', sa.Integer, default=1, nullable=False) + index=True, + ) + token = sa.Column("token", sa.Unicode(36), nullable=False) + note = sa.Column("note", sa.Unicode(128), nullable=True) + enabled = sa.Column("enabled", sa.Boolean, nullable=False, default=True) + expiration_time = sa.Column("expiration_time", sa.DateTime, nullable=True) + creation_time = sa.Column("creation_time", sa.DateTime, nullable=False) + last_edit_time = sa.Column("last_edit_time", sa.DateTime) + last_usage_time = sa.Column("last_usage_time", sa.DateTime) + version = sa.Column("version", sa.Integer, default=1, nullable=False) - user = sa.orm.relationship('User') + user = sa.orm.relationship("User") diff --git a/server/szurubooru/model/util.py b/server/szurubooru/model/util.py index e82539f..bece9e9 100644 --- a/server/szurubooru/model/util.py +++ b/server/szurubooru/model/util.py @@ -1,15 +1,19 @@ -from typing import Tuple, Any, Dict, Callable, Union, Optional +from typing import Any, Callable, Dict, Optional, Tuple, Union + import sqlalchemy as sa + from szurubooru.model.base import Base from szurubooru.model.user import User def get_resource_info(entity: Base) -> Tuple[Any, Any, Union[str, int]]: serializers = { - 'tag': lambda tag: tag.first_name, - 'tag_category': lambda category: category.name, - 'comment': lambda comment: comment.comment_id, - 'post': lambda post: post.post_id, + "tag": lambda tag: tag.first_name, + "tag_category": lambda category: category.name, + "comment": lambda comment: comment.comment_id, + "post": lambda post: post.post_id, + "pool": lambda pool: pool.pool_id, + "pool_category": lambda category: category.name, } # type: Dict[str, Callable[[Base], Any]] resource_type = entity.__table__.name @@ -29,14 +33,15 @@ def get_resource_info(entity: Base) -> Tuple[Any, Any, Union[str, int]]: def get_aux_entity( - session: Any, - get_table_info: Callable[[Base], Tuple[Base, Callable[[Base], Any]]], - entity: Base, - user: User) -> Optional[Base]: + session: Any, + get_table_info: Callable[[Base], Tuple[Base, Callable[[Base], Any]]], + entity: Base, + user: User, +) -> Optional[Base]: table, get_column = get_table_info(entity) return ( - session - .query(table) + session.query(table) .filter(get_column(table) == get_column(entity)) .filter(table.user_id == user.user_id) - .one_or_none()) + .one_or_none() + ) diff --git a/server/szurubooru/rest/__init__.py b/server/szurubooru/rest/__init__.py index d6b3ef2..6db22e2 100644 --- a/server/szurubooru/rest/__init__.py +++ b/server/szurubooru/rest/__init__.py @@ -1,3 +1,3 @@ +import szurubooru.rest.routes from szurubooru.rest.app import application from szurubooru.rest.context import Context, Response -import szurubooru.rest.routes diff --git a/server/szurubooru/rest/app.py b/server/szurubooru/rest/app.py index 8c9efba..a6f10fb 100644 --- a/server/szurubooru/rest/app.py +++ b/server/szurubooru/rest/app.py @@ -1,20 +1,21 @@ -import urllib.parse import cgi import json import re -from typing import Dict, Any, Callable, Tuple +import urllib.parse from datetime import datetime +from typing import Any, Callable, Dict, Tuple + from szurubooru import db from szurubooru.func import util -from szurubooru.rest import errors, middleware, routes, context +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' + serial = obj.isoformat("T") + "Z" return serial - raise TypeError('Type not serializable') + raise TypeError("Type not serializable") def _dump_json(obj: Any) -> str: @@ -24,71 +25,75 @@ def _dump_json(obj: Any) -> str: def _get_headers(env: Dict[str, Any]) -> Dict[str, str]: headers = {} # type: Dict[str, str] for key, value in env.items(): - if key.startswith('HTTP_'): + if key.startswith("HTTP_"): key = util.snake_case_to_upper_train_case(key[5:]) headers[key] = value return headers def _create_context(env: Dict[str, Any]) -> context.Context: - method = env['REQUEST_METHOD'] - path = '/' + env['PATH_INFO'].lstrip('/') - path = path.encode('latin-1').decode('utf-8') # PEP-3333 + method = env["REQUEST_METHOD"] + path = "/" + env["PATH_INFO"].lstrip("/") + path = path.encode("latin-1").decode("utf-8") # PEP-3333 headers = _get_headers(env) files = {} - params = dict(urllib.parse.parse_qsl(env.get('QUERY_STRING', ''))) + params = dict(urllib.parse.parse_qsl(env.get("QUERY_STRING", ""))) - if 'multipart' in env.get('CONTENT_TYPE', ''): - form = cgi.FieldStorage(fp=env['wsgi.input'], environ=env) + if "multipart" in env.get("CONTENT_TYPE", ""): + form = cgi.FieldStorage(fp=env["wsgi.input"], environ=env) if not form.list: raise errors.HttpBadRequest( - 'ValidationError', 'No files attached.') - body = form.getvalue('metadata') + "ValidationError", "No files attached." + ) + body = form.getvalue("metadata") for key in form: files[key] = form.getvalue(key) else: - body = env['wsgi.input'].read() + body = env["wsgi.input"].read() if body: try: if isinstance(body, bytes): - body = body.decode('utf-8') + body = body.decode("utf-8") for key, value in json.loads(body).items(): params[key] = value except (ValueError, UnicodeDecodeError): raise errors.HttpBadRequest( - 'ValidationError', - 'Could not decode the request body. The JSON ' - 'was incorrect or was not encoded as UTF-8.') + "ValidationError", + "Could not decode the request body. The JSON " + "was incorrect or was not encoded as UTF-8.", + ) return context.Context(env, method, path, headers, params, files) def application( - env: Dict[str, Any], - start_response: Callable[[str, Any], Any]) -> Tuple[bytes]: + env: Dict[str, Any], start_response: Callable[[str, Any], Any] +) -> Tuple[bytes]: try: ctx = _create_context(env) - if 'application/json' not in ctx.get_header('Accept'): + if "application/json" not in ctx.get_header("Accept"): raise errors.HttpNotAcceptable( - 'ValidationError', - 'This API only supports JSON responses.') + "ValidationError", "This API only supports JSON responses." + ) for url, allowed_methods in routes.routes.items(): match = re.fullmatch(url, ctx.url) if match: if ctx.method not in allowed_methods: raise errors.HttpMethodNotAllowed( - 'ValidationError', - 'Allowed methods: %r' % allowed_methods) + "ValidationError", + "Allowed methods: %r" % allowed_methods, + ) handler = allowed_methods[ctx.method] break else: raise errors.HttpNotFound( - 'ValidationError', - 'Requested path ' + ctx.url + ' was not found.') + "ValidationError", + "Requested path " + ctx.url + " was not found.", + ) try: ctx.session = db.session() @@ -106,8 +111,8 @@ def application( finally: db.session.remove() - start_response('200', [('content-type', 'application/json')]) - return (_dump_json(response).encode('utf-8'),) + start_response("200", [("content-type", "application/json")]) + return (_dump_json(response).encode("utf-8"),) except Exception as ex: for exception_type, ex_handler in errors.error_handlers.items(): @@ -117,14 +122,15 @@ def application( except errors.BaseHttpError as ex: start_response( - '%d %s' % (ex.code, ex.reason), - [('content-type', 'application/json')]) + "%d %s" % (ex.code, ex.reason), + [("content-type", "application/json")], + ) blob = { - 'name': ex.name, - 'title': ex.title, - 'description': ex.description, + "name": ex.name, + "title": ex.title, + "description": ex.description, } if ex.extra_fields is not None: for key, value in ex.extra_fields.items(): blob[key] = value - return (_dump_json(blob).encode('utf-8'),) + return (_dump_json(blob).encode("utf-8"),) diff --git a/server/szurubooru/rest/context.py b/server/szurubooru/rest/context.py index 0d4535a..cc5393c 100644 --- a/server/szurubooru/rest/context.py +++ b/server/szurubooru/rest/context.py @@ -1,7 +1,7 @@ -from typing import Any, Union, List, Dict, Optional, cast -from szurubooru import model, errors -from szurubooru.func import net, file_uploads +from typing import Any, Dict, List, Optional, Union, cast +from szurubooru import errors, model +from szurubooru.func import file_uploads, net MISSING = object() Request = Dict[str, Any] @@ -10,13 +10,14 @@ Response = Optional[Dict[str, Any]] class Context: def __init__( - self, - env: Dict[str, Any], - method: str, - url: str, - headers: Dict[str, str] = None, - params: Request = None, - files: Dict[str, bytes] = None) -> None: + self, + env: Dict[str, Any], + method: str, + url: str, + headers: Dict[str, str] = None, + params: Request = None, + files: Dict[str, bytes] = None, + ) -> None: self.env = env self.method = method self.url = url @@ -26,7 +27,7 @@ class Context: self.user = model.User() self.user.name = None - self.user.rank = 'anonymous' + self.user.rank = "anonymous" self.session = None # type: Any @@ -34,37 +35,45 @@ class Context: return name in self._headers def get_header(self, name: str) -> str: - return self._headers.get(name, '') + return self._headers.get(name, "") def has_file(self, name: str, allow_tokens: bool = True) -> bool: return ( - name in self._files or - name + 'Url' in self._params or - (allow_tokens and name + 'Token' in self._params)) + name in self._files + or name + "Url" in self._params + or (allow_tokens and name + "Token" in self._params) + ) def get_file( - self, - name: str, - default: Union[object, bytes] = MISSING, - allow_tokens: bool = True) -> bytes: + self, + name: str, + default: Union[object, bytes] = MISSING, + use_video_downloader: bool = False, + allow_tokens: bool = True, + ) -> bytes: if name in self._files and self._files[name]: return self._files[name] - if name + 'Url' in self._params: - return net.download(self._params[name + 'Url']) + if name + "Url" in self._params: + return net.download( + self._params[name + "Url"], + use_video_downloader=use_video_downloader, + ) - if allow_tokens and name + 'Token' in self._params: - ret = file_uploads.get(self._params[name + 'Token']) + if allow_tokens and name + "Token" in self._params: + ret = file_uploads.get(self._params[name + "Token"]) if ret: return ret elif default is not MISSING: raise errors.MissingOrExpiredRequiredFileError( - 'Required file %r is missing or has expired.' % name) + "Required file %r is missing or has expired." % name + ) if default is not MISSING: return cast(bytes, default) raise errors.MissingRequiredFileError( - 'Required file %r is missing.' % name) + "Required file %r is missing." % name + ) def has_param(self, name: str) -> bool: return name in self._params @@ -73,61 +82,62 @@ class Context: return self._params[name] def get_param_as_list( - self, - name: str, - default: Union[object, List[Any]] = MISSING) -> List[Any]: + self, name: str, default: Union[object, List[Any]] = MISSING + ) -> List[Any]: if name not in self._params: if default is not MISSING: return cast(List[Any], default) raise errors.MissingRequiredParameterError( - 'Required parameter %r is missing.' % name) + "Required parameter %r is missing." % name + ) value = self._params[name] if type(value) is str: - if ',' in value: - return value.split(',') + if "," in value: + return value.split(",") return [value] if type(value) is list: return value raise errors.InvalidParameterError( - 'Parameter %r must be a list.' % name) + "Parameter %r must be a list." % name + ) def get_param_as_int_list( - self, - name: str, - default: Union[object, List[int]] = MISSING) -> List[int]: + self, name: str, default: Union[object, List[int]] = MISSING + ) -> List[int]: ret = self.get_param_as_list(name, default) for item in ret: if type(item) is not int: raise errors.InvalidParameterError( - 'Parameter %r must be a list of integer values.' % name) + "Parameter %r must be a list of integer values." % name + ) return ret def get_param_as_string_list( - self, - name: str, - default: Union[object, List[str]] = MISSING) -> List[str]: + self, name: str, default: Union[object, List[str]] = MISSING + ) -> List[str]: ret = self.get_param_as_list(name, default) for item in ret: if type(item) is not str: raise errors.InvalidParameterError( - 'Parameter %r must be a list of string values.' % name) + "Parameter %r must be a list of string values." % name + ) return ret def get_param_as_string( - self, - name: str, - default: Union[object, str] = MISSING) -> str: + self, name: str, default: Union[object, str] = MISSING + ) -> str: if name not in self._params: if default is not MISSING: return cast(str, default) raise errors.MissingRequiredParameterError( - 'Required parameter %r is missing.' % name) + "Required parameter %r is missing." % name + ) value = self._params[name] try: if value is None: - return '' + return "" if type(value) is list: - return ','.join(value) + return ",".join(value) if type(value) is int or type(value) is float: return str(value) if type(value) is str: @@ -135,33 +145,39 @@ class Context: except TypeError: pass raise errors.InvalidParameterError( - 'Parameter %r must be a string value.' % name) + "Parameter %r must be a string value." % name + ) def get_param_as_int( - self, - name: str, - default: Union[object, int] = MISSING, - min: Optional[int] = None, - max: Optional[int] = None) -> int: + self, + name: str, + default: Union[object, int] = MISSING, + min: Optional[int] = None, + max: Optional[int] = None, + ) -> int: if name not in self._params: if default is not MISSING: return cast(int, default) raise errors.MissingRequiredParameterError( - 'Required parameter %r is missing.' % name) + "Required parameter %r is missing." % name + ) value = self._params[name] try: value = int(value) if min is not None and value < min: raise errors.InvalidParameterError( - 'Parameter %r must be at least %r.' % (name, min)) + "Parameter %r must be at least %r." % (name, min) + ) if max is not None and value > max: raise errors.InvalidParameterError( - 'Parameter %r may not exceed %r.' % (name, max)) + "Parameter %r may not exceed %r." % (name, max) + ) return value except (ValueError, TypeError): pass raise errors.InvalidParameterError( - 'Parameter %r must be an integer value.' % name) + "Parameter %r must be an integer value." % name + ) def get_param_as_float( self, @@ -190,22 +206,23 @@ class Context: 'Parameter %r must be a float value.' % name) def get_param_as_bool( - self, - name: str, - default: Union[object, bool] = MISSING) -> bool: + self, name: str, default: Union[object, bool] = MISSING + ) -> bool: if name not in self._params: if default is not MISSING: return cast(bool, default) raise errors.MissingRequiredParameterError( - 'Required parameter %r is missing.' % name) + "Required parameter %r is missing." % name + ) value = self._params[name] try: value = str(value).lower() except TypeError: pass - if value in ['1', 'y', 'yes', 'yeah', 'yep', 'yup', 't', 'true']: + if value in ["1", "y", "yes", "yeah", "yep", "yup", "t", "true"]: return True - if value in ['0', 'n', 'no', 'nope', 'f', 'false']: + if value in ["0", "n", "no", "nope", "f", "false"]: return False raise errors.InvalidParameterError( - 'Parameter %r must be a boolean value.' % name) + "Parameter %r must be a boolean value." % name + ) diff --git a/server/szurubooru/rest/errors.py b/server/szurubooru/rest/errors.py index f90ac25..446b757 100644 --- a/server/szurubooru/rest/errors.py +++ b/server/szurubooru/rest/errors.py @@ -1,19 +1,19 @@ -from typing import Optional, Callable, Type, Dict +from typing import Callable, Dict, Optional, Type - -error_handlers = {} # pylint: disable=invalid-name +error_handlers = {} class BaseHttpError(RuntimeError): code = -1 - reason = '' + reason = "" def __init__( - self, - name: str, - description: str, - title: Optional[str] = None, - extra_fields: Optional[Dict[str, str]] = None) -> None: + self, + name: str, + description: str, + title: Optional[str] = None, + extra_fields: Optional[Dict[str, str]] = None, + ) -> None: super().__init__() # error name for programmers self.name = name @@ -27,40 +27,40 @@ class BaseHttpError(RuntimeError): class HttpBadRequest(BaseHttpError): code = 400 - reason = 'Bad Request' + reason = "Bad Request" class HttpForbidden(BaseHttpError): code = 403 - reason = 'Forbidden' + reason = "Forbidden" class HttpNotFound(BaseHttpError): code = 404 - reason = 'Not Found' + reason = "Not Found" class HttpNotAcceptable(BaseHttpError): code = 406 - reason = 'Not Acceptable' + reason = "Not Acceptable" class HttpConflict(BaseHttpError): code = 409 - reason = 'Conflict' + reason = "Conflict" class HttpMethodNotAllowed(BaseHttpError): code = 405 - reason = 'Method Not Allowed' + reason = "Method Not Allowed" class HttpInternalServerError(BaseHttpError): code = 500 - reason = 'Internal Server Error' + reason = "Internal Server Error" def handle( - exception_type: Type[Exception], - handler: Callable[[Exception], None]) -> None: + exception_type: Type[Exception], handler: Callable[[Exception], None] +) -> None: error_handlers[exception_type] = handler diff --git a/server/szurubooru/rest/middleware.py b/server/szurubooru/rest/middleware.py index ce457e0..2936abf 100644 --- a/server/szurubooru/rest/middleware.py +++ b/server/szurubooru/rest/middleware.py @@ -1,8 +1,7 @@ -from typing import List, Callable -from szurubooru.rest.context import Context +from typing import Callable, List +from szurubooru.rest.context import Context -# pylint: disable=invalid-name pre_hooks = [] # type: List[Callable[[Context], None]] post_hooks = [] # type: List[Callable[[Context], None]] diff --git a/server/szurubooru/rest/routes.py b/server/szurubooru/rest/routes.py index 569cbe1..b0946fb 100644 --- a/server/szurubooru/rest/routes.py +++ b/server/szurubooru/rest/routes.py @@ -1,36 +1,39 @@ -from typing import Callable, Dict from collections import defaultdict -from szurubooru.rest.context import Context, Response +from typing import Callable, Dict +from szurubooru.rest.context import Context, Response -# pylint: disable=invalid-name RouteHandler = Callable[[Context, Dict[str, str]], Response] routes = defaultdict(dict) # type: Dict[str, Dict[str, RouteHandler]] def get(url: str) -> Callable[[RouteHandler], RouteHandler]: def wrapper(handler: RouteHandler) -> RouteHandler: - routes[url]['GET'] = handler + routes[url]["GET"] = handler return handler + return wrapper def put(url: str) -> Callable[[RouteHandler], RouteHandler]: def wrapper(handler: RouteHandler) -> RouteHandler: - routes[url]['PUT'] = handler + routes[url]["PUT"] = handler return handler + return wrapper def post(url: str) -> Callable[[RouteHandler], RouteHandler]: def wrapper(handler: RouteHandler) -> RouteHandler: - routes[url]['POST'] = handler + routes[url]["POST"] = handler return handler + return wrapper def delete(url: str) -> Callable[[RouteHandler], RouteHandler]: def wrapper(handler: RouteHandler) -> RouteHandler: - routes[url]['DELETE'] = handler + routes[url]["DELETE"] = handler return handler + return wrapper diff --git a/server/szurubooru/search/__init__.py b/server/szurubooru/search/__init__.py index 919475f..6ad1920 100644 --- a/server/szurubooru/search/__init__.py +++ b/server/szurubooru/search/__init__.py @@ -1,2 +1,2 @@ -from szurubooru.search.executor import Executor import szurubooru.search.configs +from szurubooru.search.executor import Executor diff --git a/server/szurubooru/search/configs/__init__.py b/server/szurubooru/search/configs/__init__.py index 73cc604..72bd5bc 100644 --- a/server/szurubooru/search/configs/__init__.py +++ b/server/szurubooru/search/configs/__init__.py @@ -1,6 +1,7 @@ -from .user_search_config import UserSearchConfig -from .tag_search_config import TagSearchConfig -from .post_search_config import PostSearchConfig -from .snapshot_search_config import SnapshotSearchConfig from .comment_search_config import CommentSearchConfig from .post_metric_search_config import PostMetricSearchConfig +from .pool_search_config import PoolSearchConfig +from .post_search_config import PostSearchConfig +from .snapshot_search_config import SnapshotSearchConfig +from .tag_search_config import TagSearchConfig +from .user_search_config import UserSearchConfig diff --git a/server/szurubooru/search/configs/base_search_config.py b/server/szurubooru/search/configs/base_search_config.py index 0cb814d..d60f361 100644 --- a/server/szurubooru/search/configs/base_search_config.py +++ b/server/szurubooru/search/configs/base_search_config.py @@ -1,5 +1,6 @@ -from typing import Optional, Tuple, Dict, Callable -from szurubooru.search import tokens, criteria +from typing import Callable, Dict, Optional, Tuple + +from szurubooru.search import criteria, tokens from szurubooru.search.query import SearchQuery from szurubooru.search.typing import SaColumn, SaQuery diff --git a/server/szurubooru/search/configs/comment_search_config.py b/server/szurubooru/search/configs/comment_search_config.py index 8b15446..1145e51 100644 --- a/server/szurubooru/search/configs/comment_search_config.py +++ b/server/szurubooru/search/configs/comment_search_config.py @@ -1,10 +1,14 @@ -from typing import Tuple, Dict +from typing import Dict, Tuple + import sqlalchemy as sa + from szurubooru import db, model -from szurubooru.search.typing import SaColumn, SaQuery from szurubooru.search.configs import util as search_util from szurubooru.search.configs.base_search_config import ( - BaseSearchConfig, Filter) + BaseSearchConfig, + Filter, +) +from szurubooru.search.typing import SaColumn, SaQuery class CommentSearchConfig(BaseSearchConfig): @@ -27,36 +31,42 @@ class CommentSearchConfig(BaseSearchConfig): @property def named_filters(self) -> Dict[str, Filter]: return { - 'id': search_util.create_num_filter(model.Comment.comment_id), - 'post': search_util.create_num_filter(model.Comment.post_id), - 'user': search_util.create_str_filter(model.User.name), - 'author': search_util.create_str_filter(model.User.name), - 'text': search_util.create_str_filter(model.Comment.text), - 'creation-date': - search_util.create_date_filter(model.Comment.creation_time), - 'creation-time': - search_util.create_date_filter(model.Comment.creation_time), - 'last-edit-date': - search_util.create_date_filter(model.Comment.last_edit_time), - 'last-edit-time': - search_util.create_date_filter(model.Comment.last_edit_time), - 'edit-date': - search_util.create_date_filter(model.Comment.last_edit_time), - 'edit-time': - search_util.create_date_filter(model.Comment.last_edit_time), + "id": search_util.create_num_filter(model.Comment.comment_id), + "post": search_util.create_num_filter(model.Comment.post_id), + "user": search_util.create_str_filter(model.User.name), + "author": search_util.create_str_filter(model.User.name), + "text": search_util.create_str_filter(model.Comment.text), + "creation-date": search_util.create_date_filter( + model.Comment.creation_time + ), + "creation-time": search_util.create_date_filter( + model.Comment.creation_time + ), + "last-edit-date": search_util.create_date_filter( + model.Comment.last_edit_time + ), + "last-edit-time": search_util.create_date_filter( + model.Comment.last_edit_time + ), + "edit-date": search_util.create_date_filter( + model.Comment.last_edit_time + ), + "edit-time": search_util.create_date_filter( + model.Comment.last_edit_time + ), } @property def sort_columns(self) -> Dict[str, Tuple[SaColumn, str]]: return { - 'random': (sa.sql.expression.func.random(), self.SORT_NONE), - 'user': (model.User.name, self.SORT_ASC), - 'author': (model.User.name, self.SORT_ASC), - 'post': (model.Comment.post_id, self.SORT_DESC), - 'creation-date': (model.Comment.creation_time, self.SORT_DESC), - 'creation-time': (model.Comment.creation_time, self.SORT_DESC), - 'last-edit-date': (model.Comment.last_edit_time, self.SORT_DESC), - 'last-edit-time': (model.Comment.last_edit_time, self.SORT_DESC), - 'edit-date': (model.Comment.last_edit_time, self.SORT_DESC), - 'edit-time': (model.Comment.last_edit_time, self.SORT_DESC), + "random": (sa.sql.expression.func.random(), self.SORT_NONE), + "user": (model.User.name, self.SORT_ASC), + "author": (model.User.name, self.SORT_ASC), + "post": (model.Comment.post_id, self.SORT_DESC), + "creation-date": (model.Comment.creation_time, self.SORT_DESC), + "creation-time": (model.Comment.creation_time, self.SORT_DESC), + "last-edit-date": (model.Comment.last_edit_time, self.SORT_DESC), + "last-edit-time": (model.Comment.last_edit_time, self.SORT_DESC), + "edit-date": (model.Comment.last_edit_time, self.SORT_DESC), + "edit-time": (model.Comment.last_edit_time, self.SORT_DESC), } diff --git a/server/szurubooru/search/configs/pool_search_config.py b/server/szurubooru/search/configs/pool_search_config.py new file mode 100644 index 0000000..88b30a6 --- /dev/null +++ b/server/szurubooru/search/configs/pool_search_config.py @@ -0,0 +1,111 @@ +from typing import Dict, Tuple + +import sqlalchemy as sa + +from szurubooru import db, model +from szurubooru.func import util +from szurubooru.search.configs import util as search_util +from szurubooru.search.configs.base_search_config import ( + BaseSearchConfig, + Filter, +) +from szurubooru.search.typing import SaColumn, SaQuery + + +class PoolSearchConfig(BaseSearchConfig): + def create_filter_query(self, _disable_eager_loads: bool) -> SaQuery: + strategy = ( + sa.orm.lazyload if _disable_eager_loads else sa.orm.subqueryload + ) + return ( + db.session.query(model.Pool) + .join(model.PoolCategory) + .options(strategy(model.Pool.names)) + ) + + def create_count_query(self, _disable_eager_loads: bool) -> SaQuery: + return db.session.query(model.Pool) + + def create_around_query(self) -> SaQuery: + raise NotImplementedError() + + def finalize_query(self, query: SaQuery) -> SaQuery: + return query.order_by(model.Pool.first_name.asc()) + + @property + def anonymous_filter(self) -> Filter: + return search_util.create_subquery_filter( + model.Pool.pool_id, + model.PoolName.pool_id, + model.PoolName.name, + search_util.create_str_filter, + ) + + @property + def named_filters(self) -> Dict[str, Filter]: + return util.unalias_dict( + [ + ( + ["name"], + search_util.create_subquery_filter( + model.Pool.pool_id, + model.PoolName.pool_id, + model.PoolName.name, + search_util.create_str_filter, + ), + ), + ( + ["category"], + search_util.create_subquery_filter( + model.Pool.category_id, + model.PoolCategory.pool_category_id, + model.PoolCategory.name, + search_util.create_str_filter, + ), + ), + ( + ["creation-date", "creation-time"], + search_util.create_date_filter(model.Pool.creation_time), + ), + ( + [ + "last-edit-date", + "last-edit-time", + "edit-date", + "edit-time", + ], + search_util.create_date_filter(model.Pool.last_edit_time), + ), + ( + ["post-count"], + search_util.create_num_filter(model.Pool.post_count), + ), + ] + ) + + @property + def sort_columns(self) -> Dict[str, Tuple[SaColumn, str]]: + return util.unalias_dict( + [ + ( + ["random"], + (sa.sql.expression.func.random(), self.SORT_NONE), + ), + (["name"], (model.Pool.first_name, self.SORT_ASC)), + (["category"], (model.PoolCategory.name, self.SORT_ASC)), + ( + ["creation-date", "creation-time"], + (model.Pool.creation_time, self.SORT_DESC), + ), + ( + [ + "last-edit-date", + "last-edit-time", + "edit-date", + "edit-time", + ], + (model.Pool.last_edit_time, self.SORT_DESC), + ), + (["post-count"], (model.Pool.post_count, self.SORT_DESC)), + ] + ) diff --git a/server/szurubooru/search/configs/post_search_config.py b/server/szurubooru/search/configs/post_search_config.py index 8596bce..71bd16a 100644 --- a/server/szurubooru/search/configs/post_search_config.py +++ b/server/szurubooru/search/configs/post_search_config.py @@ -1,87 +1,92 @@ -from typing import Any, Optional, Tuple, Dict +from typing import Any, Dict, Optional, Tuple + import sqlalchemy as sa -from szurubooru import db, model, errors -from szurubooru.func import util, metrics + +from szurubooru import db, errors, model +from szurubooru.func import metrics, util from szurubooru.search import criteria, tokens -from szurubooru.search.typing import SaColumn, SaQuery -from szurubooru.search.query import SearchQuery from szurubooru.search.configs import util as search_util from szurubooru.search.configs.base_search_config import ( - BaseSearchConfig, Filter) + BaseSearchConfig, + Filter, +) +from szurubooru.search.query import SearchQuery +from szurubooru.search.typing import SaColumn, SaQuery def _type_transformer(value: str) -> str: available_values = { - 'image': model.Post.TYPE_IMAGE, - 'animation': model.Post.TYPE_ANIMATION, - 'animated': model.Post.TYPE_ANIMATION, - 'anim': model.Post.TYPE_ANIMATION, - 'gif': model.Post.TYPE_ANIMATION, - 'video': model.Post.TYPE_VIDEO, - 'webm': model.Post.TYPE_VIDEO, - 'flash': model.Post.TYPE_FLASH, - 'swf': model.Post.TYPE_FLASH, + "image": model.Post.TYPE_IMAGE, + "animation": model.Post.TYPE_ANIMATION, + "animated": model.Post.TYPE_ANIMATION, + "anim": model.Post.TYPE_ANIMATION, + "gif": model.Post.TYPE_ANIMATION, + "video": model.Post.TYPE_VIDEO, + "webm": model.Post.TYPE_VIDEO, + "flash": model.Post.TYPE_FLASH, + "swf": model.Post.TYPE_FLASH, } return search_util.enum_transformer(available_values, value) def _safety_transformer(value: str) -> str: available_values = { - 'safe': model.Post.SAFETY_SAFE, - 'sketchy': model.Post.SAFETY_SKETCHY, - 'questionable': model.Post.SAFETY_SKETCHY, - 'unsafe': model.Post.SAFETY_UNSAFE, + "safe": model.Post.SAFETY_SAFE, + "sketchy": model.Post.SAFETY_SKETCHY, + "questionable": model.Post.SAFETY_SKETCHY, + "unsafe": model.Post.SAFETY_UNSAFE, } return search_util.enum_transformer(available_values, value) def _flag_transformer(value: str) -> str: available_values = { - 'loop': model.Post.FLAG_LOOP, - 'sound': model.Post.FLAG_SOUND, + "loop": model.Post.FLAG_LOOP, + "sound": model.Post.FLAG_SOUND, } - return '%' + search_util.enum_transformer(available_values, value) + '%' + return "%" + search_util.enum_transformer(available_values, value) + "%" def _source_transformer(value: str) -> str: - return search_util.wildcard_transformer('*' + value + '*') + return search_util.wildcard_transformer("*" + value + "*") def _create_score_filter(score: int) -> Filter: def wrapper( - query: SaQuery, - criterion: Optional[criteria.BaseCriterion], - negated: bool) -> SaQuery: + query: SaQuery, + criterion: Optional[criteria.BaseCriterion], + negated: bool, + ) -> SaQuery: assert criterion - if not getattr(criterion, 'internal', False): + if not getattr(criterion, "internal", False): raise errors.SearchError( - 'Votes cannot be seen publicly. Did you mean %r?' - % 'special:liked') + "Votes cannot be seen publicly. Did you mean %r?" + % "special:liked" + ) user_alias = sa.orm.aliased(model.User) score_alias = sa.orm.aliased(model.PostScore) expr = score_alias.score == score expr = expr & search_util.apply_str_criterion_to_column( - user_alias.name, criterion) + user_alias.name, criterion + ) if negated: expr = ~expr ret = ( - query - .join(score_alias, score_alias.post_id == model.Post.post_id) + query.join(score_alias, score_alias.post_id == model.Post.post_id) .join(user_alias, user_alias.user_id == score_alias.user_id) - .filter(expr)) + .filter(expr) + ) return ret + return wrapper def _user_filter( - query: SaQuery, - criterion: Optional[criteria.BaseCriterion], - negated: bool) -> SaQuery: + query: SaQuery, criterion: Optional[criteria.BaseCriterion], negated: bool +) -> SaQuery: assert criterion - if isinstance(criterion, criteria.PlainCriterion) \ - and not criterion.value: - # pylint: disable=singleton-comparison - expr = model.Post.user_id == None + if isinstance(criterion, criteria.PlainCriterion) and not criterion.value: + expr = model.Post.user_id == None # noqa: E711 if negated: expr = ~expr return query.filter(expr) @@ -89,25 +94,40 @@ def _user_filter( model.Post.user_id, model.User.user_id, model.User.name, - search_util.create_str_filter)(query, criterion, negated) + search_util.create_str_filter, + )(query, criterion, negated) def _note_filter( - query: SaQuery, - criterion: Optional[criteria.BaseCriterion], - negated: bool) -> SaQuery: + query: SaQuery, criterion: Optional[criteria.BaseCriterion], negated: bool +) -> SaQuery: assert criterion return search_util.create_subquery_filter( model.Post.post_id, model.PostNote.post_id, model.PostNote.text, - search_util.create_str_filter)(query, criterion, negated) + search_util.create_str_filter, + )(query, criterion, negated) + + +def _pool_filter( + query: SaQuery, criterion: Optional[criteria.BaseCriterion], negated: bool +) -> SaQuery: + assert criterion + return search_util.create_subquery_filter( + model.Post.post_id, + model.PoolPost.post_id, + model.PoolPost.pool_id, + search_util.create_num_filter, + )(query, criterion, negated) def _create_metric_num_filter(name: str): - def wrapper(query: SaQuery, - criterion: Optional[criteria.BaseCriterion], - negated: bool) -> SaQuery: + def wrapper( + query: SaQuery, + criterion: Optional[criteria.BaseCriterion], + negated: bool, + ) -> SaQuery: assert criterion t = sa.orm.aliased(model.TagName) pm = sa.orm.aliased(model.PostMetric) @@ -126,9 +146,10 @@ def _create_metric_num_filter(name: str): def _metric_presence_filter( - query: SaQuery, - criterion: Optional[criteria.BaseCriterion], - negated: bool) -> SaQuery: + query: SaQuery, + criterion: Optional[criteria.BaseCriterion], + negated: bool, +) -> SaQuery: assert criterion t = sa.orm.aliased(model.TagName) tag_name_filter = search_util.apply_str_criterion_to_column( @@ -168,53 +189,54 @@ class PostSearchConfig(BaseSearchConfig): def on_search_query_parsed(self, search_query: SearchQuery) -> SaQuery: new_special_tokens = [] for token in search_query.special_tokens: - if token.value in ('fav', 'liked', 'disliked'): + if token.value in ("fav", "liked", "disliked"): assert self.user - if self.user.rank == 'anonymous': + if self.user.rank == "anonymous": raise errors.SearchError( - 'Must be logged in to use this feature.') + "Must be logged in to use this feature." + ) criterion = criteria.PlainCriterion( - original_text=self.user.name, - value=self.user.name) - setattr(criterion, 'internal', True) + original_text=self.user.name, value=self.user.name + ) + setattr(criterion, "internal", True) search_query.named_tokens.append( tokens.NamedToken( name=token.value, criterion=criterion, - negated=token.negated)) + negated=token.negated, + ) + ) else: new_special_tokens.append(token) search_query.special_tokens = new_special_tokens def create_around_query(self) -> SaQuery: self.refresh_metrics() - return db.session.query(model.Post).options(sa.orm.lazyload('*')) + return db.session.query(model.Post).options(sa.orm.lazyload("*")) def create_filter_query(self, disable_eager_loads: bool) -> SaQuery: self.refresh_metrics() strategy = ( - sa.orm.lazyload - if disable_eager_loads - else sa.orm.subqueryload) - return ( - db.session.query(model.Post) - .options( - sa.orm.lazyload('*'), - # use config optimized for official client - # sa.orm.defer(model.Post.score), - # sa.orm.defer(model.Post.favorite_count), - # sa.orm.defer(model.Post.comment_count), - sa.orm.defer(model.Post.last_favorite_time), - sa.orm.defer(model.Post.feature_count), - sa.orm.defer(model.Post.last_feature_time), - sa.orm.defer(model.Post.last_comment_creation_time), - sa.orm.defer(model.Post.last_comment_edit_time), - sa.orm.defer(model.Post.note_count), - sa.orm.defer(model.Post.tag_count), - strategy(model.Post.tags).subqueryload(model.Tag.names), - strategy(model.Post.tags).defer(model.Tag.post_count), - strategy(model.Post.tags).lazyload(model.Tag.implications), - strategy(model.Post.tags).lazyload(model.Tag.suggestions))) + sa.orm.lazyload if disable_eager_loads else sa.orm.subqueryload + ) + return db.session.query(model.Post).options( + sa.orm.lazyload("*"), + # use config optimized for official client + # sa.orm.defer(model.Post.score), + # sa.orm.defer(model.Post.favorite_count), + # sa.orm.defer(model.Post.comment_count), + sa.orm.defer(model.Post.last_favorite_time), + sa.orm.defer(model.Post.feature_count), + sa.orm.defer(model.Post.last_feature_time), + sa.orm.defer(model.Post.last_comment_creation_time), + sa.orm.defer(model.Post.last_comment_edit_time), + sa.orm.defer(model.Post.note_count), + sa.orm.defer(model.Post.tag_count), + strategy(model.Post.tags).subqueryload(model.Tag.names), + strategy(model.Post.tags).defer(model.Tag.post_count), + strategy(model.Post.tags).lazyload(model.Tag.implications), + strategy(model.Post.tags).lazyload(model.Tag.suggestions), + ) def create_count_query(self, _disable_eager_loads: bool) -> SaQuery: return db.session.query(model.Post) @@ -233,316 +255,264 @@ class PostSearchConfig(BaseSearchConfig): model.PostTag.post_id, model.TagName.name, search_util.create_str_filter, - lambda subquery: subquery.join(model.Tag).join(model.TagName)) + lambda subquery: subquery.join(model.Tag).join(model.TagName), + ) @property def named_filters(self) -> Dict[str, Filter]: - filters = {'metric-' + name: _create_metric_num_filter(name) + filters = {"metric-" + name: _create_metric_num_filter(name) for name in self.all_metric_names} - filters.update(util.unalias_dict([ - ( - ['id'], - search_util.create_num_filter(model.Post.post_id) - ), - - ( - ['tag'], - search_util.create_subquery_filter( - model.Post.post_id, - model.PostTag.post_id, - model.TagName.name, - search_util.create_str_filter, - lambda subquery: - subquery.join(model.Tag).join(model.TagName)) - ), - - ( - ['metric'], - _metric_presence_filter - ), - - ( - ['score'], - search_util.create_num_filter(model.Post.score) - ), - - ( - ['uploader', 'upload', 'submit'], - _user_filter - ), - - ( - ['comment'], - search_util.create_subquery_filter( - model.Post.post_id, - model.Comment.post_id, - model.User.name, - search_util.create_str_filter, - lambda subquery: subquery.join(model.User)) - ), - - ( - ['fav'], - search_util.create_subquery_filter( - model.Post.post_id, - model.PostFavorite.post_id, - model.User.name, - search_util.create_str_filter, - lambda subquery: subquery.join(model.User)) - ), - - ( - ['liked'], - _create_score_filter(1) - ), - ( - ['disliked'], - _create_score_filter(-1) - ), - - ( - ['source'], - search_util.create_str_filter( - model.Post.source, _source_transformer) - ), - - ( - ['tag-count'], - search_util.create_num_filter(model.Post.tag_count) - ), - - ( - ['comment-count'], - search_util.create_num_filter(model.Post.comment_count) - ), - - ( - ['fav-count'], - search_util.create_num_filter(model.Post.favorite_count) - ), - - ( - ['note-count'], - search_util.create_num_filter(model.Post.note_count) - ), - - ( - ['relation-count'], - search_util.create_num_filter(model.Post.relation_count) - ), - - ( - ['feature-count'], - search_util.create_num_filter(model.Post.feature_count) - ), - - ( - ['type'], - search_util.create_str_filter( - model.Post.type, _type_transformer) - ), - - ( - ['content-checksum'], - search_util.create_str_filter(model.Post.checksum) - ), - - ( - ['file-size'], - search_util.create_num_filter(model.Post.file_size) - ), - - ( - ['image-width', 'width'], - search_util.create_num_filter(model.Post.canvas_width) - ), - - ( - ['image-height', 'height'], - search_util.create_num_filter(model.Post.canvas_height) - ), - - ( - ['image-area', 'area'], - search_util.create_num_filter(model.Post.canvas_area) - ), - - ( - ['image-aspect-ratio', 'image-ar', 'aspect-ratio', 'ar'], - search_util.create_num_filter( - model.Post.canvas_aspect_ratio, - transformer=search_util.float_transformer) - ), - - ( - ['creation-date', 'creation-time', 'date', 'time'], - search_util.create_date_filter(model.Post.creation_time) - ), - - ( - ['last-edit-date', 'last-edit-time', 'edit-date', 'edit-time'], - search_util.create_date_filter(model.Post.last_edit_time) - ), - - ( - ['comment-date', 'comment-time'], - search_util.create_date_filter( - model.Post.last_comment_creation_time) - ), - - ( - ['fav-date', 'fav-time'], - search_util.create_date_filter(model.Post.last_favorite_time) - ), - - ( - ['feature-date', 'feature-time'], - search_util.create_date_filter(model.Post.last_feature_time) - ), - - ( - ['safety', 'rating'], - search_util.create_str_filter( - model.Post.safety, _safety_transformer) - ), - - ( - ['note-text'], - _note_filter - ), - - ( - ['flag'], - search_util.create_str_filter( - model.Post.flags_string, _flag_transformer) - ), - ])) + filters.update(util.unalias_dict( + [ + (["id"], search_util.create_num_filter(model.Post.post_id)), + ( + ["tag"], + search_util.create_subquery_filter( + model.Post.post_id, + model.PostTag.post_id, + model.TagName.name, + search_util.create_str_filter, + lambda subquery: subquery.join(model.Tag).join( + model.TagName + ), + ), + ), + (["metric"], _metric_presence_filter), + (["score"], search_util.create_num_filter(model.Post.score)), + (["uploader", "upload", "submit"], _user_filter), + ( + ["comment"], + search_util.create_subquery_filter( + model.Post.post_id, + model.Comment.post_id, + model.User.name, + search_util.create_str_filter, + lambda subquery: subquery.join(model.User), + ), + ), + ( + ["fav"], + search_util.create_subquery_filter( + model.Post.post_id, + model.PostFavorite.post_id, + model.User.name, + search_util.create_str_filter, + lambda subquery: subquery.join(model.User), + ), + ), + (["liked"], _create_score_filter(1)), + (["disliked"], _create_score_filter(-1)), + ( + ["source"], + search_util.create_str_filter( + model.Post.source, _source_transformer + ), + ), + ( + ["tag-count"], + search_util.create_num_filter(model.Post.tag_count), + ), + ( + ["comment-count"], + search_util.create_num_filter(model.Post.comment_count), + ), + ( + ["fav-count"], + search_util.create_num_filter(model.Post.favorite_count), + ), + ( + ["note-count"], + search_util.create_num_filter(model.Post.note_count), + ), + ( + ["relation-count"], + search_util.create_num_filter(model.Post.relation_count), + ), + ( + ["feature-count"], + search_util.create_num_filter(model.Post.feature_count), + ), + ( + ["type"], + search_util.create_str_filter( + model.Post.type, _type_transformer + ), + ), + ( + ["content-checksum"], + search_util.create_str_filter(model.Post.checksum), + ), + ( + ["file-size"], + search_util.create_num_filter(model.Post.file_size), + ), + ( + ["image-width", "width"], + search_util.create_num_filter(model.Post.canvas_width), + ), + ( + ["image-height", "height"], + search_util.create_num_filter(model.Post.canvas_height), + ), + ( + ["image-area", "area"], + search_util.create_num_filter(model.Post.canvas_area), + ), + ( + ["image-aspect-ratio", "image-ar", "aspect-ratio", "ar"], + search_util.create_num_filter( + model.Post.canvas_aspect_ratio, + transformer=search_util.float_transformer, + ), + ), + ( + ["creation-date", "creation-time", "date", "time"], + search_util.create_date_filter(model.Post.creation_time), + ), + ( + [ + "last-edit-date", + "last-edit-time", + "edit-date", + "edit-time", + ], + search_util.create_date_filter(model.Post.last_edit_time), + ), + ( + ["comment-date", "comment-time"], + search_util.create_date_filter( + model.Post.last_comment_creation_time + ), + ), + ( + ["fav-date", "fav-time"], + search_util.create_date_filter( + model.Post.last_favorite_time + ), + ), + ( + ["feature-date", "feature-time"], + search_util.create_date_filter( + model.Post.last_feature_time + ), + ), + ( + ["safety", "rating"], + search_util.create_str_filter( + model.Post.safety, _safety_transformer + ), + ), + (["note-text"], _note_filter), + ( + ["flag"], + search_util.create_str_filter( + model.Post.flags_string, _flag_transformer + ), + ), + (["pool"], _pool_filter), + ] + )) return filters @property def sort_columns(self) -> Dict[str, Tuple[SaColumn, str]]: - filters = {'metric-' + name: + filters = {"metric-" + name: (_create_metric_sort_column(name), self.SORT_ASC) for name in self.all_metric_names} - filters.update(util.unalias_dict([ - ( - ['random'], - (sa.sql.expression.func.random(), self.SORT_NONE) - ), - - ( - ['id'], - (model.Post.post_id, self.SORT_DESC) - ), - - ( - ['score'], - (model.Post.score, self.SORT_DESC) - ), - - ( - ['tag-count'], - (model.Post.tag_count, self.SORT_DESC) - ), - - ( - ['comment-count'], - (model.Post.comment_count, self.SORT_DESC) - ), - - ( - ['fav-count'], - (model.Post.favorite_count, self.SORT_DESC) - ), - - ( - ['note-count'], - (model.Post.note_count, self.SORT_DESC) - ), - - ( - ['relation-count'], - (model.Post.relation_count, self.SORT_DESC) - ), - - ( - ['feature-count'], - (model.Post.feature_count, self.SORT_DESC) - ), - - ( - ['file-size'], - (model.Post.file_size, self.SORT_DESC) - ), - - ( - ['image-width', 'width'], - (model.Post.canvas_width, self.SORT_DESC) - ), - - ( - ['image-height', 'height'], - (model.Post.canvas_height, self.SORT_DESC) - ), - - ( - ['image-area', 'area'], - (model.Post.canvas_area, self.SORT_DESC) - ), - - ( - ['creation-date', 'creation-time', 'date', 'time'], - (model.Post.creation_time, self.SORT_DESC) - ), - - ( - ['last-edit-date', 'last-edit-time', 'edit-date', 'edit-time'], - (model.Post.last_edit_time, self.SORT_DESC) - ), - - ( - ['comment-date', 'comment-time'], - (model.Post.last_comment_creation_time, self.SORT_DESC) - ), - - ( - ['fav-date', 'fav-time'], - (model.Post.last_favorite_time, self.SORT_DESC) - ), - - ( - ['feature-date', 'feature-time'], - (model.Post.last_feature_time, self.SORT_DESC) - ), - ])) + filters.update(util.unalias_dict( + [ + ( + ["random"], + (sa.sql.expression.func.random(), self.SORT_NONE), + ), + (["id"], (model.Post.post_id, self.SORT_DESC)), + (["score"], (model.Post.score, self.SORT_DESC)), + (["tag-count"], (model.Post.tag_count, self.SORT_DESC)), + ( + ["comment-count"], + (model.Post.comment_count, self.SORT_DESC), + ), + (["fav-count"], (model.Post.favorite_count, self.SORT_DESC)), + (["note-count"], (model.Post.note_count, self.SORT_DESC)), + ( + ["relation-count"], + (model.Post.relation_count, self.SORT_DESC), + ), + ( + ["feature-count"], + (model.Post.feature_count, self.SORT_DESC), + ), + (["file-size"], (model.Post.file_size, self.SORT_DESC)), + ( + ["image-width", "width"], + (model.Post.canvas_width, self.SORT_DESC), + ), + ( + ["image-height", "height"], + (model.Post.canvas_height, self.SORT_DESC), + ), + ( + ["image-area", "area"], + (model.Post.canvas_area, self.SORT_DESC), + ), + ( + ["creation-date", "creation-time", "date", "time"], + (model.Post.creation_time, self.SORT_DESC), + ), + ( + [ + "last-edit-date", + "last-edit-time", + "edit-date", + "edit-time", + ], + (model.Post.last_edit_time, self.SORT_DESC), + ), + ( + ["comment-date", "comment-time"], + (model.Post.last_comment_creation_time, self.SORT_DESC), + ), + ( + ["fav-date", "fav-time"], + (model.Post.last_favorite_time, self.SORT_DESC), + ), + ( + ["feature-date", "feature-time"], + (model.Post.last_feature_time, self.SORT_DESC), + ), + ] + )) return filters @property def special_filters(self) -> Dict[str, Filter]: return { # handled by parser - 'fav': self.noop_filter, - 'liked': self.noop_filter, - 'disliked': self.noop_filter, - 'tumbleweed': self.tumbleweed_filter, + "fav": self.noop_filter, + "liked": self.noop_filter, + "disliked": self.noop_filter, + "tumbleweed": self.tumbleweed_filter, } def noop_filter( - self, - query: SaQuery, - _criterion: Optional[criteria.BaseCriterion], - _negated: bool) -> SaQuery: + self, + query: SaQuery, + _criterion: Optional[criteria.BaseCriterion], + _negated: bool, + ) -> SaQuery: return query def tumbleweed_filter( - self, - query: SaQuery, - _criterion: Optional[criteria.BaseCriterion], - negated: bool) -> SaQuery: + self, + query: SaQuery, + _criterion: Optional[criteria.BaseCriterion], + negated: bool, + ) -> SaQuery: expr = ( (model.Post.comment_count == 0) & (model.Post.favorite_count == 0) - & (model.Post.score == 0)) + & (model.Post.score == 0) + ) if negated: expr = ~expr return query.filter(expr) diff --git a/server/szurubooru/search/configs/snapshot_search_config.py b/server/szurubooru/search/configs/snapshot_search_config.py index 0fdb69d..df722d2 100644 --- a/server/szurubooru/search/configs/snapshot_search_config.py +++ b/server/szurubooru/search/configs/snapshot_search_config.py @@ -1,9 +1,12 @@ from typing import Dict + from szurubooru import db, model -from szurubooru.search.typing import SaQuery from szurubooru.search.configs import util as search_util from szurubooru.search.configs.base_search_config import ( - BaseSearchConfig, Filter) + BaseSearchConfig, + Filter, +) +from szurubooru.search.typing import SaQuery class SnapshotSearchConfig(BaseSearchConfig): @@ -22,16 +25,18 @@ class SnapshotSearchConfig(BaseSearchConfig): @property def named_filters(self) -> Dict[str, Filter]: return { - 'type': - search_util.create_str_filter(model.Snapshot.resource_type), - 'id': - search_util.create_str_filter(model.Snapshot.resource_name), - 'date': - search_util.create_date_filter(model.Snapshot.creation_time), - 'time': - search_util.create_date_filter(model.Snapshot.creation_time), - 'operation': - search_util.create_str_filter(model.Snapshot.operation), - 'user': - search_util.create_str_filter(model.User.name), + "type": search_util.create_str_filter( + model.Snapshot.resource_type + ), + "id": search_util.create_str_filter(model.Snapshot.resource_name), + "date": search_util.create_date_filter( + model.Snapshot.creation_time + ), + "time": search_util.create_date_filter( + model.Snapshot.creation_time + ), + "operation": search_util.create_str_filter( + model.Snapshot.operation + ), + "user": search_util.create_str_filter(model.User.name), } diff --git a/server/szurubooru/search/configs/tag_search_config.py b/server/szurubooru/search/configs/tag_search_config.py index db3b4b2..5d41603 100644 --- a/server/szurubooru/search/configs/tag_search_config.py +++ b/server/szurubooru/search/configs/tag_search_config.py @@ -1,19 +1,22 @@ -from typing import Tuple, Dict +from typing import Dict, Tuple + import sqlalchemy as sa + from szurubooru import db, model from szurubooru.func import util -from szurubooru.search.typing import SaColumn, SaQuery from szurubooru.search.configs import util as search_util from szurubooru.search.configs.base_search_config import ( - BaseSearchConfig, Filter) + BaseSearchConfig, + Filter, +) +from szurubooru.search.typing import SaColumn, SaQuery class TagSearchConfig(BaseSearchConfig): def create_filter_query(self, _disable_eager_loads: bool) -> SaQuery: strategy = ( - sa.orm.lazyload - if _disable_eager_loads - else sa.orm.subqueryload) + sa.orm.lazyload if _disable_eager_loads else sa.orm.subqueryload + ) return ( db.session.query(model.Tag) .join(model.TagCategory) @@ -24,7 +27,9 @@ class TagSearchConfig(BaseSearchConfig): sa.orm.defer(model.Tag.post_count), strategy(model.Tag.names), strategy(model.Tag.suggestions).joinedload(model.Tag.names), - strategy(model.Tag.implications).joinedload(model.Tag.names))) + strategy(model.Tag.implications).joinedload(model.Tag.names), + ) + ) def create_count_query(self, _disable_eager_loads: bool) -> SaQuery: return db.session.query(model.Tag) @@ -41,95 +46,93 @@ class TagSearchConfig(BaseSearchConfig): model.Tag.tag_id, model.TagName.tag_id, model.TagName.name, - search_util.create_str_filter) + search_util.create_str_filter, + ) @property def named_filters(self) -> Dict[str, Filter]: - return util.unalias_dict([ - ( - ['name'], - search_util.create_subquery_filter( - model.Tag.tag_id, - model.TagName.tag_id, - model.TagName.name, - search_util.create_str_filter) - ), - - ( - ['category'], - search_util.create_subquery_filter( - model.Tag.category_id, - model.TagCategory.tag_category_id, - model.TagCategory.name, - search_util.create_str_filter) - ), - - ( - ['creation-date', 'creation-time'], - search_util.create_date_filter(model.Tag.creation_time) - ), - - ( - ['last-edit-date', 'last-edit-time', 'edit-date', 'edit-time'], - search_util.create_date_filter(model.Tag.last_edit_time) - ), - - ( - ['usage-count', 'post-count', 'usages'], - search_util.create_num_filter(model.Tag.post_count) - ), - - ( - ['suggestion-count'], - search_util.create_num_filter(model.Tag.suggestion_count) - ), - - ( - ['implication-count'], - search_util.create_num_filter(model.Tag.implication_count) - ), - ]) + return util.unalias_dict( + [ + ( + ["name"], + search_util.create_subquery_filter( + model.Tag.tag_id, + model.TagName.tag_id, + model.TagName.name, + search_util.create_str_filter, + ), + ), + ( + ["category"], + search_util.create_subquery_filter( + model.Tag.category_id, + model.TagCategory.tag_category_id, + model.TagCategory.name, + search_util.create_str_filter, + ), + ), + ( + ["creation-date", "creation-time"], + search_util.create_date_filter(model.Tag.creation_time), + ), + ( + [ + "last-edit-date", + "last-edit-time", + "edit-date", + "edit-time", + ], + search_util.create_date_filter(model.Tag.last_edit_time), + ), + ( + ["usage-count", "post-count", "usages"], + search_util.create_num_filter(model.Tag.post_count), + ), + ( + ["suggestion-count"], + search_util.create_num_filter(model.Tag.suggestion_count), + ), + ( + ["implication-count"], + search_util.create_num_filter(model.Tag.implication_count), + ), + ] + ) @property def sort_columns(self) -> Dict[str, Tuple[SaColumn, str]]: - return util.unalias_dict([ - ( - ['random'], - (sa.sql.expression.func.random(), self.SORT_NONE) - ), - - ( - ['name'], - (model.Tag.first_name, self.SORT_ASC) - ), - - ( - ['category'], - (model.TagCategory.name, self.SORT_ASC) - ), - - ( - ['creation-date', 'creation-time'], - (model.Tag.creation_time, self.SORT_DESC) - ), - - ( - ['last-edit-date', 'last-edit-time', 'edit-date', 'edit-time'], - (model.Tag.last_edit_time, self.SORT_DESC) - ), - - ( - ['usage-count', 'post-count', 'usages'], - (model.Tag.post_count, self.SORT_DESC) - ), - - ( - ['suggestion-count'], - (model.Tag.suggestion_count, self.SORT_DESC) - ), - - ( - ['implication-count'], - (model.Tag.implication_count, self.SORT_DESC) - ), - ]) + return util.unalias_dict( + [ + ( + ["random"], + (sa.sql.expression.func.random(), self.SORT_NONE), + ), + (["name"], (model.Tag.first_name, self.SORT_ASC)), + (["category"], (model.TagCategory.name, self.SORT_ASC)), + ( + ["creation-date", "creation-time"], + (model.Tag.creation_time, self.SORT_DESC), + ), + ( + [ + "last-edit-date", + "last-edit-time", + "edit-date", + "edit-time", + ], + (model.Tag.last_edit_time, self.SORT_DESC), + ), + ( + ["usage-count", "post-count", "usages"], + (model.Tag.post_count, self.SORT_DESC), + ), + ( + ["suggestion-count"], + (model.Tag.suggestion_count, self.SORT_DESC), + ), + ( + ["implication-count"], + (model.Tag.implication_count, self.SORT_DESC), + ), + ] + ) diff --git a/server/szurubooru/search/configs/user_search_config.py b/server/szurubooru/search/configs/user_search_config.py index 6453400..bbf4034 100644 --- a/server/szurubooru/search/configs/user_search_config.py +++ b/server/szurubooru/search/configs/user_search_config.py @@ -1,10 +1,14 @@ -from typing import Tuple, Dict +from typing import Dict, Tuple + import sqlalchemy as sa + from szurubooru import db, model -from szurubooru.search.typing import SaColumn, SaQuery from szurubooru.search.configs import util as search_util from szurubooru.search.configs.base_search_config import ( - BaseSearchConfig, Filter) + BaseSearchConfig, + Filter, +) +from szurubooru.search.typing import SaColumn, SaQuery class UserSearchConfig(BaseSearchConfig): @@ -27,31 +31,36 @@ class UserSearchConfig(BaseSearchConfig): @property def named_filters(self) -> Dict[str, Filter]: return { - 'name': - search_util.create_str_filter(model.User.name), - 'creation-date': - search_util.create_date_filter(model.User.creation_time), - 'creation-time': - search_util.create_date_filter(model.User.creation_time), - 'last-login-date': - search_util.create_date_filter(model.User.last_login_time), - 'last-login-time': - search_util.create_date_filter(model.User.last_login_time), - 'login-date': - search_util.create_date_filter(model.User.last_login_time), - 'login-time': - search_util.create_date_filter(model.User.last_login_time), + "name": search_util.create_str_filter(model.User.name), + "creation-date": search_util.create_date_filter( + model.User.creation_time + ), + "creation-time": search_util.create_date_filter( + model.User.creation_time + ), + "last-login-date": search_util.create_date_filter( + model.User.last_login_time + ), + "last-login-time": search_util.create_date_filter( + model.User.last_login_time + ), + "login-date": search_util.create_date_filter( + model.User.last_login_time + ), + "login-time": search_util.create_date_filter( + model.User.last_login_time + ), } @property def sort_columns(self) -> Dict[str, Tuple[SaColumn, str]]: return { - 'random': (sa.sql.expression.func.random(), self.SORT_NONE), - 'name': (model.User.name, self.SORT_ASC), - 'creation-date': (model.User.creation_time, self.SORT_DESC), - 'creation-time': (model.User.creation_time, self.SORT_DESC), - 'last-login-date': (model.User.last_login_time, self.SORT_DESC), - 'last-login-time': (model.User.last_login_time, self.SORT_DESC), - 'login-date': (model.User.last_login_time, self.SORT_DESC), - 'login-time': (model.User.last_login_time, self.SORT_DESC), + "random": (sa.sql.expression.func.random(), self.SORT_NONE), + "name": (model.User.name, self.SORT_ASC), + "creation-date": (model.User.creation_time, self.SORT_DESC), + "creation-time": (model.User.creation_time, self.SORT_DESC), + "last-login-date": (model.User.last_login_time, self.SORT_DESC), + "last-login-time": (model.User.last_login_time, self.SORT_DESC), + "login-date": (model.User.last_login_time, self.SORT_DESC), + "login-time": (model.User.last_login_time, self.SORT_DESC), } diff --git a/server/szurubooru/search/configs/util.py b/server/szurubooru/search/configs/util.py index 183dbab..659f546 100644 --- a/server/szurubooru/search/configs/util.py +++ b/server/szurubooru/search/configs/util.py @@ -1,33 +1,36 @@ -from typing import Any, Optional, Union, Dict, Callable +from typing import Any, Callable, Dict, Optional, Union + import sqlalchemy as sa + from szurubooru import db, errors from szurubooru.func import util from szurubooru.search import criteria -from szurubooru.search.typing import SaColumn, SaQuery from szurubooru.search.configs.base_search_config import Filter - +from szurubooru.search.typing import SaColumn, SaQuery Number = Union[int, float] -WILDCARD = '(--wildcard--)' # something unlikely to be used by the users +WILDCARD = "(--wildcard--)" # something unlikely to be used by the users def unescape(text: str, make_wildcards_special: bool = False) -> str: - output = '' + output = "" i = 0 while i < len(text): - if text[i] == '\\': + if text[i] == "\\": try: - char = text[i+1] + char = text[i + 1] i += 1 except IndexError: raise errors.SearchError( - 'Unterminated escape sequence (did you forget to escape ' - 'the ending backslash?)') - if char not in '*\\:-.,': + "Unterminated escape sequence (did you forget to escape " + "the ending backslash?)" + ) + if char not in "*\\:-.,": raise errors.SearchError( - 'Unknown escape sequence (did you forget to escape ' - 'the backslash?)') - elif text[i] == '*' and make_wildcards_special: + "Unknown escape sequence (did you forget to escape " + "the backslash?)" + ) + elif text[i] == "*" and make_wildcards_special: char = WILDCARD else: char = text[i] @@ -39,10 +42,11 @@ def unescape(text: str, make_wildcards_special: bool = False) -> str: def wildcard_transformer(value: str) -> str: return ( unescape(value, make_wildcards_special=True) - .replace('\\', '\\\\') - .replace('%', '\\%') - .replace('_', '\\_') - .replace(WILDCARD, '%')) + .replace("\\", "\\\\") + .replace("%", "\\%") + .replace("_", "\\_") + .replace(WILDCARD, "%") + ) def enum_transformer(available_values: Dict[str, Any], value: str) -> str: @@ -50,8 +54,9 @@ def enum_transformer(available_values: Dict[str, Any], value: str) -> str: return available_values[unescape(value.lower())] except KeyError: raise errors.SearchError( - 'Invalid value: %r. Possible values: %r.' % ( - value, list(sorted(available_values.keys())))) + "Invalid value: %r. Possible values: %r." + % (value, list(sorted(available_values.keys()))) + ) def integer_transformer(value: str) -> int: @@ -59,7 +64,7 @@ def integer_transformer(value: str) -> int: def float_transformer(value: str) -> float: - for sep in list('/:'): + for sep in list("/:"): if sep in value: a, b = value.split(sep, 1) return float(unescape(a)) / float(unescape(b)) @@ -67,9 +72,10 @@ def float_transformer(value: str) -> float: def apply_num_criterion_to_column( - column: Any, - criterion: criteria.BaseCriterion, - transformer: Callable[[str], Number] = integer_transformer) -> SaQuery: + column: Any, + criterion: criteria.BaseCriterion, + transformer: Callable[[str], Number] = integer_transformer, +) -> SaQuery: try: if isinstance(criterion, criteria.PlainCriterion): expr = column == transformer(criterion.value) @@ -80,7 +86,8 @@ def apply_num_criterion_to_column( if criterion.min_value and criterion.max_value: expr = column.between( transformer(criterion.min_value), - transformer(criterion.max_value)) + transformer(criterion.max_value), + ) elif criterion.min_value: expr = column >= transformer(criterion.min_value) elif criterion.max_value: @@ -89,22 +96,25 @@ def apply_num_criterion_to_column( assert False except ValueError: raise errors.SearchError( - 'Criterion value %r must be a number.' % (criterion,)) + "Criterion value %r must be a number." % (criterion,) + ) return expr def create_num_filter( - column: Any, - transformer: Callable[[str], Number] = integer_transformer) -> SaQuery: + column: Any, transformer: Callable[[str], Number] = integer_transformer +) -> SaQuery: def wrapper( - query: SaQuery, - criterion: Optional[criteria.BaseCriterion], - negated: bool) -> SaQuery: + query: SaQuery, + criterion: Optional[criteria.BaseCriterion], + negated: bool, + ) -> SaQuery: assert criterion expr = apply_num_criterion_to_column(column, criterion, transformer) if negated: expr = ~expr return query.filter(expr) + return wrapper @@ -113,9 +123,10 @@ def create_float_filter(column: Any) -> SaQuery: def apply_str_criterion_to_column( - column: SaColumn, - criterion: criteria.BaseCriterion, - transformer: Callable[[str], str] = wildcard_transformer) -> SaQuery: + column: SaColumn, + criterion: criteria.BaseCriterion, + transformer: Callable[[str], str] = wildcard_transformer, +) -> SaQuery: if isinstance(criterion, criteria.PlainCriterion): expr = column.ilike(transformer(criterion.value)) elif isinstance(criterion, criteria.ArrayCriterion): @@ -124,30 +135,34 @@ def apply_str_criterion_to_column( expr = expr | column.ilike(transformer(value)) elif isinstance(criterion, criteria.RangedCriterion): raise errors.SearchError( - 'Ranged criterion is invalid in this context. ' - 'Did you forget to escape the dots?') + "Ranged criterion is invalid in this context. " + "Did you forget to escape the dots?" + ) else: assert False return expr def create_str_filter( - column: SaColumn, - transformer: Callable[[str], str] = wildcard_transformer) -> Filter: + column: SaColumn, transformer: Callable[[str], str] = wildcard_transformer +) -> Filter: def wrapper( - query: SaQuery, - criterion: Optional[criteria.BaseCriterion], - negated: bool) -> SaQuery: + query: SaQuery, + criterion: Optional[criteria.BaseCriterion], + negated: bool, + ) -> SaQuery: assert criterion expr = apply_str_criterion_to_column(column, criterion, transformer) if negated: expr = ~expr return query.filter(expr) + return wrapper def apply_date_criterion_to_column( - column: SaQuery, criterion: criteria.BaseCriterion) -> SaQuery: + column: SaQuery, criterion: criteria.BaseCriterion +) -> SaQuery: if isinstance(criterion, criteria.PlainCriterion): min_date, max_date = util.parse_time_range(criterion.value) expr = column.between(min_date, max_date) @@ -175,36 +190,40 @@ def apply_date_criterion_to_column( def create_date_filter(column: SaColumn) -> Filter: def wrapper( - query: SaQuery, - criterion: Optional[criteria.BaseCriterion], - negated: bool) -> SaQuery: + query: SaQuery, + criterion: Optional[criteria.BaseCriterion], + negated: bool, + ) -> SaQuery: assert criterion expr = apply_date_criterion_to_column(column, criterion) if negated: expr = ~expr return query.filter(expr) + return wrapper def create_subquery_filter( - left_id_column: SaColumn, - right_id_column: SaColumn, - filter_column: SaColumn, - filter_factory: SaColumn, - subquery_decorator: Callable[[SaQuery], None] = None) -> Filter: + left_id_column: SaColumn, + right_id_column: SaColumn, + filter_column: SaColumn, + filter_factory: SaColumn, + subquery_decorator: Callable[[SaQuery], None] = None, +) -> Filter: filter_func = filter_factory(filter_column) def wrapper( - query: SaQuery, - criterion: Optional[criteria.BaseCriterion], - negated: bool) -> SaQuery: + query: SaQuery, + criterion: Optional[criteria.BaseCriterion], + negated: bool, + ) -> SaQuery: assert criterion - subquery = db.session.query(right_id_column.label('foreign_id')) + subquery = db.session.query(right_id_column.label("foreign_id")) if subquery_decorator: subquery = subquery_decorator(subquery) - subquery = subquery.options(sa.orm.lazyload('*')) + subquery = subquery.options(sa.orm.lazyload("*")) subquery = filter_func(subquery, criterion, False) - subquery = subquery.subquery('t') + subquery = subquery.subquery("t") expression = left_id_column.in_(subquery) if negated: expression = ~expression diff --git a/server/szurubooru/search/criteria.py b/server/szurubooru/search/criteria.py index 4512b0f..633c6f2 100644 --- a/server/szurubooru/search/criteria.py +++ b/server/szurubooru/search/criteria.py @@ -1,4 +1,5 @@ -from typing import Optional, List +from typing import List, Optional + from szurubooru.search.typing import SaQuery @@ -12,16 +13,17 @@ class BaseCriterion: class RangedCriterion(BaseCriterion): def __init__( - self, - original_text: str, - min_value: Optional[str], - max_value: Optional[str]) -> None: + self, + original_text: str, + min_value: Optional[str], + max_value: Optional[str], + ) -> None: super().__init__(original_text) self.min_value = min_value self.max_value = max_value def __hash__(self) -> int: - return hash(('range', self.min_value, self.max_value)) + return hash(("range", self.min_value, self.max_value)) class PlainCriterion(BaseCriterion): @@ -39,4 +41,4 @@ class ArrayCriterion(BaseCriterion): self.values = values def __hash__(self) -> int: - return hash(tuple(['array'] + self.values)) + return hash(tuple(["array"] + self.values)) diff --git a/server/szurubooru/search/executor.py b/server/szurubooru/search/executor.py index 3a8ec68..92a7e2f 100644 --- a/server/szurubooru/search/executor.py +++ b/server/szurubooru/search/executor.py @@ -1,11 +1,13 @@ -from typing import Union, Tuple, List, Dict, Callable +from typing import Callable, Dict, List, Tuple, Union + import sqlalchemy as sa -from szurubooru import db, model, errors, rest + +from szurubooru import db, errors, model, rest from szurubooru.func import cache -from szurubooru.search import tokens, parser -from szurubooru.search.typing import SaQuery -from szurubooru.search.query import SearchQuery +from szurubooru.search import parser, tokens from szurubooru.search.configs.base_search_config import BaseSearchConfig +from szurubooru.search.query import SearchQuery +from szurubooru.search.typing import SaQuery def _format_dict_keys(source: Dict) -> List[str]: @@ -25,10 +27,10 @@ def _get_order(order: str, default_order: str) -> Union[bool, str]: class Executor: - ''' + """ Class for search parsing and execution. Handles plaintext parsing and delegates sqlalchemy filter decoration to SearchConfig instances. - ''' + """ AROUND_NEXT = 'up' AROUND_PREV = 'down' @@ -37,9 +39,8 @@ class Executor: self.parser = parser.Parser() def get_around( - self, - query_text: str, - entity_id: int) -> Tuple[model.Base, model.Base, model.Base]: + self, query_text: str, entity_id: int + ) -> Tuple[model.Base, model.Base, model.Base]: search_query = self.parser.parse(query_text) self.config.on_search_query_parsed(search_query) filter_query = ( @@ -68,21 +69,19 @@ class Executor: self, ctx: rest.Context, entity_id: int, - serializer: Callable[[model.Base], rest.Response] + serializer: Callable[[model.Base], rest.Response], ) -> rest.Response: entities = self.get_around( - ctx.get_param_as_string('query', default=''), entity_id) + ctx.get_param_as_string("query", default=""), entity_id + ) return { - 'prev': serializer(entities[0]), - 'next': serializer(entities[1]), - 'random': serializer(entities[2]), + "prev": serializer(entities[0]), + "next": serializer(entities[1]), + "random": serializer(entities[2]), } def execute( - self, - query_text: str, - offset: int, - limit: int + self, query_text: str, offset: int, limit: int ) -> Tuple[int, List[model.Base]]: search_query = self.parser.parse(query_text) self.config.on_search_query_parsed(search_query) @@ -93,7 +92,7 @@ class Executor: disable_eager_loads = False for token in search_query.sort_tokens: - if token.name == 'random': + if token.name == "random": disable_eager_loads = True key = (id(self.config), hash(search_query), offset, limit) @@ -101,22 +100,16 @@ class Executor: return cache.get(key) filter_query = self.config.create_filter_query(disable_eager_loads) - filter_query = filter_query.options(sa.orm.lazyload('*')) + filter_query = filter_query.options(sa.orm.lazyload("*")) filter_query = self._prepare_db_query(filter_query, search_query, True) - entities = ( - filter_query - .offset(offset) - .limit(limit) - .all()) + entities = filter_query.offset(offset).limit(limit).all() count_query = self.config.create_count_query(disable_eager_loads) - count_query = count_query.options(sa.orm.lazyload('*')) + count_query = count_query.options(sa.orm.lazyload("*")) count_query = self._prepare_db_query(count_query, search_query, False) - count_statement = ( - count_query - .statement - .with_only_columns([sa.func.count()]) - .order_by(None)) + count_statement = count_query.statement.with_only_columns( + [sa.func.count()] + ).order_by(None) count = db.session.execute(count_statement).scalar() ret = (count, entities) @@ -126,18 +119,18 @@ class Executor: def execute_and_serialize( self, ctx: rest.Context, - serializer: Callable[[model.Base], rest.Response] + serializer: Callable[[model.Base], rest.Response], ) -> rest.Response: - query = ctx.get_param_as_string('query', default='') - offset = ctx.get_param_as_int('offset', default=0, min=0) - limit = ctx.get_param_as_int('limit', default=100, min=1, max=100) + query = ctx.get_param_as_string("query", default="") + offset = ctx.get_param_as_int("offset", default=0, min=0) + limit = ctx.get_param_as_int("limit", default=100, min=1, max=100) count, entities = self.execute(query, offset, limit) return { - 'query': query, - 'offset': offset, - 'limit': limit, - 'total': count, - 'results': list([serializer(entity) for entity in entities]), + "query": query, + "offset": offset, + "limit": limit, + "total": count, + "results": list([serializer(entity) for entity in entities]), } def count(self, query_text:str) -> int: @@ -155,46 +148,58 @@ class Executor: return count def _prepare_db_query( - self, - db_query: SaQuery, - search_query: SearchQuery, - use_sort: bool) -> SaQuery: + self, db_query: SaQuery, search_query: SearchQuery, use_sort: bool + ) -> SaQuery: for anon_token in search_query.anonymous_tokens: if not self.config.anonymous_filter: raise errors.SearchError( - 'Anonymous tokens are not valid in this context.') + "Anonymous tokens are not valid in this context." + ) db_query = self.config.anonymous_filter( - db_query, anon_token.criterion, anon_token.negated) + db_query, anon_token.criterion, anon_token.negated + ) for named_token in search_query.named_tokens: if named_token.name not in self.config.named_filters: raise errors.SearchError( - 'Unknown named token: %r. Available named tokens: %r.' % ( + "Unknown named token: %r. Available named tokens: %r." + % ( named_token.name, - _format_dict_keys(self.config.named_filters))) + _format_dict_keys(self.config.named_filters), + ) + ) db_query = self.config.named_filters[named_token.name]( - db_query, named_token.criterion, named_token.negated) + db_query, named_token.criterion, named_token.negated + ) for sp_token in search_query.special_tokens: if sp_token.value not in self.config.special_filters: raise errors.SearchError( - 'Unknown special token: %r. ' - 'Available special tokens: %r.' % ( + "Unknown special token: %r. " + "Available special tokens: %r." + % ( sp_token.value, - _format_dict_keys(self.config.special_filters))) + _format_dict_keys(self.config.special_filters), + ) + ) db_query = self.config.special_filters[sp_token.value]( - db_query, None, sp_token.negated) + db_query, None, sp_token.negated + ) if use_sort: for sort_token in search_query.sort_tokens: if sort_token.name not in self.config.sort_columns: raise errors.SearchError( - 'Unknown sort token: %r. ' - 'Available sort tokens: %r.' % ( + "Unknown sort token: %r. " + "Available sort tokens: %r." + % ( sort_token.name, - _format_dict_keys(self.config.sort_columns))) - column, default_order = ( - self.config.sort_columns[sort_token.name]) + _format_dict_keys(self.config.sort_columns), + ) + ) + column, default_order = self.config.sort_columns[ + sort_token.name + ] order = _get_order(sort_token.order, default_order) if order == sort_token.SORT_ASC: db_query = db_query.order_by(column.asc()) diff --git a/server/szurubooru/search/parser.py b/server/szurubooru/search/parser.py index 79cf968..d5e0f2f 100644 --- a/server/szurubooru/search/parser.py +++ b/server/szurubooru/search/parser.py @@ -1,21 +1,23 @@ import re + from szurubooru import errors from szurubooru.search import criteria, tokens -from szurubooru.search.query import SearchQuery from szurubooru.search.configs import util +from szurubooru.search.query import SearchQuery def _create_criterion( - original_value: str, value: str) -> criteria.BaseCriterion: - if re.search(r'(?<!\\),', value): - values = re.split(r'(?<!\\),', value) + original_value: str, value: str +) -> criteria.BaseCriterion: + if re.search(r"(?<!\\),", value): + values = re.split(r"(?<!\\),", value) if any(not term.strip() for term in values): - raise errors.SearchError('Empty compound value') + raise errors.SearchError("Empty compound value") return criteria.ArrayCriterion(original_value, values) - if re.search(r'(?<!\\)\.(?<!\\)\.', value): - low, high = re.split(r'(?<!\\)\.(?<!\\)\.', value, 1) + if re.search(r"(?<!\\)\.(?<!\\)\.", value): + low, high = re.split(r"(?<!\\)\.(?<!\\)\.", value, 1) if not low and not high: - raise errors.SearchError('Empty ranged value') + raise errors.SearchError("Empty ranged value") return criteria.RangedCriterion(original_value, low, high) return criteria.PlainCriterion(original_value, value) @@ -27,12 +29,12 @@ def _parse_anonymous(value: str, negated: bool) -> tokens.AnonymousToken: def _parse_named(key: str, value: str, negated: bool) -> tokens.NamedToken: original_value = value - if key.endswith('-min'): + if key.endswith("-min"): key = key[:-4] - value += '..' - elif key.endswith('-max'): + value += ".." + elif key.endswith("-max"): key = key[:-4] - value = '..' + value + value = ".." + value criterion = _create_criterion(original_value, value) return tokens.NamedToken(key, criterion, negated) @@ -42,32 +44,27 @@ def _parse_special(value: str, negated: bool) -> tokens.SpecialToken: def _parse_sort(value: str, negated: bool) -> tokens.SortToken: - if value.count(',') == 0: + if value.count(",") == 0: order_str = None - elif value.count(',') == 1: - value, order_str = value.split(',') + elif value.count(",") == 1: + value, order_str = value.split(",") else: - raise errors.SearchError('Too many commas in sort style token.') + raise errors.SearchError("Too many commas in sort style token.") try: order = { - 'asc': tokens.SortToken.SORT_ASC, - 'desc': tokens.SortToken.SORT_DESC, - '': tokens.SortToken.SORT_DEFAULT, + "asc": tokens.SortToken.SORT_ASC, + "desc": tokens.SortToken.SORT_DESC, + "": tokens.SortToken.SORT_DEFAULT, None: tokens.SortToken.SORT_DEFAULT, }[order_str] except KeyError: - raise errors.SearchError( - 'Unknown search direction: %r.' % order_str) + raise errors.SearchError("Unknown search direction: %r." % order_str) if negated: order = { - tokens.SortToken.SORT_ASC: - tokens.SortToken.SORT_DESC, - tokens.SortToken.SORT_DESC: - tokens.SortToken.SORT_ASC, - tokens.SortToken.SORT_DEFAULT: - tokens.SortToken.SORT_NEGATED_DEFAULT, - tokens.SortToken.SORT_NEGATED_DEFAULT: - tokens.SortToken.SORT_DEFAULT, + tokens.SortToken.SORT_ASC: tokens.SortToken.SORT_DESC, + tokens.SortToken.SORT_DESC: tokens.SortToken.SORT_ASC, + tokens.SortToken.SORT_DEFAULT: tokens.SortToken.SORT_NEGATED_DEFAULT, # noqa: E501 + tokens.SortToken.SORT_NEGATED_DEFAULT: tokens.SortToken.SORT_DEFAULT, # noqa: E501 }[order] return tokens.SortToken(value, order) @@ -75,29 +72,27 @@ def _parse_sort(value: str, negated: bool) -> tokens.SortToken: class Parser: def parse(self, query_text: str) -> SearchQuery: query = SearchQuery() - for chunk in re.split(r'\s+', (query_text or '').lower()): + for chunk in re.split(r"\s+", (query_text or "").lower()): if not chunk: continue negated = False - if chunk[0] == '-': + if chunk[0] == "-": chunk = chunk[1:] negated = True if not chunk: - raise errors.SearchError('Empty negated token.') - match = re.match(r'^(.*?)(?<!\\):(.*)$', chunk) + raise errors.SearchError("Empty negated token.") + match = re.match(r"^(.*?)(?<!\\):(.*)$", chunk) if match: key, value = list(match.groups()) key = util.unescape(key) - if key == 'sort': - query.sort_tokens.append( - _parse_sort(value, negated)) - elif key == 'special': - query.special_tokens.append( - _parse_special(value, negated)) + if key == "sort": + query.sort_tokens.append(_parse_sort(value, negated)) + elif key == "special": + query.special_tokens.append(_parse_special(value, negated)) else: query.named_tokens.append( - _parse_named(key, value, negated)) + _parse_named(key, value, negated) + ) else: - query.anonymous_tokens.append( - _parse_anonymous(chunk, negated)) + query.anonymous_tokens.append(_parse_anonymous(chunk, negated)) return query diff --git a/server/szurubooru/search/query.py b/server/szurubooru/search/query.py index 3d304f8..50255d6 100644 --- a/server/szurubooru/search/query.py +++ b/server/szurubooru/search/query.py @@ -1,6 +1,7 @@ -from szurubooru.search import tokens from typing import List +from szurubooru.search import tokens + class SearchQuery: def __init__(self) -> None: @@ -10,8 +11,11 @@ class SearchQuery: self.sort_tokens = [] # type: List[tokens.SortToken] def __hash__(self) -> int: - return hash(( - tuple(self.anonymous_tokens), - tuple(self.named_tokens), - tuple(self.special_tokens), - tuple(self.sort_tokens))) + return hash( + ( + tuple(self.anonymous_tokens), + tuple(self.named_tokens), + tuple(self.special_tokens), + tuple(self.sort_tokens), + ) + ) diff --git a/server/szurubooru/search/tokens.py b/server/szurubooru/search/tokens.py index 0cd7fd7..9f4eeed 100644 --- a/server/szurubooru/search/tokens.py +++ b/server/szurubooru/search/tokens.py @@ -12,7 +12,8 @@ class AnonymousToken: class NamedToken(AnonymousToken): def __init__( - self, name: str, criterion: BaseCriterion, negated: bool) -> None: + self, name: str, criterion: BaseCriterion, negated: bool + ) -> None: super().__init__(criterion, negated) self.name = name @@ -21,11 +22,11 @@ class NamedToken(AnonymousToken): class SortToken: - SORT_DESC = 'desc' - SORT_ASC = 'asc' - SORT_NONE = '' - SORT_DEFAULT = 'default' - SORT_NEGATED_DEFAULT = 'negated default' + SORT_DESC = "desc" + SORT_ASC = "asc" + SORT_NONE = "" + SORT_DEFAULT = "default" + SORT_NEGATED_DEFAULT = "negated default" def __init__(self, name: str, order: str) -> None: self.name = name diff --git a/server/szurubooru/search/typing.py b/server/szurubooru/search/typing.py index ebb1b30..686c2cb 100644 --- a/server/szurubooru/search/typing.py +++ b/server/szurubooru/search/typing.py @@ -1,6 +1,5 @@ from typing import Any, Callable - SaColumn = Any SaQuery = Any SaQueryFactory = Callable[[], SaQuery] diff --git a/server/szurubooru/tests/api/test_comment_creating.py b/server/szurubooru/tests/api/test_comment_creating.py index ad24366..b16ce65 100644 --- a/server/szurubooru/tests/api/test_comment_creating.py +++ b/server/szurubooru/tests/api/test_comment_creating.py @@ -1,70 +1,82 @@ from datetime import datetime from unittest.mock import patch + import pytest -from szurubooru import api, db, model, errors + +from szurubooru import api, db, errors, model from szurubooru.func import comments, posts @pytest.fixture(autouse=True) def inject_config(config_injector): config_injector( - {'privileges': {'comments:create': model.User.RANK_REGULAR}}) + {"privileges": {"comments:create": model.User.RANK_REGULAR}} + ) def test_creating_comment( - user_factory, post_factory, context_factory, fake_datetime): + user_factory, post_factory, context_factory, fake_datetime +): post = post_factory() user = user_factory(rank=model.User.RANK_REGULAR) db.session.add_all([post, user]) db.session.flush() - with patch('szurubooru.func.comments.serialize_comment'), \ - fake_datetime('1997-01-01'): - comments.serialize_comment.return_value = 'serialized comment' + with patch("szurubooru.func.comments.serialize_comment"), fake_datetime( + "1997-01-01" + ): + comments.serialize_comment.return_value = "serialized comment" result = api.comment_api.create_comment( context_factory( - params={'text': 'input', 'postId': post.post_id}, - user=user)) - assert result == 'serialized comment' + params={"text": "input", "postId": post.post_id}, user=user + ) + ) + assert result == "serialized comment" comment = db.session.query(model.Comment).one() - assert comment.text == 'input' + assert comment.text == "input" assert comment.creation_time == datetime(1997, 1, 1) assert comment.last_edit_time is None assert comment.user and comment.user.user_id == user.user_id assert comment.post and comment.post.post_id == post.post_id -@pytest.mark.parametrize('params', [ - {'text': None}, - {'text': ''}, - {'text': [None]}, - {'text': ['']}, -]) +@pytest.mark.parametrize( + "params", + [ + {"text": None}, + {"text": ""}, + {"text": [None]}, + {"text": [""]}, + ], +) def test_trying_to_pass_invalid_params( - user_factory, post_factory, context_factory, params): + user_factory, post_factory, context_factory, params +): post = post_factory() user = user_factory(rank=model.User.RANK_REGULAR) db.session.add_all([post, user]) db.session.flush() - real_params = {'text': 'input', 'postId': post.post_id} + real_params = {"text": "input", "postId": post.post_id} for key, value in params.items(): real_params[key] = value with pytest.raises(errors.ValidationError): api.comment_api.create_comment( - context_factory(params=real_params, user=user)) + context_factory(params=real_params, user=user) + ) -@pytest.mark.parametrize('field', ['text', 'postId']) +@pytest.mark.parametrize("field", ["text", "postId"]) def test_trying_to_omit_mandatory_field(user_factory, context_factory, field): params = { - 'text': 'input', - 'postId': 1, + "text": "input", + "postId": 1, } del params[field] with pytest.raises(errors.ValidationError): api.comment_api.create_comment( context_factory( - params={}, - user=user_factory(rank=model.User.RANK_REGULAR))) + params={}, user=user_factory(rank=model.User.RANK_REGULAR) + ) + ) def test_trying_to_comment_non_existing(user_factory, context_factory): @@ -73,13 +85,14 @@ def test_trying_to_comment_non_existing(user_factory, context_factory): db.session.flush() with pytest.raises(posts.PostNotFoundError): api.comment_api.create_comment( - context_factory( - params={'text': 'bad', 'postId': 5}, user=user)) + context_factory(params={"text": "bad", "postId": 5}, user=user) + ) def test_trying_to_create_without_privileges(user_factory, context_factory): with pytest.raises(errors.AuthError): api.comment_api.create_comment( context_factory( - params={}, - user=user_factory(rank=model.User.RANK_ANONYMOUS))) + params={}, user=user_factory(rank=model.User.RANK_ANONYMOUS) + ) + ) diff --git a/server/szurubooru/tests/api/test_comment_deleting.py b/server/szurubooru/tests/api/test_comment_deleting.py index e1d1baa..71df1ff 100644 --- a/server/szurubooru/tests/api/test_comment_deleting.py +++ b/server/szurubooru/tests/api/test_comment_deleting.py @@ -1,16 +1,19 @@ import pytest -from szurubooru import api, db, model, errors + +from szurubooru import api, db, errors, model from szurubooru.func import comments @pytest.fixture(autouse=True) def inject_config(config_injector): - config_injector({ - 'privileges': { - 'comments:delete:own': model.User.RANK_REGULAR, - 'comments:delete:any': model.User.RANK_MODERATOR, - }, - }) + config_injector( + { + "privileges": { + "comments:delete:own": model.User.RANK_REGULAR, + "comments:delete:any": model.User.RANK_MODERATOR, + }, + } + ) def test_deleting_own_comment(user_factory, comment_factory, context_factory): @@ -19,27 +22,31 @@ def test_deleting_own_comment(user_factory, comment_factory, context_factory): db.session.add(comment) db.session.commit() result = api.comment_api.delete_comment( - context_factory(params={'version': 1}, user=user), - {'comment_id': comment.comment_id}) + context_factory(params={"version": 1}, user=user), + {"comment_id": comment.comment_id}, + ) assert result == {} assert db.session.query(model.Comment).count() == 0 def test_deleting_someones_else_comment( - user_factory, comment_factory, context_factory): + user_factory, comment_factory, context_factory +): user1 = user_factory(rank=model.User.RANK_REGULAR) user2 = user_factory(rank=model.User.RANK_MODERATOR) comment = comment_factory(user=user1) db.session.add(comment) db.session.commit() api.comment_api.delete_comment( - context_factory(params={'version': 1}, user=user2), - {'comment_id': comment.comment_id}) + context_factory(params={"version": 1}, user=user2), + {"comment_id": comment.comment_id}, + ) assert db.session.query(model.Comment).count() == 0 def test_trying_to_delete_someones_else_comment_without_privileges( - user_factory, comment_factory, context_factory): + user_factory, comment_factory, context_factory +): user1 = user_factory(rank=model.User.RANK_REGULAR) user2 = user_factory(rank=model.User.RANK_REGULAR) comment = comment_factory(user=user1) @@ -47,8 +54,9 @@ def test_trying_to_delete_someones_else_comment_without_privileges( db.session.commit() with pytest.raises(errors.AuthError): api.comment_api.delete_comment( - context_factory(params={'version': 1}, user=user2), - {'comment_id': comment.comment_id}) + context_factory(params={"version": 1}, user=user2), + {"comment_id": comment.comment_id}, + ) assert db.session.query(model.Comment).count() == 1 @@ -56,6 +64,8 @@ def test_trying_to_delete_non_existing(user_factory, context_factory): with pytest.raises(comments.CommentNotFoundError): api.comment_api.delete_comment( context_factory( - params={'version': 1}, - user=user_factory(rank=model.User.RANK_REGULAR)), - {'comment_id': 1}) + params={"version": 1}, + user=user_factory(rank=model.User.RANK_REGULAR), + ), + {"comment_id": 1}, + ) diff --git a/server/szurubooru/tests/api/test_comment_rating.py b/server/szurubooru/tests/api/test_comment_rating.py index aae5e24..efb4bc2 100644 --- a/server/szurubooru/tests/api/test_comment_rating.py +++ b/server/szurubooru/tests/api/test_comment_rating.py @@ -1,116 +1,134 @@ from unittest.mock import patch + import pytest -from szurubooru import api, db, model, errors + +from szurubooru import api, db, errors, model from szurubooru.func import comments @pytest.fixture(autouse=True) def inject_config(config_injector): config_injector( - {'privileges': {'comments:score': model.User.RANK_REGULAR}}) + {"privileges": {"comments:score": model.User.RANK_REGULAR}} + ) def test_simple_rating( - user_factory, comment_factory, context_factory, fake_datetime): + user_factory, comment_factory, context_factory, fake_datetime +): user = user_factory(rank=model.User.RANK_REGULAR) comment = comment_factory(user=user) db.session.add(comment) db.session.commit() - with patch('szurubooru.func.comments.serialize_comment'), \ - fake_datetime('1997-12-01'): - comments.serialize_comment.return_value = 'serialized comment' + with patch("szurubooru.func.comments.serialize_comment"), fake_datetime( + "1997-12-01" + ): + comments.serialize_comment.return_value = "serialized comment" result = api.comment_api.set_comment_score( - context_factory(params={'score': 1}, user=user), - {'comment_id': comment.comment_id}) - assert result == 'serialized comment' + context_factory(params={"score": 1}, user=user), + {"comment_id": comment.comment_id}, + ) + assert result == "serialized comment" assert db.session.query(model.CommentScore).count() == 1 assert comment is not None assert comment.score == 1 def test_updating_rating( - user_factory, comment_factory, context_factory, fake_datetime): + user_factory, comment_factory, context_factory, fake_datetime +): user = user_factory(rank=model.User.RANK_REGULAR) comment = comment_factory(user=user) db.session.add(comment) db.session.commit() - with patch('szurubooru.func.comments.serialize_comment'): - with fake_datetime('1997-12-01'): + with patch("szurubooru.func.comments.serialize_comment"): + with fake_datetime("1997-12-01"): api.comment_api.set_comment_score( - context_factory(params={'score': 1}, user=user), - {'comment_id': comment.comment_id}) - with fake_datetime('1997-12-02'): + context_factory(params={"score": 1}, user=user), + {"comment_id": comment.comment_id}, + ) + with fake_datetime("1997-12-02"): api.comment_api.set_comment_score( - context_factory(params={'score': -1}, user=user), - {'comment_id': comment.comment_id}) + context_factory(params={"score": -1}, user=user), + {"comment_id": comment.comment_id}, + ) comment = db.session.query(model.Comment).one() assert db.session.query(model.CommentScore).count() == 1 assert comment.score == -1 def test_updating_rating_to_zero( - user_factory, comment_factory, context_factory, fake_datetime): + user_factory, comment_factory, context_factory, fake_datetime +): user = user_factory(rank=model.User.RANK_REGULAR) comment = comment_factory(user=user) db.session.add(comment) db.session.commit() - with patch('szurubooru.func.comments.serialize_comment'): - with fake_datetime('1997-12-01'): + with patch("szurubooru.func.comments.serialize_comment"): + with fake_datetime("1997-12-01"): api.comment_api.set_comment_score( - context_factory(params={'score': 1}, user=user), - {'comment_id': comment.comment_id}) - with fake_datetime('1997-12-02'): + context_factory(params={"score": 1}, user=user), + {"comment_id": comment.comment_id}, + ) + with fake_datetime("1997-12-02"): api.comment_api.set_comment_score( - context_factory(params={'score': 0}, user=user), - {'comment_id': comment.comment_id}) + context_factory(params={"score": 0}, user=user), + {"comment_id": comment.comment_id}, + ) comment = db.session.query(model.Comment).one() assert db.session.query(model.CommentScore).count() == 0 assert comment.score == 0 def test_deleting_rating( - user_factory, comment_factory, context_factory, fake_datetime): + user_factory, comment_factory, context_factory, fake_datetime +): user = user_factory(rank=model.User.RANK_REGULAR) comment = comment_factory(user=user) db.session.add(comment) db.session.commit() - with patch('szurubooru.func.comments.serialize_comment'): - with fake_datetime('1997-12-01'): + with patch("szurubooru.func.comments.serialize_comment"): + with fake_datetime("1997-12-01"): api.comment_api.set_comment_score( - context_factory(params={'score': 1}, user=user), - {'comment_id': comment.comment_id}) - with fake_datetime('1997-12-02'): + context_factory(params={"score": 1}, user=user), + {"comment_id": comment.comment_id}, + ) + with fake_datetime("1997-12-02"): api.comment_api.delete_comment_score( - context_factory(user=user), - {'comment_id': comment.comment_id}) + context_factory(user=user), {"comment_id": comment.comment_id} + ) comment = db.session.query(model.Comment).one() assert db.session.query(model.CommentScore).count() == 0 assert comment.score == 0 def test_ratings_from_multiple_users( - user_factory, comment_factory, context_factory, fake_datetime): + user_factory, comment_factory, context_factory, fake_datetime +): user1 = user_factory(rank=model.User.RANK_REGULAR) user2 = user_factory(rank=model.User.RANK_REGULAR) comment = comment_factory() db.session.add_all([user1, user2, comment]) db.session.commit() - with patch('szurubooru.func.comments.serialize_comment'): - with fake_datetime('1997-12-01'): + with patch("szurubooru.func.comments.serialize_comment"): + with fake_datetime("1997-12-01"): api.comment_api.set_comment_score( - context_factory(params={'score': 1}, user=user1), - {'comment_id': comment.comment_id}) - with fake_datetime('1997-12-02'): + context_factory(params={"score": 1}, user=user1), + {"comment_id": comment.comment_id}, + ) + with fake_datetime("1997-12-02"): api.comment_api.set_comment_score( - context_factory(params={'score': -1}, user=user2), - {'comment_id': comment.comment_id}) + context_factory(params={"score": -1}, user=user2), + {"comment_id": comment.comment_id}, + ) comment = db.session.query(model.Comment).one() assert db.session.query(model.CommentScore).count() == 2 assert comment.score == 0 def test_trying_to_omit_mandatory_field( - user_factory, comment_factory, context_factory): + user_factory, comment_factory, context_factory +): user = user_factory() comment = comment_factory(user=user) db.session.add(comment) @@ -118,26 +136,32 @@ def test_trying_to_omit_mandatory_field( with pytest.raises(errors.ValidationError): api.comment_api.set_comment_score( context_factory(params={}, user=user), - {'comment_id': comment.comment_id}) + {"comment_id": comment.comment_id}, + ) def test_trying_to_update_non_existing(user_factory, context_factory): with pytest.raises(comments.CommentNotFoundError): api.comment_api.set_comment_score( context_factory( - params={'score': 1}, - user=user_factory(rank=model.User.RANK_REGULAR)), - {'comment_id': 5}) + params={"score": 1}, + user=user_factory(rank=model.User.RANK_REGULAR), + ), + {"comment_id": 5}, + ) def test_trying_to_rate_without_privileges( - user_factory, comment_factory, context_factory): + user_factory, comment_factory, context_factory +): comment = comment_factory() db.session.add(comment) db.session.commit() with pytest.raises(errors.AuthError): api.comment_api.set_comment_score( context_factory( - params={'score': 1}, - user=user_factory(rank=model.User.RANK_ANONYMOUS)), - {'comment_id': comment.comment_id}) + params={"score": 1}, + user=user_factory(rank=model.User.RANK_ANONYMOUS), + ), + {"comment_id": comment.comment_id}, + ) diff --git a/server/szurubooru/tests/api/test_comment_retrieving.py b/server/szurubooru/tests/api/test_comment_retrieving.py index 5c846bb..404af76 100644 --- a/server/szurubooru/tests/api/test_comment_retrieving.py +++ b/server/szurubooru/tests/api/test_comment_retrieving.py @@ -1,72 +1,83 @@ from unittest.mock import patch + import pytest -from szurubooru import api, db, model, errors + +from szurubooru import api, db, errors, model from szurubooru.func import comments @pytest.fixture(autouse=True) def inject_config(config_injector): - config_injector({ - 'privileges': { - 'comments:list': model.User.RANK_REGULAR, - 'comments:view': model.User.RANK_REGULAR, - }, - }) + config_injector( + { + "privileges": { + "comments:list": model.User.RANK_REGULAR, + "comments:view": model.User.RANK_REGULAR, + }, + } + ) def test_retrieving_multiple(user_factory, comment_factory, context_factory): - comment1 = comment_factory(text='text 1') - comment2 = comment_factory(text='text 2') + comment1 = comment_factory(text="text 1") + comment2 = comment_factory(text="text 2") db.session.add_all([comment1, comment2]) db.session.flush() - with patch('szurubooru.func.comments.serialize_comment'): - comments.serialize_comment.return_value = 'serialized comment' + with patch("szurubooru.func.comments.serialize_comment"): + comments.serialize_comment.return_value = "serialized comment" result = api.comment_api.get_comments( context_factory( - params={'query': '', 'offset': 0}, - user=user_factory(rank=model.User.RANK_REGULAR))) + params={"query": "", "offset": 0}, + user=user_factory(rank=model.User.RANK_REGULAR), + ) + ) assert result == { - 'query': '', - 'offset': 0, - 'limit': 100, - 'total': 2, - 'results': ['serialized comment', 'serialized comment'], + "query": "", + "offset": 0, + "limit": 100, + "total": 2, + "results": ["serialized comment", "serialized comment"], } def test_trying_to_retrieve_multiple_without_privileges( - user_factory, context_factory): + user_factory, context_factory +): with pytest.raises(errors.AuthError): api.comment_api.get_comments( context_factory( - params={'query': '', 'offset': 0}, - user=user_factory(rank=model.User.RANK_ANONYMOUS))) + params={"query": "", "offset": 0}, + user=user_factory(rank=model.User.RANK_ANONYMOUS), + ) + ) def test_retrieving_single(user_factory, comment_factory, context_factory): - comment = comment_factory(text='dummy text') + comment = comment_factory(text="dummy text") db.session.add(comment) db.session.flush() - with patch('szurubooru.func.comments.serialize_comment'): - comments.serialize_comment.return_value = 'serialized comment' + with patch("szurubooru.func.comments.serialize_comment"): + comments.serialize_comment.return_value = "serialized comment" result = api.comment_api.get_comment( - context_factory( - user=user_factory(rank=model.User.RANK_REGULAR)), - {'comment_id': comment.comment_id}) - assert result == 'serialized comment' + context_factory(user=user_factory(rank=model.User.RANK_REGULAR)), + {"comment_id": comment.comment_id}, + ) + assert result == "serialized comment" def test_trying_to_retrieve_single_non_existing(user_factory, context_factory): with pytest.raises(comments.CommentNotFoundError): api.comment_api.get_comment( - context_factory( - user=user_factory(rank=model.User.RANK_REGULAR)), - {'comment_id': 5}) + context_factory(user=user_factory(rank=model.User.RANK_REGULAR)), + {"comment_id": 5}, + ) def test_trying_to_retrieve_single_without_privileges( - user_factory, context_factory): + user_factory, context_factory +): with pytest.raises(errors.AuthError): api.comment_api.get_comment( context_factory(user=user_factory(rank=model.User.RANK_ANONYMOUS)), - {'comment_id': 5}) + {"comment_id": 5}, + ) diff --git a/server/szurubooru/tests/api/test_comment_updating.py b/server/szurubooru/tests/api/test_comment_updating.py index 761b1ce..e7c4fbe 100644 --- a/server/szurubooru/tests/api/test_comment_updating.py +++ b/server/szurubooru/tests/api/test_comment_updating.py @@ -1,84 +1,97 @@ from datetime import datetime from unittest.mock import patch + import pytest -from szurubooru import api, db, model, errors + +from szurubooru import api, db, errors, model from szurubooru.func import comments @pytest.fixture(autouse=True) def inject_config(config_injector): - config_injector({ - 'privileges': { - 'comments:edit:own': model.User.RANK_REGULAR, - 'comments:edit:any': model.User.RANK_MODERATOR, - }, - }) + config_injector( + { + "privileges": { + "comments:edit:own": model.User.RANK_REGULAR, + "comments:edit:any": model.User.RANK_MODERATOR, + }, + } + ) def test_simple_updating( - user_factory, comment_factory, context_factory, fake_datetime): + user_factory, comment_factory, context_factory, fake_datetime +): user = user_factory(rank=model.User.RANK_REGULAR) comment = comment_factory(user=user) db.session.add(comment) db.session.commit() - with patch('szurubooru.func.comments.serialize_comment'), \ - fake_datetime('1997-12-01'): - comments.serialize_comment.return_value = 'serialized comment' + with patch("szurubooru.func.comments.serialize_comment"), fake_datetime( + "1997-12-01" + ): + comments.serialize_comment.return_value = "serialized comment" result = api.comment_api.update_comment( context_factory( - params={'text': 'new text', 'version': 1}, user=user), - {'comment_id': comment.comment_id}) - assert result == 'serialized comment' + params={"text": "new text", "version": 1}, user=user + ), + {"comment_id": comment.comment_id}, + ) + assert result == "serialized comment" assert comment.last_edit_time == datetime(1997, 12, 1) -@pytest.mark.parametrize('params,expected_exception', [ - ({'text': None}, comments.EmptyCommentTextError), - ({'text': ''}, comments.EmptyCommentTextError), - ({'text': []}, comments.EmptyCommentTextError), - ({'text': [None]}, errors.ValidationError), - ({'text': ['']}, comments.EmptyCommentTextError), -]) +@pytest.mark.parametrize( + "params,expected_exception", + [ + ({"text": None}, comments.EmptyCommentTextError), + ({"text": ""}, comments.EmptyCommentTextError), + ({"text": []}, comments.EmptyCommentTextError), + ({"text": [None]}, errors.ValidationError), + ({"text": [""]}, comments.EmptyCommentTextError), + ], +) def test_trying_to_pass_invalid_params( - user_factory, - comment_factory, - context_factory, - params, - expected_exception): + user_factory, comment_factory, context_factory, params, expected_exception +): user = user_factory() comment = comment_factory(user=user) db.session.add(comment) db.session.commit() with pytest.raises(expected_exception): api.comment_api.update_comment( - context_factory( - params={**params, **{'version': 1}}, user=user), - {'comment_id': comment.comment_id}) + context_factory(params={**params, **{"version": 1}}, user=user), + {"comment_id": comment.comment_id}, + ) def test_trying_to_omit_mandatory_field( - user_factory, comment_factory, context_factory): + user_factory, comment_factory, context_factory +): user = user_factory() comment = comment_factory(user=user) db.session.add(comment) db.session.commit() with pytest.raises(errors.ValidationError): api.comment_api.update_comment( - context_factory(params={'version': 1}, user=user), - {'comment_id': comment.comment_id}) + context_factory(params={"version": 1}, user=user), + {"comment_id": comment.comment_id}, + ) def test_trying_to_update_non_existing(user_factory, context_factory): with pytest.raises(comments.CommentNotFoundError): api.comment_api.update_comment( context_factory( - params={'text': 'new text'}, - user=user_factory(rank=model.User.RANK_REGULAR)), - {'comment_id': 5}) + params={"text": "new text"}, + user=user_factory(rank=model.User.RANK_REGULAR), + ), + {"comment_id": 5}, + ) def test_trying_to_update_someones_comment_without_privileges( - user_factory, comment_factory, context_factory): + user_factory, comment_factory, context_factory +): user = user_factory(rank=model.User.RANK_REGULAR) user2 = user_factory(rank=model.User.RANK_REGULAR) comment = comment_factory(user=user) @@ -87,19 +100,24 @@ def test_trying_to_update_someones_comment_without_privileges( with pytest.raises(errors.AuthError): api.comment_api.update_comment( context_factory( - params={'text': 'new text', 'version': 1}, user=user2), - {'comment_id': comment.comment_id}) + params={"text": "new text", "version": 1}, user=user2 + ), + {"comment_id": comment.comment_id}, + ) def test_updating_someones_comment_with_privileges( - user_factory, comment_factory, context_factory): + user_factory, comment_factory, context_factory +): user = user_factory(rank=model.User.RANK_REGULAR) user2 = user_factory(rank=model.User.RANK_MODERATOR) comment = comment_factory(user=user) db.session.add(comment) db.session.commit() - with patch('szurubooru.func.comments.serialize_comment'): + with patch("szurubooru.func.comments.serialize_comment"): api.comment_api.update_comment( context_factory( - params={'text': 'new text', 'version': 1}, user=user2), - {'comment_id': comment.comment_id}) + params={"text": "new text", "version": 1}, user=user2 + ), + {"comment_id": comment.comment_id}, + ) diff --git a/server/szurubooru/tests/api/test_info.py b/server/szurubooru/tests/api/test_info.py index cd15727..37099e8 100644 --- a/server/szurubooru/tests/api/test_info.py +++ b/server/szurubooru/tests/api/test_info.py @@ -1,88 +1,96 @@ from datetime import datetime + from szurubooru import api, db, model def test_info_api( - tmpdir, config_injector, context_factory, post_factory, user_factory, - fake_datetime): - directory = tmpdir.mkdir('data') - directory.join('test.txt').write('abc') + tmpdir, + config_injector, + context_factory, + post_factory, + user_factory, + fake_datetime, +): + directory = tmpdir.mkdir("data") + directory.join("test.txt").write("abc") auth_user = user_factory(rank=model.User.RANK_REGULAR) anon_user = user_factory(rank=model.User.RANK_ANONYMOUS) - config_injector({ - 'name': 'test installation', - 'contact_email': 'test@example.com', - 'enable_safety': True, - 'data_dir': str(directory), - 'user_name_regex': '1', - 'password_regex': '2', - 'tag_name_regex': '3', - 'tag_category_name_regex': '4', - 'default_rank': '5', - 'privileges': { - 'test_key1': 'test_value1', - 'test_key2': 'test_value2', - 'posts:view:featured': 'regular', - }, - 'smtp': { - 'host': 'example.com', + config_injector( + { + "name": "test installation", + "contact_email": "test@example.com", + "enable_safety": True, + "data_dir": str(directory), + "user_name_regex": "1", + "password_regex": "2", + "tag_name_regex": "3", + "tag_category_name_regex": "4", + "default_rank": "5", + "privileges": { + "test_key1": "test_value1", + "test_key2": "test_value2", + "posts:view:featured": "regular", + }, + "smtp": { + "host": "example.com", + }, } - }) + ) db.session.add_all([post_factory(), post_factory()]) db.session.flush() expected_config_key = { - 'name': 'test installation', - 'contactEmail': 'test@example.com', - 'enableSafety': True, - 'userNameRegex': '1', - 'passwordRegex': '2', - 'tagNameRegex': '3', - 'tagCategoryNameRegex': '4', - 'defaultUserRank': '5', - 'privileges': { - 'testKey1': 'test_value1', - 'testKey2': 'test_value2', - 'posts:view:featured': 'regular', + "name": "test installation", + "contactEmail": "test@example.com", + "enableSafety": True, + "userNameRegex": "1", + "passwordRegex": "2", + "tagNameRegex": "3", + "tagCategoryNameRegex": "4", + "defaultUserRank": "5", + "privileges": { + "testKey1": "test_value1", + "testKey2": "test_value2", + "posts:view:featured": "regular", }, - 'canSendMails': True + "canSendMails": True, } - with fake_datetime('2016-01-01 13:00'): + with fake_datetime("2016-01-01 13:00"): assert api.info_api.get_info(context_factory(user=auth_user)) == { - 'postCount': 2, - 'diskUsage': 3, - 'featuredPost': None, - 'featuringTime': None, - 'featuringUser': None, - 'serverTime': datetime(2016, 1, 1, 13, 0), - 'config': expected_config_key, + "postCount": 2, + "diskUsage": 3, + "featuredPost": None, + "featuringTime": None, + "featuringUser": None, + "serverTime": datetime(2016, 1, 1, 13, 0), + "config": expected_config_key, } - directory.join('test2.txt').write('abc') - with fake_datetime('2016-01-03 12:59'): + directory.join("test2.txt").write("abc") + with fake_datetime("2016-01-03 12:59"): assert api.info_api.get_info(context_factory(user=auth_user)) == { - 'postCount': 2, - 'diskUsage': 3, # still 3 - it's cached - 'featuredPost': None, - 'featuringTime': None, - 'featuringUser': None, - 'serverTime': datetime(2016, 1, 3, 12, 59), - 'config': expected_config_key, + "postCount": 2, + "diskUsage": 3, # still 3 - it's cached + "featuredPost": None, + "featuringTime": None, + "featuringUser": None, + "serverTime": datetime(2016, 1, 3, 12, 59), + "config": expected_config_key, } - with fake_datetime('2016-01-03 13:01'): + with fake_datetime("2016-01-03 13:01"): assert api.info_api.get_info(context_factory(user=auth_user)) == { - 'postCount': 2, - 'diskUsage': 6, # cache expired - 'featuredPost': None, - 'featuringTime': None, - 'featuringUser': None, - 'serverTime': datetime(2016, 1, 3, 13, 1), - 'config': expected_config_key, + "postCount": 2, + "diskUsage": 6, # cache expired + "featuredPost": None, + "featuringTime": None, + "featuringUser": None, + "serverTime": datetime(2016, 1, 3, 13, 1), + "config": expected_config_key, } - with fake_datetime('2016-01-03 13:01'): + with fake_datetime("2016-01-03 13:01"): assert api.info_api.get_info(context_factory(user=anon_user)) == { - 'postCount': 2, - 'diskUsage': 6, # cache expired - 'serverTime': datetime(2016, 1, 3, 13, 1), - 'config': expected_config_key, + "postCount": 2, + "diskUsage": 6, # cache expired + "serverTime": datetime(2016, 1, 3, 13, 1), + "config": expected_config_key, } diff --git a/server/szurubooru/tests/api/test_password_reset.py b/server/szurubooru/tests/api/test_password_reset.py index e46dbbe..bf1ab5c 100644 --- a/server/szurubooru/tests/api/test_password_reset.py +++ b/server/szurubooru/tests/api/test_password_reset.py @@ -1,84 +1,114 @@ from unittest.mock import patch + import pytest -from szurubooru import api, db, model, errors + +from szurubooru import api, db, errors, model from szurubooru.func import auth, mailer @pytest.fixture(autouse=True) def inject_config(config_injector): - config_injector({ - 'secret': 'x', - 'base_url': 'http://example.com/', - 'name': 'Test instance', - }) + config_injector( + { + "secret": "x", + "domain": "http://example.com", + "name": "Test instance", + "smtp": { + "from": "noreply@example.com", + }, + } + ) def test_reset_sending_email(context_factory, user_factory): - db.session.add(user_factory( - name='u1', rank=model.User.RANK_REGULAR, email='user@example.com')) + db.session.add( + user_factory( + name="u1", rank=model.User.RANK_REGULAR, email="user@example.com" + ) + ) db.session.flush() - for initiating_user in ['u1', 'user@example.com']: - with patch('szurubooru.func.mailer.send_mail'): - assert api.password_reset_api.start_password_reset( - context_factory(), {'user_name': initiating_user}) == {} + for initiating_user in ["u1", "user@example.com"]: + with patch("szurubooru.func.mailer.send_mail"): + assert ( + api.password_reset_api.start_password_reset( + context_factory(), {"user_name": initiating_user} + ) + == {} + ) mailer.send_mail.assert_called_once_with( - 'noreply@Test instance', - 'user@example.com', - 'Password reset for Test instance', - 'You (or someone else) requested to reset your password ' + - 'on Test instance.\nIf you wish to proceed, click this l' + - 'ink: http://example.com/password-reset/u1:4ac0be176fb36' + - '4f13ee6b634c43220e2\nOtherwise, please ignore this email.') + "noreply@example.com", + "user@example.com", + "Password reset for Test instance", + "You (or someone else) requested to reset your password " + + "on Test instance.\nIf you wish to proceed, click this l" + + "ink: http://example.com/password-reset/u1:4ac0be176fb36" + + "4f13ee6b634c43220e2\nOtherwise, please ignore this email.", + ) def test_trying_to_reset_non_existing(context_factory): with pytest.raises(errors.NotFoundError): api.password_reset_api.start_password_reset( - context_factory(), {'user_name': 'u1'}) + context_factory(), {"user_name": "u1"} + ) def test_trying_to_reset_without_email(context_factory, user_factory): db.session.add( - user_factory(name='u1', rank=model.User.RANK_REGULAR, email=None)) + user_factory(name="u1", rank=model.User.RANK_REGULAR, email=None) + ) db.session.flush() with pytest.raises(errors.ValidationError): api.password_reset_api.start_password_reset( - context_factory(), {'user_name': 'u1'}) + context_factory(), {"user_name": "u1"} + ) def test_confirming_with_good_token(context_factory, user_factory): user = user_factory( - name='u1', rank=model.User.RANK_REGULAR, email='user@example.com') + name="u1", rank=model.User.RANK_REGULAR, email="user@example.com" + ) old_hash = user.password_hash db.session.add(user) db.session.flush() context = context_factory( - params={'token': '4ac0be176fb364f13ee6b634c43220e2'}) + params={"token": "4ac0be176fb364f13ee6b634c43220e2"} + ) result = api.password_reset_api.finish_password_reset( - context, {'user_name': 'u1'}) + context, {"user_name": "u1"} + ) assert user.password_hash != old_hash - assert auth.is_valid_password(user, result['password']) is True + assert auth.is_valid_password(user, result["password"]) is True def test_trying_to_confirm_non_existing(context_factory): with pytest.raises(errors.NotFoundError): api.password_reset_api.finish_password_reset( - context_factory(), {'user_name': 'u1'}) + context_factory(), {"user_name": "u1"} + ) def test_trying_to_confirm_without_token(context_factory, user_factory): - db.session.add(user_factory( - name='u1', rank=model.User.RANK_REGULAR, email='user@example.com')) + db.session.add( + user_factory( + name="u1", rank=model.User.RANK_REGULAR, email="user@example.com" + ) + ) db.session.flush() with pytest.raises(errors.ValidationError): api.password_reset_api.finish_password_reset( - context_factory(params={}), {'user_name': 'u1'}) + context_factory(params={}), {"user_name": "u1"} + ) def test_trying_to_confirm_with_bad_token(context_factory, user_factory): - db.session.add(user_factory( - name='u1', rank=model.User.RANK_REGULAR, email='user@example.com')) + db.session.add( + user_factory( + name="u1", rank=model.User.RANK_REGULAR, email="user@example.com" + ) + ) db.session.flush() with pytest.raises(errors.ValidationError): api.password_reset_api.finish_password_reset( - context_factory(params={'token': 'bad'}), {'user_name': 'u1'}) + context_factory(params={"token": "bad"}), {"user_name": "u1"} + ) diff --git a/server/szurubooru/tests/api/test_pool_category_creating.py b/server/szurubooru/tests/api/test_pool_category_creating.py new file mode 100644 index 0000000..b9235d0 --- /dev/null +++ b/server/szurubooru/tests/api/test_pool_category_creating.py @@ -0,0 +1,73 @@ +from unittest.mock import patch + +import pytest + +from szurubooru import api, db, errors, model +from szurubooru.func import pool_categories, snapshots + + +def _update_category_name(category, name): + category.name = name + + +@pytest.fixture(autouse=True) +def inject_config(config_injector): + config_injector( + { + "privileges": {"pool_categories:create": model.User.RANK_REGULAR}, + } + ) + + +def test_creating_category( + pool_category_factory, user_factory, context_factory +): + auth_user = user_factory(rank=model.User.RANK_REGULAR) + category = pool_category_factory(name="meta") + db.session.add(category) + + with patch("szurubooru.func.pool_categories.create_category"), patch( + "szurubooru.func.pool_categories.serialize_category" + ), patch("szurubooru.func.pool_categories.update_category_name"), patch( + "szurubooru.func.snapshots.create" + ): + pool_categories.create_category.return_value = category + pool_categories.update_category_name.side_effect = ( + _update_category_name + ) + pool_categories.serialize_category.return_value = "serialized category" + result = api.pool_category_api.create_pool_category( + context_factory( + params={"name": "meta", "color": "black"}, user=auth_user + ) + ) + assert result == "serialized category" + pool_categories.create_category.assert_called_once_with( + "meta", "black" + ) + snapshots.create.assert_called_once_with(category, auth_user) + + +@pytest.mark.parametrize("field", ["name", "color"]) +def test_trying_to_omit_mandatory_field(user_factory, context_factory, field): + params = { + "name": "meta", + "color": "black", + } + del params[field] + with pytest.raises(errors.ValidationError): + api.pool_category_api.create_pool_category( + context_factory( + params=params, user=user_factory(rank=model.User.RANK_REGULAR) + ) + ) + + +def test_trying_to_create_without_privileges(user_factory, context_factory): + with pytest.raises(errors.AuthError): + api.pool_category_api.create_pool_category( + context_factory( + params={"name": "meta", "color": "black"}, + user=user_factory(rank=model.User.RANK_ANONYMOUS), + ) + ) diff --git a/server/szurubooru/tests/api/test_pool_category_deleting.py b/server/szurubooru/tests/api/test_pool_category_deleting.py new file mode 100644 index 0000000..b50f961 --- /dev/null +++ b/server/szurubooru/tests/api/test_pool_category_deleting.py @@ -0,0 +1,91 @@ +from unittest.mock import patch + +import pytest + +from szurubooru import api, db, errors, model +from szurubooru.func import pool_categories, snapshots + + +@pytest.fixture(autouse=True) +def inject_config(config_injector): + config_injector( + { + "privileges": {"pool_categories:delete": model.User.RANK_REGULAR}, + } + ) + + +def test_deleting(user_factory, pool_category_factory, context_factory): + auth_user = user_factory(rank=model.User.RANK_REGULAR) + category = pool_category_factory(name="category") + db.session.add(pool_category_factory(name="root")) + db.session.add(category) + db.session.flush() + with patch("szurubooru.func.snapshots.delete"): + result = api.pool_category_api.delete_pool_category( + context_factory(params={"version": 1}, user=auth_user), + {"category_name": "category"}, + ) + assert result == {} + assert db.session.query(model.PoolCategory).count() == 1 + assert db.session.query(model.PoolCategory).one().name == "root" + snapshots.delete.assert_called_once_with(category, auth_user) + + +def test_trying_to_delete_used( + user_factory, pool_category_factory, pool_factory, context_factory +): + category = pool_category_factory(name="category") + db.session.add(category) + db.session.flush() + pool = pool_factory(names=["pool"], category=category) + db.session.add(pool) + db.session.commit() + with pytest.raises(pool_categories.PoolCategoryIsInUseError): + api.pool_category_api.delete_pool_category( + context_factory( + params={"version": 1}, + user=user_factory(rank=model.User.RANK_REGULAR), + ), + {"category_name": "category"}, + ) + assert db.session.query(model.PoolCategory).count() == 1 + + +def test_trying_to_delete_last( + user_factory, pool_category_factory, context_factory +): + db.session.add(pool_category_factory(name="root")) + db.session.commit() + with pytest.raises(pool_categories.PoolCategoryIsInUseError): + api.pool_category_api.delete_pool_category( + context_factory( + params={"version": 1}, + user=user_factory(rank=model.User.RANK_REGULAR), + ), + {"category_name": "root"}, + ) + + +def test_trying_to_delete_non_existing(user_factory, context_factory): + with pytest.raises(pool_categories.PoolCategoryNotFoundError): + api.pool_category_api.delete_pool_category( + context_factory(user=user_factory(rank=model.User.RANK_REGULAR)), + {"category_name": "bad"}, + ) + + +def test_trying_to_delete_without_privileges( + user_factory, pool_category_factory, context_factory +): + db.session.add(pool_category_factory(name="category")) + db.session.commit() + with pytest.raises(errors.AuthError): + api.pool_category_api.delete_pool_category( + context_factory( + params={"version": 1}, + user=user_factory(rank=model.User.RANK_ANONYMOUS), + ), + {"category_name": "category"}, + ) + assert db.session.query(model.PoolCategory).count() == 1 diff --git a/server/szurubooru/tests/api/test_pool_category_retrieving.py b/server/szurubooru/tests/api/test_pool_category_retrieving.py new file mode 100644 index 0000000..7820567 --- /dev/null +++ b/server/szurubooru/tests/api/test_pool_category_retrieving.py @@ -0,0 +1,68 @@ +import pytest + +from szurubooru import api, db, errors, model +from szurubooru.func import pool_categories + + +@pytest.fixture(autouse=True) +def inject_config(config_injector): + config_injector( + { + "privileges": { + "pool_categories:list": model.User.RANK_REGULAR, + "pool_categories:view": model.User.RANK_REGULAR, + }, + } + ) + + +def test_retrieving_multiple( + user_factory, pool_category_factory, context_factory +): + db.session.add_all( + [ + pool_category_factory(name="c1"), + pool_category_factory(name="c2"), + ] + ) + db.session.flush() + result = api.pool_category_api.get_pool_categories( + context_factory(user=user_factory(rank=model.User.RANK_REGULAR)) + ) + assert [cat["name"] for cat in result["results"]] == ["c1", "c2"] + + +def test_retrieving_single( + user_factory, pool_category_factory, context_factory +): + db.session.add(pool_category_factory(name="cat")) + db.session.flush() + result = api.pool_category_api.get_pool_category( + context_factory(user=user_factory(rank=model.User.RANK_REGULAR)), + {"category_name": "cat"}, + ) + assert result == { + "name": "cat", + "color": "dummy", + "usages": 0, + "default": False, + "version": 1, + } + + +def test_trying_to_retrieve_single_non_existing(user_factory, context_factory): + with pytest.raises(pool_categories.PoolCategoryNotFoundError): + api.pool_category_api.get_pool_category( + context_factory(user=user_factory(rank=model.User.RANK_REGULAR)), + {"category_name": "-"}, + ) + + +def test_trying_to_retrieve_single_without_privileges( + user_factory, context_factory +): + with pytest.raises(errors.AuthError): + api.pool_category_api.get_pool_category( + context_factory(user=user_factory(rank=model.User.RANK_ANONYMOUS)), + {"category_name": "-"}, + ) diff --git a/server/szurubooru/tests/api/test_pool_category_updating.py b/server/szurubooru/tests/api/test_pool_category_updating.py new file mode 100644 index 0000000..5e26209 --- /dev/null +++ b/server/szurubooru/tests/api/test_pool_category_updating.py @@ -0,0 +1,136 @@ +from unittest.mock import patch + +import pytest + +from szurubooru import api, db, errors, model +from szurubooru.func import pool_categories, snapshots + + +def _update_category_name(category, name): + category.name = name + + +@pytest.fixture(autouse=True) +def inject_config(config_injector): + config_injector( + { + "privileges": { + "pool_categories:edit:name": model.User.RANK_REGULAR, + "pool_categories:edit:color": model.User.RANK_REGULAR, + "pool_categories:set_default": model.User.RANK_REGULAR, + }, + } + ) + + +def test_simple_updating(user_factory, pool_category_factory, context_factory): + auth_user = user_factory(rank=model.User.RANK_REGULAR) + category = pool_category_factory(name="name", color="black") + db.session.add(category) + db.session.flush() + with patch("szurubooru.func.pool_categories.serialize_category"), patch( + "szurubooru.func.pool_categories.update_category_name" + ), patch("szurubooru.func.pool_categories.update_category_color"), patch( + "szurubooru.func.snapshots.modify" + ): + pool_categories.update_category_name.side_effect = ( + _update_category_name + ) + pool_categories.serialize_category.return_value = "serialized category" + result = api.pool_category_api.update_pool_category( + context_factory( + params={"name": "changed", "color": "white", "version": 1}, + user=auth_user, + ), + {"category_name": "name"}, + ) + assert result == "serialized category" + pool_categories.update_category_name.assert_called_once_with( + category, "changed" + ) + pool_categories.update_category_color.assert_called_once_with( + category, "white" + ) + snapshots.modify.assert_called_once_with(category, auth_user) + + +@pytest.mark.parametrize("field", ["name", "color"]) +def test_omitting_optional_field( + user_factory, pool_category_factory, context_factory, field +): + db.session.add(pool_category_factory(name="name", color="black")) + db.session.commit() + params = { + "name": "changed", + "color": "white", + } + del params[field] + with patch("szurubooru.func.pool_categories.serialize_category"), patch( + "szurubooru.func.pool_categories.update_category_name" + ), patch("szurubooru.func.snapshots._post_to_webhooks"): + api.pool_category_api.update_pool_category( + context_factory( + params={**params, **{"version": 1}}, + user=user_factory(rank=model.User.RANK_REGULAR), + ), + {"category_name": "name"}, + ) + + +def test_trying_to_update_non_existing(user_factory, context_factory): + with pytest.raises(pool_categories.PoolCategoryNotFoundError): + api.pool_category_api.update_pool_category( + context_factory( + params={"name": ["dummy"]}, + user=user_factory(rank=model.User.RANK_REGULAR), + ), + {"category_name": "bad"}, + ) + + +@pytest.mark.parametrize( + "params", + [ + {"name": "whatever"}, + {"color": "whatever"}, + ], +) +def test_trying_to_update_without_privileges( + user_factory, pool_category_factory, context_factory, params +): + db.session.add(pool_category_factory(name="dummy")) + db.session.commit() + with pytest.raises(errors.AuthError): + api.pool_category_api.update_pool_category( + context_factory( + params={**params, **{"version": 1}}, + user=user_factory(rank=model.User.RANK_ANONYMOUS), + ), + {"category_name": "dummy"}, + ) + + +def test_set_as_default(user_factory, pool_category_factory, context_factory): + category = pool_category_factory(name="name", color="black") + db.session.add(category) + db.session.commit() + with patch("szurubooru.func.pool_categories.serialize_category"), patch( + "szurubooru.func.pool_categories.set_default_category" + ): + pool_categories.update_category_name.side_effect = ( + _update_category_name + ) + pool_categories.serialize_category.return_value = "serialized category" + result = api.pool_category_api.set_pool_category_as_default( + context_factory( + params={ + "name": "changed", + "color": "white", + "version": 1, + }, + user=user_factory(rank=model.User.RANK_REGULAR), + ), + {"category_name": "name"}, + ) + assert result == "serialized category" + pool_categories.set_default_category.assert_called_once_with(category) diff --git a/server/szurubooru/tests/api/test_pool_creating.py b/server/szurubooru/tests/api/test_pool_creating.py new file mode 100644 index 0000000..4fd8939 --- /dev/null +++ b/server/szurubooru/tests/api/test_pool_creating.py @@ -0,0 +1,95 @@ +from unittest.mock import patch + +import pytest + +from szurubooru import api, errors, model +from szurubooru.func import pools, posts, snapshots + + +@pytest.fixture(autouse=True) +def inject_config(config_injector): + config_injector({"privileges": {"pools:create": model.User.RANK_REGULAR}}) + + +def test_creating_simple_pools(pool_factory, user_factory, context_factory): + auth_user = user_factory(rank=model.User.RANK_REGULAR) + pool = pool_factory() + with patch("szurubooru.func.pools.create_pool"), patch( + "szurubooru.func.pools.get_or_create_pools_by_names" + ), patch("szurubooru.func.pools.serialize_pool"), patch( + "szurubooru.func.snapshots.create" + ): + posts.get_posts_by_ids.return_value = ([], []) + pools.create_pool.return_value = pool + pools.serialize_pool.return_value = "serialized pool" + result = api.pool_api.create_pool( + context_factory( + params={ + "names": ["pool1", "pool2"], + "category": "default", + "description": "desc", + "posts": [1, 2], + }, + user=auth_user, + ) + ) + assert result == "serialized pool" + pools.create_pool.assert_called_once_with( + ["pool1", "pool2"], "default", [1, 2] + ) + snapshots.create.assert_called_once_with(pool, auth_user) + + +@pytest.mark.parametrize("field", ["names", "category"]) +def test_trying_to_omit_mandatory_field(user_factory, context_factory, field): + params = { + "names": ["pool1", "pool2"], + "category": "default", + "description": "desc", + "posts": [], + } + del params[field] + with pytest.raises(errors.ValidationError): + api.pool_api.create_pool( + context_factory( + params=params, user=user_factory(rank=model.User.RANK_REGULAR) + ) + ) + + +@pytest.mark.parametrize("field", ["description", "posts"]) +def test_omitting_optional_field( + pool_factory, user_factory, context_factory, field +): + params = { + "names": ["pool1", "pool2"], + "category": "default", + "description": "desc", + "posts": [], + } + del params[field] + with patch("szurubooru.func.pools.create_pool"), patch( + "szurubooru.func.pools.serialize_pool" + ), patch("szurubooru.func.snapshots._post_to_webhooks"): + pools.create_pool.return_value = pool_factory() + api.pool_api.create_pool( + context_factory( + params=params, user=user_factory(rank=model.User.RANK_REGULAR) + ) + ) + + +def test_trying_to_create_pool_without_privileges( + user_factory, context_factory +): + with pytest.raises(errors.AuthError): + api.pool_api.create_pool( + context_factory( + params={ + "names": ["pool"], + "category": "default", + "posts": [], + }, + user=user_factory(rank=model.User.RANK_ANONYMOUS), + ) + ) diff --git a/server/szurubooru/tests/api/test_pool_deleting.py b/server/szurubooru/tests/api/test_pool_deleting.py new file mode 100644 index 0000000..5c5adcc --- /dev/null +++ b/server/szurubooru/tests/api/test_pool_deleting.py @@ -0,0 +1,72 @@ +from unittest.mock import patch + +import pytest + +from szurubooru import api, db, errors, model +from szurubooru.func import pools, snapshots + + +@pytest.fixture(autouse=True) +def inject_config(config_injector): + config_injector({"privileges": {"pools:delete": model.User.RANK_REGULAR}}) + + +def test_deleting(user_factory, pool_factory, context_factory): + auth_user = user_factory(rank=model.User.RANK_REGULAR) + pool = pool_factory(id=1) + db.session.add(pool) + db.session.commit() + with patch("szurubooru.func.snapshots.delete"): + result = api.pool_api.delete_pool( + context_factory(params={"version": 1}, user=auth_user), + {"pool_id": 1}, + ) + assert result == {} + assert db.session.query(model.Pool).count() == 0 + snapshots.delete.assert_called_once_with(pool, auth_user) + + +def test_deleting_used( + user_factory, pool_factory, context_factory, post_factory +): + pool = pool_factory(id=1) + post = post_factory(id=1) + pool.posts.append(post) + db.session.add_all([pool, post]) + db.session.commit() + with patch("szurubooru.func.snapshots._post_to_webhooks"): + api.pool_api.delete_pool( + context_factory( + params={"version": 1}, + user=user_factory(rank=model.User.RANK_REGULAR), + ), + {"pool_id": 1}, + ) + db.session.refresh(post) + assert db.session.query(model.Pool).count() == 0 + assert db.session.query(model.PoolPost).count() == 0 + assert post.pools == [] + + +def test_trying_to_delete_non_existing(user_factory, context_factory): + with pytest.raises(pools.PoolNotFoundError): + api.pool_api.delete_pool( + context_factory(user=user_factory(rank=model.User.RANK_REGULAR)), + {"pool_id": 9999}, + ) + + +def test_trying_to_delete_without_privileges( + user_factory, pool_factory, context_factory +): + db.session.add(pool_factory(id=1)) + db.session.commit() + with pytest.raises(errors.AuthError): + api.pool_api.delete_pool( + context_factory( + params={"version": 1}, + user=user_factory(rank=model.User.RANK_ANONYMOUS), + ), + {"pool_id": 1}, + ) + assert db.session.query(model.Pool).count() == 1 diff --git a/server/szurubooru/tests/api/test_pool_merging.py b/server/szurubooru/tests/api/test_pool_merging.py new file mode 100644 index 0000000..3357780 --- /dev/null +++ b/server/szurubooru/tests/api/test_pool_merging.py @@ -0,0 +1,118 @@ +from unittest.mock import patch + +import pytest + +from szurubooru import api, db, errors, model +from szurubooru.func import pools, snapshots + + +@pytest.fixture(autouse=True) +def inject_config(config_injector): + config_injector({"privileges": {"pools:merge": model.User.RANK_REGULAR}}) + + +def test_merging(user_factory, pool_factory, context_factory, post_factory): + auth_user = user_factory(rank=model.User.RANK_REGULAR) + source_pool = pool_factory(id=1) + target_pool = pool_factory(id=2) + db.session.add_all([source_pool, target_pool]) + db.session.flush() + assert source_pool.post_count == 0 + assert target_pool.post_count == 0 + post = post_factory(id=1) + source_pool.posts = [post] + db.session.add(post) + db.session.commit() + assert source_pool.post_count == 1 + assert target_pool.post_count == 0 + with patch("szurubooru.func.pools.serialize_pool"), patch( + "szurubooru.func.pools.merge_pools" + ), patch("szurubooru.func.snapshots.merge"): + api.pool_api.merge_pools( + context_factory( + params={ + "removeVersion": 1, + "mergeToVersion": 1, + "remove": 1, + "mergeTo": 2, + }, + user=auth_user, + ) + ) + pools.merge_pools.called_once_with(source_pool, target_pool) + snapshots.merge.assert_called_once_with( + source_pool, target_pool, auth_user + ) + + +@pytest.mark.parametrize( + "field", ["remove", "mergeTo", "removeVersion", "mergeToVersion"] +) +def test_trying_to_omit_mandatory_field( + user_factory, pool_factory, context_factory, field +): + db.session.add_all( + [ + pool_factory(id=1), + pool_factory(id=2), + ] + ) + db.session.commit() + params = { + "removeVersion": 1, + "mergeToVersion": 1, + "remove": 1, + "mergeTo": 2, + } + del params[field] + with pytest.raises(errors.ValidationError): + api.pool_api.merge_pools( + context_factory( + params=params, user=user_factory(rank=model.User.RANK_REGULAR) + ) + ) + + +def test_trying_to_merge_non_existing( + user_factory, pool_factory, context_factory +): + db.session.add(pool_factory(id=1)) + db.session.commit() + with pytest.raises(pools.PoolNotFoundError): + api.pool_api.merge_pools( + context_factory( + params={"remove": 1, "mergeTo": 9999}, + user=user_factory(rank=model.User.RANK_REGULAR), + ) + ) + with pytest.raises(pools.PoolNotFoundError): + api.pool_api.merge_pools( + context_factory( + params={"remove": 9999, "mergeTo": 1}, + user=user_factory(rank=model.User.RANK_REGULAR), + ) + ) + + +def test_trying_to_merge_without_privileges( + user_factory, pool_factory, context_factory +): + db.session.add_all( + [ + pool_factory(id=1), + pool_factory(id=2), + ] + ) + db.session.commit() + with pytest.raises(errors.AuthError): + api.pool_api.merge_pools( + context_factory( + params={ + "removeVersion": 1, + "mergeToVersion": 1, + "remove": 1, + "mergeTo": 2, + }, + user=user_factory(rank=model.User.RANK_ANONYMOUS), + ) + ) diff --git a/server/szurubooru/tests/api/test_pool_retrieving.py b/server/szurubooru/tests/api/test_pool_retrieving.py new file mode 100644 index 0000000..688dfa7 --- /dev/null +++ b/server/szurubooru/tests/api/test_pool_retrieving.py @@ -0,0 +1,82 @@ +from unittest.mock import patch + +import pytest + +from szurubooru import api, db, errors, model +from szurubooru.func import pools + + +@pytest.fixture(autouse=True) +def inject_config(config_injector): + config_injector( + { + "privileges": { + "pools:list": model.User.RANK_REGULAR, + "pools:view": model.User.RANK_REGULAR, + }, + } + ) + + +def test_retrieving_multiple(user_factory, pool_factory, context_factory): + pool1 = pool_factory(id=1) + pool2 = pool_factory(id=2) + db.session.add_all([pool2, pool1]) + db.session.flush() + with patch("szurubooru.func.pools.serialize_pool"): + pools.serialize_pool.return_value = "serialized pool" + result = api.pool_api.get_pools( + context_factory( + params={"query": "", "offset": 0}, + user=user_factory(rank=model.User.RANK_REGULAR), + ) + ) + assert result == { + "query": "", + "offset": 0, + "limit": 100, + "total": 2, + "results": ["serialized pool", "serialized pool"], + } + + +def test_trying_to_retrieve_multiple_without_privileges( + user_factory, context_factory +): + with pytest.raises(errors.AuthError): + api.pool_api.get_pools( + context_factory( + params={"query": "", "offset": 0}, + user=user_factory(rank=model.User.RANK_ANONYMOUS), + ) + ) + + +def test_retrieving_single(user_factory, pool_factory, context_factory): + db.session.add(pool_factory(id=1)) + db.session.flush() + with patch("szurubooru.func.pools.serialize_pool"): + pools.serialize_pool.return_value = "serialized pool" + result = api.pool_api.get_pool( + context_factory(user=user_factory(rank=model.User.RANK_REGULAR)), + {"pool_id": 1}, + ) + assert result == "serialized pool" + + +def test_trying_to_retrieve_single_non_existing(user_factory, context_factory): + with pytest.raises(pools.PoolNotFoundError): + api.pool_api.get_pool( + context_factory(user=user_factory(rank=model.User.RANK_REGULAR)), + {"pool_id": 1}, + ) + + +def test_trying_to_retrieve_single_without_privileges( + user_factory, context_factory +): + with pytest.raises(errors.AuthError): + api.pool_api.get_pool( + context_factory(user=user_factory(rank=model.User.RANK_ANONYMOUS)), + {"pool_id": 1}, + ) diff --git a/server/szurubooru/tests/api/test_pool_updating.py b/server/szurubooru/tests/api/test_pool_updating.py new file mode 100644 index 0000000..507289f --- /dev/null +++ b/server/szurubooru/tests/api/test_pool_updating.py @@ -0,0 +1,160 @@ +from unittest.mock import patch + +import pytest + +from szurubooru import api, db, errors, model +from szurubooru.func import pools, posts, snapshots + + +@pytest.fixture(autouse=True) +def inject_config(config_injector): + config_injector( + { + "privileges": { + "pools:create": model.User.RANK_REGULAR, + "pools:edit:names": model.User.RANK_REGULAR, + "pools:edit:category": model.User.RANK_REGULAR, + "pools:edit:description": model.User.RANK_REGULAR, + "pools:edit:posts": model.User.RANK_REGULAR, + }, + } + ) + + +def test_simple_updating(user_factory, pool_factory, context_factory): + auth_user = user_factory(rank=model.User.RANK_REGULAR) + pool = pool_factory(id=1, names=["pool1", "pool2"]) + db.session.add(pool) + db.session.commit() + with patch("szurubooru.func.pools.create_pool"), patch( + "szurubooru.func.posts.get_posts_by_ids" + ), patch("szurubooru.func.pools.update_pool_names"), patch( + "szurubooru.func.pools.update_pool_category_name" + ), patch( + "szurubooru.func.pools.update_pool_description" + ), patch( + "szurubooru.func.pools.update_pool_posts" + ), patch( + "szurubooru.func.pools.serialize_pool" + ), patch( + "szurubooru.func.snapshots.modify" + ): + posts.get_posts_by_ids.return_value = ([], []) + pools.serialize_pool.return_value = "serialized pool" + result = api.pool_api.update_pool( + context_factory( + params={ + "version": 1, + "names": ["pool3"], + "category": "series", + "description": "desc", + "posts": [1, 2], + }, + user=auth_user, + ), + {"pool_id": 1}, + ) + assert result == "serialized pool" + pools.create_pool.assert_not_called() + pools.update_pool_names.assert_called_once_with(pool, ["pool3"]) + pools.update_pool_category_name.assert_called_once_with(pool, "series") + pools.update_pool_description.assert_called_once_with(pool, "desc") + pools.update_pool_posts.assert_called_once_with(pool, [1, 2]) + pools.serialize_pool.assert_called_once_with(pool, options=[]) + snapshots.modify.assert_called_once_with(pool, auth_user) + + +@pytest.mark.parametrize( + "field", + [ + "names", + "category", + "description", + "posts", + ], +) +def test_omitting_optional_field( + user_factory, pool_factory, context_factory, field +): + db.session.add(pool_factory(id=1)) + db.session.commit() + params = { + "names": ["pool1", "pool2"], + "category": "default", + "description": "desc", + "posts": [], + } + del params[field] + with patch("szurubooru.func.pools.create_pool"), patch( + "szurubooru.func.pools.update_pool_names" + ), patch("szurubooru.func.pools.update_pool_category_name"), patch( + "szurubooru.func.pools.serialize_pool" + ): + api.pool_api.update_pool( + context_factory( + params={**params, **{"version": 1}}, + user=user_factory(rank=model.User.RANK_REGULAR), + ), + {"pool_id": 1}, + ) + + +def test_trying_to_update_non_existing(user_factory, context_factory): + with pytest.raises(pools.PoolNotFoundError): + api.pool_api.update_pool( + context_factory( + params={"names": ["dummy"]}, + user=user_factory(rank=model.User.RANK_REGULAR), + ), + {"pool_id": 9999}, + ) + + +@pytest.mark.parametrize( + "params", + [ + {"names": ["whatever"]}, + {"category": "whatever"}, + {"posts": [1]}, + ], +) +def test_trying_to_update_without_privileges( + user_factory, pool_factory, context_factory, params +): + db.session.add(pool_factory(id=1)) + db.session.commit() + with pytest.raises(errors.AuthError): + api.pool_api.update_pool( + context_factory( + params={**params, **{"version": 1}}, + user=user_factory(rank=model.User.RANK_ANONYMOUS), + ), + {"pool_id": 1}, + ) + + +def test_trying_to_create_pools_without_privileges( + config_injector, context_factory, pool_factory, user_factory +): + pool = pool_factory(id=1) + db.session.add(pool) + db.session.commit() + config_injector( + { + "privileges": { + "pools:create": model.User.RANK_ADMINISTRATOR, + "pools:edit:posts": model.User.RANK_REGULAR, + }, + "delete_source_files": False, + } + ) + with patch("szurubooru.func.posts.get_posts_by_ids"): + posts.get_posts_by_ids.return_value = ([], ["new-post"]) + with pytest.raises(errors.AuthError): + api.pool_api.create_pool( + context_factory( + params={"posts": [1, 2], "version": 1}, + user=user_factory(rank=model.User.RANK_REGULAR), + ), + {"pool_id": 1}, + ) diff --git a/server/szurubooru/tests/api/test_post_creating.py b/server/szurubooru/tests/api/test_post_creating.py index 6edcdd3..a1ad4de 100644 --- a/server/szurubooru/tests/api/test_post_creating.py +++ b/server/szurubooru/tests/api/test_post_creating.py @@ -1,65 +1,82 @@ from unittest.mock import patch + import pytest -from szurubooru import api, db, model, errors -from szurubooru.func import posts, tags, snapshots, net + +from szurubooru import api, db, errors, model +from szurubooru.func import net, posts, snapshots, tags @pytest.fixture(autouse=True) def inject_config(config_injector): - config_injector({ - 'privileges': { - 'posts:create:anonymous': model.User.RANK_REGULAR, - 'posts:create:identified': model.User.RANK_REGULAR, - 'tags:create': model.User.RANK_REGULAR, - }, - }) + config_injector( + { + "privileges": { + "posts:create:anonymous": model.User.RANK_REGULAR, + "posts:create:identified": model.User.RANK_REGULAR, + "tags:create": model.User.RANK_REGULAR, + "uploads:use_downloader": model.User.RANK_REGULAR, + }, + "allow_broken_uploads": False, + } + ) -def test_creating_minimal_posts( - context_factory, post_factory, user_factory): +def test_creating_minimal_posts(context_factory, post_factory, user_factory): auth_user = user_factory(rank=model.User.RANK_REGULAR) post = post_factory() db.session.add(post) db.session.flush() - with patch('szurubooru.func.posts.create_post'), \ - patch('szurubooru.func.posts.update_post_safety'), \ - patch('szurubooru.func.posts.update_post_source'), \ - patch('szurubooru.func.posts.update_post_relations'), \ - patch('szurubooru.func.posts.update_post_notes'), \ - patch('szurubooru.func.posts.update_post_flags'), \ - patch('szurubooru.func.posts.update_post_thumbnail'), \ - patch('szurubooru.func.posts.serialize_post'), \ - patch('szurubooru.func.snapshots.create'): + with patch("szurubooru.func.posts.create_post"), patch( + "szurubooru.func.posts.update_post_safety" + ), patch("szurubooru.func.posts.update_post_source"), patch( + "szurubooru.func.posts.update_post_relations" + ), patch( + "szurubooru.func.posts.update_post_notes" + ), patch( + "szurubooru.func.posts.update_post_flags" + ), patch( + "szurubooru.func.posts.update_post_thumbnail" + ), patch( + "szurubooru.func.posts.serialize_post" + ), patch( + "szurubooru.func.snapshots.create" + ): posts.create_post.return_value = (post, []) - posts.serialize_post.return_value = 'serialized post' + posts.serialize_post.return_value = "serialized post" result = api.post_api.create_post( context_factory( params={ - 'safety': 'safe', - 'tags': ['tag1', 'tag2'], + "safety": "safe", + "tags": ["tag1", "tag2"], }, files={ - 'content': 'post-content', - 'thumbnail': 'post-thumbnail', + "content": "post-content", + "thumbnail": "post-thumbnail", }, - user=auth_user)) + user=auth_user, + ) + ) - assert result == 'serialized post' + assert result == "serialized post" posts.create_post.assert_called_once_with( - 'post-content', ['tag1', 'tag2'], auth_user) + "post-content", ["tag1", "tag2"], auth_user + ) posts.update_post_thumbnail.assert_called_once_with( - post, 'post-thumbnail') - posts.update_post_safety.assert_called_once_with(post, 'safe') - posts.update_post_source.assert_called_once_with(post, '') + post, "post-thumbnail" + ) + posts.update_post_safety.assert_called_once_with(post, "safe") + posts.update_post_source.assert_called_once_with(post, "") posts.update_post_relations.assert_called_once_with(post, []) posts.update_post_notes.assert_called_once_with(post, []) posts.update_post_flags.assert_called_once_with(post, []) posts.update_post_thumbnail.assert_called_once_with( - post, 'post-thumbnail') + post, "post-thumbnail" + ) posts.serialize_post.assert_called_once_with( - post, auth_user, options=[]) + post, auth_user, options=[] + ) snapshots.create.assert_called_once_with(post, auth_user) @@ -69,232 +86,302 @@ def test_creating_full_posts(context_factory, post_factory, user_factory): db.session.add(post) db.session.flush() - with patch('szurubooru.func.posts.create_post'), \ - patch('szurubooru.func.posts.update_post_safety'), \ - patch('szurubooru.func.posts.update_post_source'), \ - patch('szurubooru.func.posts.update_post_relations'), \ - patch('szurubooru.func.posts.update_post_notes'), \ - patch('szurubooru.func.posts.update_post_flags'), \ - patch('szurubooru.func.posts.serialize_post'), \ - patch('szurubooru.func.snapshots.create'): + with patch("szurubooru.func.posts.create_post"), patch( + "szurubooru.func.posts.update_post_safety" + ), patch("szurubooru.func.posts.update_post_source"), patch( + "szurubooru.func.posts.update_post_relations" + ), patch( + "szurubooru.func.posts.update_post_notes" + ), patch( + "szurubooru.func.posts.update_post_flags" + ), patch( + "szurubooru.func.posts.serialize_post" + ), patch( + "szurubooru.func.snapshots.create" + ): posts.create_post.return_value = (post, []) - posts.serialize_post.return_value = 'serialized post' + posts.serialize_post.return_value = "serialized post" result = api.post_api.create_post( context_factory( params={ - 'safety': 'safe', - 'tags': ['tag1', 'tag2'], - 'relations': [1, 2], - 'source': 'source', - 'notes': ['note1', 'note2'], - 'flags': ['flag1', 'flag2'], + "safety": "safe", + "tags": ["tag1", "tag2"], + "relations": [1, 2], + "source": "source", + "notes": ["note1", "note2"], + "flags": ["flag1", "flag2"], }, files={ - 'content': 'post-content', + "content": "post-content", }, - user=auth_user)) + user=auth_user, + ) + ) - assert result == 'serialized post' + assert result == "serialized post" posts.create_post.assert_called_once_with( - 'post-content', ['tag1', 'tag2'], auth_user) - posts.update_post_safety.assert_called_once_with(post, 'safe') - posts.update_post_source.assert_called_once_with(post, 'source') + "post-content", ["tag1", "tag2"], auth_user + ) + posts.update_post_safety.assert_called_once_with(post, "safe") + posts.update_post_source.assert_called_once_with(post, "source") posts.update_post_relations.assert_called_once_with(post, [1, 2]) posts.update_post_notes.assert_called_once_with( - post, ['note1', 'note2']) + post, ["note1", "note2"] + ) posts.update_post_flags.assert_called_once_with( - post, ['flag1', 'flag2']) + post, ["flag1", "flag2"] + ) posts.serialize_post.assert_called_once_with( - post, auth_user, options=[]) + post, auth_user, options=[] + ) snapshots.create.assert_called_once_with(post, auth_user) def test_anonymous_uploads( - config_injector, context_factory, post_factory, user_factory): + config_injector, context_factory, post_factory, user_factory +): auth_user = user_factory(rank=model.User.RANK_REGULAR) post = post_factory() db.session.add(post) db.session.flush() - with patch('szurubooru.func.posts.serialize_post'), \ - patch('szurubooru.func.posts.create_post'), \ - patch('szurubooru.func.posts.update_post_source'): - config_injector({ - 'privileges': {'posts:create:anonymous': model.User.RANK_REGULAR}, - }) + with patch("szurubooru.func.posts.serialize_post"), patch( + "szurubooru.func.posts.create_post" + ), patch("szurubooru.func.posts.update_post_source"), patch( + "szurubooru.func.snapshots._post_to_webhooks" + ): + config_injector( + { + "privileges": { + "posts:create:anonymous": model.User.RANK_REGULAR, + "uploads:use_downloader": model.User.RANK_POWER, + }, + } + ) posts.create_post.return_value = [post, []] api.post_api.create_post( context_factory( params={ - 'safety': 'safe', - 'tags': ['tag1', 'tag2'], - 'anonymous': 'True', + "safety": "safe", + "tags": ["tag1", "tag2"], + "anonymous": "True", }, files={ - 'content': 'post-content', + "content": "post-content", }, - user=auth_user)) + user=auth_user, + ) + ) posts.create_post.assert_called_once_with( - 'post-content', ['tag1', 'tag2'], None) + "post-content", ["tag1", "tag2"], None + ) def test_creating_from_url_saves_source( - config_injector, context_factory, post_factory, user_factory): + config_injector, context_factory, post_factory, user_factory +): auth_user = user_factory(rank=model.User.RANK_REGULAR) post = post_factory() db.session.add(post) db.session.flush() - with patch('szurubooru.func.net.download'), \ - patch('szurubooru.func.posts.serialize_post'), \ - patch('szurubooru.func.posts.create_post'), \ - patch('szurubooru.func.posts.update_post_source'): - config_injector({ - 'privileges': {'posts:create:identified': model.User.RANK_REGULAR}, - }) - net.download.return_value = b'content' + with patch("szurubooru.func.net.download"), patch( + "szurubooru.func.posts.serialize_post" + ), patch("szurubooru.func.posts.create_post"), patch( + "szurubooru.func.posts.update_post_source" + ), patch( + "szurubooru.func.snapshots._post_to_webhooks" + ): + config_injector( + { + "privileges": { + "posts:create:identified": model.User.RANK_REGULAR, + "uploads:use_downloader": model.User.RANK_POWER, + }, + } + ) + net.download.return_value = b"content" posts.create_post.return_value = [post, []] api.post_api.create_post( context_factory( params={ - 'safety': 'safe', - 'tags': ['tag1', 'tag2'], - 'contentUrl': 'example.com', + "safety": "safe", + "tags": ["tag1", "tag2"], + "contentUrl": "example.com", }, - user=auth_user)) - net.download.assert_called_once_with('example.com') + user=auth_user, + ) + ) + net.download.assert_called_once_with( + "example.com", use_video_downloader=False + ) posts.create_post.assert_called_once_with( - b'content', ['tag1', 'tag2'], auth_user) - posts.update_post_source.assert_called_once_with(post, 'example.com') + b"content", ["tag1", "tag2"], auth_user + ) + posts.update_post_source.assert_called_once_with(post, "example.com") def test_creating_from_url_with_source_specified( - config_injector, context_factory, post_factory, user_factory): + config_injector, context_factory, post_factory, user_factory +): auth_user = user_factory(rank=model.User.RANK_REGULAR) post = post_factory() db.session.add(post) db.session.flush() - with patch('szurubooru.func.net.download'), \ - patch('szurubooru.func.posts.serialize_post'), \ - patch('szurubooru.func.posts.create_post'), \ - patch('szurubooru.func.posts.update_post_source'): - config_injector({ - 'privileges': {'posts:create:identified': model.User.RANK_REGULAR}, - }) - net.download.return_value = b'content' + with patch("szurubooru.func.net.download"), patch( + "szurubooru.func.posts.serialize_post" + ), patch("szurubooru.func.posts.create_post"), patch( + "szurubooru.func.posts.update_post_source" + ), patch( + "szurubooru.func.snapshots._post_to_webhooks" + ): + config_injector( + { + "privileges": { + "posts:create:identified": model.User.RANK_REGULAR, + "uploads:use_downloader": model.User.RANK_REGULAR, + }, + } + ) + net.download.return_value = b"content" posts.create_post.return_value = [post, []] api.post_api.create_post( context_factory( params={ - 'safety': 'safe', - 'tags': ['tag1', 'tag2'], - 'contentUrl': 'example.com', - 'source': 'example2.com', + "safety": "safe", + "tags": ["tag1", "tag2"], + "contentUrl": "example.com", + "source": "example2.com", }, - user=auth_user)) - net.download.assert_called_once_with('example.com') + user=auth_user, + ) + ) + net.download.assert_called_once_with( + "example.com", use_video_downloader=True + ) posts.create_post.assert_called_once_with( - b'content', ['tag1', 'tag2'], auth_user) - posts.update_post_source.assert_called_once_with(post, 'example2.com') + b"content", ["tag1", "tag2"], auth_user + ) + posts.update_post_source.assert_called_once_with(post, "example2.com") -@pytest.mark.parametrize('field', ['safety']) +@pytest.mark.parametrize("field", ["safety"]) def test_trying_to_omit_mandatory_field(context_factory, user_factory, field): params = { - 'safety': 'safe', + "safety": "safe", } del params[field] with pytest.raises(errors.MissingRequiredParameterError): api.post_api.create_post( context_factory( params=params, - files={'content': '...'}, - user=user_factory(rank=model.User.RANK_REGULAR))) + files={"content": "..."}, + user=user_factory(rank=model.User.RANK_REGULAR), + ) + ) @pytest.mark.parametrize( - 'field', ['tags', 'relations', 'source', 'notes', 'flags']) + "field", ["tags", "relations", "source", "notes", "flags"] +) def test_omitting_optional_field( - field, context_factory, post_factory, user_factory): + field, context_factory, post_factory, user_factory +): auth_user = user_factory(rank=model.User.RANK_REGULAR) post = post_factory() db.session.add(post) db.session.flush() params = { - 'safety': 'safe', - 'tags': ['tag1', 'tag2'], - 'relations': [1, 2], - 'source': 'source', - 'notes': ['note1', 'note2'], - 'flags': ['flag1', 'flag2'], + "safety": "safe", + "tags": ["tag1", "tag2"], + "relations": [1, 2], + "source": "source", + "notes": ["note1", "note2"], + "flags": ["flag1", "flag2"], } del params[field] - with patch('szurubooru.func.posts.create_post'), \ - patch('szurubooru.func.posts.update_post_safety'), \ - patch('szurubooru.func.posts.update_post_source'), \ - patch('szurubooru.func.posts.update_post_relations'), \ - patch('szurubooru.func.posts.update_post_notes'), \ - patch('szurubooru.func.posts.update_post_flags'), \ - patch('szurubooru.func.posts.serialize_post'), \ - patch('szurubooru.func.snapshots.create'): + with patch("szurubooru.func.posts.create_post"), patch( + "szurubooru.func.posts.update_post_safety" + ), patch("szurubooru.func.posts.update_post_source"), patch( + "szurubooru.func.posts.update_post_relations" + ), patch( + "szurubooru.func.posts.update_post_notes" + ), patch( + "szurubooru.func.posts.update_post_flags" + ), patch( + "szurubooru.func.posts.serialize_post" + ), patch( + "szurubooru.func.snapshots.create" + ): posts.create_post.return_value = (post, []) - posts.serialize_post.return_value = 'serialized post' + posts.serialize_post.return_value = "serialized post" result = api.post_api.create_post( context_factory( params=params, - files={'content': 'post-content'}, - user=auth_user)) - assert result == 'serialized post' + files={"content": "post-content"}, + user=auth_user, + ) + ) + assert result == "serialized post" def test_errors_not_spending_ids( - config_injector, tmpdir, context_factory, read_asset, user_factory, - skip_post_hashing): - config_injector({ - 'data_dir': str(tmpdir.mkdir('data')), - 'data_url': 'example.com', - 'thumbnails': { - 'post_width': 300, - 'post_height': 300, - }, - 'privileges': { - 'posts:create:identified': model.User.RANK_REGULAR, - }, - 'secret': 'test', - }) + config_injector, tmpdir, context_factory, read_asset, user_factory +): + config_injector( + { + "data_dir": str(tmpdir.mkdir("data")), + "data_url": "example.com", + "thumbnails": { + "post_width": 300, + "post_height": 300, + }, + "privileges": { + "posts:create:identified": model.User.RANK_REGULAR, + "uploads:use_downloader": model.User.RANK_POWER, + }, + "secret": "test", + } + ) auth_user = user_factory(rank=model.User.RANK_REGULAR) # successful request - with patch('szurubooru.func.posts.serialize_post'), \ - patch('szurubooru.func.posts.update_post_tags'): + with patch("szurubooru.func.posts.serialize_post"), patch( + "szurubooru.func.posts.update_post_tags" + ), patch("szurubooru.func.snapshots._post_to_webhooks"): posts.serialize_post.side_effect = lambda post, *_, **__: post.post_id post1_id = api.post_api.create_post( context_factory( - params={'safety': 'safe', 'tags': []}, - files={'content': read_asset('png.png')}, - user=auth_user)) - db.session.commit() + params={"safety": "safe", "tags": []}, + files={"content": read_asset("png.png")}, + user=auth_user, + ) + ) # erroreous request (duplicate post) with pytest.raises(posts.PostAlreadyUploadedError): api.post_api.create_post( context_factory( - params={'safety': 'safe', 'tags': []}, - files={'content': read_asset('png.png')}, - user=auth_user)) - db.session.rollback() + params={"safety": "safe", "tags": []}, + files={"content": read_asset("png.png")}, + user=auth_user, + ) + ) # successful request - with patch('szurubooru.func.posts.serialize_post'), \ - patch('szurubooru.func.posts.update_post_tags'): + with patch("szurubooru.func.posts.serialize_post"), patch( + "szurubooru.func.posts.update_post_tags" + ), patch("szurubooru.func.snapshots._post_to_webhooks"): posts.serialize_post.side_effect = lambda post, *_, **__: post.post_id post2_id = api.post_api.create_post( context_factory( - params={'safety': 'safe', 'tags': []}, - files={'content': read_asset('jpeg.jpg')}, - user=auth_user)) + params={"safety": "safe", "tags": []}, + files={"content": read_asset("jpeg.jpg")}, + user=auth_user, + ) + ) assert post1_id > 0 assert post2_id > 0 @@ -306,40 +393,52 @@ def test_trying_to_omit_content(context_factory, user_factory): api.post_api.create_post( context_factory( params={ - 'safety': 'safe', - 'tags': ['tag1', 'tag2'], + "safety": "safe", + "tags": ["tag1", "tag2"], }, - user=user_factory(rank=model.User.RANK_REGULAR))) + user=user_factory(rank=model.User.RANK_REGULAR), + ) + ) def test_trying_to_create_post_without_privileges( - context_factory, user_factory): + context_factory, user_factory +): with pytest.raises(errors.AuthError): - api.post_api.create_post(context_factory( - params='whatever', - user=user_factory(rank=model.User.RANK_ANONYMOUS))) + api.post_api.create_post( + context_factory( + params="whatever", + user=user_factory(rank=model.User.RANK_ANONYMOUS), + ) + ) def test_trying_to_create_tags_without_privileges( - config_injector, context_factory, user_factory): - config_injector({ - 'privileges': { - 'posts:create:anonymous': model.User.RANK_REGULAR, - 'posts:create:identified': model.User.RANK_REGULAR, - 'tags:create': model.User.RANK_ADMINISTRATOR, - }, - }) - with pytest.raises(errors.AuthError), \ - patch('szurubooru.func.posts.update_post_content'), \ - patch('szurubooru.func.posts.update_post_tags'): - posts.update_post_tags.return_value = ['new-tag'] + config_injector, context_factory, user_factory +): + config_injector( + { + "privileges": { + "posts:create:anonymous": model.User.RANK_REGULAR, + "posts:create:identified": model.User.RANK_REGULAR, + "tags:create": model.User.RANK_ADMINISTRATOR, + "uploads:use_downloader": model.User.RANK_POWER, + }, + } + ) + with pytest.raises(errors.AuthError), patch( + "szurubooru.func.posts.update_post_content" + ), patch("szurubooru.func.posts.update_post_tags"): + posts.update_post_tags.return_value = ["new-tag"] api.post_api.create_post( context_factory( params={ - 'safety': 'safe', - 'tags': ['tag1', 'tag2'], + "safety": "safe", + "tags": ["tag1", "tag2"], }, files={ - 'content': posts.EMPTY_PIXEL, + "content": posts.EMPTY_PIXEL, }, - user=user_factory(rank=model.User.RANK_REGULAR))) + user=user_factory(rank=model.User.RANK_REGULAR), + ) + ) diff --git a/server/szurubooru/tests/api/test_post_deleting.py b/server/szurubooru/tests/api/test_post_deleting.py index bb5f9ce..fd2cb75 100644 --- a/server/szurubooru/tests/api/test_post_deleting.py +++ b/server/szurubooru/tests/api/test_post_deleting.py @@ -1,19 +1,21 @@ from unittest.mock import patch + import pytest -from szurubooru import api, db, model, errors + +from szurubooru import api, db, errors, model from szurubooru.func import posts, snapshots @pytest.fixture(autouse=True) def inject_config(config_injector): - config_injector({ - 'secret': 'secret', - 'data_dir': '', - 'delete_source_files': False, - 'privileges': { - 'posts:delete': model.User.RANK_REGULAR + config_injector( + { + "secret": "secret", + "data_dir": "", + "delete_source_files": False, + "privileges": {"posts:delete": model.User.RANK_REGULAR}, } - }) + ) def test_deleting(user_factory, post_factory, context_factory): @@ -21,10 +23,11 @@ def test_deleting(user_factory, post_factory, context_factory): post = post_factory(id=1) db.session.add(post) db.session.flush() - with patch('szurubooru.func.snapshots.delete'): + with patch("szurubooru.func.snapshots.delete"): result = api.post_api.delete_post( - context_factory(params={'version': 1}, user=auth_user), - {'post_id': 1}) + context_factory(params={"version": 1}, user=auth_user), + {"post_id": 1}, + ) assert result == {} assert db.session.query(model.Post).count() == 0 snapshots.delete.assert_called_once_with(post, auth_user) @@ -34,15 +37,18 @@ def test_trying_to_delete_non_existing(user_factory, context_factory): with pytest.raises(posts.PostNotFoundError): api.post_api.delete_post( context_factory(user=user_factory(rank=model.User.RANK_REGULAR)), - {'post_id': 999}) + {"post_id": 999}, + ) def test_trying_to_delete_without_privileges( - user_factory, post_factory, context_factory): + user_factory, post_factory, context_factory +): db.session.add(post_factory(id=1)) db.session.commit() with pytest.raises(errors.AuthError): api.post_api.delete_post( context_factory(user=user_factory(rank=model.User.RANK_ANONYMOUS)), - {'post_id': 1}) + {"post_id": 1}, + ) assert db.session.query(model.Post).count() == 1 diff --git a/server/szurubooru/tests/api/test_post_favoriting.py b/server/szurubooru/tests/api/test_post_favoriting.py index ce91a02..dc92bd9 100644 --- a/server/szurubooru/tests/api/test_post_favoriting.py +++ b/server/szurubooru/tests/api/test_post_favoriting.py @@ -1,29 +1,34 @@ from datetime import datetime from unittest.mock import patch + import pytest -from szurubooru import api, db, model, errors + +from szurubooru import api, db, errors, model from szurubooru.func import posts @pytest.fixture(autouse=True) def inject_config(config_injector): config_injector( - {'privileges': {'posts:favorite': model.User.RANK_REGULAR}}) + {"privileges": {"posts:favorite": model.User.RANK_REGULAR}} + ) def test_adding_to_favorites( - user_factory, post_factory, context_factory, fake_datetime): + user_factory, post_factory, context_factory, fake_datetime +): post = post_factory() db.session.add(post) db.session.commit() assert post.score == 0 - with patch('szurubooru.func.posts.serialize_post'), \ - fake_datetime('1997-12-01'): - posts.serialize_post.return_value = 'serialized post' + with patch("szurubooru.func.posts.serialize_post"), fake_datetime( + "1997-12-01" + ): + posts.serialize_post.return_value = "serialized post" result = api.post_api.add_post_to_favorites( - context_factory(user=user_factory()), - {'post_id': post.post_id}) - assert result == 'serialized post' + context_factory(user=user_factory()), {"post_id": post.post_id} + ) + assert result == "serialized post" post = db.session.query(model.Post).one() assert db.session.query(model.PostFavorite).count() == 1 assert post is not None @@ -32,22 +37,23 @@ def test_adding_to_favorites( def test_removing_from_favorites( - user_factory, post_factory, context_factory, fake_datetime): + user_factory, post_factory, context_factory, fake_datetime +): user = user_factory() post = post_factory() db.session.add(post) db.session.commit() assert post.score == 0 - with patch('szurubooru.func.posts.serialize_post'): - with fake_datetime('1997-12-01'): + with patch("szurubooru.func.posts.serialize_post"): + with fake_datetime("1997-12-01"): api.post_api.add_post_to_favorites( - context_factory(user=user), - {'post_id': post.post_id}) + context_factory(user=user), {"post_id": post.post_id} + ) assert post.score == 1 - with fake_datetime('1997-12-02'): + with fake_datetime("1997-12-02"): api.post_api.delete_post_from_favorites( - context_factory(user=user), - {'post_id': post.post_id}) + context_factory(user=user), {"post_id": post.post_id} + ) post = db.session.query(model.Post).one() assert post.score == 1 assert db.session.query(model.PostFavorite).count() == 0 @@ -55,65 +61,68 @@ def test_removing_from_favorites( def test_favoriting_twice( - user_factory, post_factory, context_factory, fake_datetime): + user_factory, post_factory, context_factory, fake_datetime +): user = user_factory() post = post_factory() db.session.add(post) db.session.commit() - with patch('szurubooru.func.posts.serialize_post'): - with fake_datetime('1997-12-01'): + with patch("szurubooru.func.posts.serialize_post"): + with fake_datetime("1997-12-01"): api.post_api.add_post_to_favorites( - context_factory(user=user), - {'post_id': post.post_id}) - with fake_datetime('1997-12-02'): + context_factory(user=user), {"post_id": post.post_id} + ) + with fake_datetime("1997-12-02"): api.post_api.add_post_to_favorites( - context_factory(user=user), - {'post_id': post.post_id}) + context_factory(user=user), {"post_id": post.post_id} + ) post = db.session.query(model.Post).one() assert db.session.query(model.PostFavorite).count() == 1 assert post.favorite_count == 1 def test_removing_twice( - user_factory, post_factory, context_factory, fake_datetime): + user_factory, post_factory, context_factory, fake_datetime +): user = user_factory() post = post_factory() db.session.add(post) db.session.commit() - with patch('szurubooru.func.posts.serialize_post'): - with fake_datetime('1997-12-01'): + with patch("szurubooru.func.posts.serialize_post"): + with fake_datetime("1997-12-01"): api.post_api.add_post_to_favorites( - context_factory(user=user), - {'post_id': post.post_id}) - with fake_datetime('1997-12-02'): + context_factory(user=user), {"post_id": post.post_id} + ) + with fake_datetime("1997-12-02"): api.post_api.delete_post_from_favorites( - context_factory(user=user), - {'post_id': post.post_id}) - with fake_datetime('1997-12-02'): + context_factory(user=user), {"post_id": post.post_id} + ) + with fake_datetime("1997-12-02"): api.post_api.delete_post_from_favorites( - context_factory(user=user), - {'post_id': post.post_id}) + context_factory(user=user), {"post_id": post.post_id} + ) post = db.session.query(model.Post).one() assert db.session.query(model.PostFavorite).count() == 0 assert post.favorite_count == 0 def test_favorites_from_multiple_users( - user_factory, post_factory, context_factory, fake_datetime): + user_factory, post_factory, context_factory, fake_datetime +): user1 = user_factory() user2 = user_factory() post = post_factory() db.session.add_all([user1, user2, post]) db.session.commit() - with patch('szurubooru.func.posts.serialize_post'): - with fake_datetime('1997-12-01'): + with patch("szurubooru.func.posts.serialize_post"): + with fake_datetime("1997-12-01"): api.post_api.add_post_to_favorites( - context_factory(user=user1), - {'post_id': post.post_id}) - with fake_datetime('1997-12-02'): + context_factory(user=user1), {"post_id": post.post_id} + ) + with fake_datetime("1997-12-02"): api.post_api.add_post_to_favorites( - context_factory(user=user2), - {'post_id': post.post_id}) + context_factory(user=user2), {"post_id": post.post_id} + ) post = db.session.query(model.Post).one() assert db.session.query(model.PostFavorite).count() == 2 assert post.favorite_count == 2 @@ -123,16 +132,18 @@ def test_favorites_from_multiple_users( def test_trying_to_update_non_existing(user_factory, context_factory): with pytest.raises(posts.PostNotFoundError): api.post_api.add_post_to_favorites( - context_factory(user=user_factory()), - {'post_id': 5}) + context_factory(user=user_factory()), {"post_id": 5} + ) def test_trying_to_rate_without_privileges( - user_factory, post_factory, context_factory): + user_factory, post_factory, context_factory +): post = post_factory() db.session.add(post) db.session.commit() with pytest.raises(errors.AuthError): api.post_api.add_post_to_favorites( context_factory(user=user_factory(rank=model.User.RANK_ANONYMOUS)), - {'post_id': post.post_id}) + {"post_id": post.post_id}, + ) diff --git a/server/szurubooru/tests/api/test_post_featuring.py b/server/szurubooru/tests/api/test_post_featuring.py index 6e9e756..d83d4b6 100644 --- a/server/szurubooru/tests/api/test_post_featuring.py +++ b/server/szurubooru/tests/api/test_post_featuring.py @@ -1,18 +1,22 @@ from unittest.mock import patch + import pytest -from szurubooru import api, db, model, errors + +from szurubooru import api, db, errors, model from szurubooru.func import posts, snapshots @pytest.fixture(autouse=True) def inject_config(config_injector): - config_injector({ - 'privileges': { - 'posts:feature': model.User.RANK_REGULAR, - 'posts:view': model.User.RANK_REGULAR, - 'posts:view:featured': model.User.RANK_REGULAR, - }, - }) + config_injector( + { + "privileges": { + "posts:feature": model.User.RANK_REGULAR, + "posts:view": model.User.RANK_REGULAR, + "posts:view:featured": model.User.RANK_REGULAR, + }, + } + ) def test_featuring(user_factory, post_factory, context_factory): @@ -21,64 +25,80 @@ def test_featuring(user_factory, post_factory, context_factory): db.session.add(post) db.session.flush() assert not posts.get_post_by_id(1).is_featured - with patch('szurubooru.func.posts.serialize_post'), \ - patch('szurubooru.func.snapshots.modify'): - posts.serialize_post.return_value = 'serialized post' + with patch("szurubooru.func.posts.serialize_post"), patch( + "szurubooru.func.snapshots.modify" + ): + posts.serialize_post.return_value = "serialized post" result = api.post_api.set_featured_post( - context_factory(params={'id': 1}, user=auth_user)) - assert result == 'serialized post' + context_factory(params={"id": 1}, user=auth_user) + ) + assert result == "serialized post" assert posts.try_get_featured_post() is not None assert posts.try_get_featured_post().post_id == 1 assert posts.get_post_by_id(1).is_featured result = api.post_api.get_featured_post( - context_factory( - user=user_factory(rank=model.User.RANK_REGULAR))) - assert result == 'serialized post' + context_factory(user=user_factory(rank=model.User.RANK_REGULAR)) + ) + assert result == "serialized post" snapshots.modify.assert_called_once_with(post, auth_user) def test_trying_to_omit_required_parameter(user_factory, context_factory): with pytest.raises(errors.MissingRequiredParameterError): api.post_api.set_featured_post( - context_factory( - user=user_factory(rank=model.User.RANK_REGULAR))) + context_factory(user=user_factory(rank=model.User.RANK_REGULAR)) + ) def test_trying_to_feature_the_same_post_twice( - user_factory, post_factory, context_factory): + user_factory, post_factory, context_factory +): db.session.add(post_factory(id=1)) db.session.commit() - with patch('szurubooru.func.posts.serialize_post'): + with patch("szurubooru.func.posts.serialize_post"), patch( + "szurubooru.func.snapshots._post_to_webhooks" + ): api.post_api.set_featured_post( context_factory( - params={'id': 1}, - user=user_factory(rank=model.User.RANK_REGULAR))) + params={"id": 1}, + user=user_factory(rank=model.User.RANK_REGULAR), + ) + ) with pytest.raises(posts.PostAlreadyFeaturedError): api.post_api.set_featured_post( context_factory( - params={'id': 1}, - user=user_factory(rank=model.User.RANK_REGULAR))) + params={"id": 1}, + user=user_factory(rank=model.User.RANK_REGULAR), + ) + ) def test_featuring_one_post_after_another( - user_factory, post_factory, context_factory, fake_datetime): + user_factory, post_factory, context_factory, fake_datetime +): db.session.add(post_factory(id=1)) db.session.add(post_factory(id=2)) db.session.commit() assert posts.try_get_featured_post() is None assert not posts.get_post_by_id(1).is_featured assert not posts.get_post_by_id(2).is_featured - with patch('szurubooru.func.posts.serialize_post'): - with fake_datetime('1997'): + with patch("szurubooru.func.posts.serialize_post"), patch( + "szurubooru.func.snapshots._post_to_webhooks" + ): + with fake_datetime("1997"): api.post_api.set_featured_post( context_factory( - params={'id': 1}, - user=user_factory(rank=model.User.RANK_REGULAR))) - with fake_datetime('1998'): + params={"id": 1}, + user=user_factory(rank=model.User.RANK_REGULAR), + ) + ) + with fake_datetime("1998"): api.post_api.set_featured_post( context_factory( - params={'id': 2}, - user=user_factory(rank=model.User.RANK_REGULAR))) + params={"id": 2}, + user=user_factory(rank=model.User.RANK_REGULAR), + ) + ) assert posts.try_get_featured_post() is not None assert posts.try_get_featured_post().post_id == 2 assert not posts.get_post_by_id(1).is_featured @@ -89,18 +109,21 @@ def test_trying_to_feature_non_existing(user_factory, context_factory): with pytest.raises(posts.PostNotFoundError): api.post_api.set_featured_post( context_factory( - params={'id': 1}, - user=user_factory(rank=model.User.RANK_REGULAR))) + params={"id": 1}, + user=user_factory(rank=model.User.RANK_REGULAR), + ) + ) -def test_trying_to_retrieve_without_privileges( - user_factory, context_factory): +def test_trying_to_retrieve_without_privileges(user_factory, context_factory): with pytest.raises(errors.AuthError): api.post_api.get_featured_post( - context_factory(user=user_factory(rank=model.User.RANK_ANONYMOUS))) + context_factory(user=user_factory(rank=model.User.RANK_ANONYMOUS)) + ) def test_trying_to_feature_without_privileges(user_factory, context_factory): with pytest.raises(errors.AuthError): api.post_api.set_featured_post( - context_factory(user=user_factory(rank=model.User.RANK_ANONYMOUS))) + context_factory(user=user_factory(rank=model.User.RANK_ANONYMOUS)) + ) diff --git a/server/szurubooru/tests/api/test_post_merging.py b/server/szurubooru/tests/api/test_post_merging.py index eb8464f..cdcb0af 100644 --- a/server/szurubooru/tests/api/test_post_merging.py +++ b/server/szurubooru/tests/api/test_post_merging.py @@ -1,12 +1,14 @@ from unittest.mock import patch + import pytest -from szurubooru import api, db, model, errors + +from szurubooru import api, db, errors, model from szurubooru.func import posts, snapshots @pytest.fixture(autouse=True) def inject_config(config_injector): - config_injector({'privileges': {'posts:merge': model.User.RANK_REGULAR}}) + config_injector({"privileges": {"posts:merge": model.User.RANK_REGULAR}}) def test_merging(user_factory, context_factory, post_factory): @@ -15,66 +17,78 @@ def test_merging(user_factory, context_factory, post_factory): target_post = post_factory() db.session.add_all([source_post, target_post]) db.session.flush() - with patch('szurubooru.func.posts.serialize_post'), \ - patch('szurubooru.func.posts.merge_posts'), \ - patch('szurubooru.func.snapshots.merge'): + with patch("szurubooru.func.posts.serialize_post"), patch( + "szurubooru.func.posts.merge_posts" + ), patch("szurubooru.func.snapshots.merge"): api.post_api.merge_posts( context_factory( params={ - 'removeVersion': 1, - 'mergeToVersion': 1, - 'remove': source_post.post_id, - 'mergeTo': target_post.post_id, - 'replaceContent': False, + "removeVersion": 1, + "mergeToVersion": 1, + "remove": source_post.post_id, + "mergeTo": target_post.post_id, + "replaceContent": False, }, - user=auth_user)) + user=auth_user, + ) + ) posts.merge_posts.called_once_with(source_post, target_post) snapshots.merge.assert_called_once_with( - source_post, target_post, auth_user) + source_post, target_post, auth_user + ) @pytest.mark.parametrize( - 'field', ['remove', 'mergeTo', 'removeVersion', 'mergeToVersion']) + "field", ["remove", "mergeTo", "removeVersion", "mergeToVersion"] +) def test_trying_to_omit_mandatory_field( - user_factory, post_factory, context_factory, field): + user_factory, post_factory, context_factory, field +): source_post = post_factory() target_post = post_factory() db.session.add_all([source_post, target_post]) db.session.commit() params = { - 'removeVersion': 1, - 'mergeToVersion': 1, - 'remove': source_post.post_id, - 'mergeTo': target_post.post_id, - 'replaceContent': False, + "removeVersion": 1, + "mergeToVersion": 1, + "remove": source_post.post_id, + "mergeTo": target_post.post_id, + "replaceContent": False, } del params[field] with pytest.raises(errors.ValidationError): api.post_api.merge_posts( context_factory( - params=params, - user=user_factory(rank=model.User.RANK_REGULAR))) + params=params, user=user_factory(rank=model.User.RANK_REGULAR) + ) + ) def test_trying_to_merge_non_existing( - user_factory, post_factory, context_factory): + user_factory, post_factory, context_factory +): post = post_factory() db.session.add(post) db.session.commit() with pytest.raises(posts.PostNotFoundError): api.post_api.merge_posts( context_factory( - params={'remove': post.post_id, 'mergeTo': 999}, - user=user_factory(rank=model.User.RANK_REGULAR))) + params={"remove": post.post_id, "mergeTo": 999}, + user=user_factory(rank=model.User.RANK_REGULAR), + ) + ) with pytest.raises(posts.PostNotFoundError): api.post_api.merge_posts( context_factory( - params={'remove': 999, 'mergeTo': post.post_id}, - user=user_factory(rank=model.User.RANK_REGULAR))) + params={"remove": 999, "mergeTo": post.post_id}, + user=user_factory(rank=model.User.RANK_REGULAR), + ) + ) def test_trying_to_merge_without_privileges( - user_factory, post_factory, context_factory): + user_factory, post_factory, context_factory +): source_post = post_factory() target_post = post_factory() db.session.add_all([source_post, target_post]) @@ -83,10 +97,12 @@ def test_trying_to_merge_without_privileges( api.post_api.merge_posts( context_factory( params={ - 'removeVersion': 1, - 'mergeToVersion': 1, - 'remove': source_post.post_id, - 'mergeTo': target_post.post_id, - 'replaceContent': False, + "removeVersion": 1, + "mergeToVersion": 1, + "remove": source_post.post_id, + "mergeTo": target_post.post_id, + "replaceContent": False, }, - user=user_factory(rank=model.User.RANK_ANONYMOUS))) + user=user_factory(rank=model.User.RANK_ANONYMOUS), + ) + ) diff --git a/server/szurubooru/tests/api/test_post_rating.py b/server/szurubooru/tests/api/test_post_rating.py index 0fca2f5..2db17db 100644 --- a/server/szurubooru/tests/api/test_post_rating.py +++ b/server/szurubooru/tests/api/test_post_rating.py @@ -1,27 +1,31 @@ from unittest.mock import patch + import pytest -from szurubooru import api, db, model, errors + +from szurubooru import api, db, errors, model from szurubooru.func import posts @pytest.fixture(autouse=True) def inject_config(config_injector): - config_injector({'privileges': {'posts:score': model.User.RANK_REGULAR}}) + config_injector({"privileges": {"posts:score": model.User.RANK_REGULAR}}) def test_simple_rating( - user_factory, post_factory, context_factory, fake_datetime): + user_factory, post_factory, context_factory, fake_datetime +): post = post_factory() db.session.add(post) db.session.commit() - with patch('szurubooru.func.posts.serialize_post'), \ - fake_datetime('1997-12-01'): - posts.serialize_post.return_value = 'serialized post' + with patch("szurubooru.func.posts.serialize_post"), fake_datetime( + "1997-12-01" + ): + posts.serialize_post.return_value = "serialized post" result = api.post_api.set_post_score( - context_factory( - params={'score': 1}, user=user_factory()), - {'post_id': post.post_id}) - assert result == 'serialized post' + context_factory(params={"score": 1}, user=user_factory()), + {"post_id": post.post_id}, + ) + assert result == "serialized post" post = db.session.query(model.Post).one() assert db.session.query(model.PostScore).count() == 1 assert post is not None @@ -29,112 +33,129 @@ def test_simple_rating( def test_updating_rating( - user_factory, post_factory, context_factory, fake_datetime): + user_factory, post_factory, context_factory, fake_datetime +): user = user_factory() post = post_factory() db.session.add(post) db.session.commit() - with patch('szurubooru.func.posts.serialize_post'): - with fake_datetime('1997-12-01'): + with patch("szurubooru.func.posts.serialize_post"): + with fake_datetime("1997-12-01"): api.post_api.set_post_score( - context_factory(params={'score': 1}, user=user), - {'post_id': post.post_id}) - with fake_datetime('1997-12-02'): + context_factory(params={"score": 1}, user=user), + {"post_id": post.post_id}, + ) + with fake_datetime("1997-12-02"): api.post_api.set_post_score( - context_factory(params={'score': -1}, user=user), - {'post_id': post.post_id}) + context_factory(params={"score": -1}, user=user), + {"post_id": post.post_id}, + ) post = db.session.query(model.Post).one() assert db.session.query(model.PostScore).count() == 1 assert post.score == -1 def test_updating_rating_to_zero( - user_factory, post_factory, context_factory, fake_datetime): + user_factory, post_factory, context_factory, fake_datetime +): user = user_factory() post = post_factory() db.session.add(post) db.session.commit() - with patch('szurubooru.func.posts.serialize_post'): - with fake_datetime('1997-12-01'): + with patch("szurubooru.func.posts.serialize_post"): + with fake_datetime("1997-12-01"): api.post_api.set_post_score( - context_factory(params={'score': 1}, user=user), - {'post_id': post.post_id}) - with fake_datetime('1997-12-02'): + context_factory(params={"score": 1}, user=user), + {"post_id": post.post_id}, + ) + with fake_datetime("1997-12-02"): api.post_api.set_post_score( - context_factory(params={'score': 0}, user=user), - {'post_id': post.post_id}) + context_factory(params={"score": 0}, user=user), + {"post_id": post.post_id}, + ) post = db.session.query(model.Post).one() assert db.session.query(model.PostScore).count() == 0 assert post.score == 0 def test_deleting_rating( - user_factory, post_factory, context_factory, fake_datetime): + user_factory, post_factory, context_factory, fake_datetime +): user = user_factory() post = post_factory() db.session.add(post) db.session.commit() - with patch('szurubooru.func.posts.serialize_post'): - with fake_datetime('1997-12-01'): + with patch("szurubooru.func.posts.serialize_post"): + with fake_datetime("1997-12-01"): api.post_api.set_post_score( - context_factory(params={'score': 1}, user=user), - {'post_id': post.post_id}) - with fake_datetime('1997-12-02'): + context_factory(params={"score": 1}, user=user), + {"post_id": post.post_id}, + ) + with fake_datetime("1997-12-02"): api.post_api.delete_post_score( - context_factory(user=user), - {'post_id': post.post_id}) + context_factory(user=user), {"post_id": post.post_id} + ) post = db.session.query(model.Post).one() assert db.session.query(model.PostScore).count() == 0 assert post.score == 0 def test_ratings_from_multiple_users( - user_factory, post_factory, context_factory, fake_datetime): + user_factory, post_factory, context_factory, fake_datetime +): user1 = user_factory() user2 = user_factory() post = post_factory() db.session.add_all([user1, user2, post]) db.session.commit() - with patch('szurubooru.func.posts.serialize_post'): - with fake_datetime('1997-12-01'): + with patch("szurubooru.func.posts.serialize_post"): + with fake_datetime("1997-12-01"): api.post_api.set_post_score( - context_factory(params={'score': 1}, user=user1), - {'post_id': post.post_id}) - with fake_datetime('1997-12-02'): + context_factory(params={"score": 1}, user=user1), + {"post_id": post.post_id}, + ) + with fake_datetime("1997-12-02"): api.post_api.set_post_score( - context_factory(params={'score': -1}, user=user2), - {'post_id': post.post_id}) + context_factory(params={"score": -1}, user=user2), + {"post_id": post.post_id}, + ) post = db.session.query(model.Post).one() assert db.session.query(model.PostScore).count() == 2 assert post.score == 0 def test_trying_to_omit_mandatory_field( - user_factory, post_factory, context_factory): + user_factory, post_factory, context_factory +): post = post_factory() db.session.add(post) db.session.commit() with pytest.raises(errors.ValidationError): api.post_api.set_post_score( context_factory(params={}, user=user_factory()), - {'post_id': post.post_id}) + {"post_id": post.post_id}, + ) def test_trying_to_update_non_existing(user_factory, context_factory): with pytest.raises(posts.PostNotFoundError): api.post_api.set_post_score( - context_factory(params={'score': 1}, user=user_factory()), - {'post_id': 5}) + context_factory(params={"score": 1}, user=user_factory()), + {"post_id": 5}, + ) def test_trying_to_rate_without_privileges( - user_factory, post_factory, context_factory): + user_factory, post_factory, context_factory +): post = post_factory() db.session.add(post) db.session.commit() with pytest.raises(errors.AuthError): api.post_api.set_post_score( context_factory( - params={'score': 1}, - user=user_factory(rank=model.User.RANK_ANONYMOUS)), - {'post_id': post.post_id}) + params={"score": 1}, + user=user_factory(rank=model.User.RANK_ANONYMOUS), + ), + {"post_id": post.post_id}, + ) diff --git a/server/szurubooru/tests/api/test_post_retrieving.py b/server/szurubooru/tests/api/test_post_retrieving.py index 1e4ed03..b64074c 100644 --- a/server/szurubooru/tests/api/test_post_retrieving.py +++ b/server/szurubooru/tests/api/test_post_retrieving.py @@ -1,20 +1,24 @@ from datetime import datetime from unittest.mock import patch + import pytest -from szurubooru import api, db, model, errors + +from szurubooru import api, db, errors, model from szurubooru.func import posts @pytest.fixture(autouse=True) def inject_config(config_injector): - config_injector({ - 'data_url': 'http://example.com/', - 'secret': 'test', - 'privileges': { - 'posts:list': model.User.RANK_REGULAR, - 'posts:view': model.User.RANK_REGULAR, - }, - }) + config_injector( + { + "data_url": "http://example.com/", + "secret": "test", + "privileges": { + "posts:list": model.User.RANK_REGULAR, + "posts:view": model.User.RANK_REGULAR, + }, + } + ) def test_retrieving_multiple(user_factory, post_factory, context_factory): @@ -22,18 +26,20 @@ def test_retrieving_multiple(user_factory, post_factory, context_factory): post2 = post_factory(id=2) db.session.add_all([post1, post2]) db.session.flush() - with patch('szurubooru.func.posts.serialize_post'): - posts.serialize_post.return_value = 'serialized post' + with patch("szurubooru.func.posts.serialize_post"): + posts.serialize_post.return_value = "serialized post" result = api.post_api.get_posts( context_factory( - params={'query': '', 'offset': 0}, - user=user_factory(rank=model.User.RANK_REGULAR))) + params={"query": "", "offset": 0}, + user=user_factory(rank=model.User.RANK_REGULAR), + ) + ) assert result == { - 'query': '', - 'offset': 0, - 'limit': 100, - 'total': 2, - 'results': ['serialized post', 'serialized post'], + "query": "", + "offset": 0, + "limit": 100, + "total": 2, + "results": ["serialized post", "serialized post"], } @@ -41,83 +47,98 @@ def test_using_special_tokens(user_factory, post_factory, context_factory): auth_user = user_factory(rank=model.User.RANK_REGULAR) post1 = post_factory(id=1) post2 = post_factory(id=2) - post1.favorited_by = [model.PostFavorite( - user=auth_user, time=datetime.utcnow())] + post1.favorited_by = [ + model.PostFavorite(user=auth_user, time=datetime.utcnow()) + ] db.session.add_all([post1, post2, auth_user]) db.session.flush() - with patch('szurubooru.func.posts.serialize_post'): - posts.serialize_post.side_effect = lambda post, *_args, **_kwargs: \ - 'serialized post %d' % post.post_id + with patch("szurubooru.func.posts.serialize_post"): + posts.serialize_post.side_effect = ( + lambda post, *_args, **_kwargs: "serialized post %d" % post.post_id + ) result = api.post_api.get_posts( context_factory( - params={'query': 'special:fav', 'offset': 0}, - user=auth_user)) + params={"query": "special:fav", "offset": 0}, user=auth_user + ) + ) assert result == { - 'query': 'special:fav', - 'offset': 0, - 'limit': 100, - 'total': 1, - 'results': ['serialized post 1'], + "query": "special:fav", + "offset": 0, + "limit": 100, + "total": 1, + "results": ["serialized post 1"], } def test_trying_to_use_special_tokens_without_logging_in( - user_factory, context_factory, config_injector): - config_injector({ - 'privileges': {'posts:list': 'anonymous'}, - }) + user_factory, context_factory, config_injector +): + config_injector( + { + "privileges": {"posts:list": "anonymous"}, + } + ) with pytest.raises(errors.SearchError): api.post_api.get_posts( context_factory( - params={'query': 'special:fav', 'offset': 0}, - user=user_factory(rank=model.User.RANK_ANONYMOUS))) + params={"query": "special:fav", "offset": 0}, + user=user_factory(rank=model.User.RANK_ANONYMOUS), + ) + ) def test_trying_to_retrieve_multiple_without_privileges( - user_factory, context_factory): + user_factory, context_factory +): with pytest.raises(errors.AuthError): api.post_api.get_posts( context_factory( - params={'query': '', 'offset': 0}, - user=user_factory(rank=model.User.RANK_ANONYMOUS))) + params={"query": "", "offset": 0}, + user=user_factory(rank=model.User.RANK_ANONYMOUS), + ) + ) def test_retrieving_single(user_factory, post_factory, context_factory): db.session.add(post_factory(id=1)) db.session.flush() - with patch('szurubooru.func.posts.serialize_post'): - posts.serialize_post.return_value = 'serialized post' + with patch("szurubooru.func.posts.serialize_post"): + posts.serialize_post.return_value = "serialized post" result = api.post_api.get_post( context_factory(user=user_factory(rank=model.User.RANK_REGULAR)), - {'post_id': 1}) - assert result == 'serialized post' + {"post_id": 1}, + ) + assert result == "serialized post" def test_trying_to_retrieve_single_non_existing(user_factory, context_factory): with pytest.raises(posts.PostNotFoundError): api.post_api.get_post( context_factory(user=user_factory(rank=model.User.RANK_REGULAR)), - {'post_id': 999}) + {"post_id": 999}, + ) def test_trying_to_retrieve_single_without_privileges( - user_factory, context_factory): + user_factory, context_factory +): with pytest.raises(errors.AuthError): api.post_api.get_post( context_factory(user=user_factory(rank=model.User.RANK_ANONYMOUS)), - {'post_id': 999}) + {"post_id": 999}, + ) -@pytest.mark.parametrize('query,expected_id', [ - ('sort:id,asc', 2), - ('sort:id,asc id:2..', 2), - ('sort:id,desc id:2..', 3), - ('sort:id,asc id:3..', 3), - ('sort:id,desc id:3..', 3), - ('sort:id id:4..', None), - ('sort:tag-count', 3), - ('sort:tag-count,asc id:..2', 1), - ('sort:tag-count,desc id:..2', 2), +@pytest.mark.parametrize("query,expected_id", [ + ("sort:id,asc", 2), + ("sort:id,asc id:2..", 2), + ("sort:id,desc id:2..", 3), + ("sort:id,asc id:3..", 3), + ("sort:id,desc id:3..", 3), + ("sort:id id:4..", None), + ("sort:tag-count", 3), + ("sort:tag-count,asc id:..2", 1), + ("sort:tag-count,desc id:..2", 2), ]) def test_median( query, @@ -134,16 +155,16 @@ def test_median( post3 = post_factory(id=3, tags=[tag1, tag2]) db.session.add_all([tag1, tag2, tag3, post1, post2, post3]) db.session.flush() - with patch('szurubooru.func.comments.serialize_comment'), \ - patch('szurubooru.func.users.serialize_micro_user'), \ - patch('szurubooru.func.posts.files.has'): + with patch("szurubooru.func.comments.serialize_comment"), \ + patch("szurubooru.func.users.serialize_micro_user"), \ + patch("szurubooru.func.posts.files.has"): response = api.post_api.get_posts_median( context_factory( - params={'query': query}, + params={"query": query}, user=user_factory(rank=model.User.RANK_REGULAR))) if not expected_id: - assert response['total'] == 0 - assert len(response['results']) == 0 + assert response["total"] == 0 + assert len(response["results"]) == 0 else: - assert response['total'] == 1 - assert response['results'][0]['id'] == expected_id + assert response["total"] == 1 + assert response["results"][0]["id"] == expected_id diff --git a/server/szurubooru/tests/api/test_post_updating.py b/server/szurubooru/tests/api/test_post_updating.py index 7675488..7d830c9 100644 --- a/server/szurubooru/tests/api/test_post_updating.py +++ b/server/szurubooru/tests/api/test_post_updating.py @@ -1,184 +1,235 @@ from datetime import datetime from unittest.mock import patch + import pytest -from szurubooru import api, db, model, errors -from szurubooru.func import posts, tags, metrics, snapshots, net + +from szurubooru import api, db, errors, model +from szurubooru.func import metrics, net, posts, snapshots, tags @pytest.fixture(autouse=True) def inject_config(config_injector): - config_injector({ - 'privileges': { - 'posts:edit:tags': model.User.RANK_REGULAR, - 'posts:edit:content': model.User.RANK_REGULAR, - 'posts:edit:safety': model.User.RANK_REGULAR, - 'posts:edit:source': model.User.RANK_REGULAR, - 'posts:edit:relations': model.User.RANK_REGULAR, - 'posts:edit:notes': model.User.RANK_REGULAR, - 'posts:edit:flags': model.User.RANK_REGULAR, - 'posts:edit:thumbnail': model.User.RANK_REGULAR, - 'tags:create': model.User.RANK_MODERATOR, - 'metrics:edit:posts': model.User.RANK_REGULAR, - }, - }) + config_injector( + { + "privileges": { + "posts:edit:tags": model.User.RANK_REGULAR, + "posts:edit:content": model.User.RANK_REGULAR, + "posts:edit:safety": model.User.RANK_REGULAR, + "posts:edit:source": model.User.RANK_REGULAR, + "posts:edit:relations": model.User.RANK_REGULAR, + "posts:edit:notes": model.User.RANK_REGULAR, + "posts:edit:flags": model.User.RANK_REGULAR, + "posts:edit:thumbnail": model.User.RANK_REGULAR, + "tags:create": model.User.RANK_MODERATOR, + "metrics:edit:posts": model.User.RANK_REGULAR, + "uploads:use_downloader": model.User.RANK_REGULAR, + }, + "allow_broken_uploads": False, + } + ) def test_post_updating( - context_factory, post_factory, user_factory, fake_datetime): + context_factory, post_factory, user_factory, fake_datetime +): auth_user = user_factory(rank=model.User.RANK_REGULAR) post = post_factory() db.session.add(post) db.session.flush() - with patch('szurubooru.func.posts.create_post'), \ - patch('szurubooru.func.posts.update_post_tags'), \ - patch('szurubooru.func.posts.update_post_content'), \ - patch('szurubooru.func.posts.update_post_thumbnail'), \ - patch('szurubooru.func.posts.update_post_safety'), \ - patch('szurubooru.func.posts.update_post_source'), \ - patch('szurubooru.func.posts.update_post_relations'), \ - patch('szurubooru.func.posts.update_post_notes'), \ - patch('szurubooru.func.posts.update_post_flags'), \ - patch('szurubooru.func.posts.serialize_post'), \ - patch('szurubooru.func.snapshots.modify'), \ - patch('szurubooru.func.metrics.update_or_create_post_metrics'), \ - patch('szurubooru.func.metrics.update_or_create_post_metric_ranges'), \ - fake_datetime('1997-01-01'): - posts.serialize_post.return_value = 'serialized post' + with patch("szurubooru.func.posts.create_post"), patch( + "szurubooru.func.posts.update_post_tags" + ), patch("szurubooru.func.posts.update_post_content"), patch( + "szurubooru.func.posts.update_post_thumbnail" + ), patch( + "szurubooru.func.posts.update_post_safety" + ), patch( + "szurubooru.func.posts.update_post_source" + ), patch( + "szurubooru.func.posts.update_post_relations" + ), patch( + "szurubooru.func.posts.update_post_notes" + ), patch( + "szurubooru.func.posts.update_post_flags" + ), patch( + "szurubooru.func.posts.serialize_post" + ), patch( + "szurubooru.func.snapshots.modify" + ), patch( + "szurubooru.func.metrics.update_or_create_post_metrics" + ), patch( + "szurubooru.func.metrics.update_or_create_post_metric_ranges" + ), fake_datetime( + "1997-01-01" + ): + posts.serialize_post.return_value = "serialized post" result = api.post_api.update_post( context_factory( params={ - 'version': 1, - 'safety': 'safe', - 'tags': ['tag1', 'tag2'], - 'relations': [1, 2], - 'source': 'source', - 'notes': ['note1', 'note2'], - 'flags': ['flag1', 'flag2'], - 'metrics': [{'tag_name': 'tag1', 'value': 1.2}], - 'metricRanges': [{'tag_name': 'tag2', 'low': 1, 'high': 2}], + "version": 1, + "safety": "safe", + "tags": ["tag1", "tag2"], + "relations": [1, 2], + "source": "source", + "notes": ["note1", "note2"], + "flags": ["flag1", "flag2"], + "metrics": [{"tag_name": "tag1", "value": 1.2}], + "metricRanges": [{"tag_name": "tag2", "low": 1, "high": 2}], }, files={ - 'content': 'post-content', - 'thumbnail': 'post-thumbnail', + "content": "post-content", + "thumbnail": "post-thumbnail", }, - user=auth_user), - {'post_id': post.post_id}) + user=auth_user, + ), + {"post_id": post.post_id}, + ) - assert result == 'serialized post' + assert result == "serialized post" posts.create_post.assert_not_called() - posts.update_post_tags.assert_called_once_with(post, ['tag1', 'tag2']) - posts.update_post_content.assert_called_once_with(post, 'post-content') + posts.update_post_tags.assert_called_once_with(post, ["tag1", "tag2"]) + posts.update_post_content.assert_called_once_with(post, "post-content") posts.update_post_thumbnail.assert_called_once_with( - post, 'post-thumbnail') - posts.update_post_safety.assert_called_once_with(post, 'safe') - posts.update_post_source.assert_called_once_with(post, 'source') + post, "post-thumbnail" + ) + posts.update_post_safety.assert_called_once_with(post, "safe") + posts.update_post_source.assert_called_once_with(post, "source") posts.update_post_relations.assert_called_once_with(post, [1, 2]) posts.update_post_notes.assert_called_once_with( - post, ['note1', 'note2']) + post, ["note1", "note2"] + ) posts.update_post_flags.assert_called_once_with( - post, ['flag1', 'flag2']) + post, ["flag1", "flag2"] + ) posts.serialize_post.assert_called_once_with( - post, auth_user, options=[]) + post, auth_user, options=[] + ) snapshots.modify.assert_called_once_with(post, auth_user) metrics.update_or_create_post_metrics.assert_called_once_with( - post, [{'tag_name': 'tag1', 'value': 1.2}]) + post, [{"tag_name": "tag1", "value": 1.2}]) metrics.update_or_create_post_metric_ranges.assert_called_once_with( - post, [{'tag_name': 'tag2', 'low': 1, 'high': 2}]) + post, [{"tag_name": "tag2", "low": 1, "high": 2}]) assert post.last_edit_time == datetime(1997, 1, 1) def test_uploading_from_url_saves_source( - context_factory, post_factory, user_factory): + context_factory, post_factory, user_factory +): post = post_factory() db.session.add(post) db.session.flush() - with patch('szurubooru.func.net.download'), \ - patch('szurubooru.func.posts.serialize_post'), \ - patch('szurubooru.func.posts.update_post_content'), \ - patch('szurubooru.func.posts.update_post_source'), \ - patch('szurubooru.func.snapshots.modify'): - net.download.return_value = b'content' + with patch("szurubooru.func.net.download"), patch( + "szurubooru.func.posts.serialize_post" + ), patch("szurubooru.func.posts.update_post_content"), patch( + "szurubooru.func.posts.update_post_source" + ), patch( + "szurubooru.func.snapshots.modify" + ): + net.download.return_value = b"content" api.post_api.update_post( context_factory( - params={'contentUrl': 'example.com', 'version': 1}, - user=user_factory(rank=model.User.RANK_REGULAR)), - {'post_id': post.post_id}) - net.download.assert_called_once_with('example.com') - posts.update_post_content.assert_called_once_with(post, b'content') - posts.update_post_source.assert_called_once_with(post, 'example.com') + params={"contentUrl": "example.com", "version": 1}, + user=user_factory(rank=model.User.RANK_REGULAR), + ), + {"post_id": post.post_id}, + ) + net.download.assert_called_once_with( + "example.com", use_video_downloader=True + ) + posts.update_post_content.assert_called_once_with(post, b"content") + posts.update_post_source.assert_called_once_with(post, "example.com") def test_uploading_from_url_with_source_specified( - context_factory, post_factory, user_factory): + context_factory, post_factory, user_factory +): post = post_factory() db.session.add(post) db.session.flush() - with patch('szurubooru.func.net.download'), \ - patch('szurubooru.func.posts.serialize_post'), \ - patch('szurubooru.func.posts.update_post_content'), \ - patch('szurubooru.func.posts.update_post_source'), \ - patch('szurubooru.func.snapshots.modify'): - net.download.return_value = b'content' + with patch("szurubooru.func.net.download"), patch( + "szurubooru.func.posts.serialize_post" + ), patch("szurubooru.func.posts.update_post_content"), patch( + "szurubooru.func.posts.update_post_source" + ), patch( + "szurubooru.func.snapshots.modify" + ): + net.download.return_value = b"content" api.post_api.update_post( context_factory( params={ - 'contentUrl': 'example.com', - 'source': 'example2.com', - 'version': 1}, - user=user_factory(rank=model.User.RANK_REGULAR)), - {'post_id': post.post_id}) - net.download.assert_called_once_with('example.com') - posts.update_post_content.assert_called_once_with(post, b'content') - posts.update_post_source.assert_called_once_with(post, 'example2.com') + "contentUrl": "example.com", + "source": "example2.com", + "version": 1, + }, + user=user_factory(rank=model.User.RANK_REGULAR), + ), + {"post_id": post.post_id}, + ) + net.download.assert_called_once_with( + "example.com", use_video_downloader=True + ) + posts.update_post_content.assert_called_once_with(post, b"content") + posts.update_post_source.assert_called_once_with(post, "example2.com") def test_trying_to_update_non_existing(context_factory, user_factory): with pytest.raises(posts.PostNotFoundError): api.post_api.update_post( context_factory( - params='whatever', - user=user_factory(rank=model.User.RANK_REGULAR)), - {'post_id': 1}) + params="whatever", + user=user_factory(rank=model.User.RANK_REGULAR), + ), + {"post_id": 1}, + ) -@pytest.mark.parametrize('files,params', [ - ({}, {'tags': '...'}), - ({}, {'safety': '...'}), - ({}, {'source': '...'}), - ({}, {'relations': '...'}), - ({}, {'notes': '...'}), - ({}, {'flags': '...'}), - ({'content': '...'}, {}), - ({'thumbnail': '...'}, {}), - ({}, {'metrics': '...'}), - ({}, {'metricRanges': '...'}), -]) +@pytest.mark.parametrize( + "files,params", + [ + ({}, {"tags": "..."}), + ({}, {"safety": "..."}), + ({}, {"source": "..."}), + ({}, {"relations": "..."}), + ({}, {"notes": "..."}), + ({}, {"flags": "..."}), + ({"content": "..."}, {}), + ({"thumbnail": "..."}, {}), + ({}, {"metrics": "..."}), + ({}, {"metricRanges": "..."}), + ], +) def test_trying_to_update_field_without_privileges( - context_factory, post_factory, user_factory, files, params): + context_factory, post_factory, user_factory, files, params +): post = post_factory() db.session.add(post) db.session.flush() with pytest.raises(errors.AuthError): api.post_api.update_post( context_factory( - params={**params, **{'version': 1}}, + params={**params, **{"version": 1}}, files=files, - user=user_factory(rank=model.User.RANK_ANONYMOUS)), - {'post_id': post.post_id}) + user=user_factory(rank=model.User.RANK_ANONYMOUS), + ), + {"post_id": post.post_id}, + ) def test_trying_to_create_tags_without_privileges( - context_factory, post_factory, user_factory): + context_factory, post_factory, user_factory +): post = post_factory() db.session.add(post) db.session.flush() - with pytest.raises(errors.AuthError), \ - patch('szurubooru.func.posts.update_post_tags'): - posts.update_post_tags.return_value = ['new-tag'] + with pytest.raises(errors.AuthError), patch( + "szurubooru.func.posts.update_post_tags" + ): + posts.update_post_tags.return_value = ["new-tag"] api.post_api.update_post( context_factory( - params={'tags': ['tag1', 'tag2'], 'version': 1}, - user=user_factory(rank=model.User.RANK_REGULAR)), - {'post_id': post.post_id}) + params={"tags": ["tag1", "tag2"], "version": 1}, + user=user_factory(rank=model.User.RANK_REGULAR), + ), + {"post_id": post.post_id}, + ) diff --git a/server/szurubooru/tests/api/test_snapshot_retrieving.py b/server/szurubooru/tests/api/test_snapshot_retrieving.py index 41f0beb..ea59d77 100644 --- a/server/szurubooru/tests/api/test_snapshot_retrieving.py +++ b/server/szurubooru/tests/api/test_snapshot_retrieving.py @@ -1,24 +1,28 @@ from datetime import datetime + import pytest -from szurubooru import api, db, model, errors + +from szurubooru import api, db, errors, model def snapshot_factory(): snapshot = model.Snapshot() snapshot.creation_time = datetime(1999, 1, 1) - snapshot.resource_type = 'dummy' + snapshot.resource_type = "dummy" snapshot.resource_pkey = 1 - snapshot.resource_name = 'dummy' - snapshot.operation = 'added' - snapshot.data = '{}' + snapshot.resource_name = "dummy" + snapshot.operation = "added" + snapshot.data = "{}" return snapshot @pytest.fixture(autouse=True) def inject_config(config_injector): - config_injector({ - 'privileges': {'snapshots:list': model.User.RANK_REGULAR}, - }) + config_injector( + { + "privileges": {"snapshots:list": model.User.RANK_REGULAR}, + } + ) def test_retrieving_multiple(user_factory, context_factory): @@ -28,19 +32,24 @@ def test_retrieving_multiple(user_factory, context_factory): db.session.flush() result = api.snapshot_api.get_snapshots( context_factory( - params={'query': '', 'offset': 0}, - user=user_factory(rank=model.User.RANK_REGULAR))) - assert result['query'] == '' - assert result['offset'] == 0 - assert result['limit'] == 100 - assert result['total'] == 2 - assert len(result['results']) == 2 + params={"query": "", "offset": 0}, + user=user_factory(rank=model.User.RANK_REGULAR), + ) + ) + assert result["query"] == "" + assert result["offset"] == 0 + assert result["limit"] == 100 + assert result["total"] == 2 + assert len(result["results"]) == 2 def test_trying_to_retrieve_multiple_without_privileges( - user_factory, context_factory): + user_factory, context_factory +): with pytest.raises(errors.AuthError): api.snapshot_api.get_snapshots( context_factory( - params={'query': '', 'offset': 0}, - user=user_factory(rank=model.User.RANK_ANONYMOUS))) + params={"query": "", "offset": 0}, + user=user_factory(rank=model.User.RANK_ANONYMOUS), + ) + ) diff --git a/server/szurubooru/tests/api/test_tag_category_creating.py b/server/szurubooru/tests/api/test_tag_category_creating.py index 47e8405..6798cbe 100644 --- a/server/szurubooru/tests/api/test_tag_category_creating.py +++ b/server/szurubooru/tests/api/test_tag_category_creating.py @@ -1,7 +1,9 @@ from unittest.mock import patch + import pytest -from szurubooru import api, db, model, errors -from szurubooru.func import tag_categories, tags, snapshots + +from szurubooru import api, db, errors, model +from szurubooru.func import snapshots, tag_categories, tags def _update_category_name(category, name): @@ -10,49 +12,61 @@ def _update_category_name(category, name): @pytest.fixture(autouse=True) def inject_config(config_injector): - config_injector({ - 'privileges': {'tag_categories:create': model.User.RANK_REGULAR}, - }) + config_injector( + { + "privileges": {"tag_categories:create": model.User.RANK_REGULAR}, + } + ) def test_creating_category( - tag_category_factory, user_factory, context_factory): + tag_category_factory, user_factory, context_factory +): auth_user = user_factory(rank=model.User.RANK_REGULAR) - category = tag_category_factory(name='meta') + category = tag_category_factory(name="meta") db.session.add(category) - with patch('szurubooru.func.tag_categories.create_category'), \ - patch('szurubooru.func.tag_categories.serialize_category'), \ - patch('szurubooru.func.tag_categories.update_category_name'), \ - patch('szurubooru.func.snapshots.create'): + with patch("szurubooru.func.tag_categories.create_category"), patch( + "szurubooru.func.tag_categories.serialize_category" + ), patch("szurubooru.func.tag_categories.update_category_name"), patch( + "szurubooru.func.snapshots.create" + ): tag_categories.create_category.return_value = category tag_categories.update_category_name.side_effect = _update_category_name - tag_categories.serialize_category.return_value = 'serialized category' + tag_categories.serialize_category.return_value = "serialized category" result = api.tag_category_api.create_tag_category( context_factory( - params={'name': 'meta', 'color': 'black'}, user=auth_user)) - assert result == 'serialized category' - tag_categories.create_category.assert_called_once_with('meta', 'black') + params={"name": "meta", "color": "black", "order": 0}, + user=auth_user, + ) + ) + assert result == "serialized category" + tag_categories.create_category.assert_called_once_with( + "meta", "black", 0 + ) snapshots.create.assert_called_once_with(category, auth_user) -@pytest.mark.parametrize('field', ['name', 'color']) +@pytest.mark.parametrize("field", ["name", "color"]) def test_trying_to_omit_mandatory_field(user_factory, context_factory, field): params = { - 'name': 'meta', - 'color': 'black', + "name": "meta", + "color": "black", } del params[field] with pytest.raises(errors.ValidationError): api.tag_category_api.create_tag_category( context_factory( - params=params, - user=user_factory(rank=model.User.RANK_REGULAR))) + params=params, user=user_factory(rank=model.User.RANK_REGULAR) + ) + ) def test_trying_to_create_without_privileges(user_factory, context_factory): with pytest.raises(errors.AuthError): api.tag_category_api.create_tag_category( context_factory( - params={'name': 'meta', 'color': 'black'}, - user=user_factory(rank=model.User.RANK_ANONYMOUS))) + params={"name": "meta", "color": "black"}, + user=user_factory(rank=model.User.RANK_ANONYMOUS), + ) + ) diff --git a/server/szurubooru/tests/api/test_tag_category_deleting.py b/server/szurubooru/tests/api/test_tag_category_deleting.py index 2bee513..23a3a42 100644 --- a/server/szurubooru/tests/api/test_tag_category_deleting.py +++ b/server/szurubooru/tests/api/test_tag_category_deleting.py @@ -1,76 +1,91 @@ from unittest.mock import patch + import pytest -from szurubooru import api, db, model, errors -from szurubooru.func import tag_categories, tags, snapshots + +from szurubooru import api, db, errors, model +from szurubooru.func import snapshots, tag_categories, tags @pytest.fixture(autouse=True) def inject_config(config_injector): - config_injector({ - 'privileges': {'tag_categories:delete': model.User.RANK_REGULAR}, - }) + config_injector( + { + "privileges": {"tag_categories:delete": model.User.RANK_REGULAR}, + } + ) def test_deleting(user_factory, tag_category_factory, context_factory): auth_user = user_factory(rank=model.User.RANK_REGULAR) - category = tag_category_factory(name='category') - db.session.add(tag_category_factory(name='root')) + category = tag_category_factory(name="category") + db.session.add(tag_category_factory(name="root")) db.session.add(category) db.session.flush() - with patch('szurubooru.func.snapshots.delete'): + with patch("szurubooru.func.snapshots.delete"): result = api.tag_category_api.delete_tag_category( - context_factory(params={'version': 1}, user=auth_user), - {'category_name': 'category'}) + context_factory(params={"version": 1}, user=auth_user), + {"category_name": "category"}, + ) assert result == {} assert db.session.query(model.TagCategory).count() == 1 - assert db.session.query(model.TagCategory).one().name == 'root' + assert db.session.query(model.TagCategory).one().name == "root" snapshots.delete.assert_called_once_with(category, auth_user) def test_trying_to_delete_used( - user_factory, tag_category_factory, tag_factory, context_factory): - category = tag_category_factory(name='category') + user_factory, tag_category_factory, tag_factory, context_factory +): + category = tag_category_factory(name="category") db.session.add(category) db.session.flush() - tag = tag_factory(names=['tag'], category=category) + tag = tag_factory(names=["tag"], category=category) db.session.add(tag) db.session.commit() with pytest.raises(tag_categories.TagCategoryIsInUseError): api.tag_category_api.delete_tag_category( context_factory( - params={'version': 1}, - user=user_factory(rank=model.User.RANK_REGULAR)), - {'category_name': 'category'}) + params={"version": 1}, + user=user_factory(rank=model.User.RANK_REGULAR), + ), + {"category_name": "category"}, + ) assert db.session.query(model.TagCategory).count() == 1 def test_trying_to_delete_last( - user_factory, tag_category_factory, context_factory): - db.session.add(tag_category_factory(name='root')) + user_factory, tag_category_factory, context_factory +): + db.session.add(tag_category_factory(name="root")) db.session.commit() with pytest.raises(tag_categories.TagCategoryIsInUseError): api.tag_category_api.delete_tag_category( context_factory( - params={'version': 1}, - user=user_factory(rank=model.User.RANK_REGULAR)), - {'category_name': 'root'}) + params={"version": 1}, + user=user_factory(rank=model.User.RANK_REGULAR), + ), + {"category_name": "root"}, + ) def test_trying_to_delete_non_existing(user_factory, context_factory): with pytest.raises(tag_categories.TagCategoryNotFoundError): api.tag_category_api.delete_tag_category( context_factory(user=user_factory(rank=model.User.RANK_REGULAR)), - {'category_name': 'bad'}) + {"category_name": "bad"}, + ) def test_trying_to_delete_without_privileges( - user_factory, tag_category_factory, context_factory): - db.session.add(tag_category_factory(name='category')) + user_factory, tag_category_factory, context_factory +): + db.session.add(tag_category_factory(name="category")) db.session.commit() with pytest.raises(errors.AuthError): api.tag_category_api.delete_tag_category( context_factory( - params={'version': 1}, - user=user_factory(rank=model.User.RANK_ANONYMOUS)), - {'category_name': 'category'}) + params={"version": 1}, + user=user_factory(rank=model.User.RANK_ANONYMOUS), + ), + {"category_name": "category"}, + ) assert db.session.query(model.TagCategory).count() == 1 diff --git a/server/szurubooru/tests/api/test_tag_category_retrieving.py b/server/szurubooru/tests/api/test_tag_category_retrieving.py index 0b98d74..cec3657 100644 --- a/server/szurubooru/tests/api/test_tag_category_retrieving.py +++ b/server/szurubooru/tests/api/test_tag_category_retrieving.py @@ -1,43 +1,53 @@ import pytest -from szurubooru import api, db, model, errors + +from szurubooru import api, db, errors, model from szurubooru.func import tag_categories @pytest.fixture(autouse=True) def inject_config(config_injector): - config_injector({ - 'privileges': { - 'tag_categories:list': model.User.RANK_REGULAR, - 'tag_categories:view': model.User.RANK_REGULAR, - }, - }) + config_injector( + { + "privileges": { + "tag_categories:list": model.User.RANK_REGULAR, + "tag_categories:view": model.User.RANK_REGULAR, + }, + } + ) def test_retrieving_multiple( - user_factory, tag_category_factory, context_factory): - db.session.add_all([ - tag_category_factory(name='c1'), - tag_category_factory(name='c2'), - ]) + user_factory, tag_category_factory, context_factory +): + db.session.add_all( + [ + tag_category_factory(name="c1"), + tag_category_factory(name="c2"), + ] + ) db.session.flush() result = api.tag_category_api.get_tag_categories( - context_factory(user=user_factory(rank=model.User.RANK_REGULAR))) - assert [cat['name'] for cat in result['results']] == ['c1', 'c2'] + context_factory(user=user_factory(rank=model.User.RANK_REGULAR)) + ) + assert [cat["name"] for cat in result["results"]] == ["c1", "c2"] def test_retrieving_single( - user_factory, tag_category_factory, context_factory): - db.session.add(tag_category_factory(name='cat')) + user_factory, tag_category_factory, context_factory +): + db.session.add(tag_category_factory(name="cat")) db.session.flush() result = api.tag_category_api.get_tag_category( context_factory(user=user_factory(rank=model.User.RANK_REGULAR)), - {'category_name': 'cat'}) + {"category_name": "cat"}, + ) assert result == { - 'name': 'cat', - 'color': 'dummy', - 'usages': 0, - 'default': False, - 'version': 1, + "name": "cat", + "color": "dummy", + "usages": 0, + "default": False, + "order": 1, + "version": 1, } @@ -45,12 +55,15 @@ def test_trying_to_retrieve_single_non_existing(user_factory, context_factory): with pytest.raises(tag_categories.TagCategoryNotFoundError): api.tag_category_api.get_tag_category( context_factory(user=user_factory(rank=model.User.RANK_REGULAR)), - {'category_name': '-'}) + {"category_name": "-"}, + ) def test_trying_to_retrieve_single_without_privileges( - user_factory, context_factory): + user_factory, context_factory +): with pytest.raises(errors.AuthError): api.tag_category_api.get_tag_category( context_factory(user=user_factory(rank=model.User.RANK_ANONYMOUS)), - {'category_name': '-'}) + {"category_name": "-"}, + ) diff --git a/server/szurubooru/tests/api/test_tag_category_updating.py b/server/szurubooru/tests/api/test_tag_category_updating.py index 24a9f6e..f12ce36 100644 --- a/server/szurubooru/tests/api/test_tag_category_updating.py +++ b/server/szurubooru/tests/api/test_tag_category_updating.py @@ -1,7 +1,9 @@ from unittest.mock import patch + import pytest -from szurubooru import api, db, model, errors -from szurubooru.func import tag_categories, tags, snapshots + +from szurubooru import api, db, errors, model +from szurubooru.func import snapshots, tag_categories, tags def _update_category_name(category, name): @@ -10,99 +12,122 @@ def _update_category_name(category, name): @pytest.fixture(autouse=True) def inject_config(config_injector): - config_injector({ - 'privileges': { - 'tag_categories:edit:name': model.User.RANK_REGULAR, - 'tag_categories:edit:color': model.User.RANK_REGULAR, - 'tag_categories:set_default': model.User.RANK_REGULAR, - }, - }) + config_injector( + { + "privileges": { + "tag_categories:edit:name": model.User.RANK_REGULAR, + "tag_categories:edit:color": model.User.RANK_REGULAR, + "tag_categories:edit:order": model.User.RANK_REGULAR, + "tag_categories:set_default": model.User.RANK_REGULAR, + }, + } + ) def test_simple_updating(user_factory, tag_category_factory, context_factory): auth_user = user_factory(rank=model.User.RANK_REGULAR) - category = tag_category_factory(name='name', color='black') + category = tag_category_factory(name="name", color="black") db.session.add(category) db.session.flush() - with patch('szurubooru.func.tag_categories.serialize_category'), \ - patch('szurubooru.func.tag_categories.update_category_name'), \ - patch('szurubooru.func.tag_categories.update_category_color'), \ - patch('szurubooru.func.snapshots.modify'): + with patch("szurubooru.func.tag_categories.serialize_category"), patch( + "szurubooru.func.tag_categories.update_category_name" + ), patch("szurubooru.func.tag_categories.update_category_color"), patch( + "szurubooru.func.snapshots.modify" + ): tag_categories.update_category_name.side_effect = _update_category_name - tag_categories.serialize_category.return_value = 'serialized category' + tag_categories.serialize_category.return_value = "serialized category" result = api.tag_category_api.update_tag_category( context_factory( - params={'name': 'changed', 'color': 'white', 'version': 1}, - user=auth_user), - {'category_name': 'name'}) - assert result == 'serialized category' + params={"name": "changed", "color": "white", "version": 1}, + user=auth_user, + ), + {"category_name": "name"}, + ) + assert result == "serialized category" tag_categories.update_category_name.assert_called_once_with( - category, 'changed') + category, "changed" + ) tag_categories.update_category_color.assert_called_once_with( - category, 'white') + category, "white" + ) snapshots.modify.assert_called_once_with(category, auth_user) -@pytest.mark.parametrize('field', ['name', 'color']) +@pytest.mark.parametrize("field", ["name", "color"]) def test_omitting_optional_field( - user_factory, tag_category_factory, context_factory, field): - db.session.add(tag_category_factory(name='name', color='black')) + user_factory, tag_category_factory, context_factory, field +): + db.session.add(tag_category_factory(name="name", color="black")) db.session.commit() params = { - 'name': 'changed', - 'color': 'white', + "name": "changed", + "color": "white", } del params[field] - with patch('szurubooru.func.tag_categories.serialize_category'), \ - patch('szurubooru.func.tag_categories.update_category_name'): + with patch("szurubooru.func.tag_categories.serialize_category"), patch( + "szurubooru.func.tag_categories.update_category_name" + ), patch("szurubooru.func.snapshots._post_to_webhooks"): api.tag_category_api.update_tag_category( context_factory( - params={**params, **{'version': 1}}, - user=user_factory(rank=model.User.RANK_REGULAR)), - {'category_name': 'name'}) + params={**params, **{"version": 1}}, + user=user_factory(rank=model.User.RANK_REGULAR), + ), + {"category_name": "name"}, + ) def test_trying_to_update_non_existing(user_factory, context_factory): with pytest.raises(tag_categories.TagCategoryNotFoundError): api.tag_category_api.update_tag_category( context_factory( - params={'name': ['dummy']}, - user=user_factory(rank=model.User.RANK_REGULAR)), - {'category_name': 'bad'}) + params={"name": ["dummy"]}, + user=user_factory(rank=model.User.RANK_REGULAR), + ), + {"category_name": "bad"}, + ) -@pytest.mark.parametrize('params', [ - {'name': 'whatever'}, - {'color': 'whatever'}, -]) +@pytest.mark.parametrize( + "params", + [ + {"name": "whatever"}, + {"color": "whatever"}, + ], +) def test_trying_to_update_without_privileges( - user_factory, tag_category_factory, context_factory, params): - db.session.add(tag_category_factory(name='dummy')) + user_factory, tag_category_factory, context_factory, params +): + db.session.add(tag_category_factory(name="dummy")) db.session.commit() with pytest.raises(errors.AuthError): api.tag_category_api.update_tag_category( context_factory( - params={**params, **{'version': 1}}, - user=user_factory(rank=model.User.RANK_ANONYMOUS)), - {'category_name': 'dummy'}) + params={**params, **{"version": 1}}, + user=user_factory(rank=model.User.RANK_ANONYMOUS), + ), + {"category_name": "dummy"}, + ) def test_set_as_default(user_factory, tag_category_factory, context_factory): - category = tag_category_factory(name='name', color='black') + category = tag_category_factory(name="name", color="black") db.session.add(category) db.session.commit() - with patch('szurubooru.func.tag_categories.serialize_category'), \ - patch('szurubooru.func.tag_categories.set_default_category'): + with patch("szurubooru.func.tag_categories.serialize_category"), patch( + "szurubooru.func.tag_categories.set_default_category" + ): tag_categories.update_category_name.side_effect = _update_category_name - tag_categories.serialize_category.return_value = 'serialized category' + tag_categories.serialize_category.return_value = "serialized category" result = api.tag_category_api.set_tag_category_as_default( context_factory( params={ - 'name': 'changed', - 'color': 'white', - 'version': 1, + "name": "changed", + "color": "white", + "version": 1, }, - user=user_factory(rank=model.User.RANK_REGULAR)), - {'category_name': 'name'}) - assert result == 'serialized category' + user=user_factory(rank=model.User.RANK_REGULAR), + ), + {"category_name": "name"}, + ) + assert result == "serialized category" tag_categories.set_default_category.assert_called_once_with(category) diff --git a/server/szurubooru/tests/api/test_tag_creating.py b/server/szurubooru/tests/api/test_tag_creating.py index 4cee710..2f4264d 100644 --- a/server/szurubooru/tests/api/test_tag_creating.py +++ b/server/szurubooru/tests/api/test_tag_creating.py @@ -1,84 +1,97 @@ from unittest.mock import patch + import pytest -from szurubooru import api, model, errors -from szurubooru.func import tags, snapshots + +from szurubooru import api, errors, model +from szurubooru.func import snapshots, tags @pytest.fixture(autouse=True) def inject_config(config_injector): - config_injector({'privileges': {'tags:create': model.User.RANK_REGULAR}}) + config_injector({"privileges": {"tags:create": model.User.RANK_REGULAR}}) def test_creating_simple_tags(tag_factory, user_factory, context_factory): auth_user = user_factory(rank=model.User.RANK_REGULAR) tag = tag_factory() - with patch('szurubooru.func.tags.create_tag'), \ - patch('szurubooru.func.tags.get_or_create_tags_by_names'), \ - patch('szurubooru.func.tags.serialize_tag'), \ - patch('szurubooru.func.snapshots.create'): + with patch("szurubooru.func.tags.create_tag"), patch( + "szurubooru.func.tags.get_or_create_tags_by_names" + ), patch("szurubooru.func.tags.serialize_tag"), patch( + "szurubooru.func.snapshots.create" + ): tags.get_or_create_tags_by_names.return_value = ([], []) tags.create_tag.return_value = tag - tags.serialize_tag.return_value = 'serialized tag' + tags.serialize_tag.return_value = "serialized tag" result = api.tag_api.create_tag( context_factory( params={ - 'names': ['tag1', 'tag2'], - 'category': 'meta', - 'description': 'desc', - 'suggestions': ['sug1', 'sug2'], - 'implications': ['imp1', 'imp2'], + "names": ["tag1", "tag2"], + "category": "meta", + "description": "desc", + "suggestions": ["sug1", "sug2"], + "implications": ["imp1", "imp2"], }, - user=auth_user)) - assert result == 'serialized tag' + user=auth_user, + ) + ) + assert result == "serialized tag" tags.create_tag.assert_called_once_with( - ['tag1', 'tag2'], 'meta', ['sug1', 'sug2'], ['imp1', 'imp2']) + ["tag1", "tag2"], "meta", ["sug1", "sug2"], ["imp1", "imp2"] + ) snapshots.create.assert_called_once_with(tag, auth_user) -@pytest.mark.parametrize('field', ['names', 'category']) +@pytest.mark.parametrize("field", ["names", "category"]) def test_trying_to_omit_mandatory_field(user_factory, context_factory, field): params = { - 'names': ['tag1', 'tag2'], - 'category': 'meta', - 'suggestions': [], - 'implications': [], + "names": ["tag1", "tag2"], + "category": "meta", + "suggestions": [], + "implications": [], } del params[field] with pytest.raises(errors.ValidationError): api.tag_api.create_tag( context_factory( - params=params, - user=user_factory(rank=model.User.RANK_REGULAR))) + params=params, user=user_factory(rank=model.User.RANK_REGULAR) + ) + ) -@pytest.mark.parametrize('field', ['implications', 'suggestions']) +@pytest.mark.parametrize("field", ["implications", "suggestions"]) def test_omitting_optional_field( - tag_factory, user_factory, context_factory, field): + tag_factory, user_factory, context_factory, field +): params = { - 'names': ['tag1', 'tag2'], - 'category': 'meta', - 'suggestions': [], - 'implications': [], + "names": ["tag1", "tag2"], + "category": "meta", + "suggestions": [], + "implications": [], } del params[field] - with patch('szurubooru.func.tags.create_tag'), \ - patch('szurubooru.func.tags.serialize_tag'): + with patch("szurubooru.func.tags.create_tag"), patch( + "szurubooru.func.tags.serialize_tag" + ), patch("szurubooru.func.snapshots._post_to_webhooks"): tags.create_tag.return_value = tag_factory() api.tag_api.create_tag( context_factory( - params=params, - user=user_factory(rank=model.User.RANK_REGULAR))) + params=params, user=user_factory(rank=model.User.RANK_REGULAR) + ) + ) def test_trying_to_create_tag_without_privileges( - user_factory, context_factory): + user_factory, context_factory +): with pytest.raises(errors.AuthError): api.tag_api.create_tag( context_factory( params={ - 'names': ['tag'], - 'category': 'meta', - 'suggestions': ['tag'], - 'implications': [], + "names": ["tag"], + "category": "meta", + "suggestions": ["tag"], + "implications": [], }, - user=user_factory(rank=model.User.RANK_ANONYMOUS))) + user=user_factory(rank=model.User.RANK_ANONYMOUS), + ) + ) diff --git a/server/szurubooru/tests/api/test_tag_deleting.py b/server/szurubooru/tests/api/test_tag_deleting.py index a0367f2..59a19f8 100644 --- a/server/szurubooru/tests/api/test_tag_deleting.py +++ b/server/szurubooru/tests/api/test_tag_deleting.py @@ -1,60 +1,71 @@ from unittest.mock import patch + import pytest -from szurubooru import api, db, model, errors -from szurubooru.func import tags, snapshots + +from szurubooru import api, db, errors, model +from szurubooru.func import snapshots, tags @pytest.fixture(autouse=True) def inject_config(config_injector): - config_injector({'privileges': {'tags:delete': model.User.RANK_REGULAR}}) + config_injector({"privileges": {"tags:delete": model.User.RANK_REGULAR}}) def test_deleting(user_factory, tag_factory, context_factory): auth_user = user_factory(rank=model.User.RANK_REGULAR) - tag = tag_factory(names=['tag']) + tag = tag_factory(names=["tag"]) db.session.add(tag) db.session.commit() - with patch('szurubooru.func.snapshots.delete'): + with patch("szurubooru.func.snapshots.delete"): result = api.tag_api.delete_tag( - context_factory(params={'version': 1}, user=auth_user), - {'tag_name': 'tag'}) + context_factory(params={"version": 1}, user=auth_user), + {"tag_name": "tag"}, + ) assert result == {} assert db.session.query(model.Tag).count() == 0 snapshots.delete.assert_called_once_with(tag, auth_user) def test_deleting_used( - user_factory, tag_factory, context_factory, post_factory): - tag = tag_factory(names=['tag']) + user_factory, tag_factory, context_factory, post_factory +): + tag = tag_factory(names=["tag"]) post = post_factory() post.tags.append(tag) db.session.add_all([tag, post]) db.session.commit() - api.tag_api.delete_tag( - context_factory( - params={'version': 1}, - user=user_factory(rank=model.User.RANK_REGULAR)), - {'tag_name': 'tag'}) - db.session.refresh(post) - assert db.session.query(model.Tag).count() == 0 - assert post.tags == [] + with patch("szurubooru.func.snapshots._post_to_webhooks"): + api.tag_api.delete_tag( + context_factory( + params={"version": 1}, + user=user_factory(rank=model.User.RANK_REGULAR), + ), + {"tag_name": "tag"}, + ) + db.session.refresh(post) + assert db.session.query(model.Tag).count() == 0 + assert post.tags == [] def test_trying_to_delete_non_existing(user_factory, context_factory): with pytest.raises(tags.TagNotFoundError): api.tag_api.delete_tag( context_factory(user=user_factory(rank=model.User.RANK_REGULAR)), - {'tag_name': 'bad'}) + {"tag_name": "bad"}, + ) def test_trying_to_delete_without_privileges( - user_factory, tag_factory, context_factory): - db.session.add(tag_factory(names=['tag'])) + user_factory, tag_factory, context_factory +): + db.session.add(tag_factory(names=["tag"])) db.session.commit() with pytest.raises(errors.AuthError): api.tag_api.delete_tag( context_factory( - params={'version': 1}, - user=user_factory(rank=model.User.RANK_ANONYMOUS)), - {'tag_name': 'tag'}) + params={"version": 1}, + user=user_factory(rank=model.User.RANK_ANONYMOUS), + ), + {"tag_name": "tag"}, + ) assert db.session.query(model.Tag).count() == 1 diff --git a/server/szurubooru/tests/api/test_tag_merging.py b/server/szurubooru/tests/api/test_tag_merging.py index 671e2e4..84c13d3 100644 --- a/server/szurubooru/tests/api/test_tag_merging.py +++ b/server/szurubooru/tests/api/test_tag_merging.py @@ -1,18 +1,20 @@ from unittest.mock import patch + import pytest -from szurubooru import api, db, model, errors -from szurubooru.func import tags, snapshots + +from szurubooru import api, db, errors, model +from szurubooru.func import snapshots, tags @pytest.fixture(autouse=True) def inject_config(config_injector): - config_injector({'privileges': {'tags:merge': model.User.RANK_REGULAR}}) + config_injector({"privileges": {"tags:merge": model.User.RANK_REGULAR}}) def test_merging(user_factory, tag_factory, context_factory, post_factory): auth_user = user_factory(rank=model.User.RANK_REGULAR) - source_tag = tag_factory(names=['source']) - target_tag = tag_factory(names=['target']) + source_tag = tag_factory(names=["source"]) + target_tag = tag_factory(names=["target"]) db.session.add_all([source_tag, target_tag]) db.session.flush() assert source_tag.post_count == 0 @@ -23,76 +25,94 @@ def test_merging(user_factory, tag_factory, context_factory, post_factory): db.session.commit() assert source_tag.post_count == 1 assert target_tag.post_count == 0 - with patch('szurubooru.func.tags.serialize_tag'), \ - patch('szurubooru.func.tags.merge_tags'), \ - patch('szurubooru.func.snapshots.merge'): + with patch("szurubooru.func.tags.serialize_tag"), patch( + "szurubooru.func.tags.merge_tags" + ), patch("szurubooru.func.snapshots.merge"): api.tag_api.merge_tags( context_factory( params={ - 'removeVersion': 1, - 'mergeToVersion': 1, - 'remove': 'source', - 'mergeTo': 'target', + "removeVersion": 1, + "mergeToVersion": 1, + "remove": "source", + "mergeTo": "target", }, - user=auth_user)) + user=auth_user, + ) + ) tags.merge_tags.called_once_with(source_tag, target_tag) snapshots.merge.assert_called_once_with( - source_tag, target_tag, auth_user) + source_tag, target_tag, auth_user + ) @pytest.mark.parametrize( - 'field', ['remove', 'mergeTo', 'removeVersion', 'mergeToVersion']) + "field", ["remove", "mergeTo", "removeVersion", "mergeToVersion"] +) def test_trying_to_omit_mandatory_field( - user_factory, tag_factory, context_factory, field): - db.session.add_all([ - tag_factory(names=['source']), - tag_factory(names=['target']), - ]) + user_factory, tag_factory, context_factory, field +): + db.session.add_all( + [ + tag_factory(names=["source"]), + tag_factory(names=["target"]), + ] + ) db.session.commit() params = { - 'removeVersion': 1, - 'mergeToVersion': 1, - 'remove': 'source', - 'mergeTo': 'target', + "removeVersion": 1, + "mergeToVersion": 1, + "remove": "source", + "mergeTo": "target", } del params[field] with pytest.raises(errors.ValidationError): api.tag_api.merge_tags( context_factory( - params=params, - user=user_factory(rank=model.User.RANK_REGULAR))) + params=params, user=user_factory(rank=model.User.RANK_REGULAR) + ) + ) def test_trying_to_merge_non_existing( - user_factory, tag_factory, context_factory): - db.session.add(tag_factory(names=['good'])) + user_factory, tag_factory, context_factory +): + db.session.add(tag_factory(names=["good"])) db.session.commit() with pytest.raises(tags.TagNotFoundError): api.tag_api.merge_tags( context_factory( - params={'remove': 'good', 'mergeTo': 'bad'}, - user=user_factory(rank=model.User.RANK_REGULAR))) + params={"remove": "good", "mergeTo": "bad"}, + user=user_factory(rank=model.User.RANK_REGULAR), + ) + ) with pytest.raises(tags.TagNotFoundError): api.tag_api.merge_tags( context_factory( - params={'remove': 'bad', 'mergeTo': 'good'}, - user=user_factory(rank=model.User.RANK_REGULAR))) + params={"remove": "bad", "mergeTo": "good"}, + user=user_factory(rank=model.User.RANK_REGULAR), + ) + ) def test_trying_to_merge_without_privileges( - user_factory, tag_factory, context_factory): - db.session.add_all([ - tag_factory(names=['source']), - tag_factory(names=['target']), - ]) + user_factory, tag_factory, context_factory +): + db.session.add_all( + [ + tag_factory(names=["source"]), + tag_factory(names=["target"]), + ] + ) db.session.commit() with pytest.raises(errors.AuthError): api.tag_api.merge_tags( context_factory( params={ - 'removeVersion': 1, - 'mergeToVersion': 1, - 'remove': 'source', - 'mergeTo': 'target', + "removeVersion": 1, + "mergeToVersion": 1, + "remove": "source", + "mergeTo": "target", }, - user=user_factory(rank=model.User.RANK_ANONYMOUS))) + user=user_factory(rank=model.User.RANK_ANONYMOUS), + ) + ) diff --git a/server/szurubooru/tests/api/test_tag_retrieving.py b/server/szurubooru/tests/api/test_tag_retrieving.py index 43a0766..8b51de5 100644 --- a/server/szurubooru/tests/api/test_tag_retrieving.py +++ b/server/szurubooru/tests/api/test_tag_retrieving.py @@ -1,72 +1,82 @@ from unittest.mock import patch + import pytest -from szurubooru import api, db, model, errors + +from szurubooru import api, db, errors, model from szurubooru.func import tags @pytest.fixture(autouse=True) def inject_config(config_injector): - config_injector({ - 'privileges': { - 'tags:list': model.User.RANK_REGULAR, - 'tags:view': model.User.RANK_REGULAR, - }, - }) + config_injector( + { + "privileges": { + "tags:list": model.User.RANK_REGULAR, + "tags:view": model.User.RANK_REGULAR, + }, + } + ) def test_retrieving_multiple(user_factory, tag_factory, context_factory): - tag1 = tag_factory(names=['t1']) - tag2 = tag_factory(names=['t2']) - db.session.add_all([tag1, tag2]) + tag1 = tag_factory(names=["t1"]) + tag2 = tag_factory(names=["t2"]) + db.session.add_all([tag2, tag1]) db.session.flush() - with patch('szurubooru.func.tags.serialize_tag'): - tags.serialize_tag.return_value = 'serialized tag' + with patch("szurubooru.func.tags.serialize_tag"): + tags.serialize_tag.return_value = "serialized tag" result = api.tag_api.get_tags( context_factory( - params={'query': '', 'offset': 0}, - user=user_factory(rank=model.User.RANK_REGULAR))) + params={"query": "", "offset": 0}, + user=user_factory(rank=model.User.RANK_REGULAR), + ) + ) assert result == { - 'query': '', - 'offset': 0, - 'limit': 100, - 'total': 2, - 'results': ['serialized tag', 'serialized tag'], + "query": "", + "offset": 0, + "limit": 100, + "total": 2, + "results": ["serialized tag", "serialized tag"], } def test_trying_to_retrieve_multiple_without_privileges( - user_factory, context_factory): + user_factory, context_factory +): with pytest.raises(errors.AuthError): api.tag_api.get_tags( context_factory( - params={'query': '', 'offset': 0}, - user=user_factory(rank=model.User.RANK_ANONYMOUS))) + params={"query": "", "offset": 0}, + user=user_factory(rank=model.User.RANK_ANONYMOUS), + ) + ) def test_retrieving_single(user_factory, tag_factory, context_factory): - db.session.add(tag_factory(names=['tag'])) + db.session.add(tag_factory(names=["tag"])) db.session.flush() - with patch('szurubooru.func.tags.serialize_tag'): - tags.serialize_tag.return_value = 'serialized tag' + with patch("szurubooru.func.tags.serialize_tag"): + tags.serialize_tag.return_value = "serialized tag" result = api.tag_api.get_tag( - context_factory( - user=user_factory(rank=model.User.RANK_REGULAR)), - {'tag_name': 'tag'}) - assert result == 'serialized tag' + context_factory(user=user_factory(rank=model.User.RANK_REGULAR)), + {"tag_name": "tag"}, + ) + assert result == "serialized tag" def test_trying_to_retrieve_single_non_existing(user_factory, context_factory): with pytest.raises(tags.TagNotFoundError): api.tag_api.get_tag( - context_factory( - user=user_factory(rank=model.User.RANK_REGULAR)), - {'tag_name': '-'}) + context_factory(user=user_factory(rank=model.User.RANK_REGULAR)), + {"tag_name": "-"}, + ) def test_trying_to_retrieve_single_without_privileges( - user_factory, context_factory): + user_factory, context_factory +): with pytest.raises(errors.AuthError): api.tag_api.get_tag( - context_factory( - user=user_factory(rank=model.User.RANK_ANONYMOUS)), - {'tag_name': '-'}) + context_factory(user=user_factory(rank=model.User.RANK_ANONYMOUS)), + {"tag_name": "-"}, + ) diff --git a/server/szurubooru/tests/api/test_tag_siblings_retrieving.py b/server/szurubooru/tests/api/test_tag_siblings_retrieving.py index fc2f5aa..7453b98 100644 --- a/server/szurubooru/tests/api/test_tag_siblings_retrieving.py +++ b/server/szurubooru/tests/api/test_tag_siblings_retrieving.py @@ -1,37 +1,43 @@ from unittest.mock import patch + import pytest -from szurubooru import api, db, model, errors + +from szurubooru import api, db, errors, model from szurubooru.func import tags @pytest.fixture(autouse=True) def inject_config(config_injector): - config_injector({'privileges': {'tags:view': model.User.RANK_REGULAR}}) + config_injector({"privileges": {"tags:view": model.User.RANK_REGULAR}}) def test_get_tag_siblings(user_factory, tag_factory, context_factory): - db.session.add(tag_factory(names=['tag'])) + db.session.add(tag_factory(names=["tag"])) db.session.flush() - with patch('szurubooru.func.tags.serialize_tag'), \ - patch('szurubooru.func.tags.get_tag_siblings'): - tags.serialize_tag.side_effect = lambda tag, *args, **kwargs: \ - 'serialized tag %s' % tag.names[0].name + with patch("szurubooru.func.tags.serialize_tag"), patch( + "szurubooru.func.tags.get_tag_siblings" + ): + tags.serialize_tag.side_effect = ( + lambda tag, *args, **kwargs: "serialized tag %s" + % tag.names[0].name + ) tags.get_tag_siblings.return_value = [ - (tag_factory(names=['sib1']), 1), - (tag_factory(names=['sib2']), 3), + (tag_factory(names=["sib1"]), 1), + (tag_factory(names=["sib2"]), 3), ] result = api.tag_api.get_tag_siblings( context_factory(user=user_factory(rank=model.User.RANK_REGULAR)), - {'tag_name': 'tag'}) + {"tag_name": "tag"}, + ) assert result == { - 'results': [ + "results": [ { - 'tag': 'serialized tag sib1', - 'occurrences': 1, + "tag": "serialized tag sib1", + "occurrences": 1, }, { - 'tag': 'serialized tag sib2', - 'occurrences': 3, + "tag": "serialized tag sib2", + "occurrences": 3, }, ], } @@ -41,11 +47,13 @@ def test_trying_to_retrieve_non_existing(user_factory, context_factory): with pytest.raises(tags.TagNotFoundError): api.tag_api.get_tag_siblings( context_factory(user=user_factory(rank=model.User.RANK_REGULAR)), - {'tag_name': '-'}) + {"tag_name": "-"}, + ) def test_trying_to_retrieve_without_privileges(user_factory, context_factory): with pytest.raises(errors.AuthError): api.tag_api.get_tag_siblings( context_factory(user=user_factory(rank=model.User.RANK_ANONYMOUS)), - {'tag_name': '-'}) + {"tag_name": "-"}, + ) diff --git a/server/szurubooru/tests/api/test_tag_updating.py b/server/szurubooru/tests/api/test_tag_updating.py index d5d59c9..9112c29 100644 --- a/server/szurubooru/tests/api/test_tag_updating.py +++ b/server/szurubooru/tests/api/test_tag_updating.py @@ -1,163 +1,203 @@ from unittest.mock import patch + import pytest -from szurubooru import api, db, model, errors -from szurubooru.func import tags, snapshots, metrics + +from szurubooru import api, db, errors, model +from szurubooru.func import metrics, snapshots, tags @pytest.fixture(autouse=True) def inject_config(config_injector): - config_injector({ - 'privileges': { - 'tags:create': model.User.RANK_REGULAR, - 'tags:edit:names': model.User.RANK_REGULAR, - 'tags:edit:category': model.User.RANK_REGULAR, - 'tags:edit:description': model.User.RANK_REGULAR, - 'tags:edit:suggestions': model.User.RANK_REGULAR, - 'tags:edit:implications': model.User.RANK_REGULAR, - 'metrics:create': model.User.RANK_REGULAR, - 'metrics:edit:bounds': model.User.RANK_REGULAR, - }, - }) + config_injector( + { + "privileges": { + "tags:create": model.User.RANK_REGULAR, + "tags:edit:names": model.User.RANK_REGULAR, + "tags:edit:category": model.User.RANK_REGULAR, + "tags:edit:description": model.User.RANK_REGULAR, + "tags:edit:suggestions": model.User.RANK_REGULAR, + "tags:edit:implications": model.User.RANK_REGULAR, + "metrics:create": model.User.RANK_REGULAR, + "metrics:edit:bounds": model.User.RANK_REGULAR, + }, + } + ) def test_simple_updating(user_factory, tag_factory, context_factory): auth_user = user_factory(rank=model.User.RANK_REGULAR) - tag = tag_factory(names=['tag1', 'tag2']) + tag = tag_factory(names=["tag1", "tag2"]) db.session.add(tag) db.session.commit() - with patch('szurubooru.func.tags.create_tag'), \ - patch('szurubooru.func.tags.get_or_create_tags_by_names'), \ - patch('szurubooru.func.tags.update_tag_names'), \ - patch('szurubooru.func.tags.update_tag_category_name'), \ - patch('szurubooru.func.tags.update_tag_description'), \ - patch('szurubooru.func.tags.update_tag_suggestions'), \ - patch('szurubooru.func.tags.update_tag_implications'), \ - patch('szurubooru.func.tags.serialize_tag'), \ - patch('szurubooru.func.metrics.update_or_create_metric'), \ - patch('szurubooru.func.snapshots.modify'): + with patch("szurubooru.func.tags.create_tag"), patch( + "szurubooru.func.tags.get_or_create_tags_by_names" + ), patch("szurubooru.func.tags.update_tag_names"), patch( + "szurubooru.func.tags.update_tag_category_name" + ), patch( + "szurubooru.func.tags.update_tag_description" + ), patch( + "szurubooru.func.tags.update_tag_suggestions" + ), patch( + "szurubooru.func.tags.update_tag_implications" + ), patch( + "szurubooru.func.tags.serialize_tag" + ), patch( + "szurubooru.func.metrics.update_or_create_metric" + ), patch( + "szurubooru.func.snapshots.modify" + ): tags.get_or_create_tags_by_names.return_value = ([], []) - tags.serialize_tag.return_value = 'serialized tag' + tags.serialize_tag.return_value = "serialized tag" result = api.tag_api.update_tag( context_factory( params={ - 'version': 1, - 'names': ['tag3'], - 'category': 'character', - 'description': 'desc', - 'suggestions': ['sug1', 'sug2'], - 'implications': ['imp1', 'imp2'], - 'metric': {'min': -1, 'max': 1}, + "version": 1, + "names": ["tag3"], + "category": "character", + "description": "desc", + "suggestions": ["sug1", "sug2"], + "implications": ["imp1", "imp2"], + "metric": {"min": -1, "max": 1}, }, - user=auth_user), - {'tag_name': 'tag1'}) - assert result == 'serialized tag' + user=auth_user, + ), + {"tag_name": "tag1"}, + ) + assert result == "serialized tag" tags.create_tag.assert_not_called() - tags.update_tag_names.assert_called_once_with(tag, ['tag3']) - tags.update_tag_category_name.assert_called_once_with(tag, 'character') - tags.update_tag_description.assert_called_once_with(tag, 'desc') + tags.update_tag_names.assert_called_once_with(tag, ["tag3"]) + tags.update_tag_category_name.assert_called_once_with(tag, "character") + tags.update_tag_description.assert_called_once_with(tag, "desc") tags.update_tag_suggestions.assert_called_once_with( - tag, ['sug1', 'sug2']) + tag, ["sug1", "sug2"] + ) tags.update_tag_implications.assert_called_once_with( - tag, ['imp1', 'imp2']) + tag, ["imp1", "imp2"] + ) tags.serialize_tag.assert_called_once_with(tag, options=[]) metrics.update_or_create_metric.assert_called_once_with( - tag, {'min': -1, 'max': 1}) + tag, {"min": -1, "max": 1}) snapshots.modify.assert_called_once_with(tag, auth_user) @pytest.mark.parametrize( - 'field', [ - 'names', - 'category', - 'description', - 'implications', - 'suggestions', - ]) + "field", + [ + "names", + "category", + "description", + "implications", + "suggestions", + ], +) def test_omitting_optional_field( - user_factory, tag_factory, context_factory, field): - db.session.add(tag_factory(names=['tag'])) + user_factory, tag_factory, context_factory, field +): + db.session.add(tag_factory(names=["tag"])) db.session.commit() params = { - 'names': ['tag1', 'tag2'], - 'category': 'meta', - 'description': 'desc', - 'suggestions': [], - 'implications': [], + "names": ["tag1", "tag2"], + "category": "meta", + "description": "desc", + "suggestions": [], + "implications": [], } del params[field] - with patch('szurubooru.func.tags.create_tag'), \ - patch('szurubooru.func.tags.update_tag_names'), \ - patch('szurubooru.func.tags.update_tag_category_name'), \ - patch('szurubooru.func.tags.serialize_tag'): + with patch("szurubooru.func.tags.create_tag"), patch( + "szurubooru.func.tags.update_tag_names" + ), patch("szurubooru.func.tags.update_tag_category_name"), patch( + "szurubooru.func.tags.serialize_tag" + ): api.tag_api.update_tag( context_factory( - params={**params, **{'version': 1}}, - user=user_factory(rank=model.User.RANK_REGULAR)), - {'tag_name': 'tag'}) + params={**params, **{"version": 1}}, + user=user_factory(rank=model.User.RANK_REGULAR), + ), + {"tag_name": "tag"}, + ) def test_trying_to_update_non_existing(user_factory, context_factory): with pytest.raises(tags.TagNotFoundError): api.tag_api.update_tag( context_factory( - params={'names': ['dummy']}, - user=user_factory(rank=model.User.RANK_REGULAR)), - {'tag_name': 'tag1'}) + params={"names": ["dummy"]}, + user=user_factory(rank=model.User.RANK_REGULAR), + ), + {"tag_name": "tag1"}, + ) -@pytest.mark.parametrize('params', [ - {'names': 'whatever'}, - {'category': 'whatever'}, - {'suggestions': ['whatever']}, - {'implications': ['whatever']}, - {'metric': ['whatever']}, -]) +@pytest.mark.parametrize( + "params", + [ + {"names": "whatever"}, + {"category": "whatever"}, + {"suggestions": ["whatever"]}, + {"implications": ["whatever"]}, + {"metric": ["whatever"]}, + ], +) def test_trying_to_update_without_privileges( - user_factory, tag_factory, context_factory, params): - db.session.add(tag_factory(names=['tag'])) + user_factory, tag_factory, context_factory, params +): + db.session.add(tag_factory(names=["tag"])) db.session.commit() with pytest.raises(errors.AuthError): api.tag_api.update_tag( context_factory( - params={**params, **{'version': 1}}, - user=user_factory(rank=model.User.RANK_ANONYMOUS)), - {'tag_name': 'tag'}) + params={**params, **{"version": 1}}, + user=user_factory(rank=model.User.RANK_ANONYMOUS), + ), + {"tag_name": "tag"}, + ) def test_trying_to_create_metric_without_privileges( - user_factory, tag_factory, context_factory): - db.session.add(tag_factory(names=['tag'])) + user_factory, tag_factory, context_factory +): + db.session.add(tag_factory(names=["tag"])) db.session.commit() with pytest.raises(errors.AuthError): api.tag_api.update_tag( context_factory( - params={'metric': {'min': 0, 'max': 10}, **{'version': 1}}, + params={"metric": {"min": 0, "max": 10}, **{"version": 1}}, user=user_factory(rank=model.User.RANK_ANONYMOUS)), - {'tag_name': 'tag'}) + {"tag_name": "tag"} + ) def test_trying_to_create_tags_without_privileges( - config_injector, context_factory, tag_factory, user_factory): - tag = tag_factory(names=['tag']) + config_injector, context_factory, tag_factory, user_factory +): + tag = tag_factory(names=["tag"]) db.session.add(tag) db.session.commit() - config_injector({'privileges': { - 'tags:create': model.User.RANK_ADMINISTRATOR, - 'tags:edit:suggestions': model.User.RANK_REGULAR, - 'tags:edit:implications': model.User.RANK_REGULAR, - }}) - with patch('szurubooru.func.tags.get_or_create_tags_by_names'): - tags.get_or_create_tags_by_names.return_value = ([], ['new-tag']) + config_injector( + { + "privileges": { + "tags:create": model.User.RANK_ADMINISTRATOR, + "tags:edit:suggestions": model.User.RANK_REGULAR, + "tags:edit:implications": model.User.RANK_REGULAR, + } + } + ) + with patch("szurubooru.func.tags.get_or_create_tags_by_names"): + tags.get_or_create_tags_by_names.return_value = ([], ["new-tag"]) 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'}) + 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}, - user=user_factory(rank=model.User.RANK_REGULAR)), - {'tag_name': 'tag'}) + params={"implications": ["tag1", "tag2"], "version": 1}, + user=user_factory(rank=model.User.RANK_REGULAR), + ), + {"tag_name": "tag"}, + ) diff --git a/server/szurubooru/tests/api/test_user_creating.py b/server/szurubooru/tests/api/test_user_creating.py index 699bfef..d55e1f7 100644 --- a/server/szurubooru/tests/api/test_user_creating.py +++ b/server/szurubooru/tests/api/test_user_creating.py @@ -1,87 +1,104 @@ from unittest.mock import patch + import pytest -from szurubooru import api, model, errors + +from szurubooru import api, errors, model from szurubooru.func import users @pytest.fixture(autouse=True) def inject_config(config_injector): - config_injector({'privileges': {'users:create:self': 'regular'}}) + config_injector({"privileges": {"users:create:self": "regular"}}) def test_creating_user(user_factory, context_factory, fake_datetime): user = user_factory() - with patch('szurubooru.func.users.create_user'), \ - patch('szurubooru.func.users.update_user_name'), \ - patch('szurubooru.func.users.update_user_password'), \ - patch('szurubooru.func.users.update_user_email'), \ - patch('szurubooru.func.users.update_user_rank'), \ - patch('szurubooru.func.users.update_user_avatar'), \ - patch('szurubooru.func.users.serialize_user'), \ - fake_datetime('1969-02-12'): - users.serialize_user.return_value = 'serialized user' + with patch("szurubooru.func.users.create_user"), patch( + "szurubooru.func.users.update_user_name" + ), patch("szurubooru.func.users.update_user_password"), patch( + "szurubooru.func.users.update_user_email" + ), patch( + "szurubooru.func.users.update_user_rank" + ), patch( + "szurubooru.func.users.update_user_avatar" + ), patch( + "szurubooru.func.users.serialize_user" + ), fake_datetime( + "1969-02-12" + ): + users.serialize_user.return_value = "serialized user" users.create_user.return_value = user result = api.user_api.create_user( context_factory( params={ - 'name': 'chewie1', - 'email': 'asd@asd.asd', - 'password': 'oks', - 'rank': 'moderator', - 'avatarStyle': 'manual', + "name": "chewie1", + "email": "asd@asd.asd", + "password": "oks", + "rank": "moderator", + "avatarStyle": "manual", }, - files={'avatar': b'...'}, - user=user_factory(rank=model.User.RANK_REGULAR))) - assert result == 'serialized user' + files={"avatar": b"..."}, + user=user_factory(rank=model.User.RANK_REGULAR), + ) + ) + assert result == "serialized user" users.create_user.assert_called_once_with( - 'chewie1', 'oks', 'asd@asd.asd') + "chewie1", "oks", "asd@asd.asd" + ) assert not users.update_user_name.called assert not users.update_user_password.called assert not users.update_user_email.called - users.update_user_rank.called_once_with(user, 'moderator') - users.update_user_avatar.called_once_with(user, 'manual', b'...') + users.update_user_rank.called_once_with(user, "moderator") + users.update_user_avatar.called_once_with(user, "manual", b"...") -@pytest.mark.parametrize('field', ['name', 'password']) +@pytest.mark.parametrize("field", ["name", "password"]) def test_trying_to_omit_mandatory_field(user_factory, context_factory, field): params = { - 'name': 'chewie', - 'email': 'asd@asd.asd', - 'password': 'oks', + "name": "chewie", + "email": "asd@asd.asd", + "password": "oks", } user = user_factory() auth_user = user_factory(rank=model.User.RANK_REGULAR) del params[field] - with patch('szurubooru.func.users.create_user'), \ - pytest.raises(errors.MissingRequiredParameterError): + with patch("szurubooru.func.users.create_user"), pytest.raises( + errors.MissingRequiredParameterError + ): users.create_user.return_value = user api.user_api.create_user( - context_factory(params=params, user=auth_user)) + context_factory(params=params, user=auth_user) + ) -@pytest.mark.parametrize('field', ['rank', 'email', 'avatarStyle']) +@pytest.mark.parametrize("field", ["rank", "email", "avatarStyle"]) def test_omitting_optional_field(user_factory, context_factory, field): params = { - 'name': 'chewie', - 'email': 'asd@asd.asd', - 'password': 'oks', - 'rank': 'moderator', - 'avatarStyle': 'gravatar', + "name": "chewie", + "email": "asd@asd.asd", + "password": "oks", + "rank": "moderator", + "avatarStyle": "gravatar", } del params[field] user = user_factory() auth_user = user_factory(rank=model.User.RANK_MODERATOR) - with patch('szurubooru.func.users.create_user'), \ - patch('szurubooru.func.users.update_user_avatar'), \ - patch('szurubooru.func.users.serialize_user'): + with patch("szurubooru.func.users.create_user"), patch( + "szurubooru.func.users.update_user_avatar" + ), patch("szurubooru.func.users.serialize_user"): users.create_user.return_value = user api.user_api.create_user( - context_factory(params=params, user=auth_user)) + context_factory(params=params, user=auth_user) + ) def test_trying_to_create_user_without_privileges( - context_factory, user_factory): + context_factory, user_factory +): with pytest.raises(errors.AuthError): - api.user_api.create_user(context_factory( - params='whatever', - user=user_factory(rank=model.User.RANK_ANONYMOUS))) + api.user_api.create_user( + context_factory( + params="whatever", + user=user_factory(rank=model.User.RANK_ANONYMOUS), + ) + ) diff --git a/server/szurubooru/tests/api/test_user_deleting.py b/server/szurubooru/tests/api/test_user_deleting.py index 2bd53e2..6ab3f1d 100644 --- a/server/szurubooru/tests/api/test_user_deleting.py +++ b/server/szurubooru/tests/api/test_user_deleting.py @@ -1,50 +1,55 @@ import pytest -from szurubooru import api, db, model, errors + +from szurubooru import api, db, errors, model from szurubooru.func import users @pytest.fixture(autouse=True) def inject_config(config_injector): - config_injector({ - 'privileges': { - 'users:delete:self': model.User.RANK_REGULAR, - 'users:delete:any': model.User.RANK_MODERATOR, - }, - }) + config_injector( + { + "privileges": { + "users:delete:self": model.User.RANK_REGULAR, + "users:delete:any": model.User.RANK_MODERATOR, + }, + } + ) def test_deleting_oneself(user_factory, context_factory): - user = user_factory(name='u', rank=model.User.RANK_REGULAR) + user = user_factory(name="u", rank=model.User.RANK_REGULAR) db.session.add(user) db.session.commit() result = api.user_api.delete_user( - context_factory( - params={'version': 1}, user=user), {'user_name': 'u'}) + context_factory(params={"version": 1}, user=user), {"user_name": "u"} + ) assert result == {} assert db.session.query(model.User).count() == 0 def test_deleting_someone_else(user_factory, context_factory): - user1 = user_factory(name='u1', rank=model.User.RANK_REGULAR) - user2 = user_factory(name='u2', rank=model.User.RANK_MODERATOR) + user1 = user_factory(name="u1", rank=model.User.RANK_REGULAR) + user2 = user_factory(name="u2", rank=model.User.RANK_MODERATOR) db.session.add_all([user1, user2]) db.session.commit() api.user_api.delete_user( - context_factory( - params={'version': 1}, user=user2), {'user_name': 'u1'}) + context_factory(params={"version": 1}, user=user2), {"user_name": "u1"} + ) assert db.session.query(model.User).count() == 1 def test_trying_to_delete_someone_else_without_privileges( - user_factory, context_factory): - user1 = user_factory(name='u1', rank=model.User.RANK_REGULAR) - user2 = user_factory(name='u2', rank=model.User.RANK_REGULAR) + user_factory, context_factory +): + user1 = user_factory(name="u1", rank=model.User.RANK_REGULAR) + user2 = user_factory(name="u2", rank=model.User.RANK_REGULAR) db.session.add_all([user1, user2]) db.session.commit() with pytest.raises(errors.AuthError): api.user_api.delete_user( - context_factory( - params={'version': 1}, user=user2), {'user_name': 'u1'}) + context_factory(params={"version": 1}, user=user2), + {"user_name": "u1"}, + ) assert db.session.query(model.User).count() == 2 @@ -52,6 +57,8 @@ def test_trying_to_delete_non_existing(user_factory, context_factory): with pytest.raises(users.UserNotFoundError): api.user_api.delete_user( context_factory( - params={'version': 1}, - user=user_factory(rank=model.User.RANK_REGULAR)), - {'user_name': 'bad'}) + params={"version": 1}, + user=user_factory(rank=model.User.RANK_REGULAR), + ), + {"user_name": "bad"}, + ) diff --git a/server/szurubooru/tests/api/test_user_retrieving.py b/server/szurubooru/tests/api/test_user_retrieving.py index 2e797e8..b18b4d5 100644 --- a/server/szurubooru/tests/api/test_user_retrieving.py +++ b/server/szurubooru/tests/api/test_user_retrieving.py @@ -1,73 +1,86 @@ from unittest.mock import patch + import pytest -from szurubooru import api, db, model, errors + +from szurubooru import api, db, errors, model from szurubooru.func import users @pytest.fixture(autouse=True) def inject_config(config_injector): - config_injector({ - 'privileges': { - 'users:list': model.User.RANK_REGULAR, - 'users:view': model.User.RANK_REGULAR, - 'users:edit:any:email': model.User.RANK_MODERATOR, - }, - }) + config_injector( + { + "privileges": { + "users:list": model.User.RANK_REGULAR, + "users:view": model.User.RANK_REGULAR, + "users:edit:any:email": model.User.RANK_MODERATOR, + }, + } + ) def test_retrieving_multiple(user_factory, context_factory): - user1 = user_factory(name='u1', rank=model.User.RANK_MODERATOR) - user2 = user_factory(name='u2', rank=model.User.RANK_MODERATOR) + user1 = user_factory(name="u1", rank=model.User.RANK_MODERATOR) + user2 = user_factory(name="u2", rank=model.User.RANK_MODERATOR) db.session.add_all([user1, user2]) db.session.flush() - with patch('szurubooru.func.users.serialize_user'): - users.serialize_user.return_value = 'serialized user' + with patch("szurubooru.func.users.serialize_user"): + users.serialize_user.return_value = "serialized user" result = api.user_api.get_users( context_factory( - params={'query': '', 'page': 1}, - user=user_factory(rank=model.User.RANK_REGULAR))) + params={"query": "", "page": 1}, + user=user_factory(rank=model.User.RANK_REGULAR), + ) + ) assert result == { - 'query': '', - 'offset': 0, - 'limit': 100, - 'total': 2, - 'results': ['serialized user', 'serialized user'], + "query": "", + "offset": 0, + "limit": 100, + "total": 2, + "results": ["serialized user", "serialized user"], } def test_trying_to_retrieve_multiple_without_privileges( - user_factory, context_factory): + user_factory, context_factory +): with pytest.raises(errors.AuthError): api.user_api.get_users( context_factory( - params={'query': '', 'page': 1}, - user=user_factory(rank=model.User.RANK_ANONYMOUS))) + params={"query": "", "page": 1}, + user=user_factory(rank=model.User.RANK_ANONYMOUS), + ) + ) def test_retrieving_single(user_factory, context_factory): - user = user_factory(name='u1', rank=model.User.RANK_REGULAR) + user = user_factory(name="u1", rank=model.User.RANK_REGULAR) auth_user = user_factory(rank=model.User.RANK_REGULAR) db.session.add(user) db.session.flush() - with patch('szurubooru.func.users.serialize_user'): - users.serialize_user.return_value = 'serialized user' + with patch("szurubooru.func.users.serialize_user"): + users.serialize_user.return_value = "serialized user" result = api.user_api.get_user( - context_factory(user=auth_user), {'user_name': 'u1'}) - assert result == 'serialized user' + context_factory(user=auth_user), {"user_name": "u1"} + ) + assert result == "serialized user" def test_trying_to_retrieve_single_non_existing(user_factory, context_factory): auth_user = user_factory(rank=model.User.RANK_REGULAR) with pytest.raises(users.UserNotFoundError): api.user_api.get_user( - context_factory(user=auth_user), {'user_name': '-'}) + context_factory(user=auth_user), {"user_name": "-"} + ) def test_trying_to_retrieve_single_without_privileges( - user_factory, context_factory): + user_factory, context_factory +): auth_user = user_factory(rank=model.User.RANK_ANONYMOUS) - db.session.add(user_factory(name='u1', rank=model.User.RANK_REGULAR)) + db.session.add(user_factory(name="u1", rank=model.User.RANK_REGULAR)) db.session.flush() with pytest.raises(errors.AuthError): api.user_api.get_user( - context_factory(user=auth_user), {'user_name': 'u1'}) + context_factory(user=auth_user), {"user_name": "u1"} + ) diff --git a/server/szurubooru/tests/api/test_user_token_creating.py b/server/szurubooru/tests/api/test_user_token_creating.py index f550f63..bce41ec 100644 --- a/server/szurubooru/tests/api/test_user_token_creating.py +++ b/server/szurubooru/tests/api/test_user_token_creating.py @@ -1,29 +1,33 @@ from unittest.mock import patch + import pytest + from szurubooru import api from szurubooru.func import user_tokens, users @pytest.fixture(autouse=True) def inject_config(config_injector): - config_injector({'privileges': {'user_tokens:create:self': 'regular'}}) + config_injector({"privileges": {"user_tokens:create:self": "regular"}}) def test_creating_user_token( - user_token_factory, context_factory, fake_datetime): + user_token_factory, context_factory, fake_datetime +): user_token = user_token_factory() - with patch('szurubooru.func.user_tokens.create_user_token'), \ - patch('szurubooru.func.user_tokens.serialize_user_token'), \ - patch('szurubooru.func.users.get_user_by_name'), \ - fake_datetime('1969-02-12'): + with patch("szurubooru.func.user_tokens.create_user_token"), patch( + "szurubooru.func.user_tokens.serialize_user_token" + ), patch("szurubooru.func.users.get_user_by_name"), fake_datetime( + "1969-02-12" + ): users.get_user_by_name.return_value = user_token.user - user_tokens.serialize_user_token.return_value = 'serialized user token' + user_tokens.serialize_user_token.return_value = "serialized user token" user_tokens.create_user_token.return_value = user_token result = api.user_token_api.create_user_token( context_factory(user=user_token.user), - { - 'user_name': user_token.user.name - }) - assert result == 'serialized user token' + {"user_name": user_token.user.name}, + ) + assert result == "serialized user token" user_tokens.create_user_token.assert_called_once_with( - user_token.user, True) + user_token.user, True + ) diff --git a/server/szurubooru/tests/api/test_user_token_deleting.py b/server/szurubooru/tests/api/test_user_token_deleting.py index 8534152..19f93fa 100644 --- a/server/szurubooru/tests/api/test_user_token_deleting.py +++ b/server/szurubooru/tests/api/test_user_token_deleting.py @@ -1,30 +1,35 @@ from unittest.mock import patch + import pytest + from szurubooru import api, db from szurubooru.func import user_tokens, users @pytest.fixture(autouse=True) def inject_config(config_injector): - config_injector({'privileges': {'user_tokens:delete:self': 'regular'}}) + config_injector({"privileges": {"user_tokens:delete:self": "regular"}}) def test_deleting_user_token( - user_token_factory, context_factory, fake_datetime): + user_token_factory, context_factory, fake_datetime +): user_token = user_token_factory() db.session.add(user_token) db.session.commit() - with patch('szurubooru.func.user_tokens.get_by_user_and_token'), \ - patch('szurubooru.func.users.get_user_by_name'), \ - fake_datetime('1969-02-12'): + with patch("szurubooru.func.user_tokens.get_by_user_and_token"), patch( + "szurubooru.func.users.get_user_by_name" + ), fake_datetime("1969-02-12"): users.get_user_by_name.return_value = user_token.user user_tokens.get_by_user_and_token.return_value = user_token result = api.user_token_api.delete_user_token( context_factory(user=user_token.user), { - 'user_name': user_token.user.name, - 'user_token': user_token.token - }) + "user_name": user_token.user.name, + "user_token": user_token.token, + }, + ) assert result == {} user_tokens.get_by_user_and_token.assert_called_once_with( - user_token.user, user_token.token) + user_token.user, user_token.token + ) diff --git a/server/szurubooru/tests/api/test_user_token_retrieving.py b/server/szurubooru/tests/api/test_user_token_retrieving.py index 01b2534..e3351b4 100644 --- a/server/szurubooru/tests/api/test_user_token_retrieving.py +++ b/server/szurubooru/tests/api/test_user_token_retrieving.py @@ -1,31 +1,37 @@ from unittest.mock import patch + import pytest + from szurubooru import api from szurubooru.func import user_tokens, users @pytest.fixture(autouse=True) def inject_config(config_injector): - config_injector({'privileges': {'user_tokens:list:self': 'regular'}}) + config_injector({"privileges": {"user_tokens:list:self": "regular"}}) def test_retrieving_user_tokens( - user_token_factory, context_factory, fake_datetime): + user_token_factory, context_factory, fake_datetime +): user_token1 = user_token_factory() user_token2 = user_token_factory(user=user_token1.user) user_token3 = user_token_factory(user=user_token1.user) - with patch('szurubooru.func.user_tokens.get_user_tokens'), \ - patch('szurubooru.func.user_tokens.serialize_user_token'), \ - patch('szurubooru.func.users.get_user_by_name'), \ - fake_datetime('1969-02-12'): + with patch("szurubooru.func.user_tokens.get_user_tokens"), patch( + "szurubooru.func.user_tokens.serialize_user_token" + ), patch("szurubooru.func.users.get_user_by_name"), fake_datetime( + "1969-02-12" + ): users.get_user_by_name.return_value = user_token1.user - user_tokens.serialize_user_token.return_value = 'serialized user token' - user_tokens.get_user_tokens.return_value = [user_token1, user_token2, - user_token3] + user_tokens.serialize_user_token.return_value = "serialized user token" + user_tokens.get_user_tokens.return_value = [ + user_token1, + user_token2, + user_token3, + ] result = api.user_token_api.get_user_tokens( context_factory(user=user_token1.user), - { - 'user_name': user_token1.user.name - }) - assert result == {'results': ['serialized user token'] * 3} + {"user_name": user_token1.user.name}, + ) + assert result == {"results": ["serialized user token"] * 3} user_tokens.get_user_tokens.assert_called_once_with(user_token1.user) diff --git a/server/szurubooru/tests/api/test_user_token_updating.py b/server/szurubooru/tests/api/test_user_token_updating.py index bf725a3..3f66041 100644 --- a/server/szurubooru/tests/api/test_user_token_updating.py +++ b/server/szurubooru/tests/api/test_user_token_updating.py @@ -1,42 +1,52 @@ from unittest.mock import patch + import pytest + from szurubooru import api, db from szurubooru.func import user_tokens, users @pytest.fixture(autouse=True) def inject_config(config_injector): - config_injector({'privileges': {'user_tokens:edit:self': 'regular'}}) + config_injector({"privileges": {"user_tokens:edit:self": "regular"}}) def test_edit_user_token(user_token_factory, context_factory, fake_datetime): user_token = user_token_factory() db.session.add(user_token) db.session.commit() - with patch('szurubooru.func.user_tokens.get_by_user_and_token'), \ - patch('szurubooru.func.user_tokens.update_user_token_enabled'), \ - patch('szurubooru.func.user_tokens.update_user_token_edit_time'), \ - patch('szurubooru.func.user_tokens.serialize_user_token'), \ - patch('szurubooru.func.users.get_user_by_name'), \ - fake_datetime('1969-02-12'): + with patch("szurubooru.func.user_tokens.get_by_user_and_token"), patch( + "szurubooru.func.user_tokens.update_user_token_enabled" + ), patch("szurubooru.func.user_tokens.update_user_token_edit_time"), patch( + "szurubooru.func.user_tokens.serialize_user_token" + ), patch( + "szurubooru.func.users.get_user_by_name" + ), fake_datetime( + "1969-02-12" + ): users.get_user_by_name.return_value = user_token.user - user_tokens.serialize_user_token.return_value = 'serialized user token' + user_tokens.serialize_user_token.return_value = "serialized user token" user_tokens.get_by_user_and_token.return_value = user_token result = api.user_token_api.update_user_token( context_factory( params={ - 'version': user_token.version, - 'enabled': False, + "version": user_token.version, + "enabled": False, }, - user=user_token.user), + user=user_token.user, + ), { - 'user_name': user_token.user.name, - 'user_token': user_token.token - }) - assert result == 'serialized user token' + "user_name": user_token.user.name, + "user_token": user_token.token, + }, + ) + assert result == "serialized user token" user_tokens.get_by_user_and_token.assert_called_once_with( - user_token.user, user_token.token) + user_token.user, user_token.token + ) user_tokens.update_user_token_enabled.assert_called_once_with( - user_token, False) + user_token, False + ) user_tokens.update_user_token_edit_time.assert_called_once_with( - user_token) + user_token + ) diff --git a/server/szurubooru/tests/api/test_user_updating.py b/server/szurubooru/tests/api/test_user_updating.py index af75049..304e489 100644 --- a/server/szurubooru/tests/api/test_user_updating.py +++ b/server/szurubooru/tests/api/test_user_updating.py @@ -1,125 +1,149 @@ from unittest.mock import patch + import pytest -from szurubooru import api, db, model, errors + +from szurubooru import api, db, errors, model from szurubooru.func import users @pytest.fixture(autouse=True) def inject_config(config_injector): - config_injector({ - 'privileges': { - 'users:edit:self:name': model.User.RANK_REGULAR, - 'users:edit:self:pass': model.User.RANK_REGULAR, - 'users:edit:self:email': model.User.RANK_REGULAR, - 'users:edit:self:rank': model.User.RANK_MODERATOR, - 'users:edit:self:avatar': model.User.RANK_MODERATOR, - 'users:edit:any:name': model.User.RANK_MODERATOR, - 'users:edit:any:pass': model.User.RANK_MODERATOR, - 'users:edit:any:email': model.User.RANK_MODERATOR, - 'users:edit:any:rank': model.User.RANK_ADMINISTRATOR, - 'users:edit:any:avatar': model.User.RANK_ADMINISTRATOR, - }, - }) + config_injector( + { + "privileges": { + "users:edit:self:name": model.User.RANK_REGULAR, + "users:edit:self:pass": model.User.RANK_REGULAR, + "users:edit:self:email": model.User.RANK_REGULAR, + "users:edit:self:rank": model.User.RANK_MODERATOR, + "users:edit:self:avatar": model.User.RANK_MODERATOR, + "users:edit:any:name": model.User.RANK_MODERATOR, + "users:edit:any:pass": model.User.RANK_MODERATOR, + "users:edit:any:email": model.User.RANK_MODERATOR, + "users:edit:any:rank": model.User.RANK_ADMINISTRATOR, + "users:edit:any:avatar": model.User.RANK_ADMINISTRATOR, + }, + } + ) def test_updating_user(context_factory, user_factory): - user = user_factory(name='u1', rank=model.User.RANK_ADMINISTRATOR) + user = user_factory(name="u1", rank=model.User.RANK_ADMINISTRATOR) auth_user = user_factory(rank=model.User.RANK_ADMINISTRATOR) db.session.add(user) db.session.flush() - with patch('szurubooru.func.users.create_user'), \ - patch('szurubooru.func.users.update_user_name'), \ - patch('szurubooru.func.users.update_user_password'), \ - patch('szurubooru.func.users.update_user_email'), \ - patch('szurubooru.func.users.update_user_rank'), \ - patch('szurubooru.func.users.update_user_avatar'), \ - patch('szurubooru.func.users.serialize_user'): - users.serialize_user.return_value = 'serialized user' + with patch("szurubooru.func.users.create_user"), patch( + "szurubooru.func.users.update_user_name" + ), patch("szurubooru.func.users.update_user_password"), patch( + "szurubooru.func.users.update_user_email" + ), patch( + "szurubooru.func.users.update_user_rank" + ), patch( + "szurubooru.func.users.update_user_avatar" + ), patch( + "szurubooru.func.users.serialize_user" + ): + users.serialize_user.return_value = "serialized user" result = api.user_api.update_user( context_factory( params={ - 'version': 1, - 'name': 'chewie', - 'email': 'asd@asd.asd', - 'password': 'oks', - 'rank': 'moderator', - 'avatarStyle': 'manual', + "version": 1, + "name": "chewie", + "email": "asd@asd.asd", + "password": "oks", + "rank": "moderator", + "avatarStyle": "manual", }, files={ - 'avatar': b'...', + "avatar": b"...", }, - user=auth_user), - {'user_name': 'u1'}) + user=auth_user, + ), + {"user_name": "u1"}, + ) - assert result == 'serialized user' + assert result == "serialized user" users.create_user.assert_not_called() - users.update_user_name.assert_called_once_with(user, 'chewie') - users.update_user_password.assert_called_once_with(user, 'oks') - users.update_user_email.assert_called_once_with(user, 'asd@asd.asd') + users.update_user_name.assert_called_once_with(user, "chewie") + users.update_user_password.assert_called_once_with(user, "oks") + users.update_user_email.assert_called_once_with(user, "asd@asd.asd") users.update_user_rank.assert_called_once_with( - user, 'moderator', auth_user) + user, "moderator", auth_user + ) users.update_user_avatar.assert_called_once_with( - user, 'manual', b'...') + user, "manual", b"..." + ) users.serialize_user.assert_called_once_with( - user, auth_user, options=[]) + user, auth_user, options=[] + ) @pytest.mark.parametrize( - 'field', ['name', 'email', 'password', 'rank', 'avatarStyle']) + "field", ["name", "email", "password", "rank", "avatarStyle"] +) def test_omitting_optional_field(user_factory, context_factory, field): - user = user_factory(name='u1', rank=model.User.RANK_ADMINISTRATOR) + user = user_factory(name="u1", rank=model.User.RANK_ADMINISTRATOR) db.session.add(user) db.session.flush() params = { - 'name': 'chewie', - 'email': 'asd@asd.asd', - 'password': 'oks', - 'rank': 'moderator', - 'avatarStyle': 'gravatar', + "name": "chewie", + "email": "asd@asd.asd", + "password": "oks", + "rank": "moderator", + "avatarStyle": "gravatar", } del params[field] - with patch('szurubooru.func.users.create_user'), \ - patch('szurubooru.func.users.update_user_name'), \ - patch('szurubooru.func.users.update_user_password'), \ - patch('szurubooru.func.users.update_user_email'), \ - patch('szurubooru.func.users.update_user_rank'), \ - patch('szurubooru.func.users.update_user_avatar'), \ - patch('szurubooru.func.users.serialize_user'): + with patch("szurubooru.func.users.create_user"), patch( + "szurubooru.func.users.update_user_name" + ), patch("szurubooru.func.users.update_user_password"), patch( + "szurubooru.func.users.update_user_email" + ), patch( + "szurubooru.func.users.update_user_rank" + ), patch( + "szurubooru.func.users.update_user_avatar" + ), patch( + "szurubooru.func.users.serialize_user" + ): api.user_api.update_user( context_factory( - params={**params, **{'version': 1}}, - files={'avatar': b'...'}, - user=user), - {'user_name': 'u1'}) + params={**params, **{"version": 1}}, + files={"avatar": b"..."}, + user=user, + ), + {"user_name": "u1"}, + ) def test_trying_to_update_non_existing(user_factory, context_factory): - user = user_factory(name='u1', rank=model.User.RANK_ADMINISTRATOR) + user = user_factory(name="u1", rank=model.User.RANK_ADMINISTRATOR) db.session.add(user) db.session.flush() with pytest.raises(users.UserNotFoundError): api.user_api.update_user( - context_factory(user=user), {'user_name': 'u2'}) + context_factory(user=user), {"user_name": "u2"} + ) -@pytest.mark.parametrize('params', [ - {'name': 'whatever'}, - {'email': 'whatever'}, - {'rank': 'whatever'}, - {'password': 'whatever'}, - {'avatarStyle': 'whatever'}, -]) +@pytest.mark.parametrize( + "params", + [ + {"name": "whatever"}, + {"email": "whatever"}, + {"rank": "whatever"}, + {"password": "whatever"}, + {"avatarStyle": "whatever"}, + ], +) def test_trying_to_update_field_without_privileges( - user_factory, context_factory, params): - user1 = user_factory(name='u1', rank=model.User.RANK_REGULAR) - user2 = user_factory(name='u2', rank=model.User.RANK_REGULAR) + user_factory, context_factory, params +): + user1 = user_factory(name="u1", rank=model.User.RANK_REGULAR) + user2 = user_factory(name="u2", rank=model.User.RANK_REGULAR) db.session.add_all([user1, user2]) db.session.flush() with pytest.raises(errors.AuthError): api.user_api.update_user( - context_factory( - params={**params, **{'version': 1}}, - user=user1), - {'user_name': user2.name}) + context_factory(params={**params, **{"version": 1}}, user=user1), + {"user_name": user2.name}, + ) diff --git a/server/szurubooru/tests/assets/webp.webp b/server/szurubooru/tests/assets/webp.webp Binary files differnew file mode 100644 index 0000000..d116e1e --- /dev/null +++ b/server/szurubooru/tests/assets/webp.webp diff --git a/server/szurubooru/tests/conftest.py b/server/szurubooru/tests/conftest.py index d327bb2..50cbf7d 100644 --- a/server/szurubooru/tests/conftest.py +++ b/server/szurubooru/tests/conftest.py @@ -1,62 +1,20 @@ -# pylint: disable=redefined-outer-name import contextlib import os import random import string -from unittest.mock import patch from datetime import datetime -import pytest +from unittest.mock import patch + import freezegun +import pytest import sqlalchemy as sa -from szurubooru import config, db, model, rest - - -class QueryCounter: - def __init__(self): - self._statements = [] - - def __enter__(self): - self._statements = [] - - def __exit__(self, *args, **kwargs): - self._statements = [] - - def create_before_cursor_execute(self): - def before_cursor_execute( - _conn, _cursor, statement, _params, _context, _executemany): - self._statements.append(statement) - return before_cursor_execute - - @property - def statements(self): - return self._statements - - -def _set_sqlite_pragma(dbapi_connection, connection_record): - cursor = dbapi_connection.cursor() - cursor.execute('PRAGMA foreign_keys=ON') - cursor.close() - - -_test_db_url = config.config['test_database'] -if not _test_db_url: - raise RuntimeError('Test database not configured.') -_query_counter = QueryCounter() -_engine = sa.create_engine(_test_db_url) -if _test_db_url.startswith('sqlite'): - sa.event.listen(_engine, 'connect', _set_sqlite_pragma) -model.Base.metadata.drop_all(bind=_engine) -model.Base.metadata.create_all(bind=_engine) -sa.event.listen( - _engine, - 'before_cursor_execute', - _query_counter.create_before_cursor_execute()) +from szurubooru import config, db, model, rest def get_unique_name(): alphabet = string.ascii_letters + string.digits - return ''.join(random.choice(alphabet) for _ in range(8)) + return "".join(random.choice(alphabet) for _ in range(8)) @pytest.fixture @@ -67,52 +25,49 @@ def fake_datetime(): freezer.start() yield freezer.stop() - return injector - -@pytest.fixture() -def query_counter(): - return _query_counter + return injector -@pytest.fixture -def query_logger(): - if pytest.config.option.verbose > 0: +@pytest.fixture(scope="session") +def query_logger(pytestconfig): + if pytestconfig.option.verbose > 0: import logging + import coloredlogs + coloredlogs.install( - fmt='[%(asctime)-15s] %(name)s %(message)s', isatty=True) + fmt="[%(asctime)-15s] %(name)s %(message)s", isatty=True + ) logging.basicConfig() - logging.getLogger('sqlalchemy.engine').setLevel(logging.INFO) + logging.getLogger("sqlalchemy.engine").setLevel(logging.INFO) -@pytest.yield_fixture(scope='function', autouse=True) -def session(query_logger): # pylint: disable=unused-argument - db.sessionmaker = sa.orm.sessionmaker( - bind=_engine, autoflush=False) - db.session = sa.orm.scoped_session(db.sessionmaker) +@pytest.yield_fixture(scope="function", autouse=True) +def session(query_logger, postgresql_db): + db.session = postgresql_db.session + postgresql_db.create_table(*model.Base.metadata.sorted_tables) try: - yield db.session + yield postgresql_db.session finally: - db.session.remove() - for table in reversed(model.Base.metadata.sorted_tables): - db.session.execute(table.delete()) - db.session.commit() + postgresql_db.reset_db() @pytest.fixture def context_factory(session): def factory(params=None, files=None, user=None, headers=None): ctx = rest.Context( - env={'HTTP_ORIGIN': 'http://example.com'}, + env={"HTTP_ORIGIN": "http://example.com"}, method=None, url=None, headers=headers or {}, params=params or {}, - files=files or {}) + files=files or {}, + ) ctx.session = session ctx.user = user or model.User() return ctx + return factory @@ -120,58 +75,65 @@ def context_factory(session): def config_injector(): def injector(new_config_content): config.config = new_config_content + return injector @pytest.fixture def user_factory(): def factory( - name=None, - rank=model.User.RANK_REGULAR, - email='dummy', - password_salt=None, - password_hash=None): + name=None, + rank=model.User.RANK_REGULAR, + email="dummy", + password_salt=None, + password_hash=None, + ): user = model.User() user.name = name or get_unique_name() - user.password_salt = password_salt or 'dummy' - user.password_hash = password_hash or 'dummy' + user.password_salt = password_salt or "dummy" + user.password_hash = password_hash or "dummy" user.email = email user.rank = rank user.creation_time = datetime(1997, 1, 1) user.avatar_style = model.User.AVATAR_GRAVATAR return user + return factory @pytest.fixture def user_token_factory(user_factory): def factory( - user=None, - token=None, - expiration_time=None, - enabled=None, - creation_time=None): + user=None, + token=None, + expiration_time=None, + enabled=None, + creation_time=None, + ): if user is None: user = user_factory() db.session.add(user) user_token = model.UserToken() user_token.user = user - user_token.token = token or 'dummy' + user_token.token = token or "dummy" user_token.expiration_time = expiration_time user_token.enabled = enabled if enabled is not None else True user_token.creation_time = creation_time or datetime(1997, 1, 1) return user_token + return factory @pytest.fixture def tag_category_factory(): - def factory(name=None, color='dummy', default=False): + def factory(name=None, color="dummy", order=1, default=False): category = model.TagCategory() category.name = name or get_unique_name() category.color = color + category.order = order category.default = default return category + return factory @@ -190,41 +152,36 @@ def tag_factory(): if metric: tag.metric = metric return tag - return factory - -@pytest.yield_fixture -def skip_post_hashing(): - with patch('szurubooru.func.image_hash.add_image'), \ - patch('szurubooru.func.image_hash.delete_image'): - yield + return factory @pytest.fixture -def post_factory(skip_post_hashing): - # pylint: disable=invalid-name +def post_factory(): def factory( - id=None, - safety=model.Post.SAFETY_SAFE, - type=model.Post.TYPE_IMAGE, - checksum='...', - tags=[]): + id=None, + safety=model.Post.SAFETY_SAFE, + type=model.Post.TYPE_IMAGE, + checksum="...", + tags=[], + ): post = model.Post() post.post_id = id post.safety = safety post.type = type post.checksum = checksum post.flags = [] - post.mime_type = 'application/octet-stream' + post.mime_type = "application/octet-stream" post.creation_time = datetime(1996, 1, 1) post.tags = tags return post + return factory @pytest.fixture def comment_factory(user_factory, post_factory): - def factory(user=None, post=None, text='dummy', time=None): + def factory(user=None, post=None, text="dummy", time=None): if not user: user = user_factory() db.session.add(user) @@ -237,6 +194,7 @@ def comment_factory(user_factory, post_factory): comment.text = text comment.creation_time = time or datetime(1996, 1, 1) return comment + return factory @@ -248,7 +206,9 @@ def post_score_factory(user_factory, post_factory): if post is None: post = post_factory() return model.PostScore( - post=post, user=user, score=score, time=datetime(1999, 1, 1)) + post=post, user=user, score=score, time=datetime(1999, 1, 1) + ) + return factory @@ -260,7 +220,60 @@ def post_favorite_factory(user_factory, post_factory): if post is None: post = post_factory() return model.PostFavorite( - post=post, user=user, time=datetime(1999, 1, 1)) + post=post, user=user, time=datetime(1999, 1, 1) + ) + + return factory + + +@pytest.fixture +def pool_category_factory(): + def factory(name=None, color="dummy", default=False): + category = model.PoolCategory() + category.name = name or get_unique_name() + category.color = color + category.default = default + return category + + return factory + + +@pytest.fixture +def pool_factory(): + def factory( + id=None, names=None, description=None, category=None, time=None + ): + if not category: + category = model.PoolCategory(get_unique_name()) + db.session.add(category) + pool = model.Pool() + pool.pool_id = id + pool.names = [] + for i, name in enumerate(names or [get_unique_name()]): + pool.names.append(model.PoolName(name, i)) + pool.description = description + pool.category = category + pool.creation_time = time or datetime(1996, 1, 1) + return pool + + return factory + + +@pytest.fixture +def pool_post_factory(pool_factory, post_factory): + def factory(pool=None, post=None, order=None): + if not pool: + pool = pool_factory() + db.session.add(pool) + if not post: + post = post_factory() + db.session.add(post) + pool_post = model.PoolPost(post) + pool_post.pool = pool + pool_post.post = post + pool_post.order = order or 0 + return pool_post + return factory @@ -314,7 +327,8 @@ def post_metric_range_factory(post_factory, tag_factory, metric_factory): @pytest.fixture def read_asset(): def get(path): - path = os.path.join(os.path.dirname(__file__), 'assets', path) - with open(path, 'rb') as handle: + path = os.path.join(os.path.dirname(__file__), "assets", path) + with open(path, "rb") as handle: return handle.read() + return get diff --git a/server/szurubooru/tests/func/test_auth.py b/server/szurubooru/tests/func/test_auth.py index 6dc79bb..2141d3c 100644 --- a/server/szurubooru/tests/func/test_auth.py +++ b/server/szurubooru/tests/func/test_auth.py @@ -1,41 +1,44 @@ from datetime import datetime, timedelta + import pytest + from szurubooru.func import auth @pytest.fixture(autouse=True) def inject_config(config_injector): - config_injector({'secret': 'testSecret'}) + config_injector({"secret": "testSecret"}) def test_get_password_hash(): - salt, password = ('testSalt', 'pass') + salt, password = ("testSalt", "pass") result, revision = auth.get_password_hash(salt, password) assert result assert revision == 3 hash_parts = list( - filter(lambda e: e is not None and e != '', result.split('$'))) + filter(lambda e: e is not None and e != "", result.split("$")) + ) assert len(hash_parts) == 5 - assert hash_parts[0] == 'argon2id' + assert hash_parts[0] == "argon2id" def test_get_sha256_legacy_password_hash(): - salt, password = ('testSalt', 'pass') + salt, password = ("testSalt", "pass") result, revision = auth.get_sha256_legacy_password_hash(salt, password) - hash = '2031ac9631353ac9303719a7f808a24f79aa1d71712c98523e4bb4cce579428a' + hash = "2031ac9631353ac9303719a7f808a24f79aa1d71712c98523e4bb4cce579428a" assert result == hash assert revision == 2 def test_get_sha1_legacy_password_hash(): - salt, password = ('testSalt', 'pass') + salt, password = ("testSalt", "pass") result, revision = auth.get_sha1_legacy_password_hash(salt, password) - assert result == '1eb1f953d9be303a1b54627e903e6124cfb1245b' + assert result == "1eb1f953d9be303a1b54627e903e6124cfb1245b" assert revision == 1 def test_is_valid_password_auto_upgrades_user_password_hash(user_factory): - salt, password = ('testSalt', 'pass') + salt, password = ("testSalt", "pass") hash, revision = auth.get_sha256_legacy_password_hash(salt, password) user = user_factory(password_salt=salt, password_hash=hash) result = auth.is_valid_password(user, password) @@ -50,7 +53,7 @@ def test_is_valid_token(user_token_factory): def test_expired_token_is_invalid(user_token_factory): - past_expiration = (datetime.utcnow() - timedelta(minutes=30)) + past_expiration = datetime.utcnow() - timedelta(minutes=30) user_token = user_token_factory(expiration_time=past_expiration) assert not auth.is_valid_token(user_token) diff --git a/server/szurubooru/tests/func/test_comments.py b/server/szurubooru/tests/func/test_comments.py index f1e5d0f..ef5ca4f 100644 --- a/server/szurubooru/tests/func/test_comments.py +++ b/server/szurubooru/tests/func/test_comments.py @@ -1,34 +1,36 @@ -from unittest.mock import patch from datetime import datetime +from unittest.mock import patch + import pytest + from szurubooru import db from szurubooru.func import comments, users def test_serialize_user(user_factory, comment_factory): - with patch('szurubooru.func.users.get_avatar_url'): - users.get_avatar_url.return_value = 'https://example.com/avatar.png' - comment = comment_factory(user=user_factory(name='dummy')) + with patch("szurubooru.func.users.get_avatar_url"): + users.get_avatar_url.return_value = "https://example.com/avatar.png" + comment = comment_factory(user=user_factory(name="dummy")) comment.comment_id = 77 comment.creation_time = datetime(1997, 1, 1) comment.last_edit_time = datetime(1998, 1, 1) - comment.text = 'text' + comment.text = "text" db.session.add(comment) db.session.flush() auth_user = user_factory() assert comments.serialize_comment(comment, auth_user) == { - 'id': comment.comment_id, - 'postId': comment.post.post_id, - 'creationTime': datetime(1997, 1, 1, 0, 0), - 'lastEditTime': datetime(1998, 1, 1, 0, 0), - 'score': 0, - 'ownScore': 0, - 'text': 'text', - 'user': { - 'name': 'dummy', - 'avatarUrl': 'https://example.com/avatar.png', + "id": comment.comment_id, + "postId": comment.post.post_id, + "creationTime": datetime(1997, 1, 1, 0, 0), + "lastEditTime": datetime(1998, 1, 1, 0, 0), + "score": 0, + "ownScore": 0, + "text": "text", + "user": { + "name": "dummy", + "avatarUrl": "https://example.com/avatar.png", }, - 'version': 1, + "version": 1, } @@ -53,13 +55,14 @@ def test_create_comment(user_factory, post_factory, fake_datetime): user = user_factory() post = post_factory() db.session.add_all([user, post]) - with patch('szurubooru.func.comments.update_comment_text'), \ - fake_datetime('1997-01-01'): - comment = comments.create_comment(user, post, 'text') + with patch("szurubooru.func.comments.update_comment_text"), fake_datetime( + "1997-01-01" + ): + comment = comments.create_comment(user, post, "text") assert comment.creation_time == datetime(1997, 1, 1) assert comment.user == user assert comment.post == post - comments.update_comment_text.assert_called_once_with(comment, 'text') + comments.update_comment_text.assert_called_once_with(comment, "text") def test_update_comment_text_with_emptry_string(comment_factory): @@ -70,5 +73,5 @@ def test_update_comment_text_with_emptry_string(comment_factory): def test_update_comment_text(comment_factory): comment = comment_factory() - comments.update_comment_text(comment, 'text') - assert comment.text == 'text' + comments.update_comment_text(comment, "text") + assert comment.text == "text" diff --git a/server/szurubooru/tests/func/test_diff.py b/server/szurubooru/tests/func/test_diff.py index 0134a3f..5e415b5 100644 --- a/server/szurubooru/tests/func/test_diff.py +++ b/server/szurubooru/tests/func/test_diff.py @@ -1,275 +1,238 @@ import pytest -from szurubooru.func import diff - - -@pytest.mark.parametrize('old,new,expected', [ - ( - [], [], None, - ), - - ( - [], - ['added'], - {'type': 'list change', 'added': ['added'], 'removed': []}, - ), - ( - ['removed'], - [], - {'type': 'list change', 'added': [], 'removed': ['removed']}, - ), - - ( - ['untouched'], - ['untouched'], - None, - ), +from szurubooru.func import diff - ( - ['untouched'], - ['untouched', 'added'], - {'type': 'list change', 'added': ['added'], 'removed': []}, - ), - ( - ['untouched', 'removed'], - ['untouched'], - {'type': 'list change', 'added': [], 'removed': ['removed']}, - ), -]) +@pytest.mark.parametrize( + "old,new,expected", + [ + ( + [], + [], + None, + ), + ( + [], + ["added"], + {"type": "list change", "added": ["added"], "removed": []}, + ), + ( + ["removed"], + [], + {"type": "list change", "added": [], "removed": ["removed"]}, + ), + ( + ["untouched"], + ["untouched"], + None, + ), + ( + ["untouched"], + ["untouched", "added"], + {"type": "list change", "added": ["added"], "removed": []}, + ), + ( + ["untouched", "removed"], + ["untouched"], + {"type": "list change", "added": [], "removed": ["removed"]}, + ), + ], +) def test_get_list_diff(old, new, expected): assert diff.get_list_diff(old, new) == expected -@pytest.mark.parametrize('old,new,expected', [ - ( - {}, {}, None, - ), - - ( - {'removed key': 'removed value'}, - {}, - { - 'type': 'object change', - 'value': +@pytest.mark.parametrize( + "old,new,expected", + [ + ( + {}, + {}, + None, + ), + ( + {"removed key": "removed value"}, + {}, { - 'removed key': - { - 'type': 'deleted property', - 'value': 'removed value', + "type": "object change", + "value": { + "removed key": { + "type": "deleted property", + "value": "removed value", + }, }, }, - }, - ), - - ( - {}, - {'added key': 'added value'}, - { - 'type': 'object change', - 'value': + ), + ( + {}, + {"added key": "added value"}, { - 'added key': - { - 'type': 'added property', - 'value': 'added value', + "type": "object change", + "value": { + "added key": { + "type": "added property", + "value": "added value", + }, }, }, - }, - ), - - ( - {'key': 'old value'}, - {'key': 'new value'}, - { - 'type': 'object change', - 'value': + ), + ( + {"key": "old value"}, + {"key": "new value"}, { - 'key': - { - 'type': 'primitive change', - 'old-value': 'old value', - 'new-value': 'new value', + "type": "object change", + "value": { + "key": { + "type": "primitive change", + "old-value": "old value", + "new-value": "new value", + }, }, }, - }, - ), - - ( - {'key': 'untouched'}, - {'key': 'untouched'}, - None, - ), - - ( - {'key': 'untouched', 'removed key': 'removed value'}, - {'key': 'untouched'}, - { - 'type': 'object change', - 'value': + ), + ( + {"key": "untouched"}, + {"key": "untouched"}, + None, + ), + ( + {"key": "untouched", "removed key": "removed value"}, + {"key": "untouched"}, { - 'removed key': - { - 'type': 'deleted property', - 'value': 'removed value', + "type": "object change", + "value": { + "removed key": { + "type": "deleted property", + "value": "removed value", + }, }, }, - }, - ), - - ( - {'key': 'untouched'}, - {'key': 'untouched', 'added key': 'added value'}, - { - 'type': 'object change', - 'value': + ), + ( + {"key": "untouched"}, + {"key": "untouched", "added key": "added value"}, { - 'added key': - { - 'type': 'added property', - 'value': 'added value', + "type": "object change", + "value": { + "added key": { + "type": "added property", + "value": "added value", + }, }, }, - }, - ), - - ( - {'key': 'untouched', 'changed key': 'old value'}, - {'key': 'untouched', 'changed key': 'new value'}, - { - 'type': 'object change', - 'value': + ), + ( + {"key": "untouched", "changed key": "old value"}, + {"key": "untouched", "changed key": "new value"}, { - 'changed key': - { - 'type': 'primitive change', - 'old-value': 'old value', - 'new-value': 'new value', + "type": "object change", + "value": { + "changed key": { + "type": "primitive change", + "old-value": "old value", + "new-value": "new value", + }, }, }, - }, - ), - - ( - {'key': {'subkey': 'old value'}}, - {'key': {'subkey': 'new value'}}, - { - 'type': 'object change', - 'value': + ), + ( + {"key": {"subkey": "old value"}}, + {"key": {"subkey": "new value"}}, { - 'key': - { - 'type': 'object change', - 'value': - { - 'subkey': - { - 'type': 'primitive change', - 'old-value': 'old value', - 'new-value': 'new value', + "type": "object change", + "value": { + "key": { + "type": "object change", + "value": { + "subkey": { + "type": "primitive change", + "old-value": "old value", + "new-value": "new value", + }, }, }, }, }, - }, - ), - - ( - {'key': {}}, - {'key': {'subkey': 'removed value'}}, - { - 'type': 'object change', - 'value': + ), + ( + {"key": {}}, + {"key": {"subkey": "removed value"}}, { - 'key': - { - 'type': 'object change', - 'value': - { - 'subkey': - { - 'type': 'added property', - 'value': 'removed value', + "type": "object change", + "value": { + "key": { + "type": "object change", + "value": { + "subkey": { + "type": "added property", + "value": "removed value", + }, }, }, }, }, - }, - ), - - ( - {'key': {'subkey': 'removed value'}}, - {'key': {}}, - { - 'type': 'object change', - 'value': + ), + ( + {"key": {"subkey": "removed value"}}, + {"key": {}}, { - 'key': - { - 'type': 'object change', - 'value': - { - 'subkey': - { - 'type': 'deleted property', - 'value': 'removed value', + "type": "object change", + "value": { + "key": { + "type": "object change", + "value": { + "subkey": { + "type": "deleted property", + "value": "removed value", + }, }, }, }, }, - }, - ), - - ( - {'key': ['old value']}, - {'key': ['new value']}, - { - 'type': 'object change', - 'value': + ), + ( + {"key": ["old value"]}, + {"key": ["new value"]}, { - 'key': - { - 'type': 'list change', - 'added': ['new value'], - 'removed': ['old value'], + "type": "object change", + "value": { + "key": { + "type": "list change", + "added": ["new value"], + "removed": ["old value"], + }, }, }, - }, - ), - - ( - {'key': []}, - {'key': ['new value']}, - { - 'type': 'object change', - 'value': + ), + ( + {"key": []}, + {"key": ["new value"]}, { - 'key': - { - 'type': 'list change', - 'added': ['new value'], - 'removed': [], + "type": "object change", + "value": { + "key": { + "type": "list change", + "added": ["new value"], + "removed": [], + }, }, }, - }, - ), - - ( - {'key': ['removed value']}, - {'key': []}, - { - 'type': 'object change', - 'value': + ), + ( + {"key": ["removed value"]}, + {"key": []}, { - 'key': - { - 'type': 'list change', - 'added': [], - 'removed': ['removed value'], + "type": "object change", + "value": { + "key": { + "type": "list change", + "added": [], + "removed": ["removed value"], + }, }, }, - }, - ), -]) + ), + ], +) def test_get_dict_diff(old, new, expected): assert diff.get_dict_diff(old, new) == expected diff --git a/server/szurubooru/tests/func/test_image_hash.py b/server/szurubooru/tests/func/test_image_hash.py index 192d175..e7028b6 100644 --- a/server/szurubooru/tests/func/test_image_hash.py +++ b/server/szurubooru/tests/func/test_image_hash.py @@ -1,28 +1,29 @@ +import pytest +from numpy import array_equal + from szurubooru.func import image_hash -def test_hashing(read_asset, config_injector): - config_injector({ - 'elasticsearch': { - 'host': 'localhost', - 'port': 9200, - 'index': 'szurubooru_test', - }, - }) - image_hash.purge() - image_hash.add_image('test', read_asset('jpeg.jpg')) +def test_signature_functions(read_asset, config_injector): + sig1 = image_hash.generate_signature(read_asset("jpeg.jpg")) + sig2 = image_hash.generate_signature(read_asset("jpeg-similar.jpg")) + + sig1_repacked = image_hash.unpack_signature( + image_hash.pack_signature(sig1) + ) + sig2_repacked = image_hash.unpack_signature( + image_hash.pack_signature(sig2) + ) + assert array_equal(sig1, sig1_repacked) + assert array_equal(sig2, sig2_repacked) + + dist1 = image_hash.normalized_distance([sig1], sig2) + assert abs(dist1[0] - 0.19713075553164386) < 1e-8 - paths = image_hash.get_all_paths() - results_exact = image_hash.search_by_image(read_asset('jpeg.jpg')) - results_similar = image_hash.search_by_image( - read_asset('jpeg-similar.jpg')) + dist2 = image_hash.normalized_distance([sig2], sig2) + assert abs(dist2[0]) < 1e-8 - assert len(paths) == 1 - assert len(results_exact) == 1 - assert len(results_similar) == 1 - assert results_exact[0].path == 'test' - assert results_exact[0].score == 63 - assert results_exact[0].distance == 0 - assert results_similar[0].path == 'test' - assert results_similar[0].score == 17 - assert abs(results_similar[0].distance - 0.20599895341812172) < 1e-8 + words1 = image_hash.generate_words(sig1) + words2 = image_hash.generate_words(sig2) + words_match = sum(word1 == word2 for word1, word2 in zip(words1, words2)) + assert words_match == 18 diff --git a/server/szurubooru/tests/func/test_mime.py b/server/szurubooru/tests/func/test_mime.py index 4b8dcfa..0d8f645 100644 --- a/server/szurubooru/tests/func/test_mime.py +++ b/server/szurubooru/tests/func/test_mime.py @@ -1,76 +1,97 @@ import pytest + from szurubooru.func import mime -@pytest.mark.parametrize('input_path,expected_mime_type', [ - ('mp4.mp4', 'video/mp4'), - ('webm.webm', 'video/webm'), - ('flash.swf', 'application/x-shockwave-flash'), - ('png.png', 'image/png'), - ('jpeg.jpg', 'image/jpeg'), - ('gif.gif', 'image/gif'), - ('text.txt', 'application/octet-stream'), -]) +@pytest.mark.parametrize( + "input_path,expected_mime_type", + [ + ("mp4.mp4", "video/mp4"), + ("webm.webm", "video/webm"), + ("flash.swf", "application/x-shockwave-flash"), + ("png.png", "image/png"), + ("jpeg.jpg", "image/jpeg"), + ("gif.gif", "image/gif"), + ("webp.webp", "image/webp"), + ("text.txt", "application/octet-stream"), + ], +) def test_get_mime_type(read_asset, input_path, expected_mime_type): assert mime.get_mime_type(read_asset(input_path)) == expected_mime_type def test_get_mime_type_for_empty_file(): - assert mime.get_mime_type(b'') == 'application/octet-stream' + assert mime.get_mime_type(b"") == "application/octet-stream" -@pytest.mark.parametrize('mime_type,expected_extension', [ - ('video/mp4', 'mp4'), - ('video/webm', 'webm'), - ('application/x-shockwave-flash', 'swf'), - ('image/png', 'png'), - ('image/jpeg', 'jpg'), - ('image/gif', 'gif'), - ('application/octet-stream', 'dat'), -]) +@pytest.mark.parametrize( + "mime_type,expected_extension", + [ + ("video/mp4", "mp4"), + ("video/webm", "webm"), + ("application/x-shockwave-flash", "swf"), + ("image/png", "png"), + ("image/jpeg", "jpg"), + ("image/gif", "gif"), + ("image/webp", "webp"), + ("application/octet-stream", "dat"), + ], +) def test_get_extension(mime_type, expected_extension): assert mime.get_extension(mime_type) == expected_extension -@pytest.mark.parametrize('input_mime_type,expected_state', [ - ('application/x-shockwave-flash', True), - ('APPLICATION/X-SHOCKWAVE-FLASH', True), - ('application/x-shockwave', False), -]) +@pytest.mark.parametrize( + "input_mime_type,expected_state", + [ + ("application/x-shockwave-flash", True), + ("APPLICATION/X-SHOCKWAVE-FLASH", True), + ("application/x-shockwave", False), + ], +) def test_is_flash(input_mime_type, expected_state): assert mime.is_flash(input_mime_type) == expected_state -@pytest.mark.parametrize('input_mime_type,expected_state', [ - ('video/webm', True), - ('VIDEO/WEBM', True), - ('video/mp4', True), - ('VIDEO/MP4', True), - ('video/anything_else', False), - ('application/ogg', True), - ('not a video', False), -]) +@pytest.mark.parametrize( + "input_mime_type,expected_state", + [ + ("video/webm", True), + ("VIDEO/WEBM", True), + ("video/mp4", True), + ("VIDEO/MP4", True), + ("video/anything_else", False), + ("application/ogg", True), + ("not a video", False), + ], +) def test_is_video(input_mime_type, expected_state): assert mime.is_video(input_mime_type) == expected_state -@pytest.mark.parametrize('input_mime_type,expected_state', [ - ('image/gif', True), - ('image/png', True), - ('image/jpeg', True), - ('IMAGE/GIF', True), - ('IMAGE/PNG', True), - ('IMAGE/JPEG', True), - ('image/anything_else', False), - ('not an image', False), -]) +@pytest.mark.parametrize( + "input_mime_type,expected_state", + [ + ("image/gif", True), + ("image/png", True), + ("image/jpeg", True), + ("IMAGE/GIF", True), + ("IMAGE/PNG", True), + ("IMAGE/JPEG", True), + ("image/anything_else", False), + ("not an image", False), + ], +) def test_is_image(input_mime_type, expected_state): assert mime.is_image(input_mime_type) == expected_state -@pytest.mark.parametrize('input_path,expected_state', [ - ('gif.gif', False), - ('gif-animated.gif', True), -]) +@pytest.mark.parametrize( + "input_path,expected_state", + [ + ("gif.gif", False), + ("gif-animated.gif", True), + ], +) def test_is_animated_gif(read_asset, input_path, expected_state): assert mime.is_animated_gif(read_asset(input_path)) == expected_state diff --git a/server/szurubooru/tests/func/test_net.py b/server/szurubooru/tests/func/test_net.py index fb149b0..65e9048 100644 --- a/server/szurubooru/tests/func/test_net.py +++ b/server/szurubooru/tests/func/test_net.py @@ -1,49 +1,144 @@ +from datetime import datetime +from unittest.mock import patch + +import pytest + +from szurubooru import errors from szurubooru.func import net +from szurubooru.func.util import get_sha1 + +@pytest.fixture(autouse=True) +def inject_config(tmpdir, config_injector): + config_injector( + { + "user_agent": None, + "max_dl_filesize": 1.0e6, + "data_dir": str(tmpdir.mkdir("data")), + } + ) -def test_download(config_injector): - config_injector({ - 'user_agent': None - }) - url = 'http://info.cern.ch/hypertext/WWW/TheProject.html' + +def test_download(): + url = "http://info.cern.ch/hypertext/WWW/TheProject.html" expected_content = ( - b'<HEADER>\n<TITLE>The World Wide Web project</TITLE>\n<NEXTID N="' + - b'55">\n</HEADER>\n<BODY>\n<H1>World Wide Web</H1>The WorldWideWeb' + - b' (W3) is a wide-area<A\nNAME=0 HREF="WhatIs.html">\nhypermedia</' + - b'A> information retrieval\ninitiative aiming to give universal\na' + - b'ccess to a large universe of documents.<P>\nEverything there is ' + - b'online about\nW3 is linked directly or indirectly\nto this docum' + - b'ent, including an <A\nNAME=24 HREF="Summary.html">executive\nsum' + - b'mary</A> of the project, <A\nNAME=29 HREF="Administration/Mailin' + - b'g/Overview.html">Mailing lists</A>\n, <A\nNAME=30 HREF="Policy.h' + - b'tml">Policy</A> , November\'s <A\nNAME=34 HREF="News/9211.html"' + - b'>W3 news</A> ,\n<A\nNAME=41 HREF="FAQ/List.html">Frequently Ask' + - b'ed Questions</A> .\n<DL>\n<DT><A\nNAME=44 HREF="../DataSources/T' + - b'op.html">What\'s out there?</A>\n<DD> Pointers to the\nworld\'s ' + - b'online information,<A\nNAME=45 HREF="../DataSources/bySubject/Ov' + - b'erview.html"> subjects</A>\n, <A\nNAME=z54 HREF="../DataSources/' + - b'WWW/Servers.html">W3 servers</A>, etc.\n<DT><A\nNAME=46 HREF="He' + - b'lp.html">Help</A>\n<DD> on the browser you are using\n<DT><A\nNA' + - b'ME=13 HREF="Status.html">Software Products</A>\n<DD> A list of W' + - b'3 project\ncomponents and their current state.\n(e.g. <A\nNAME=2' + - b'7 HREF="LineMode/Browser.html">Line Mode</A> ,X11 <A\nNAME=35 HR' + - b'EF="Status.html#35">Viola</A> , <A\nNAME=26 HREF="NeXT/WorldWid' + - b'eWeb.html">NeXTStep</A>\n, <A\nNAME=25 HREF="Daemon/Overview.htm' + - b'l">Servers</A> , <A\nNAME=51 HREF="Tools/Overview.html">Tools</A' + - b'> ,<A\nNAME=53 HREF="MailRobot/Overview.html"> Mail robot</A> ,<' + - b'A\nNAME=52 HREF="Status.html#57">\nLibrary</A> )\n<DT><A\nNAME=4' + - b'7 HREF="Technical.html">Technical</A>\n<DD> Details of protocols' + - b', formats,\nprogram internals etc\n<DT><A\nNAME=40 HREF="Bibliog' + - b'raphy.html">Bibliography</A>\n<DD> Paper documentation\non W3 a' + - b'nd references.\n<DT><A\nNAME=14 HREF="People.html">People</A>\n<' + - b'DD> A list of some people involved\nin the project.\n<DT><A\nNAM' + - b'E=15 HREF="History.html">History</A>\n<DD> A summary of the hist' + - b'ory\nof the project.\n<DT><A\nNAME=37 HREF="Helping.html">How ca' + - b'n I help</A> ?\n<DD> If you would like\nto support the web..\n<D' + - b'T><A\nNAME=48 HREF="../README.html">Getting code</A>\n<DD> Getti' + - b'ng the code by<A\nNAME=49 HREF="LineMode/Defaults/Distribution.h' + - b'tml">\nanonymous FTP</A> , etc.</A>\n</DL>\n</BODY>\n') + b'<HEADER>\n<TITLE>The World Wide Web project</TITLE>\n<NEXTID N="' + + b'55">\n</HEADER>\n<BODY>\n<H1>World Wide Web</H1>The WorldWideWeb' + + b' (W3) is a wide-area<A\nNAME=0 HREF="WhatIs.html">\nhypermedia</' + + b"A> information retrieval\ninitiative aiming to give universal\na" + + b"ccess to a large universe of documents.<P>\nEverything there is " + + b"online about\nW3 is linked directly or indirectly\nto this docum" + + b'ent, including an <A\nNAME=24 HREF="Summary.html">executive\nsum' + + b'mary</A> of the project, <A\nNAME=29 HREF="Administration/Mailin' + + b'g/Overview.html">Mailing lists</A>\n, <A\nNAME=30 HREF="Policy.h' + + b'tml">Policy</A> , November\'s <A\nNAME=34 HREF="News/9211.html"' + + b'>W3 news</A> ,\n<A\nNAME=41 HREF="FAQ/List.html">Frequently Ask' + + b'ed Questions</A> .\n<DL>\n<DT><A\nNAME=44 HREF="../DataSources/T' + + b"op.html\">What's out there?</A>\n<DD> Pointers to the\nworld's " + + b'online information,<A\nNAME=45 HREF="../DataSources/bySubject/Ov' + + b'erview.html"> subjects</A>\n, <A\nNAME=z54 HREF="../DataSources/' + + b'WWW/Servers.html">W3 servers</A>, etc.\n<DT><A\nNAME=46 HREF="He' + + b'lp.html">Help</A>\n<DD> on the browser you are using\n<DT><A\nNA' + + b'ME=13 HREF="Status.html">Software Products</A>\n<DD> A list of W' + + b"3 project\ncomponents and their current state.\n(e.g. <A\nNAME=2" + + b'7 HREF="LineMode/Browser.html">Line Mode</A> ,X11 <A\nNAME=35 HR' + + b'EF="Status.html#35">Viola</A> , <A\nNAME=26 HREF="NeXT/WorldWid' + + b'eWeb.html">NeXTStep</A>\n, <A\nNAME=25 HREF="Daemon/Overview.htm' + + b'l">Servers</A> , <A\nNAME=51 HREF="Tools/Overview.html">Tools</A' + + b'> ,<A\nNAME=53 HREF="MailRobot/Overview.html"> Mail robot</A> ,<' + + b'A\nNAME=52 HREF="Status.html#57">\nLibrary</A> )\n<DT><A\nNAME=4' + + b'7 HREF="Technical.html">Technical</A>\n<DD> Details of protocols' + + b', formats,\nprogram internals etc\n<DT><A\nNAME=40 HREF="Bibliog' + + b'raphy.html">Bibliography</A>\n<DD> Paper documentation\non W3 a' + + b'nd references.\n<DT><A\nNAME=14 HREF="People.html">People</A>\n<' + + b"DD> A list of some people involved\nin the project.\n<DT><A\nNAM" + + b'E=15 HREF="History.html">History</A>\n<DD> A summary of the hist' + + b'ory\nof the project.\n<DT><A\nNAME=37 HREF="Helping.html">How ca' + + b"n I help</A> ?\n<DD> If you would like\nto support the web..\n<D" + + b'T><A\nNAME=48 HREF="../README.html">Getting code</A>\n<DD> Getti' + + b'ng the code by<A\nNAME=49 HREF="LineMode/Defaults/Distribution.h' + + b'tml">\nanonymous FTP</A> , etc.</A>\n</DL>\n</BODY>\n' + ) actual_content = net.download(url) assert actual_content == expected_content + + +@pytest.mark.parametrize( + "url", + [ + "https://samples.ffmpeg.org/MPEG-4/video.mp4", + ], +) +def test_too_large_download(url): + pytest.xfail("Download limit not implemented yet") + with pytest.raises(errors.ProcessingError): + net.download(url) + + +@pytest.mark.parametrize( + "url,expected_sha1", + [ + ( + "https://www.youtube.com/watch?v=C0DPdy98e4c", + "365af1c8f59c6865e1a84c6e13e3e25ff89e0ba1", + ), + ( + "https://gfycat.com/immaterialchillyiberianmole", + "953000e81d7bd1da95ce264f872e7b6c4a6484be", + ), + ], +) +def test_video_download(url, expected_sha1): + actual_content = net.download(url, use_video_downloader=True) + assert get_sha1(actual_content) == expected_sha1 + + +@pytest.mark.parametrize( + "url", + [ + "https://samples.ffmpeg.org/flac/short.flac", # not a video + "https://www.youtube.com/watch?v=dQw4w9WgXcQ", # video too large + ], +) +def test_failed_video_download(url): + with pytest.raises(errors.ThirdPartyError): + net.download(url, use_video_downloader=True) + + +def test_no_webhooks(config_injector): + config_injector({"webhooks": []}) + res = net.post_to_webhooks(None) + assert len(res) == 0 + + +@pytest.mark.parametrize( + "webhook,status_code", + [ + ("https://postman-echo.com/post", 200), + ("http://localhost/", 400), + ("https://postman-echo.com/get", 400), + ], +) +def test_single_webhook(config_injector, webhook, status_code): + ret = net._post_to_webhook(webhook, {"test_arg": "test_value"}) + assert ret == status_code + + +def test_multiple_webhooks(config_injector): + config_injector( + { + "webhooks": [ + "https://postman-echo.com/post", + "https://postman-echo.com/get", + ] + } + ) + threads = net.post_to_webhooks({"test_arg": "test_value"}) + assert len(threads) == 2 + + +def test_malformed_webhooks(config_injector): + with pytest.raises(ValueError): + net._post_to_webhook("malformed_url", {"test_arg": "test_value"}) diff --git a/server/szurubooru/tests/func/test_posts.py b/server/szurubooru/tests/func/test_posts.py index 70a521a..af6121a 100644 --- a/server/szurubooru/tests/func/test_posts.py +++ b/server/szurubooru/tests/func/test_posts.py @@ -1,40 +1,58 @@ +import os from datetime import datetime from unittest.mock import patch -import os + import pytest + from szurubooru import db, model from szurubooru.func import ( - posts, users, comments, tags, images, files, util, image_hash) + comments, + files, + image_hash, + images, + posts, + tags, + users, + util, +) -@pytest.mark.parametrize('input_mime_type,expected_url', [ - ('image/jpeg', 'http://example.com/posts/1_244c8840887984c4.jpg'), - ('image/gif', 'http://example.com/posts/1_244c8840887984c4.gif'), - ('totally/unknown', 'http://example.com/posts/1_244c8840887984c4.dat'), -]) +@pytest.mark.parametrize( + "input_mime_type,expected_url", + [ + ("image/jpeg", "http://example.com/posts/1_244c8840887984c4.jpg"), + ("image/gif", "http://example.com/posts/1_244c8840887984c4.gif"), + ("totally/unknown", "http://example.com/posts/1_244c8840887984c4.dat"), + ], +) def test_get_post_url(input_mime_type, expected_url, config_injector): - config_injector({'data_url': 'http://example.com/', 'secret': 'test'}) + config_injector({"data_url": "http://example.com/", "secret": "test"}) post = model.Post() post.post_id = 1 post.mime_type = input_mime_type assert posts.get_post_content_url(post) == expected_url -@pytest.mark.parametrize('input_mime_type', ['image/jpeg', 'image/gif']) +@pytest.mark.parametrize("input_mime_type", ["image/jpeg", "image/gif"]) def test_get_post_thumbnail_url(input_mime_type, config_injector): - config_injector({'data_url': 'http://example.com/', 'secret': 'test'}) + config_injector({"data_url": "http://example.com/", "secret": "test"}) post = model.Post() post.post_id = 1 post.mime_type = input_mime_type - assert posts.get_post_thumbnail_url(post) \ - == 'http://example.com/generated-thumbnails/1_244c8840887984c4.jpg' + assert ( + posts.get_post_thumbnail_url(post) + == "http://example.com/generated-thumbnails/1_244c8840887984c4.jpg" + ) -@pytest.mark.parametrize('input_mime_type,expected_path', [ - ('image/jpeg', 'posts/1_244c8840887984c4.jpg'), - ('image/gif', 'posts/1_244c8840887984c4.gif'), - ('totally/unknown', 'posts/1_244c8840887984c4.dat'), -]) +@pytest.mark.parametrize( + "input_mime_type,expected_path", + [ + ("image/jpeg", "posts/1_244c8840887984c4.jpg"), + ("image/gif", "posts/1_244c8840887984c4.gif"), + ("totally/unknown", "posts/1_244c8840887984c4.dat"), + ], +) def test_get_post_content_path(input_mime_type, expected_path): post = model.Post() post.post_id = 1 @@ -42,31 +60,35 @@ def test_get_post_content_path(input_mime_type, expected_path): assert posts.get_post_content_path(post) == expected_path -@pytest.mark.parametrize('input_mime_type', ['image/jpeg', 'image/gif']) +@pytest.mark.parametrize("input_mime_type", ["image/jpeg", "image/gif"]) def test_get_post_thumbnail_path(input_mime_type): post = model.Post() post.post_id = 1 post.mime_type = input_mime_type - assert posts.get_post_thumbnail_path(post) \ - == 'generated-thumbnails/1_244c8840887984c4.jpg' + assert ( + posts.get_post_thumbnail_path(post) + == "generated-thumbnails/1_244c8840887984c4.jpg" + ) -@pytest.mark.parametrize('input_mime_type', ['image/jpeg', 'image/gif']) +@pytest.mark.parametrize("input_mime_type", ["image/jpeg", "image/gif"]) def test_get_post_thumbnail_backup_path(input_mime_type): post = model.Post() post.post_id = 1 post.mime_type = input_mime_type - assert posts.get_post_thumbnail_backup_path(post) \ - == 'posts/custom-thumbnails/1_244c8840887984c4.dat' + assert ( + posts.get_post_thumbnail_backup_path(post) + == "posts/custom-thumbnails/1_244c8840887984c4.dat" + ) def test_serialize_note(): note = model.PostNote() note.polygon = [[0, 1], [1, 1], [1, 0], [0, 0]] - note.text = '...' + note.text = "..." assert posts.serialize_note(note) == { - 'polygon': [[0, 1], [1, 1], [1, 0], [0, 0]], - 'text': '...' + "polygon": [[0, 1], [1, 1], [1, 0], [0, 0]], + "text": "...", } @@ -75,174 +97,227 @@ def test_serialize_post_when_empty(): def test_serialize_post( - user_factory, - comment_factory, - tag_factory, - tag_category_factory, - metric_factory, - post_metric_factory, - post_metric_range_factory, - config_injector): - config_injector({'data_url': 'http://example.com/', 'secret': 'test'}) - with patch('szurubooru.func.comments.serialize_comment'), \ - patch('szurubooru.func.users.serialize_micro_user'), \ - patch('szurubooru.func.posts.files.has'): + user_factory, + comment_factory, + tag_factory, + tag_category_factory, + metric_factory, + post_metric_factory, + post_metric_range_factory, + pool_factory, + pool_category_factory, + config_injector, +): + config_injector({"data_url": "http://example.com/", "secret": "test"}) + with patch("szurubooru.func.comments.serialize_comment"), patch( + "szurubooru.func.users.serialize_micro_user" + ), patch("szurubooru.func.posts.files.has"): files.has.return_value = True - users.serialize_micro_user.side_effect \ - = lambda user, auth_user: user.name - comments.serialize_comment.side_effect \ - = lambda comment, auth_user: comment.user.name + users.serialize_micro_user.side_effect = ( + lambda user, auth_user: user.name + ) + comments.serialize_comment.side_effect = ( + lambda comment, auth_user: comment.user.name + ) - auth_user = user_factory(name='auth user') + auth_user = user_factory(name="auth user") post = model.Post() post.post_id = 1 post.creation_time = datetime(1997, 1, 1) post.last_edit_time = datetime(1998, 1, 1) tag1 = tag_factory( - names=['tag1', 'tag2'], - category=tag_category_factory('test-cat1')) + names=["tag1", "tag2"], + category=tag_category_factory("test-cat1") + ) tag1.metric = metric_factory(tag=tag1, min=-2.5, max=2.5) tag3 = tag_factory( - names=['tag3'], - category=tag_category_factory('test-cat2')) + names=["tag3"], + category=tag_category_factory("test-cat2") + ) post.tags = [tag1, tag3] post.metrics = [ post_metric_factory(post=post, metric=tag1.metric, value=-1.2) ] post.metric_ranges = [ - post_metric_range_factory(post=post, metric=tag1.metric, - low=2, high=3) + post_metric_range_factory( + post=post, metric=tag1.metric, low=2, high=3 + ) ] post.safety = model.Post.SAFETY_SAFE - post.source = '4gag' + post.source = "4gag" post.type = model.Post.TYPE_IMAGE - post.checksum = 'deadbeef' - post.mime_type = 'image/jpeg' + post.checksum = "deadbeef" + post.mime_type = "image/jpeg" post.file_size = 100 - post.user = user_factory(name='post author') + post.user = user_factory(name="post author") post.canvas_width = 200 post.canvas_height = 300 - post.flags = ['loop'] + post.flags = ["loop"] db.session.add(post) db.session.flush() - db.session.add_all([ - comment_factory( - user=user_factory(name='commenter1'), - post=post, - time=datetime(1999, 1, 1)), - comment_factory( - user=user_factory(name='commenter2'), - post=post, - time=datetime(1999, 1, 2)), - model.PostFavorite( - post=post, - user=user_factory(name='fav1'), - time=datetime(1800, 1, 1)), - model.PostFeature( - post=post, - user=user_factory(), - time=datetime(1999, 1, 1)), - model.PostScore( - post=post, - user=auth_user, - score=-1, - time=datetime(1800, 1, 1)), - model.PostScore( - post=post, - user=user_factory(), - score=1, - time=datetime(1800, 1, 1)), - model.PostScore( - post=post, - user=user_factory(), - score=1, - time=datetime(1800, 1, 1))]) + db.session.add_all( + [ + comment_factory( + user=user_factory(name="commenter1"), + post=post, + time=datetime(1999, 1, 1), + ), + comment_factory( + user=user_factory(name="commenter2"), + post=post, + time=datetime(1999, 1, 2), + ), + model.PostFavorite( + post=post, + user=user_factory(name="fav1"), + time=datetime(1800, 1, 1), + ), + model.PostFeature( + post=post, user=user_factory(), time=datetime(1999, 1, 1) + ), + model.PostScore( + post=post, + user=auth_user, + score=-1, + time=datetime(1800, 1, 1), + ), + model.PostScore( + post=post, + user=user_factory(), + score=1, + time=datetime(1800, 1, 1), + ), + model.PostScore( + post=post, + user=user_factory(), + score=1, + time=datetime(1800, 1, 1), + ), + ] + ) + db.session.flush() + + pool1 = pool_factory( + id=1, + names=["pool1", "pool2"], + description="desc", + category=pool_category_factory("test-cat1"), + ) + pool1.last_edit_time = datetime(1998, 1, 1) + pool1.posts.append(post) + + pool2 = pool_factory( + id=2, + names=["pool3"], + description="desc2", + category=pool_category_factory("test-cat2"), + ) + pool2.last_edit_time = datetime(1998, 1, 1) + pool2.posts.append(post) + + db.session.add_all([pool1, pool2]) db.session.flush() result = posts.serialize_post(post, auth_user) - result['tags'].sort(key=lambda tag: tag['names'][0]) + result["tags"].sort(key=lambda tag: tag["names"][0]) assert result == { - 'id': 1, - 'version': 1, - 'creationTime': datetime(1997, 1, 1), - 'lastEditTime': datetime(1998, 1, 1), - 'safety': 'safe', - 'source': '4gag', - 'type': 'image', - 'checksum': 'deadbeef', - 'fileSize': 100, - 'canvasWidth': 200, - 'canvasHeight': 300, - 'contentUrl': 'http://example.com/posts/1_244c8840887984c4.jpg', - 'thumbnailUrl': - 'http://example.com/' - 'generated-thumbnails/1_244c8840887984c4.jpg', - 'flags': ['loop'], - 'tags': [ + "id": 1, + "version": 1, + "creationTime": datetime(1997, 1, 1), + "lastEditTime": datetime(1998, 1, 1), + "safety": "safe", + "source": "4gag", + "type": "image", + "checksum": "deadbeef", + "fileSize": 100, + "canvasWidth": 200, + "canvasHeight": 300, + "contentUrl": "http://example.com/posts/1_244c8840887984c4.jpg", + "thumbnailUrl": "http://example.com/" + "generated-thumbnails/1_244c8840887984c4.jpg", + "flags": ["loop"], + "tags": [ { - 'names': ['tag1', 'tag2'], - 'category': 'test-cat1', - 'usages': 1, - 'metric': { - 'min': -2.5, - 'max': 2.5 - } + "names": ["tag1", "tag2"], + "category": "test-cat1", + "usages": 1, + "metric": { + "min": -2.5, + "max": 2.5 + }, }, { - 'names': ['tag3'], - 'category': 'test-cat2', - 'usages': 1, - 'metric': None + "names": ["tag3"], + "category": "test-cat2", + "usages": 1, + "metric": None, }, ], - 'relations': [], - 'notes': [], - 'user': 'post author', - 'score': 1, - 'ownFavorite': False, - 'ownScore': -1, - 'tagCount': 2, - 'favoriteCount': 1, - 'commentCount': 2, - 'noteCount': 0, - 'featureCount': 1, - 'relationCount': 0, - 'lastFeatureTime': datetime(1999, 1, 1), - 'favoritedBy': ['fav1'], - 'hasCustomThumbnail': True, - 'mimeType': 'image/jpeg', - 'comments': ['commenter1', 'commenter2'], - 'metrics': [ + "relations": [], + "notes": [], + "pools": [ { - 'tag_name': 'tag1', - 'post_id': 1, - 'value': -1.2 + "id": 1, + "names": ["pool1", "pool2"], + "description": "desc", + "category": "test-cat1", + "postCount": 1, + }, + { + "id": 2, + "names": ["pool3"], + "description": "desc2", + "category": "test-cat2", + "postCount": 1, + }, + ], + "user": "post author", + "score": 1, + "ownFavorite": False, + "ownScore": -1, + "tagCount": 2, + "favoriteCount": 1, + "commentCount": 2, + "noteCount": 0, + "featureCount": 1, + "relationCount": 0, + "lastFeatureTime": datetime(1999, 1, 1), + "favoritedBy": ["fav1"], + "hasCustomThumbnail": True, + "mimeType": "image/jpeg", + "comments": ["commenter1", "commenter2"], + "metrics": [ + { + "tag_name": "tag1", + "post_id": 1, + "value": -1.2 } ], - 'metricRanges': [ + "metricRanges": [ { - 'tag_name': 'tag1', - 'post_id': 1, - 'low': 2, - 'high': 3 + "tag_name": "tag1", + "post_id": 1, + "low": 2, + "high": 3 } - ] + ], } def test_serialize_micro_post(post_factory, user_factory): - with patch('szurubooru.func.posts.get_post_thumbnail_url'): - posts.get_post_thumbnail_url.return_value \ - = 'https://example.com/thumb.png' + with patch("szurubooru.func.posts.get_post_thumbnail_url"): + posts.get_post_thumbnail_url.return_value = ( + "https://example.com/thumb.png" + ) auth_user = user_factory() post = post_factory() db.session.add(post) db.session.flush() assert posts.serialize_micro_post(post, auth_user) == { - 'id': post.post_id, - 'thumbnailUrl': 'https://example.com/thumb.png', + "id": post.post_id, + "thumbnailUrl": "https://example.com/thumb.png", } @@ -273,22 +348,25 @@ def test_get_post_by_id(post_factory): def test_create_post(user_factory, fake_datetime): - with patch('szurubooru.func.posts.update_post_content'), \ - patch('szurubooru.func.posts.update_post_tags'), \ - fake_datetime('1997-01-01'): + with patch("szurubooru.func.posts.update_post_content"), patch( + "szurubooru.func.posts.update_post_tags" + ), fake_datetime("1997-01-01"): auth_user = user_factory() - post, _new_tags = posts.create_post('content', ['tag'], auth_user) + post, _new_tags = posts.create_post("content", ["tag"], auth_user) assert post.creation_time == datetime(1997, 1, 1) assert post.last_edit_time is None - posts.update_post_tags.assert_called_once_with(post, ['tag']) - posts.update_post_content.assert_called_once_with(post, 'content') + posts.update_post_tags.assert_called_once_with(post, ["tag"]) + posts.update_post_content.assert_called_once_with(post, "content") -@pytest.mark.parametrize('input_safety,expected_safety', [ - ('safe', model.Post.SAFETY_SAFE), - ('sketchy', model.Post.SAFETY_SKETCHY), - ('unsafe', model.Post.SAFETY_UNSAFE), -]) +@pytest.mark.parametrize( + "input_safety,expected_safety", + [ + ("safe", model.Post.SAFETY_SAFE), + ("sketchy", model.Post.SAFETY_SKETCHY), + ("unsafe", model.Post.SAFETY_UNSAFE), + ], +) def test_update_post_safety(input_safety, expected_safety): post = model.Post() posts.update_post_safety(post, input_safety) @@ -298,95 +376,107 @@ def test_update_post_safety(input_safety, expected_safety): def test_update_post_safety_with_invalid_string(): post = model.Post() with pytest.raises(posts.InvalidPostSafetyError): - posts.update_post_safety(post, 'bad') + posts.update_post_safety(post, "bad") def test_update_post_source(): post = model.Post() - posts.update_post_source(post, 'x') - assert post.source == 'x' + posts.update_post_source(post, "x") + assert post.source == "x" def test_update_post_source_with_too_long_string(): post = model.Post() with pytest.raises(posts.InvalidPostSourceError): - posts.update_post_source(post, 'x' * 1000) + posts.update_post_source(post, "x" * 3000) @pytest.mark.parametrize( - 'is_existing,input_file,expected_mime_type,expected_type,output_file_name', + "is_existing,input_file,expected_mime_type,expected_type,output_file_name", [ ( True, - 'png.png', - 'image/png', + "png.png", + "image/png", model.Post.TYPE_IMAGE, - '1_244c8840887984c4.png', + "1_244c8840887984c4.png", ), ( False, - 'png.png', - 'image/png', + "png.png", + "image/png", model.Post.TYPE_IMAGE, - '1_244c8840887984c4.png', + "1_244c8840887984c4.png", ), ( False, - 'jpeg.jpg', - 'image/jpeg', + "jpeg.jpg", + "image/jpeg", model.Post.TYPE_IMAGE, - '1_244c8840887984c4.jpg', + "1_244c8840887984c4.jpg", ), ( False, - 'gif.gif', - 'image/gif', + "gif.gif", + "image/gif", model.Post.TYPE_IMAGE, - '1_244c8840887984c4.gif', + "1_244c8840887984c4.gif", ), ( False, - 'gif-animated.gif', - 'image/gif', + "gif-animated.gif", + "image/gif", model.Post.TYPE_ANIMATION, - '1_244c8840887984c4.gif', + "1_244c8840887984c4.gif", ), ( False, - 'webm.webm', - 'video/webm', + "webm.webm", + "video/webm", model.Post.TYPE_VIDEO, - '1_244c8840887984c4.webm', + "1_244c8840887984c4.webm", ), ( False, - 'mp4.mp4', - 'video/mp4', + "mp4.mp4", + "video/mp4", model.Post.TYPE_VIDEO, - '1_244c8840887984c4.mp4', + "1_244c8840887984c4.mp4", ), ( False, - 'flash.swf', - 'application/x-shockwave-flash', + "flash.swf", + "application/x-shockwave-flash", model.Post.TYPE_FLASH, - '1_244c8840887984c4.swf', + "1_244c8840887984c4.swf", ), - ]) + ], +) def test_update_post_content_for_new_post( - tmpdir, config_injector, post_factory, read_asset, is_existing, - input_file, expected_mime_type, expected_type, output_file_name): - with patch('szurubooru.func.util.get_sha1'): - util.get_sha1.return_value = 'crc' - config_injector({ - 'data_dir': str(tmpdir.mkdir('data')), - 'thumbnails': { - 'post_width': 300, - 'post_height': 300, - }, - 'secret': 'test', - }) - output_file_path = '{}/data/posts/{}'.format(tmpdir, output_file_name) + tmpdir, + config_injector, + post_factory, + read_asset, + is_existing, + input_file, + expected_mime_type, + expected_type, + output_file_name, +): + with patch("szurubooru.func.util.get_sha1"): + util.get_sha1.return_value = "crc" + config_injector( + { + "data_dir": str(tmpdir.mkdir("data")), + "thumbnails": { + "post_width": 300, + "post_height": 300, + }, + "secret": "test", + "allow_broken_uploads": False, + } + ) + output_file_path = "{}/data/posts/{}".format(tmpdir, output_file_name) post = post_factory(id=1) db.session.add(post) if is_existing: @@ -399,126 +489,157 @@ def test_update_post_content_for_new_post( db.session.flush() assert post.mime_type == expected_mime_type assert post.type == expected_type - assert post.checksum == 'crc' + assert post.checksum == "crc" assert os.path.exists(output_file_path) if post.type in (model.Post.TYPE_IMAGE, model.Post.TYPE_ANIMATION): - image_hash.delete_image.assert_called_once_with(post.post_id) - image_hash.add_image.assert_called_once_with(post.post_id, content) + assert db.session.query(model.PostSignature).count() == 1 else: - image_hash.delete_image.assert_not_called() - image_hash.add_image.assert_not_called() + assert db.session.query(model.PostSignature).count() == 0 def test_update_post_content_to_existing_content( - tmpdir, config_injector, post_factory, read_asset): - config_injector({ - 'data_dir': str(tmpdir.mkdir('data')), - 'data_url': 'example.com', - 'thumbnails': { - 'post_width': 300, - 'post_height': 300, - }, - 'secret': 'test', - }) + tmpdir, config_injector, post_factory, read_asset +): + config_injector( + { + "data_dir": str(tmpdir.mkdir("data")), + "data_url": "example.com", + "thumbnails": { + "post_width": 300, + "post_height": 300, + }, + "secret": "test", + "allow_broken_uploads": False, + } + ) post = post_factory() another_post = post_factory() db.session.add_all([post, another_post]) - posts.update_post_content(post, read_asset('png.png')) + posts.update_post_content(post, read_asset("png.png")) db.session.flush() with pytest.raises(posts.PostAlreadyUploadedError): - posts.update_post_content(another_post, read_asset('png.png')) + posts.update_post_content(another_post, read_asset("png.png")) +@pytest.mark.parametrize("allow_broken_uploads", [True, False]) def test_update_post_content_with_broken_content( - tmpdir, config_injector, post_factory, read_asset): + tmpdir, config_injector, post_factory, read_asset, allow_broken_uploads +): # the rationale behind this behavior is to salvage user upload even if the # server software thinks it's broken. chances are the server is wrong, # especially about flash movies. - config_injector({ - 'data_dir': str(tmpdir.mkdir('data')), - 'thumbnails': { - 'post_width': 300, - 'post_height': 300, - }, - 'secret': 'test', - }) + config_injector( + { + "data_dir": str(tmpdir.mkdir("data")), + "thumbnails": { + "post_width": 300, + "post_height": 300, + }, + "secret": "test", + "allow_broken_uploads": allow_broken_uploads, + } + ) post = post_factory() another_post = post_factory() db.session.add_all([post, another_post]) - posts.update_post_content(post, read_asset('png-broken.png')) - db.session.flush() - assert post.canvas_width is None - assert post.canvas_height is None + if allow_broken_uploads: + posts.update_post_content(post, read_asset("png-broken.png")) + db.session.flush() + assert post.canvas_width is None + assert post.canvas_height is None + else: + with pytest.raises(posts.InvalidPostContentError): + posts.update_post_content(post, read_asset("png-broken.png")) + db.session.flush() -@pytest.mark.parametrize('input_content', [None, b'not a media file']) -def test_update_post_content_with_invalid_content(input_content): +@pytest.mark.parametrize("input_content", [None, b"not a media file"]) +def test_update_post_content_with_invalid_content( + config_injector, input_content +): + config_injector( + { + "allow_broken_uploads": True, + } + ) post = model.Post() with pytest.raises(posts.InvalidPostContentError): posts.update_post_content(post, input_content) -@pytest.mark.parametrize('is_existing', (True, False)) +@pytest.mark.parametrize("is_existing", (True, False)) def test_update_post_thumbnail_to_new_one( - tmpdir, config_injector, read_asset, post_factory, is_existing): - config_injector({ - 'data_dir': str(tmpdir.mkdir('data')), - 'thumbnails': { - 'post_width': 300, - 'post_height': 300, - }, - 'secret': 'test', - }) + tmpdir, config_injector, read_asset, post_factory, is_existing +): + config_injector( + { + "data_dir": str(tmpdir.mkdir("data")), + "thumbnails": { + "post_width": 300, + "post_height": 300, + }, + "secret": "test", + "allow_broken_uploads": False, + } + ) post = post_factory(id=1) db.session.add(post) if is_existing: db.session.flush() assert post.post_id generated_path = ( - '{}/data/generated-thumbnails/1_244c8840887984c4.jpg' - .format(tmpdir)) + "{}/data/generated-thumbnails/".format(tmpdir) + + "1_244c8840887984c4.jpg" + ) source_path = ( - '{}/data/posts/custom-thumbnails/1_244c8840887984c4.dat' - .format(tmpdir)) + "{}/data/posts/custom-thumbnails/".format(tmpdir) + + "1_244c8840887984c4.dat" + ) assert not os.path.exists(generated_path) assert not os.path.exists(source_path) - posts.update_post_content(post, read_asset('png.png')) - posts.update_post_thumbnail(post, read_asset('jpeg.jpg')) + posts.update_post_content(post, read_asset("png.png")) + posts.update_post_thumbnail(post, read_asset("jpeg.jpg")) assert not os.path.exists(generated_path) assert not os.path.exists(source_path) db.session.flush() assert os.path.exists(generated_path) assert os.path.exists(source_path) - with open(source_path, 'rb') as handle: - assert handle.read() == read_asset('jpeg.jpg') + with open(source_path, "rb") as handle: + assert handle.read() == read_asset("jpeg.jpg") -@pytest.mark.parametrize('is_existing', (True, False)) +@pytest.mark.parametrize("is_existing", (True, False)) def test_update_post_thumbnail_to_default( - tmpdir, config_injector, read_asset, post_factory, is_existing): - config_injector({ - 'data_dir': str(tmpdir.mkdir('data')), - 'thumbnails': { - 'post_width': 300, - 'post_height': 300, - }, - 'secret': 'test', - }) + tmpdir, config_injector, read_asset, post_factory, is_existing +): + config_injector( + { + "data_dir": str(tmpdir.mkdir("data")), + "thumbnails": { + "post_width": 300, + "post_height": 300, + }, + "secret": "test", + "allow_broken_uploads": False, + } + ) post = post_factory(id=1) db.session.add(post) if is_existing: db.session.flush() assert post.post_id generated_path = ( - '{}/data/generated-thumbnails/1_244c8840887984c4.jpg' - .format(tmpdir)) + "{}/data/generated-thumbnails/".format(tmpdir) + + "1_244c8840887984c4.jpg" + ) source_path = ( - '{}/data/posts/custom-thumbnails/1_244c8840887984c4.dat' - .format(tmpdir)) + "{}/data/posts/custom-thumbnails/".format(tmpdir) + + "1_244c8840887984c4.dat" + ) assert not os.path.exists(generated_path) assert not os.path.exists(source_path) - posts.update_post_content(post, read_asset('png.png')) - posts.update_post_thumbnail(post, read_asset('jpeg.jpg')) + posts.update_post_content(post, read_asset("png.png")) + posts.update_post_thumbnail(post, read_asset("jpeg.jpg")) posts.update_post_thumbnail(post, None) assert not os.path.exists(generated_path) assert not os.path.exists(source_path) @@ -527,80 +648,94 @@ def test_update_post_thumbnail_to_default( assert not os.path.exists(source_path) -@pytest.mark.parametrize('is_existing', (True, False)) +@pytest.mark.parametrize("is_existing", (True, False)) def test_update_post_thumbnail_with_broken_thumbnail( - tmpdir, config_injector, read_asset, post_factory, is_existing): - config_injector({ - 'data_dir': str(tmpdir.mkdir('data')), - 'thumbnails': { - 'post_width': 300, - 'post_height': 300, - }, - 'secret': 'test', - }) + tmpdir, config_injector, read_asset, post_factory, is_existing +): + config_injector( + { + "data_dir": str(tmpdir.mkdir("data")), + "thumbnails": { + "post_width": 300, + "post_height": 300, + }, + "secret": "test", + "allow_broken_uploads": False, + } + ) post = post_factory(id=1) db.session.add(post) if is_existing: db.session.flush() assert post.post_id generated_path = ( - '{}/data/generated-thumbnails/1_244c8840887984c4.jpg' - .format(tmpdir)) + "{}/data/generated-thumbnails/".format(tmpdir) + + "1_244c8840887984c4.jpg" + ) source_path = ( - '{}/data/posts/custom-thumbnails/1_244c8840887984c4.dat' - .format(tmpdir)) + "{}/data/posts/custom-thumbnails/".format(tmpdir) + + "1_244c8840887984c4.dat" + ) assert not os.path.exists(generated_path) assert not os.path.exists(source_path) - posts.update_post_content(post, read_asset('png.png')) - posts.update_post_thumbnail(post, read_asset('png-broken.png')) + posts.update_post_content(post, read_asset("png.png")) + posts.update_post_thumbnail(post, read_asset("png-broken.png")) assert not os.path.exists(generated_path) assert not os.path.exists(source_path) db.session.flush() assert os.path.exists(generated_path) assert os.path.exists(source_path) - with open(source_path, 'rb') as handle: - assert handle.read() == read_asset('png-broken.png') - with open(generated_path, 'rb') as handle: + with open(source_path, "rb") as handle: + assert handle.read() == read_asset("png-broken.png") + with open(generated_path, "rb") as handle: image = images.Image(handle.read()) assert image.width == 1 assert image.height == 1 def test_update_post_content_leaving_custom_thumbnail( - tmpdir, config_injector, read_asset, post_factory): - config_injector({ - 'data_dir': str(tmpdir.mkdir('data')), - 'thumbnails': { - 'post_width': 300, - 'post_height': 300, - }, - 'secret': 'test', - }) + tmpdir, config_injector, read_asset, post_factory +): + config_injector( + { + "data_dir": str(tmpdir.mkdir("data")), + "thumbnails": { + "post_width": 300, + "post_height": 300, + }, + "secret": "test", + "allow_broken_uploads": False, + } + ) post = post_factory(id=1) db.session.add(post) - posts.update_post_content(post, read_asset('png.png')) - posts.update_post_thumbnail(post, read_asset('jpeg.jpg')) - posts.update_post_content(post, read_asset('png.png')) + posts.update_post_content(post, read_asset("png.png")) + posts.update_post_thumbnail(post, read_asset("jpeg.jpg")) + posts.update_post_content(post, read_asset("png.png")) db.session.flush() generated_path = ( - '{}/data/generated-thumbnails/1_244c8840887984c4.jpg' - .format(tmpdir)) + "{}/data/generated-thumbnails/".format(tmpdir) + + "1_244c8840887984c4.jpg" + ) source_path = ( - '{}/data/posts/custom-thumbnails/1_244c8840887984c4.dat' - .format(tmpdir)) + "{}/data/posts/custom-thumbnails/".format(tmpdir) + + "1_244c8840887984c4.dat" + ) assert os.path.exists(source_path) assert os.path.exists(generated_path) def test_update_post_tags(tag_factory): post = model.Post() - with patch('szurubooru.func.tags.get_or_create_tags_by_names'): - tags.get_or_create_tags_by_names.side_effect = lambda tag_names: \ - ([tag_factory(names=[name]) for name in tag_names], []) - posts.update_post_tags(post, ['tag1', 'tag2']) + with patch("szurubooru.func.tags.get_or_create_tags_by_names"): + tags.get_or_create_tags_by_names.side_effect = lambda tag_names: ( + [tag_factory(names=[name]) for name in tag_names], + [], + ) + posts.update_post_tags(post, ["tag1", "tag2"]) assert len(post.tags) == 2 - assert post.tags[0].names[0].name == 'tag1' - assert post.tags[1].names[0].name == 'tag2' + assert post.tags[0].names[0].name == "tag1" + assert post.tags[1].names[0].name == "tag2" def test_update_post_relations(post_factory): @@ -612,7 +747,9 @@ def test_update_post_relations(post_factory): posts.update_post_relations(post, [relation1.post_id, relation2.post_id]) assert len(post.relations) == 2 assert sorted(r.post_id for r in post.relations) == [ - relation1.post_id, relation2.post_id] + relation1.post_id, + relation2.post_id, + ] def test_update_post_relations_bidirectionality(post_factory): @@ -647,35 +784,44 @@ def test_update_post_notes(): posts.update_post_notes( post, [ - {'polygon': [[0, 0], [0, 1], [1, 0], [0, 0]], 'text': 'text1'}, - {'polygon': [[0, 0], [0, 1], [1, 0], [0, 0]], 'text': 'text2'}, - ]) + {"polygon": [[0, 0], [0, 1], [1, 0], [0, 0]], "text": "text1"}, + {"polygon": [[0, 0], [0, 1], [1, 0], [0, 0]], "text": "text2"}, + ], + ) assert len(post.notes) == 2 assert post.notes[0].polygon == [[0, 0], [0, 1], [1, 0], [0, 0]] - assert post.notes[0].text == 'text1' + assert post.notes[0].text == "text1" assert post.notes[1].polygon == [[0, 0], [0, 1], [1, 0], [0, 0]] - assert post.notes[1].text == 'text2' + assert post.notes[1].text == "text2" -@pytest.mark.parametrize('input', [ - [{'text': '...'}], - [{'polygon': None, 'text': '...'}], - [{'polygon': 'trash', 'text': '...'}], - [{'polygon': ['trash', 'trash', 'trash'], 'text': '...'}], - [{'polygon': {2: 'trash', 3: 'trash', 4: 'trash'}, 'text': '...'}], - [{'polygon': [[0, 0]], 'text': '...'}], - [{'polygon': [[0, 0], [0, 0], None], 'text': '...'}], - [{'polygon': [[0, 0], [0, 0], 'surprise'], 'text': '...'}], - [{'polygon': [[0, 0], [0, 0], {2: 'trash', 3: 'trash'}], 'text': '...'}], - [{'polygon': [[0, 0], [0, 0], 5], 'text': '...'}], - [{'polygon': [[0, 0], [0, 0], [0, 2]], 'text': '...'}], - [{'polygon': [[0, 0], [0, 0], [0, '...']], 'text': '...'}], - [{'polygon': [[0, 0], [0, 0], [0, 0, 0]], 'text': '...'}], - [{'polygon': [[0, 0], [0, 0], [0]], 'text': '...'}], - [{'polygon': [[0, 0], [0, 0], [0, 1]], 'text': ''}], - [{'polygon': [[0, 0], [0, 0], [0, 1]], 'text': None}], - [{'polygon': [[0, 0], [0, 0], [0, 1]]}], -]) +@pytest.mark.parametrize( + "input", + [ + [{"text": "..."}], + [{"polygon": None, "text": "..."}], + [{"polygon": "trash", "text": "..."}], + [{"polygon": ["trash", "trash", "trash"], "text": "..."}], + [{"polygon": {2: "trash", 3: "trash", 4: "trash"}, "text": "..."}], + [{"polygon": [[0, 0]], "text": "..."}], + [{"polygon": [[0, 0], [0, 0], None], "text": "..."}], + [{"polygon": [[0, 0], [0, 0], "surprise"], "text": "..."}], + [ + { + "polygon": [[0, 0], [0, 0], {2: "trash", 3: "trash"}], + "text": "...", + } + ], + [{"polygon": [[0, 0], [0, 0], 5], "text": "..."}], + [{"polygon": [[0, 0], [0, 0], [0, 2]], "text": "..."}], + [{"polygon": [[0, 0], [0, 0], [0, "..."]], "text": "..."}], + [{"polygon": [[0, 0], [0, 0], [0, 0, 0]], "text": "..."}], + [{"polygon": [[0, 0], [0, 0], [0]], "text": "..."}], + [{"polygon": [[0, 0], [0, 0], [0, 1]], "text": ""}], + [{"polygon": [[0, 0], [0, 0], [0, 1]], "text": None}], + [{"polygon": [[0, 0], [0, 0], [0, 1]]}], + ], +) def test_update_post_notes_with_invalid_content(input): post = model.Post() with pytest.raises(posts.InvalidPostNoteError): @@ -684,14 +830,14 @@ def test_update_post_notes_with_invalid_content(input): def test_update_post_flags(): post = model.Post() - posts.update_post_flags(post, ['loop']) - assert post.flags == ['loop'] + posts.update_post_flags(post, ["loop"]) + assert post.flags == ["loop"] def test_update_post_flags_with_invalid_content(): post = model.Post() with pytest.raises(posts.InvalidPostFlagError): - posts.update_post_flags(post, ['invalid']) + posts.update_post_flags(post, ["invalid"]) def test_feature_post(post_factory, user_factory): @@ -707,7 +853,7 @@ def test_feature_post(post_factory, user_factory): def test_delete(post_factory, config_injector): - config_injector({'delete_source_files': False}) + config_injector({"delete_source_files": False}) post = post_factory() db.session.add(post) db.session.flush() @@ -718,7 +864,7 @@ def test_delete(post_factory, config_injector): def test_merge_posts_deletes_source_post(post_factory, config_injector): - config_injector({'delete_source_files': False}) + config_injector({"delete_source_files": False}) source_post = post_factory() target_post = post_factory() db.session.add_all([source_post, target_post]) @@ -731,7 +877,7 @@ def test_merge_posts_deletes_source_post(post_factory, config_injector): def test_merge_posts_with_itself(post_factory, config_injector): - config_injector({'delete_source_files': False}) + config_injector({"delete_source_files": False}) source_post = post_factory() db.session.add(source_post) db.session.flush() @@ -740,7 +886,7 @@ def test_merge_posts_with_itself(post_factory, config_injector): def test_merge_posts_moves_tags(post_factory, tag_factory, config_injector): - config_injector({'delete_source_files': False}) + config_injector({"delete_source_files": False}) source_post = post_factory() target_post = post_factory() tag = tag_factory() @@ -756,8 +902,9 @@ def test_merge_posts_moves_tags(post_factory, tag_factory, config_injector): def test_merge_posts_doesnt_duplicate_tags( - post_factory, tag_factory, config_injector): - config_injector({'delete_source_files': False}) + post_factory, tag_factory, config_injector +): + config_injector({"delete_source_files": False}) source_post = post_factory() target_post = post_factory() tag = tag_factory() @@ -773,8 +920,9 @@ def test_merge_posts_doesnt_duplicate_tags( def test_merge_posts_moves_comments( - post_factory, comment_factory, config_injector): - config_injector({'delete_source_files': False}) + post_factory, comment_factory, config_injector +): + config_injector({"delete_source_files": False}) source_post = post_factory() target_post = post_factory() comment = comment_factory(post=source_post) @@ -789,8 +937,9 @@ def test_merge_posts_moves_comments( def test_merge_posts_moves_scores( - post_factory, post_score_factory, config_injector): - config_injector({'delete_source_files': False}) + post_factory, post_score_factory, config_injector +): + config_injector({"delete_source_files": False}) source_post = post_factory() target_post = post_factory() score = post_score_factory(post=source_post, score=1) @@ -805,8 +954,9 @@ def test_merge_posts_moves_scores( def test_merge_posts_doesnt_duplicate_scores( - post_factory, user_factory, post_score_factory, config_injector): - config_injector({'delete_source_files': False}) + post_factory, user_factory, post_score_factory, config_injector +): + config_injector({"delete_source_files": False}) source_post = post_factory() target_post = post_factory() user = user_factory() @@ -823,8 +973,9 @@ def test_merge_posts_doesnt_duplicate_scores( def test_merge_posts_moves_favorites( - post_factory, post_favorite_factory, config_injector): - config_injector({'delete_source_files': False}) + post_factory, post_favorite_factory, config_injector +): + config_injector({"delete_source_files": False}) source_post = post_factory() target_post = post_factory() favorite = post_favorite_factory(post=source_post) @@ -839,8 +990,9 @@ def test_merge_posts_moves_favorites( def test_merge_posts_doesnt_duplicate_favorites( - post_factory, user_factory, post_favorite_factory, config_injector): - config_injector({'delete_source_files': False}) + post_factory, user_factory, post_favorite_factory, config_injector +): + config_injector({"delete_source_files": False}) source_post = post_factory() target_post = post_factory() user = user_factory() @@ -857,7 +1009,7 @@ def test_merge_posts_doesnt_duplicate_favorites( def test_merge_posts_moves_child_relations(post_factory, config_injector): - config_injector({'delete_source_files': False}) + config_injector({"delete_source_files": False}) source_post = post_factory() target_post = post_factory() related_post = post_factory() @@ -873,8 +1025,9 @@ def test_merge_posts_moves_child_relations(post_factory, config_injector): def test_merge_posts_doesnt_duplicate_child_relations( - post_factory, config_injector): - config_injector({'delete_source_files': False}) + post_factory, config_injector +): + config_injector({"delete_source_files": False}) source_post = post_factory() target_post = post_factory() related_post = post_factory() @@ -891,7 +1044,7 @@ def test_merge_posts_doesnt_duplicate_child_relations( def test_merge_posts_moves_parent_relations(post_factory, config_injector): - config_injector({'delete_source_files': False}) + config_injector({"delete_source_files": False}) source_post = post_factory() target_post = post_factory() related_post = post_factory() @@ -909,8 +1062,9 @@ def test_merge_posts_moves_parent_relations(post_factory, config_injector): def test_merge_posts_doesnt_duplicate_parent_relations( - post_factory, config_injector): - config_injector({'delete_source_files': False}) + post_factory, config_injector +): + config_injector({"delete_source_files": False}) source_post = post_factory() target_post = post_factory() related_post = post_factory() @@ -928,8 +1082,9 @@ def test_merge_posts_doesnt_duplicate_parent_relations( def test_merge_posts_doesnt_create_relation_loop_for_children( - post_factory, config_injector): - config_injector({'delete_source_files': False}) + post_factory, config_injector +): + config_injector({"delete_source_files": False}) source_post = post_factory() target_post = post_factory() source_post.relations = [target_post] @@ -944,8 +1099,9 @@ def test_merge_posts_doesnt_create_relation_loop_for_children( def test_merge_posts_doesnt_create_relation_loop_for_parents( - post_factory, config_injector): - config_injector({'delete_source_files': False}) + post_factory, config_injector +): + config_injector({"delete_source_files": False}) source_post = post_factory() target_post = post_factory() target_post.relations = [source_post] @@ -960,30 +1116,36 @@ def test_merge_posts_doesnt_create_relation_loop_for_parents( def test_merge_posts_replaces_content( - post_factory, config_injector, tmpdir, read_asset): - config_injector({ - 'data_dir': str(tmpdir.mkdir('data')), - 'data_url': 'example.com', - 'delete_source_files': False, - 'thumbnails': { - 'post_width': 300, - 'post_height': 300, - }, - 'secret': 'test', - }) + post_factory, config_injector, tmpdir, read_asset +): + config_injector( + { + "data_dir": str(tmpdir.mkdir("data")), + "data_url": "example.com", + "delete_source_files": False, + "thumbnails": { + "post_width": 300, + "post_height": 300, + }, + "secret": "test", + } + ) source_post = post_factory(id=1) target_post = post_factory(id=2) - content = read_asset('png.png') + content = read_asset("png.png") db.session.add_all([source_post, target_post]) db.session.commit() posts.update_post_content(source_post, content) db.session.flush() - source_path = ( - os.path.join('{}/data/posts/1_244c8840887984c4.png'.format(tmpdir))) - target_path1 = ( - os.path.join('{}/data/posts/2_49caeb3ec1643406.png'.format(tmpdir))) - target_path2 = ( - os.path.join('{}/data/posts/2_49caeb3ec1643406.dat'.format(tmpdir))) + source_path = os.path.join( + "{}/data/posts/1_244c8840887984c4.png".format(tmpdir) + ) + target_path1 = os.path.join( + "{}/data/posts/2_49caeb3ec1643406.png".format(tmpdir) + ) + target_path2 = os.path.join( + "{}/data/posts/2_49caeb3ec1643406.dat".format(tmpdir) + ) assert os.path.exists(source_path) assert not os.path.exists(target_path1) assert not os.path.exists(target_path2) @@ -995,3 +1157,19 @@ def test_merge_posts_replaces_content( assert os.path.exists(source_path) assert os.path.exists(target_path1) assert not os.path.exists(target_path2) + + +def test_search_by_image(post_factory, config_injector, read_asset): + config_injector({"allow_broken_uploads": False}) + post = post_factory() + posts.generate_post_signature(post, read_asset("jpeg.jpg")) + db.session.flush() + + result1 = posts.search_by_image(read_asset("jpeg-similar.jpg")) + assert len(result1) == 1 + result1_distance, result1_post = result1[0] + assert abs(result1_distance - 0.19713075553164386) < 1e-8 + assert result1_post.post_id == post.post_id + + result2 = posts.search_by_image(read_asset("png.png")) + assert not result2 diff --git a/server/szurubooru/tests/func/test_snapshots.py b/server/szurubooru/tests/func/test_snapshots.py index 0949199..da93530 100644 --- a/server/szurubooru/tests/func/test_snapshots.py +++ b/server/szurubooru/tests/func/test_snapshots.py @@ -1,55 +1,57 @@ -from unittest.mock import patch from datetime import datetime +from unittest.mock import patch + import pytest + from szurubooru import db, model from szurubooru.func import snapshots, users def test_get_tag_category_snapshot(tag_category_factory): - category = tag_category_factory(name='name', color='color') + category = tag_category_factory(name="name", color="color") assert snapshots.get_tag_category_snapshot(category) == { - 'name': 'name', - 'color': 'color', - 'default': False, + "name": "name", + "color": "color", + "default": False, } category.default = True assert snapshots.get_tag_category_snapshot(category) == { - 'name': 'name', - 'color': 'color', - 'default': True, + "name": "name", + "color": "color", + "default": True, } def test_get_tag_snapshot(tag_factory, tag_category_factory): - category = tag_category_factory(name='dummy') - tag = tag_factory(names=['main_name', 'alias'], category=category) + category = tag_category_factory(name="dummy") + tag = tag_factory(names=["main_name", "alias"], category=category) assert snapshots.get_tag_snapshot(tag) == { - 'names': ['main_name', 'alias'], - 'category': 'dummy', - 'suggestions': [], - 'implications': [], + "names": ["main_name", "alias"], + "category": "dummy", + "suggestions": [], + "implications": [], } - tag = tag_factory(names=['main_name', 'alias'], category=category) - imp1 = tag_factory(names=['imp1_main_name', 'imp1_alias']) - imp2 = tag_factory(names=['imp2_main_name', 'imp2_alias']) - sug1 = tag_factory(names=['sug1_main_name', 'sug1_alias']) - sug2 = tag_factory(names=['sug2_main_name', 'sug2_alias']) + tag = tag_factory(names=["main_name", "alias"], category=category) + imp1 = tag_factory(names=["imp1_main_name", "imp1_alias"]) + imp2 = tag_factory(names=["imp2_main_name", "imp2_alias"]) + sug1 = tag_factory(names=["sug1_main_name", "sug1_alias"]) + sug2 = tag_factory(names=["sug2_main_name", "sug2_alias"]) db.session.add_all([imp1, imp2, sug1, sug2]) tag.implications = [imp1, imp2] tag.suggestions = [sug1, sug2] db.session.flush() assert snapshots.get_tag_snapshot(tag) == { - 'names': ['main_name', 'alias'], - 'category': 'dummy', - 'implications': ['imp1_main_name', 'imp2_main_name'], - 'suggestions': ['sug1_main_name', 'sug2_main_name'], + "names": ["main_name", "alias"], + "category": "dummy", + "implications": ["imp1_main_name", "imp2_main_name"], + "suggestions": ["sug1_main_name", "sug2_main_name"], } def test_get_post_snapshot(post_factory, user_factory, tag_factory): - user = user_factory(name='dummy-user') - tag1 = tag_factory(names=['dummy-tag1']) - tag2 = tag_factory(names=['dummy-tag2']) + user = user_factory(name="dummy-user") + tag1 = tag_factory(names=["dummy-tag1"]) + tag2 = tag_factory(names=["dummy-tag2"]) post = post_factory(id=1) related_post1 = post_factory(id=2) related_post2 = post_factory(id=3) @@ -72,13 +74,13 @@ def test_get_post_snapshot(post_factory, user_factory, tag_factory): note = model.PostNote() note.post = post note.polygon = [(1, 1), (200, 1), (200, 200), (1, 200)] - note.text = 'some text' + note.text = "some text" db.session.add_all([score]) db.session.flush() post.user = user - post.checksum = 'deadbeef' - post.source = 'example.com' + post.checksum = "deadbeef" + post.source = "example.com" post.tags.append(tag1) post.tags.append(tag2) post.relations.append(related_post1) @@ -89,17 +91,19 @@ def test_get_post_snapshot(post_factory, user_factory, tag_factory): post.notes.append(note) assert snapshots.get_post_snapshot(post) == { - 'checksum': 'deadbeef', - 'featured': True, - 'flags': [], - 'notes': [{ - 'polygon': [[1, 1], [200, 1], [200, 200], [1, 200]], - 'text': 'some text', - }], - 'relations': [2, 3], - 'safety': 'safe', - 'source': 'example.com', - 'tags': ['dummy-tag1', 'dummy-tag2'], + "checksum": "deadbeef", + "featured": True, + "flags": [], + "notes": [ + { + "polygon": [[1, 1], [200, 1], [200, 200], [1, 200]], + "text": "some text", + } + ], + "relations": [2, 3], + "safety": "safe", + "source": "example.com", + "tags": ["dummy-tag1", "dummy-tag2"], } @@ -107,75 +111,81 @@ def test_serialize_snapshot(user_factory): auth_user = user_factory() snapshot = model.Snapshot() snapshot.operation = snapshot.OPERATION_CREATED - snapshot.resource_type = 'type' - snapshot.resource_name = 'id' - snapshot.user = user_factory(name='issuer') - snapshot.data = {'complex': list('object')} + snapshot.resource_type = "type" + snapshot.resource_name = "id" + snapshot.user = user_factory(name="issuer") + snapshot.data = {"complex": list("object")} snapshot.creation_time = datetime(1997, 1, 1) - with patch('szurubooru.func.users.serialize_micro_user'): - users.serialize_micro_user.return_value = 'mocked' + with patch("szurubooru.func.users.serialize_micro_user"): + users.serialize_micro_user.return_value = "mocked" assert snapshots.serialize_snapshot(snapshot, auth_user) == { - 'operation': 'created', - 'type': 'type', - 'id': 'id', - 'user': 'mocked', - 'data': {'complex': list('object')}, - 'time': datetime(1997, 1, 1), + "operation": "created", + "type": "type", + "id": "id", + "user": "mocked", + "data": {"complex": list("object")}, + "time": datetime(1997, 1, 1), } def test_create(tag_factory, user_factory): - tag = tag_factory(names=['dummy']) + tag = tag_factory(names=["dummy"]) db.session.add(tag) db.session.flush() - with patch('szurubooru.func.snapshots.get_tag_snapshot'): - snapshots.get_tag_snapshot.return_value = 'mocked' + with patch("szurubooru.func.snapshots.get_tag_snapshot"), patch( + "szurubooru.func.snapshots._post_to_webhooks" + ): + snapshots.get_tag_snapshot.return_value = "mocked" snapshots.create(tag, user_factory()) db.session.flush() results = db.session.query(model.Snapshot).all() assert len(results) == 1 assert results[0].operation == model.Snapshot.OPERATION_CREATED - assert results[0].data == 'mocked' + assert results[0].data == "mocked" def test_modify_saves_non_empty_diffs(post_factory, user_factory): - if 'sqlite' in db.sessionmaker.kw['bind'].driver: + if "sqlite" in db.session.get_bind().driver: pytest.xfail( - 'SQLite doesn\'t support transaction isolation, ' - 'which is required to retrieve original entity') + "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')] + 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() - snapshots.modify(post, user) + post.source = "new source" + post.notes = [model.PostNote(polygon=[(0, 0), (0, 1), (1, 1)], text="new")] 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'}], + 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']) + tag = tag_factory(names=["dummy"]) user = user_factory() db.session.add_all([tag, user]) db.session.commit() @@ -185,26 +195,29 @@ def test_modify_doesnt_save_empty_diffs(tag_factory, user_factory): def test_delete(tag_factory, user_factory): - tag = tag_factory(names=['dummy']) + tag = tag_factory(names=["dummy"]) db.session.add(tag) db.session.flush() - with patch('szurubooru.func.snapshots.get_tag_snapshot'): - snapshots.get_tag_snapshot.return_value = 'mocked' + with patch("szurubooru.func.snapshots.get_tag_snapshot"), patch( + "szurubooru.func.snapshots._post_to_webhooks" + ): + snapshots.get_tag_snapshot.return_value = "mocked" snapshots.delete(tag, user_factory()) db.session.flush() results = db.session.query(model.Snapshot).all() assert len(results) == 1 assert results[0].operation == model.Snapshot.OPERATION_DELETED - assert results[0].data == 'mocked' + assert results[0].data == "mocked" def test_merge(tag_factory, user_factory): - source_tag = tag_factory(names=['source']) - target_tag = tag_factory(names=['target']) + source_tag = tag_factory(names=["source"]) + target_tag = tag_factory(names=["target"]) db.session.add_all([source_tag, target_tag]) db.session.flush() - snapshots.merge(source_tag, target_tag, user_factory()) - db.session.flush() - result = db.session.query(model.Snapshot).one() - assert result.operation == model.Snapshot.OPERATION_MERGED - assert result.data == ['tag', 'target'] + with patch("szurubooru.func.snapshots._post_to_webhooks"): + snapshots.merge(source_tag, target_tag, user_factory()) + db.session.flush() + result = db.session.query(model.Snapshot).one() + assert result.operation == model.Snapshot.OPERATION_MERGED + assert result.data == ["tag", "target"] diff --git a/server/szurubooru/tests/func/test_tag_categories.py b/server/szurubooru/tests/func/test_tag_categories.py index d1e5570..11300cf 100644 --- a/server/szurubooru/tests/func/test_tag_categories.py +++ b/server/szurubooru/tests/func/test_tag_categories.py @@ -1,7 +1,9 @@ from unittest.mock import patch + import pytest + from szurubooru import db, model -from szurubooru.func import tag_categories, cache +from szurubooru.func import cache, tag_categories @pytest.fixture(autouse=True) @@ -14,7 +16,7 @@ def test_serialize_category_when_empty(): def test_serialize_category(tag_category_factory, tag_factory): - category = tag_category_factory(name='name', color='color') + category = tag_category_factory(name="name", color="color") category.category_id = 1 category.default = True tag1 = tag_factory(category=category) @@ -23,36 +25,49 @@ def test_serialize_category(tag_category_factory, tag_factory): db.session.flush() result = tag_categories.serialize_category(category) assert result == { - 'name': 'name', - 'color': 'color', - 'default': True, - 'version': 1, - 'usages': 2, + "name": "name", + "color": "color", + "default": True, + "version": 1, + "order": 1, + "usages": 2, } def test_create_category_when_first(): - with patch('szurubooru.func.tag_categories.update_category_name'), \ - patch('szurubooru.func.tag_categories.update_category_color'): - category = tag_categories.create_category('name', 'color') + with patch("szurubooru.func.tag_categories.update_category_name"), patch( + "szurubooru.func.tag_categories.update_category_color" + ), patch("szurubooru.func.tag_categories.update_category_order"): + category = tag_categories.create_category("name", "color", 7) assert category.default - tag_categories.update_category_name \ - .assert_called_once_with(category, 'name') - tag_categories.update_category_color \ - .assert_called_once_with(category, 'color') + tag_categories.update_category_name.assert_called_once_with( + category, "name" + ) + tag_categories.update_category_color.assert_called_once_with( + category, "color" + ) + tag_categories.update_category_order.assert_called_once_with( + category, 7 + ) def test_create_category_when_subsequent(tag_category_factory): db.session.add(tag_category_factory()) db.session.flush() - with patch('szurubooru.func.tag_categories.update_category_name'), \ - patch('szurubooru.func.tag_categories.update_category_color'): - category = tag_categories.create_category('name', 'color') + with patch("szurubooru.func.tag_categories.update_category_name"), patch( + "szurubooru.func.tag_categories.update_category_color" + ), patch("szurubooru.func.tag_categories.update_category_order"): + category = tag_categories.create_category("name", "color", 7) assert not category.default - tag_categories.update_category_name \ - .assert_called_once_with(category, 'name') - tag_categories.update_category_color \ - .assert_called_once_with(category, 'color') + tag_categories.update_category_name.assert_called_once_with( + category, "name" + ) + tag_categories.update_category_color.assert_called_once_with( + category, "color" + ) + tag_categories.update_category_order.assert_called_once_with( + category, 7 + ) def test_update_category_name_with_empty_string(tag_category_factory): @@ -62,38 +77,42 @@ def test_update_category_name_with_empty_string(tag_category_factory): def test_update_category_name_with_invalid_name( - config_injector, tag_category_factory): - config_injector({'tag_category_name_regex': '^[a-z]+$'}) + config_injector, tag_category_factory +): + config_injector({"tag_category_name_regex": "^[a-z]+$"}) category = tag_category_factory() with pytest.raises(tag_categories.InvalidTagCategoryNameError): - tag_categories.update_category_name(category, '0') + tag_categories.update_category_name(category, "0") def test_update_category_name_with_too_long_string( - config_injector, tag_category_factory): - config_injector({'tag_category_name_regex': '^[a-z]+$'}) + config_injector, tag_category_factory +): + config_injector({"tag_category_name_regex": "^[a-z]+$"}) category = tag_category_factory() with pytest.raises(tag_categories.InvalidTagCategoryNameError): - tag_categories.update_category_name(category, 'a' * 3000) + tag_categories.update_category_name(category, "a" * 3000) def test_update_category_name_reusing_other_name( - config_injector, tag_category_factory): - config_injector({'tag_category_name_regex': '.*'}) - db.session.add(tag_category_factory(name='name')) + config_injector, tag_category_factory +): + config_injector({"tag_category_name_regex": ".*"}) + db.session.add(tag_category_factory(name="name")) db.session.flush() category = tag_category_factory() with pytest.raises(tag_categories.TagCategoryAlreadyExistsError): - tag_categories.update_category_name(category, 'name') + tag_categories.update_category_name(category, "name") with pytest.raises(tag_categories.TagCategoryAlreadyExistsError): - tag_categories.update_category_name(category, 'NAME') + tag_categories.update_category_name(category, "NAME") def test_update_category_name_reusing_own_name( - config_injector, tag_category_factory): - config_injector({'tag_category_name_regex': '.*'}) - for name in ['name', 'NAME']: - category = tag_category_factory(name='name') + config_injector, tag_category_factory +): + 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) @@ -110,16 +129,16 @@ def test_update_category_color_with_empty_string(tag_category_factory): def test_update_category_color_with_too_long_string(tag_category_factory): category = tag_category_factory() with pytest.raises(tag_categories.InvalidTagCategoryColorError): - tag_categories.update_category_color(category, 'a' * 3000) + tag_categories.update_category_color(category, "a" * 3000) def test_update_category_color_with_invalid_string(tag_category_factory): category = tag_category_factory() with pytest.raises(tag_categories.InvalidTagCategoryColorError): - tag_categories.update_category_color(category, 'NOPE') + tag_categories.update_category_color(category, "NOPE") -@pytest.mark.parametrize('attempt', ['#aaaaaa', '#012345', '012345', 'red']) +@pytest.mark.parametrize("attempt", ["#aaaaaa", "#012345", "012345", "red"]) def test_update_category_color(attempt, tag_category_factory): category = tag_category_factory() tag_categories.update_category_color(category, attempt) @@ -127,36 +146,36 @@ def test_update_category_color(attempt, tag_category_factory): def test_try_get_category_by_name(tag_category_factory): - category = tag_category_factory(name='test') + category = tag_category_factory(name="test") db.session.add(category) db.session.flush() - assert tag_categories.try_get_category_by_name('test') == category - assert tag_categories.try_get_category_by_name('TEST') == category - assert tag_categories.try_get_category_by_name('-') is None + assert tag_categories.try_get_category_by_name("test") == category + assert tag_categories.try_get_category_by_name("TEST") == category + assert tag_categories.try_get_category_by_name("-") is None def test_get_category_by_name(tag_category_factory): - category = tag_category_factory(name='test') + category = tag_category_factory(name="test") db.session.add(category) db.session.flush() - assert tag_categories.get_category_by_name('test') == category - assert tag_categories.get_category_by_name('TEST') == category + assert tag_categories.get_category_by_name("test") == category + assert tag_categories.get_category_by_name("TEST") == category with pytest.raises(tag_categories.TagCategoryNotFoundError): - tag_categories.get_category_by_name('-') + tag_categories.get_category_by_name("-") def test_get_all_category_names(tag_category_factory): - category1 = tag_category_factory(name='cat1') - category2 = tag_category_factory(name='cat2') - db.session.add_all([category1, category2]) + category1 = tag_category_factory(name="cat1") + category2 = tag_category_factory(name="cat2") + db.session.add_all([category2, category1]) db.session.flush() - assert tag_categories.get_all_category_names() == ['cat1', 'cat2'] + assert tag_categories.get_all_category_names() == ["cat1", "cat2"] def test_get_all_categories(tag_category_factory): - category1 = tag_category_factory(name='cat1') - category2 = tag_category_factory(name='cat2') - db.session.add_all([category1, category2]) + category1 = tag_category_factory(name="cat1") + category2 = tag_category_factory(name="cat2") + db.session.add_all([category2, category1]) db.session.flush() assert tag_categories.get_all_categories() == [category1, category2] @@ -211,12 +230,12 @@ def test_get_default_category_name_caching(tag_category_factory): def test_get_default_category(): - with patch('szurubooru.func.tag_categories.try_get_default_category'): + with patch("szurubooru.func.tag_categories.try_get_default_category"): tag_categories.try_get_default_category.return_value = None with pytest.raises(tag_categories.TagCategoryNotFoundError): tag_categories.get_default_category() - tag_categories.try_get_default_category.return_value = 'mocked' - assert tag_categories.get_default_category() == 'mocked' + tag_categories.try_get_default_category.return_value = "mocked" + assert tag_categories.get_default_category() == "mocked" def test_set_default_category_with_previous_default(tag_category_factory): @@ -257,9 +276,9 @@ def test_delete_category_with_usages(tag_category_factory, tag_factory): def test_delete_category(tag_category_factory): db.session.add(tag_category_factory()) - category = tag_category_factory(name='target') + category = tag_category_factory(name="target") db.session.add(category) db.session.flush() tag_categories.delete_category(category) db.session.flush() - assert tag_categories.try_get_category_by_name('target') is None + assert tag_categories.try_get_category_by_name("target") is None diff --git a/server/szurubooru/tests/func/test_tags.py b/server/szurubooru/tests/func/test_tags.py index 1ecc142..c938e68 100644 --- a/server/szurubooru/tests/func/test_tags.py +++ b/server/szurubooru/tests/func/test_tags.py @@ -1,10 +1,12 @@ -import os import json -from unittest.mock import patch +import os from datetime import datetime +from unittest.mock import patch + import pytest + from szurubooru import db, model -from szurubooru.func import tags, tag_categories, cache +from szurubooru.func import cache, tag_categories, tags @pytest.fixture(autouse=True) @@ -14,18 +16,35 @@ def purge_cache(): def _assert_tag_siblings(result, expected_names_and_occurrences): actual_names_and_occurences = [ - (tag.names[0].name, occurrences) for tag, occurrences in result] + (tag.names[0].name, occurrences) for tag, occurrences in result + ] assert actual_names_and_occurences == expected_names_and_occurrences -@pytest.mark.parametrize('input,expected_tag_names', [ - ([('a', 'a', True), ('b', 'b', False), ('c', 'c', False)], list('bca')), - ([('c', 'a', True), ('b', 'b', False), ('a', 'c', False)], list('bac')), - ([('a', 'c', True), ('b', 'b', False), ('c', 'a', False)], list('cba')), - ([('a', 'c', False), ('b', 'b', False), ('c', 'a', True)], list('bac')), -]) +@pytest.mark.parametrize( + "input,expected_tag_names", + [ + ( + [("a", "a", True), ("b", "b", False), ("c", "c", False)], + list("bca"), + ), + ( + [("c", "a", True), ("b", "b", False), ("a", "c", False)], + list("bac"), + ), + ( + [("a", "c", True), ("b", "b", False), ("c", "a", False)], + list("cba"), + ), + ( + [("a", "c", False), ("b", "b", False), ("c", "a", True)], + list("bac"), + ), + ], +) def test_sort_tags( - input, expected_tag_names, tag_factory, tag_category_factory): + input, expected_tag_names, tag_factory, tag_category_factory +): db_tags = [] for tag in input: tag_name, category_name, category_is_default = tag @@ -33,7 +52,10 @@ def test_sort_tags( tag_factory( names=[tag_name], category=tag_category_factory( - name=category_name, default=category_is_default))) + name=category_name, default=category_is_default + ), + ) + ) db.session.add_all(db_tags) db.session.flush() actual_tag_names = [tag.names[0].name for tag in tags.sort_tags(db_tags)] @@ -45,63 +67,73 @@ def test_serialize_tag_when_empty(): def test_serialize_tag( - post_factory, tag_factory, tag_category_factory, metric_factory): - cat = tag_category_factory(name='cat') - tag = tag_factory(names=['tag1', 'tag2'], category=cat) - tag.tag_id = 1 - tag.description = 'description' + post_factory, + tag_factory, + tag_category_factory, + metric_factory, +): + cat = tag_category_factory(name="cat") + tag = tag_factory(names=["tag1", "tag2"], category=cat) + # tag.tag_id = 1 + tag.description = "description" tag.suggestions = [ - tag_factory(names=['sug1'], category=cat), - tag_factory(names=['sug2'], category=cat), + tag_factory(names=["sug1"], category=cat), + tag_factory(names=["sug2"], category=cat), ] tag.implications = [ - tag_factory(names=['impl1'], category=cat), - tag_factory(names=['impl2'], category=cat), + tag_factory(names=["impl1"], category=cat), + tag_factory(names=["impl2"], category=cat), ] tag.last_edit_time = datetime(1998, 1, 1) + tag.metric = metric_factory(tag, min=1.5, max=10) + post1 = post_factory() - post2 = post_factory() post1.tags = [tag] + post2 = post_factory() post2.tags = [tag] db.session.add_all([tag, post1, post2]) db.session.flush() + result = tags.serialize_tag(tag) - result['suggestions'].sort(key=lambda relation: relation['names'][0]) - result['implications'].sort(key=lambda relation: relation['names'][0]) + result["suggestions"].sort(key=lambda relation: relation["names"][0]) + result["implications"].sort(key=lambda relation: relation["names"][0]) assert result == { - 'names': ['tag1', 'tag2'], - 'version': 1, - 'category': 'cat', - 'creationTime': datetime(1996, 1, 1, 0, 0), - 'lastEditTime': datetime(1998, 1, 1, 0, 0), - 'description': 'description', - 'suggestions': [ - {'names': ['sug1'], 'category': 'cat', 'usages': 0}, - {'names': ['sug2'], 'category': 'cat', 'usages': 0}, + "names": ["tag1", "tag2"], + "version": 1, + "category": "cat", + "creationTime": datetime(1996, 1, 1, 0, 0), + "lastEditTime": datetime(1998, 1, 1, 0, 0), + "description": "description", + "suggestions": [ + {"names": ["sug1"], "category": "cat", "usages": 0}, + {"names": ["sug2"], "category": "cat", "usages": 0}, ], - 'implications': [ - {'names': ['impl1'], 'category': 'cat', 'usages': 0}, - {'names': ['impl2'], 'category': 'cat', 'usages': 0}, + "implications": [ + {"names": ["impl1"], "category": "cat", "usages": 0}, + {"names": ["impl2"], "category": "cat", "usages": 0}, ], - 'metric': { - 'version': 1, - 'min': 1.5, - 'max': 10 + "metric": { + "version": 1, + "min": 1.5, + "max": 10 }, - 'usages': 2, + "usages": 2, } -@pytest.mark.parametrize('name_to_search,expected_to_find', [ - ('name', True), - ('NAME', True), - ('alias', True), - ('ALIAS', True), - ('-', False), -]) +@pytest.mark.parametrize( + "name_to_search,expected_to_find", + [ + ("name", True), + ("NAME", True), + ("alias", True), + ("ALIAS", True), + ("-", False), + ], +) def test_try_get_tag_by_name(name_to_search, expected_to_find, tag_factory): - tag = tag_factory(names=['name', 'ALIAS']) + tag = tag_factory(names=["name", "ALIAS"]) db.session.add(tag) db.session.flush() if expected_to_find: @@ -110,15 +142,18 @@ def test_try_get_tag_by_name(name_to_search, expected_to_find, tag_factory): assert tags.try_get_tag_by_name(name_to_search) is None -@pytest.mark.parametrize('name_to_search,expected_to_find', [ - ('name', True), - ('NAME', True), - ('alias', True), - ('ALIAS', True), - ('-', False), -]) +@pytest.mark.parametrize( + "name_to_search,expected_to_find", + [ + ("name", True), + ("NAME", True), + ("alias", True), + ("ALIAS", True), + ("-", False), + ], +) def test_get_tag_by_name(name_to_search, expected_to_find, tag_factory): - tag = tag_factory(names=['name', 'ALIAS']) + tag = tag_factory(names=["name", "ALIAS"]) db.session.add(tag) db.session.flush() if expected_to_find: @@ -128,25 +163,28 @@ def test_get_tag_by_name(name_to_search, expected_to_find, tag_factory): tags.get_tag_by_name(name_to_search) -@pytest.mark.parametrize('names,expected_indexes', [ - ([], []), - (['name1'], [0]), - (['NAME1'], [0]), - (['alias1'], [0]), - (['ALIAS1'], [0]), - (['name2'], [1]), - (['name1', 'name1'], [0]), - (['name1', 'NAME1'], [0]), - (['name1', 'alias1'], [0]), - (['name1', 'alias2'], [0, 1]), - (['NAME1', 'alias2'], [0, 1]), - (['name1', 'ALIAS2'], [0, 1]), - (['name2', 'alias1'], [0, 1]), -]) +@pytest.mark.parametrize( + "names,expected_indexes", + [ + ([], []), + (["name1"], [0]), + (["NAME1"], [0]), + (["alias1"], [0]), + (["ALIAS1"], [0]), + (["name2"], [1]), + (["name1", "name1"], [0]), + (["name1", "NAME1"], [0]), + (["name1", "alias1"], [0]), + (["name1", "alias2"], [0, 1]), + (["NAME1", "alias2"], [0, 1]), + (["name1", "ALIAS2"], [0, 1]), + (["name2", "alias1"], [0, 1]), + ], +) def test_get_tag_by_names(names, expected_indexes, tag_factory): input_tags = [ - tag_factory(names=['name1', 'ALIAS1']), - tag_factory(names=['name2', 'ALIAS2']), + tag_factory(names=["name1", "ALIAS1"]), + tag_factory(names=["name2", "ALIAS2"]), ] db.session.add_all(input_tags) db.session.flush() @@ -156,49 +194,52 @@ def test_get_tag_by_names(names, expected_indexes, tag_factory): @pytest.mark.parametrize( - 'names,expected_indexes,expected_created_names', [ + "names,expected_indexes,expected_created_names", + [ ([], [], []), - (['name1'], [0], []), - (['NAME1'], [0], []), - (['alias1'], [0], []), - (['ALIAS1'], [0], []), - (['name2'], [1], []), - (['name1', 'name1'], [0], []), - (['name1', 'NAME1'], [0], []), - (['name1', 'alias1'], [0], []), - (['name1', 'alias2'], [0, 1], []), - (['NAME1', 'alias2'], [0, 1], []), - (['name1', 'ALIAS2'], [0, 1], []), - (['name2', 'alias1'], [0, 1], []), - (['new'], [], ['new']), - (['new', 'name1'], [0], ['new']), - (['new', 'NAME1'], [0], ['new']), - (['new', 'alias1'], [0], ['new']), - (['new', 'ALIAS1'], [0], ['new']), - (['new', 'name2'], [1], ['new']), - (['new', 'name1', 'name1'], [0], ['new']), - (['new', 'name1', 'NAME1'], [0], ['new']), - (['new', 'name1', 'alias1'], [0], ['new']), - (['new', 'name1', 'alias2'], [0, 1], ['new']), - (['new', 'NAME1', 'alias2'], [0, 1], ['new']), - (['new', 'name1', 'ALIAS2'], [0, 1], ['new']), - (['new', 'name2', 'alias1'], [0, 1], ['new']), - (['new', 'new'], [], ['new']), - (['new', 'NEW'], [], ['new']), - (['new', 'new2'], [], ['new', 'new2']), - ]) + (["name1"], [0], []), + (["NAME1"], [0], []), + (["alias1"], [0], []), + (["ALIAS1"], [0], []), + (["name2"], [1], []), + (["name1", "name1"], [0], []), + (["name1", "NAME1"], [0], []), + (["name1", "alias1"], [0], []), + (["name1", "alias2"], [0, 1], []), + (["NAME1", "alias2"], [0, 1], []), + (["name1", "ALIAS2"], [0, 1], []), + (["name2", "alias1"], [0, 1], []), + (["new"], [], ["new"]), + (["new", "name1"], [0], ["new"]), + (["new", "NAME1"], [0], ["new"]), + (["new", "alias1"], [0], ["new"]), + (["new", "ALIAS1"], [0], ["new"]), + (["new", "name2"], [1], ["new"]), + (["new", "name1", "name1"], [0], ["new"]), + (["new", "name1", "NAME1"], [0], ["new"]), + (["new", "name1", "alias1"], [0], ["new"]), + (["new", "name1", "alias2"], [0, 1], ["new"]), + (["new", "NAME1", "alias2"], [0, 1], ["new"]), + (["new", "name1", "ALIAS2"], [0, 1], ["new"]), + (["new", "name2", "alias1"], [0, 1], ["new"]), + (["new", "new"], [], ["new"]), + (["new", "NEW"], [], ["new"]), + (["new", "new2"], [], ["new", "new2"]), + ], +) def test_get_or_create_tags_by_names( - names, - expected_indexes, - expected_created_names, - tag_factory, - tag_category_factory, - config_injector): - config_injector({'tag_name_regex': '.*'}) + names, + expected_indexes, + expected_created_names, + tag_factory, + tag_category_factory, + config_injector, +): + config_injector({"tag_name_regex": ".*"}) category = tag_category_factory() input_tags = [ - tag_factory(names=['name1', 'ALIAS1'], category=category), - tag_factory(names=['name2', 'ALIAS2'], category=category), + tag_factory(names=["name1", "ALIAS1"], category=category), + tag_factory(names=["name2", "ALIAS2"], category=category), ] db.session.add_all(input_tags) db.session.flush() @@ -211,14 +252,14 @@ def test_get_or_create_tags_by_names( def test_get_tag_siblings_for_unused(tag_factory): - tag = tag_factory(names=['tag']) + tag = tag_factory(names=["tag"]) db.session.add(tag) db.session.flush() _assert_tag_siblings(tags.get_tag_siblings(tag), []) def test_get_tag_siblings_for_used_alone(tag_factory, post_factory): - tag = tag_factory(names=['tag']) + tag = tag_factory(names=["tag"]) post = post_factory() post.tags = [tag] db.session.add_all([post, tag]) @@ -227,20 +268,20 @@ def test_get_tag_siblings_for_used_alone(tag_factory, post_factory): def test_get_tag_siblings_for_used_with_others(tag_factory, post_factory): - tag1 = tag_factory(names=['t1']) - tag2 = tag_factory(names=['t2']) + tag1 = tag_factory(names=["t1"]) + tag2 = tag_factory(names=["t2"]) post = post_factory() post.tags = [tag1, tag2] db.session.add_all([post, tag1, tag2]) db.session.flush() - _assert_tag_siblings(tags.get_tag_siblings(tag1), [('t2', 1)]) - _assert_tag_siblings(tags.get_tag_siblings(tag2), [('t1', 1)]) + _assert_tag_siblings(tags.get_tag_siblings(tag1), [("t2", 1)]) + _assert_tag_siblings(tags.get_tag_siblings(tag2), [("t1", 1)]) def test_get_tag_siblings_used_for_multiple_others(tag_factory, post_factory): - tag1 = tag_factory(names=['t1']) - tag2 = tag_factory(names=['t2']) - tag3 = tag_factory(names=['t3']) + tag1 = tag_factory(names=["t1"]) + tag2 = tag_factory(names=["t2"]) + tag3 = tag_factory(names=["t3"]) post1 = post_factory() post2 = post_factory() post3 = post_factory() @@ -251,16 +292,16 @@ def test_get_tag_siblings_used_for_multiple_others(tag_factory, post_factory): post4.tags = [tag2] db.session.add_all([post1, post2, post3, post4, tag1, tag2, tag3]) db.session.flush() - _assert_tag_siblings(tags.get_tag_siblings(tag1), [('t3', 2), ('t2', 1)]) - _assert_tag_siblings(tags.get_tag_siblings(tag2), [('t1', 1), ('t3', 1)]) + _assert_tag_siblings(tags.get_tag_siblings(tag1), [("t3", 2), ("t2", 1)]) + _assert_tag_siblings(tags.get_tag_siblings(tag2), [("t1", 1), ("t3", 1)]) # even though tag2 is used more widely, tag1 is more relevant to tag3 - _assert_tag_siblings(tags.get_tag_siblings(tag3), [('t1', 2), ('t2', 1)]) + _assert_tag_siblings(tags.get_tag_siblings(tag3), [("t1", 2), ("t2", 1)]) def test_delete(tag_factory): - tag = tag_factory(names=['tag']) - tag.suggestions = [tag_factory(names=['sug'])] - tag.implications = [tag_factory(names=['imp'])] + tag = tag_factory(names=["tag"]) + tag.suggestions = [tag_factory(names=["sug"])] + tag.implications = [tag_factory(names=["imp"])] db.session.add(tag) db.session.flush() assert db.session.query(model.Tag).count() == 3 @@ -270,19 +311,19 @@ def test_delete(tag_factory): def test_merge_tags_deletes_source_tag(tag_factory): - source_tag = tag_factory(names=['source']) - target_tag = tag_factory(names=['target']) + source_tag = tag_factory(names=["source"]) + target_tag = tag_factory(names=["target"]) db.session.add_all([source_tag, target_tag]) db.session.flush() tags.merge_tags(source_tag, target_tag) db.session.flush() - assert tags.try_get_tag_by_name('source') is None - tag = tags.get_tag_by_name('target') + assert tags.try_get_tag_by_name("source") is None + tag = tags.get_tag_by_name("target") assert tag is not None def test_merge_tags_with_itself(tag_factory): - source_tag = tag_factory(names=['source']) + source_tag = tag_factory(names=["source"]) db.session.add(source_tag) db.session.flush() with pytest.raises(tags.InvalidTagRelationError): @@ -306,8 +347,8 @@ def test_merge_tags_with_metrics(tag_factory, metric_factory): def test_merge_tags_moves_usages(tag_factory, post_factory): - source_tag = tag_factory(names=['source']) - target_tag = tag_factory(names=['target']) + source_tag = tag_factory(names=["source"]) + target_tag = tag_factory(names=["target"]) post = post_factory() post.tags = [source_tag] db.session.add_all([source_tag, target_tag, post]) @@ -316,13 +357,13 @@ def test_merge_tags_moves_usages(tag_factory, post_factory): assert target_tag.post_count == 0 tags.merge_tags(source_tag, target_tag) db.session.commit() - assert tags.try_get_tag_by_name('source') is None - assert tags.get_tag_by_name('target').post_count == 1 + assert tags.try_get_tag_by_name("source") is None + assert tags.get_tag_by_name("target").post_count == 1 def test_merge_tags_doesnt_duplicate_usages(tag_factory, post_factory): - source_tag = tag_factory(names=['source']) - target_tag = tag_factory(names=['target']) + source_tag = tag_factory(names=["source"]) + target_tag = tag_factory(names=["target"]) post = post_factory() post.tags = [source_tag, target_tag] db.session.add_all([source_tag, target_tag, post]) @@ -331,13 +372,13 @@ def test_merge_tags_doesnt_duplicate_usages(tag_factory, post_factory): assert target_tag.post_count == 1 tags.merge_tags(source_tag, target_tag) db.session.flush() - assert tags.try_get_tag_by_name('source') is None - assert tags.get_tag_by_name('target').post_count == 1 + assert tags.try_get_tag_by_name("source") is None + assert tags.get_tag_by_name("target").post_count == 1 def test_merge_tags_moves_child_relations(tag_factory): - source_tag = tag_factory(names=['source']) - target_tag = tag_factory(names=['target']) + source_tag = tag_factory(names=["source"]) + target_tag = tag_factory(names=["target"]) related_tag = tag_factory() source_tag.suggestions = [related_tag] source_tag.implications = [related_tag] @@ -349,14 +390,14 @@ def test_merge_tags_moves_child_relations(tag_factory): assert target_tag.implication_count == 0 tags.merge_tags(source_tag, target_tag) db.session.commit() - assert tags.try_get_tag_by_name('source') is None - assert tags.get_tag_by_name('target').suggestion_count == 1 - assert tags.get_tag_by_name('target').implication_count == 1 + assert tags.try_get_tag_by_name("source") is None + assert tags.get_tag_by_name("target").suggestion_count == 1 + assert tags.get_tag_by_name("target").implication_count == 1 def test_merge_tags_doesnt_duplicate_child_relations(tag_factory): - source_tag = tag_factory(names=['source']) - target_tag = tag_factory(names=['target']) + source_tag = tag_factory(names=["source"]) + target_tag = tag_factory(names=["target"]) related_tag = tag_factory() source_tag.suggestions = [related_tag] source_tag.implications = [related_tag] @@ -370,15 +411,15 @@ def test_merge_tags_doesnt_duplicate_child_relations(tag_factory): assert target_tag.implication_count == 1 tags.merge_tags(source_tag, target_tag) db.session.commit() - assert tags.try_get_tag_by_name('source') is None - assert tags.get_tag_by_name('target').suggestion_count == 1 - assert tags.get_tag_by_name('target').implication_count == 1 + assert tags.try_get_tag_by_name("source") is None + assert tags.get_tag_by_name("target").suggestion_count == 1 + assert tags.get_tag_by_name("target").implication_count == 1 def test_merge_tags_moves_parent_relations(tag_factory): - source_tag = tag_factory(names=['source']) - target_tag = tag_factory(names=['target']) - related_tag = tag_factory(names=['related']) + source_tag = tag_factory(names=["source"]) + target_tag = tag_factory(names=["target"]) + related_tag = tag_factory(names=["related"]) related_tag.suggestions = [related_tag] related_tag.implications = [related_tag] db.session.add_all([source_tag, target_tag, related_tag]) @@ -389,16 +430,16 @@ def test_merge_tags_moves_parent_relations(tag_factory): assert target_tag.implication_count == 0 tags.merge_tags(source_tag, target_tag) db.session.commit() - assert tags.try_get_tag_by_name('source') is None - assert tags.get_tag_by_name('related').suggestion_count == 1 - assert tags.get_tag_by_name('related').suggestion_count == 1 - assert tags.get_tag_by_name('target').suggestion_count == 0 - assert tags.get_tag_by_name('target').implication_count == 0 + assert tags.try_get_tag_by_name("source") is None + assert tags.get_tag_by_name("related").suggestion_count == 1 + assert tags.get_tag_by_name("related").suggestion_count == 1 + assert tags.get_tag_by_name("target").suggestion_count == 0 + assert tags.get_tag_by_name("target").implication_count == 0 def test_merge_tags_doesnt_create_relation_loop_for_children(tag_factory): - source_tag = tag_factory(names=['source']) - target_tag = tag_factory(names=['target']) + source_tag = tag_factory(names=["source"]) + target_tag = tag_factory(names=["target"]) source_tag.suggestions = [target_tag] source_tag.implications = [target_tag] db.session.add_all([source_tag, target_tag]) @@ -409,14 +450,14 @@ def test_merge_tags_doesnt_create_relation_loop_for_children(tag_factory): assert target_tag.implication_count == 0 tags.merge_tags(source_tag, target_tag) db.session.commit() - assert tags.try_get_tag_by_name('source') is None - assert tags.get_tag_by_name('target').suggestion_count == 0 - assert tags.get_tag_by_name('target').implication_count == 0 + assert tags.try_get_tag_by_name("source") is None + assert tags.get_tag_by_name("target").suggestion_count == 0 + assert tags.get_tag_by_name("target").implication_count == 0 def test_merge_tags_doesnt_create_relation_loop_for_parents(tag_factory): - source_tag = tag_factory(names=['source']) - target_tag = tag_factory(names=['target']) + source_tag = tag_factory(names=["source"]) + target_tag = tag_factory(names=["target"]) target_tag.suggestions = [source_tag] target_tag.implications = [source_tag] db.session.add_all([source_tag, target_tag]) @@ -427,33 +468,35 @@ def test_merge_tags_doesnt_create_relation_loop_for_parents(tag_factory): assert target_tag.implication_count == 1 tags.merge_tags(source_tag, target_tag) db.session.commit() - assert tags.try_get_tag_by_name('source') is None - assert tags.get_tag_by_name('target').suggestion_count == 0 - assert tags.get_tag_by_name('target').implication_count == 0 + assert tags.try_get_tag_by_name("source") is None + assert tags.get_tag_by_name("target").suggestion_count == 0 + assert tags.get_tag_by_name("target").implication_count == 0 def test_create_tag(fake_datetime): - with patch('szurubooru.func.tags.update_tag_names'), \ - patch('szurubooru.func.tags.update_tag_category_name'), \ - patch('szurubooru.func.tags.update_tag_suggestions'), \ - patch('szurubooru.func.tags.update_tag_implications'), \ - fake_datetime('1997-01-01'): - tag = tags.create_tag(['name'], 'cat', ['sug'], ['imp']) + with patch("szurubooru.func.tags.update_tag_names"), patch( + "szurubooru.func.tags.update_tag_category_name" + ), patch("szurubooru.func.tags.update_tag_suggestions"), patch( + "szurubooru.func.tags.update_tag_implications" + ), fake_datetime( + "1997-01-01" + ): + tag = tags.create_tag(["name"], "cat", ["sug"], ["imp"]) assert tag.creation_time == datetime(1997, 1, 1) assert tag.last_edit_time is None - tags.update_tag_names.assert_called_once_with(tag, ['name']) - tags.update_tag_category_name.assert_called_once_with(tag, 'cat') - tags.update_tag_suggestions.assert_called_once_with(tag, ['sug']) - tags.update_tag_implications.assert_called_once_with(tag, ['imp']) + tags.update_tag_names.assert_called_once_with(tag, ["name"]) + tags.update_tag_category_name.assert_called_once_with(tag, "cat") + tags.update_tag_suggestions.assert_called_once_with(tag, ["sug"]) + tags.update_tag_implications.assert_called_once_with(tag, ["imp"]) def test_update_tag_category_name(tag_factory): - with patch('szurubooru.func.tag_categories.get_category_by_name'): - tag_categories.get_category_by_name.return_value = 'mocked' + with patch("szurubooru.func.tag_categories.get_category_by_name"): + tag_categories.get_category_by_name.return_value = "mocked" tag = tag_factory() - tags.update_tag_category_name(tag, 'cat') - assert tag_categories.get_category_by_name.called_once_with('cat') - assert tag.category == 'mocked' + tags.update_tag_category_name(tag, "cat") + assert tag_categories.get_category_by_name.called_once_with("cat") + assert tag.category == "mocked" def test_update_tag_names_to_empty(tag_factory): @@ -463,44 +506,45 @@ def test_update_tag_names_to_empty(tag_factory): def test_update_tag_names_with_invalid_name(config_injector, tag_factory): - config_injector({'tag_name_regex': '^[a-z]*$'}) + config_injector({"tag_name_regex": "^[a-z]*$"}) tag = tag_factory() with pytest.raises(tags.InvalidTagNameError): - tags.update_tag_names(tag, ['0']) + tags.update_tag_names(tag, ["0"]) def test_update_tag_names_with_too_long_string(config_injector, tag_factory): - config_injector({'tag_name_regex': '^[a-z]*$'}) + config_injector({"tag_name_regex": "^[a-z]*$"}) tag = tag_factory() with pytest.raises(tags.InvalidTagNameError): - tags.update_tag_names(tag, ['a' * 300]) + tags.update_tag_names(tag, ["a" * 300]) def test_update_tag_names_with_duplicate_names(config_injector, tag_factory): - config_injector({'tag_name_regex': '^[a-z]*$'}) + config_injector({"tag_name_regex": "^[a-z]*$"}) tag = tag_factory() - tags.update_tag_names(tag, ['a', 'A']) - assert [tag_name.name for tag_name in tag.names] == ['a'] + tags.update_tag_names(tag, ["a", "A"]) + assert [tag_name.name for tag_name in tag.names] == ["a"] def test_update_tag_names_trying_to_use_taken_name( - config_injector, tag_factory): - config_injector({'tag_name_regex': '^[a-zA-Z]*$'}) - existing_tag = tag_factory(names=['a']) + config_injector, tag_factory +): + config_injector({"tag_name_regex": "^[a-zA-Z]*$"}) + existing_tag = tag_factory(names=["a"]) db.session.add(existing_tag) tag = tag_factory() db.session.add(tag) db.session.flush() with pytest.raises(tags.TagAlreadyExistsError): - tags.update_tag_names(tag, ['a']) + tags.update_tag_names(tag, ["a"]) with pytest.raises(tags.TagAlreadyExistsError): - tags.update_tag_names(tag, ['A']) + tags.update_tag_names(tag, ["A"]) def test_update_tag_names_reusing_own_name(config_injector, tag_factory): - config_injector({'tag_name_regex': '^[a-zA-Z]*$'}) - for name in list('aA'): - tag = tag_factory(names=['a']) + 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]) @@ -509,48 +553,48 @@ def test_update_tag_names_reusing_own_name(config_injector, tag_factory): def test_update_tag_names_changing_primary_name(config_injector, tag_factory): - config_injector({'tag_name_regex': '^[a-zA-Z]*$'}) - tag = tag_factory(names=['a', 'b']) + config_injector({"tag_name_regex": "^[a-zA-Z]*$"}) + tag = tag_factory(names=["a", "b"]) db.session.add(tag) db.session.flush() - tags.update_tag_names(tag, ['b', 'a']) + tags.update_tag_names(tag, ["b", "a"]) db.session.flush() db.session.refresh(tag) - assert [tag_name.name for tag_name in tag.names] == ['b', 'a'] + assert [tag_name.name for tag_name in tag.names] == ["b", "a"] db.session.rollback() -@pytest.mark.parametrize('attempt', ['name', 'NAME', 'alias', 'ALIAS']) +@pytest.mark.parametrize("attempt", ["name", "NAME", "alias", "ALIAS"]) def test_update_tag_suggestions_with_itself(attempt, tag_factory): - tag = tag_factory(names=['name', 'ALIAS']) + tag = tag_factory(names=["name", "ALIAS"]) with pytest.raises(tags.InvalidTagRelationError): tags.update_tag_suggestions(tag, [attempt]) def test_update_tag_suggestions(tag_factory): - tag = tag_factory(names=['name', 'ALIAS']) - with patch('szurubooru.func.tags.get_tags_by_names'): - tags.get_tags_by_names.return_value = ['returned tags'] - tags.update_tag_suggestions(tag, ['test']) - assert tag.suggestions == ['returned tags'] + tag = tag_factory(names=["name", "ALIAS"]) + with patch("szurubooru.func.tags.get_tags_by_names"): + tags.get_tags_by_names.return_value = ["returned tags"] + tags.update_tag_suggestions(tag, ["test"]) + assert tag.suggestions == ["returned tags"] -@pytest.mark.parametrize('attempt', ['name', 'NAME', 'alias', 'ALIAS']) +@pytest.mark.parametrize("attempt", ["name", "NAME", "alias", "ALIAS"]) def test_update_tag_implications_with_itself(attempt, tag_factory): - tag = tag_factory(names=['name', 'ALIAS']) + tag = tag_factory(names=["name", "ALIAS"]) with pytest.raises(tags.InvalidTagRelationError): tags.update_tag_implications(tag, [attempt]) def test_update_tag_implications(tag_factory): - tag = tag_factory(names=['name', 'ALIAS']) - with patch('szurubooru.func.tags.get_tags_by_names'): - tags.get_tags_by_names.return_value = ['returned tags'] - tags.update_tag_implications(tag, ['test']) - assert tag.implications == ['returned tags'] + tag = tag_factory(names=["name", "ALIAS"]) + with patch("szurubooru.func.tags.get_tags_by_names"): + tags.get_tags_by_names.return_value = ["returned tags"] + tags.update_tag_implications(tag, ["test"]) + assert tag.implications == ["returned tags"] def test_update_tag_description(tag_factory): tag = tag_factory() - tags.update_tag_description(tag, 'test') - assert tag.description == 'test' + tags.update_tag_description(tag, "test") + assert tag.description == "test" diff --git a/server/szurubooru/tests/func/test_user_tokens.py b/server/szurubooru/tests/func/test_user_tokens.py index 8c3577c..0422f4d 100644 --- a/server/szurubooru/tests/func/test_user_tokens.py +++ b/server/szurubooru/tests/func/test_user_tokens.py @@ -1,32 +1,35 @@ +import random +import string from datetime import datetime, timedelta from unittest.mock import patch + import pytest import pytz -import random -import string + from szurubooru import db, model -from szurubooru.func import user_tokens, users, auth, util +from szurubooru.func import auth, user_tokens, users, util def test_serialize_user_token(user_token_factory): user_token = user_token_factory() db.session.add(user_token) db.session.flush() - with patch('szurubooru.func.users.get_avatar_url'): - users.get_avatar_url.return_value = 'https://example.com/avatar.png' + with patch("szurubooru.func.users.get_avatar_url"): + users.get_avatar_url.return_value = "https://example.com/avatar.png" result = user_tokens.serialize_user_token(user_token, user_token.user) assert result == { - 'creationTime': datetime(1997, 1, 1, 0, 0), - 'enabled': True, - 'expirationTime': None, - 'lastEditTime': None, - 'lastUsageTime': None, - 'note': None, - 'token': 'dummy', - 'user': { - 'avatarUrl': 'https://example.com/avatar.png', - 'name': user_token.user.name}, - 'version': 1 + "creationTime": datetime(1997, 1, 1, 0, 0), + "enabled": True, + "expirationTime": None, + "lastEditTime": None, + "lastUsageTime": None, + "note": None, + "token": "dummy", + "user": { + "avatarUrl": "https://example.com/avatar.png", + "name": user_token.user.name, + }, + "version": 1, } @@ -41,7 +44,8 @@ def test_get_by_user_and_token(user_token_factory): db.session.flush() db.session.commit() result = user_tokens.get_by_user_and_token( - user_token.user, user_token.token) + user_token.user, user_token.token + ) assert result == user_token @@ -61,10 +65,10 @@ def test_create_user_token(user_factory): db.session.add(user) db.session.flush() db.session.commit() - with patch('szurubooru.func.auth.generate_authorization_token'): - auth.generate_authorization_token.return_value = 'test' + with patch("szurubooru.func.auth.generate_authorization_token"): + auth.generate_authorization_token.return_value = "test" result = user_tokens.create_user_token(user, True) - assert result.token == 'test' + assert result.token == "test" assert result.user == user @@ -85,8 +89,8 @@ def test_update_user_token_edit_time(user_token_factory): def test_update_user_token_note(user_token_factory): user_token = user_token_factory() assert user_token.note is None - user_tokens.update_user_token_note(user_token, ' Test Note ') - assert user_token.note == 'Test Note' + user_tokens.update_user_token_note(user_token, " Test Note ") + assert user_token.note == "Test Note" assert user_token.last_edit_time is not None @@ -94,8 +98,9 @@ def test_update_user_token_note_input_too_long(user_token_factory): user_token = user_token_factory() assert user_token.note is None note_max_length = util.get_column_size(model.UserToken.note) + 1 - note = ''.join( - random.choice(string.ascii_letters) for _ in range(note_max_length)) + note = "".join( + random.choice(string.ascii_letters) for _ in range(note_max_length) + ) with pytest.raises(user_tokens.InvalidNoteError): user_tokens.update_user_token_note(user_token, note) @@ -104,11 +109,11 @@ def test_update_user_token_expiration_time(user_token_factory): user_token = user_token_factory() assert user_token.expiration_time is None expiration_time_str = ( - (datetime.utcnow() + timedelta(days=1)) - .replace(tzinfo=pytz.utc) + (datetime.utcnow() + timedelta(days=1)).replace(tzinfo=pytz.utc) ).isoformat() user_tokens.update_user_token_expiration_time( - user_token, expiration_time_str) + user_token, expiration_time_str + ) assert user_token.expiration_time.isoformat() == expiration_time_str assert user_token.last_edit_time is not None @@ -117,39 +122,45 @@ def test_update_user_token_expiration_time_in_past(user_token_factory): user_token = user_token_factory() assert user_token.expiration_time is None expiration_time_str = ( - (datetime.utcnow() - timedelta(days=1)) - .replace(tzinfo=pytz.utc) + (datetime.utcnow() - timedelta(days=1)).replace(tzinfo=pytz.utc) ).isoformat() with pytest.raises( - user_tokens.InvalidExpirationError, - match='Expiration cannot happen in the past'): + user_tokens.InvalidExpirationError, + match="Expiration cannot happen in the past", + ): user_tokens.update_user_token_expiration_time( - user_token, expiration_time_str) + user_token, expiration_time_str + ) -@pytest.mark.parametrize('expiration_time_str', [ - datetime.utcnow().isoformat(), - (datetime.utcnow() - timedelta(days=1)).ctime(), - '1970/01/01 00:00:01.0000Z', - '70/01/01 00:00:01.0000Z', - ''.join(random.choice(string.ascii_letters) for _ in range(15)), - ''.join(random.choice(string.digits) for _ in range(8)) -]) +@pytest.mark.parametrize( + "expiration_time_str", + [ + datetime.utcnow().isoformat(), + (datetime.utcnow() - timedelta(days=1)).ctime(), + "1970/01/01 00:00:01.0000Z", + "70/01/01 00:00:01.0000Z", + "".join(random.choice(string.ascii_letters) for _ in range(15)), + "".join(random.choice(string.digits) for _ in range(8)), + ], +) def test_update_user_token_expiration_time_invalid_format( - expiration_time_str, user_token_factory): + expiration_time_str, user_token_factory +): user_token = user_token_factory() assert user_token.expiration_time is None with pytest.raises( - user_tokens.InvalidExpirationError, - match='Expiration is in an invalid format %s' - % expiration_time_str): + user_tokens.InvalidExpirationError, + match="Expiration is in an invalid format %s" % expiration_time_str, + ): user_tokens.update_user_token_expiration_time( - user_token, expiration_time_str) + user_token, expiration_time_str + ) def test_bump_usage_time(user_token_factory, fake_datetime): user_token = user_token_factory() - with fake_datetime('1997-01-01'): + with fake_datetime("1997-01-01"): user_tokens.bump_usage_time(user_token) assert user_token.last_usage_time == datetime(1997, 1, 1) diff --git a/server/szurubooru/tests/func/test_users.py b/server/szurubooru/tests/func/test_users.py index 5506127..94e9c7c 100644 --- a/server/szurubooru/tests/func/test_users.py +++ b/server/szurubooru/tests/func/test_users.py @@ -1,56 +1,70 @@ -from unittest.mock import patch from datetime import datetime +from unittest.mock import patch + import pytest -from szurubooru import db, model, errors -from szurubooru.func import auth, users, files, util +from szurubooru import db, errors, model +from szurubooru.func import auth, files, users, util EMPTY_PIXEL = ( - b'\x47\x49\x46\x38\x39\x61\x01\x00\x01\x00\x80\x01\x00\x00\x00\x00' - b'\xff\xff\xff\x21\xf9\x04\x01\x00\x00\x01\x00\x2c\x00\x00\x00\x00' - b'\x01\x00\x01\x00\x00\x02\x02\x4c\x01\x00\x3b') + b"\x47\x49\x46\x38\x39\x61\x01\x00\x01\x00\x80\x01\x00\x00\x00\x00" + b"\xff\xff\xff\x21\xf9\x04\x01\x00\x00\x01\x00\x2c\x00\x00\x00\x00" + b"\x01\x00\x01\x00\x00\x02\x02\x4c\x01\x00\x3b" +) -@pytest.mark.parametrize('user_name', ['test', 'TEST']) +@pytest.mark.parametrize("user_name", ["test", "TEST"]) def test_get_avatar_path(user_name): - assert users.get_avatar_path(user_name) == 'avatars/test.png' + assert users.get_avatar_path(user_name) == "avatars/test.png" -@pytest.mark.parametrize('user_name,user_email,avatar_style,expected_url', [ - ( - 'user', - None, - model.User.AVATAR_GRAVATAR, - ('https://gravatar.com/avatar/' + - 'ee11cbb19052e40b07aac0ca060c23ee?d=retro&s=100'), - ), - ( - None, - 'user@example.com', - model.User.AVATAR_GRAVATAR, - ('https://gravatar.com/avatar/' + - 'b58996c504c5638798eb6b511e6f49af?d=retro&s=100'), - ), - ( - 'user', - 'user@example.com', - model.User.AVATAR_GRAVATAR, - ('https://gravatar.com/avatar/' + - 'b58996c504c5638798eb6b511e6f49af?d=retro&s=100'), - ), - ( - 'user', - None, - model.User.AVATAR_MANUAL, - 'http://example.com/avatars/user.png', - ), -]) +@pytest.mark.parametrize( + "user_name,user_email,avatar_style,expected_url", + [ + ( + "user", + None, + model.User.AVATAR_GRAVATAR, + ( + "https://gravatar.com/avatar/" + + "ee11cbb19052e40b07aac0ca060c23ee?d=retro&s=100" + ), + ), + ( + None, + "user@example.com", + model.User.AVATAR_GRAVATAR, + ( + "https://gravatar.com/avatar/" + + "b58996c504c5638798eb6b511e6f49af?d=retro&s=100" + ), + ), + ( + "user", + "user@example.com", + model.User.AVATAR_GRAVATAR, + ( + "https://gravatar.com/avatar/" + + "b58996c504c5638798eb6b511e6f49af?d=retro&s=100" + ), + ), + ( + "user", + None, + model.User.AVATAR_MANUAL, + "http://example.com/avatars/user.png", + ), + ], +) def test_get_avatar_url( - user_name, user_email, avatar_style, expected_url, config_injector): - config_injector({ - 'data_url': 'http://example.com/', - 'thumbnails': {'avatar_width': 100}, - }) + user_name, user_email, avatar_style, expected_url, config_injector +): + config_injector( + { + "data_url": "http://example.com/", + "thumbnails": {"avatar_width": 100}, + } + ) user = model.User() user.name = user_name user.email = user_email @@ -59,23 +73,21 @@ def test_get_avatar_url( @pytest.mark.parametrize( - 'same_user,can_edit_any_email,force_show,expected_email', + "same_user,can_edit_any_email,force_show,expected_email", [ (False, False, False, False), - (True, False, False, 'test@example.com'), - (False, True, False, 'test@example.com'), - (False, False, True, 'test@example.com'), - ]) + (True, False, False, "test@example.com"), + (False, True, False, "test@example.com"), + (False, False, True, "test@example.com"), + ], +) def test_get_email( - same_user, - can_edit_any_email, - force_show, - expected_email, - user_factory): - with patch('szurubooru.func.auth.has_privilege'): + same_user, can_edit_any_email, force_show, expected_email, user_factory +): + with patch("szurubooru.func.auth.has_privilege"): auth.has_privilege = lambda user, name: can_edit_any_email user = user_factory() - user.email = 'test@example.com' + user.email = "test@example.com" auth_user = user if same_user else user_factory() db.session.add_all([user, auth_user]) db.session.flush() @@ -83,25 +95,28 @@ def test_get_email( @pytest.mark.parametrize( - 'same_user,score,expected_liked_post_count,expected_disliked_post_count', + "same_user,score,expected_liked_post_count,expected_disliked_post_count", [ (False, 1, False, False), (False, -1, False, False), (True, 1, 1, 0), (True, -1, 0, 1), - ]) + ], +) def test_get_liked_post_count( - same_user, - score, - expected_liked_post_count, - expected_disliked_post_count, - user_factory, - post_factory): + same_user, + score, + expected_liked_post_count, + expected_disliked_post_count, + user_factory, + post_factory, +): user = user_factory() post = post_factory() auth_user = user if same_user else user_factory() score = model.PostScore( - post=post, user=user, score=score, time=datetime.now()) + post=post, user=user, score=score, time=datetime.now() + ) db.session.add_all([post, user, score]) db.session.flush() actual_liked_post_count = users.get_liked_post_count(user, auth_user) @@ -115,16 +130,17 @@ def test_serialize_user_when_empty(): def test_serialize_user(user_factory): - with patch('szurubooru.func.users.get_email'), \ - patch('szurubooru.func.users.get_avatar_url'), \ - patch('szurubooru.func.users.get_liked_post_count'), \ - patch('szurubooru.func.users.get_disliked_post_count'): - users.get_email.return_value = 'test@example.com' - users.get_avatar_url.return_value = 'https://example.com/avatar.png' + with patch("szurubooru.func.users.get_email"), patch( + "szurubooru.func.users.get_avatar_url" + ), patch("szurubooru.func.users.get_liked_post_count"), patch( + "szurubooru.func.users.get_disliked_post_count" + ): + users.get_email.return_value = "test@example.com" + users.get_avatar_url.return_value = "https://example.com/avatar.png" users.get_liked_post_count.return_value = 66 users.get_disliked_post_count.return_value = 33 auth_user = user_factory() - user = user_factory(name='dummy user') + user = user_factory(name="dummy user") user.creation_time = datetime(1997, 1, 1) user.last_edit_time = datetime(1998, 1, 1) user.avatar_style = model.User.AVATAR_MANUAL @@ -132,36 +148,36 @@ def test_serialize_user(user_factory): db.session.add(user) db.session.flush() assert users.serialize_user(user, auth_user) == { - 'version': 1, - 'name': 'dummy user', - 'email': 'test@example.com', - 'rank': 'administrator', - 'creationTime': datetime(1997, 1, 1, 0, 0), - 'lastLoginTime': None, - 'avatarStyle': 'manual', - 'avatarUrl': 'https://example.com/avatar.png', - 'likedPostCount': 66, - 'dislikedPostCount': 33, - 'commentCount': 0, - 'favoritePostCount': 0, - 'uploadedPostCount': 0, + "version": 1, + "name": "dummy user", + "email": "test@example.com", + "rank": "administrator", + "creationTime": datetime(1997, 1, 1, 0, 0), + "lastLoginTime": None, + "avatarStyle": "manual", + "avatarUrl": "https://example.com/avatar.png", + "likedPostCount": 66, + "dislikedPostCount": 33, + "commentCount": 0, + "favoritePostCount": 0, + "uploadedPostCount": 0, } def test_serialize_micro_user(user_factory): - with patch('szurubooru.func.users.get_avatar_url'): - users.get_avatar_url.return_value = 'https://example.com/avatar.png' + with patch("szurubooru.func.users.get_avatar_url"): + users.get_avatar_url.return_value = "https://example.com/avatar.png" auth_user = user_factory() - user = user_factory(name='dummy user') + user = user_factory(name="dummy user") db.session.add(user) db.session.flush() assert users.serialize_micro_user(user, auth_user) == { - 'name': 'dummy user', - 'avatarUrl': 'https://example.com/avatar.png', + "name": "dummy user", + "avatarUrl": "https://example.com/avatar.png", } -@pytest.mark.parametrize('count', [0, 1, 2]) +@pytest.mark.parametrize("count", [0, 1, 2]) def test_get_user_count(count, user_factory): for _ in range(count): db.session.add(user_factory()) @@ -170,72 +186,73 @@ def test_get_user_count(count, user_factory): def test_try_get_user_by_name(user_factory): - user = user_factory(name='name', email='email') + user = user_factory(name="name", email="email") db.session.add(user) db.session.flush() - assert users.try_get_user_by_name('non-existing') is None - assert users.try_get_user_by_name('email') is None - assert users.try_get_user_by_name('name') is user - assert users.try_get_user_by_name('NAME') is user + assert users.try_get_user_by_name("non-existing") is None + assert users.try_get_user_by_name("email") is None + assert users.try_get_user_by_name("name") is user + assert users.try_get_user_by_name("NAME") is user def test_get_user_by_name(user_factory): - user = user_factory(name='name', email='email') + user = user_factory(name="name", email="email") db.session.add(user) db.session.flush() with pytest.raises(users.UserNotFoundError): - assert users.get_user_by_name('non-existing') + assert users.get_user_by_name("non-existing") with pytest.raises(users.UserNotFoundError): - assert users.get_user_by_name('email') - assert users.get_user_by_name('name') is user - assert users.get_user_by_name('NAME') is user + assert users.get_user_by_name("email") + assert users.get_user_by_name("name") is user + assert users.get_user_by_name("NAME") is user def test_try_get_user_by_name_or_email(user_factory): - user = user_factory(name='name', email='email') + user = user_factory(name="name", email="email") db.session.add(user) db.session.flush() - assert users.try_get_user_by_name_or_email('non-existing') is None - assert users.try_get_user_by_name_or_email('email') is user - assert users.try_get_user_by_name_or_email('EMAIL') is user - assert users.try_get_user_by_name_or_email('name') is user - assert users.try_get_user_by_name_or_email('NAME') is user + assert users.try_get_user_by_name_or_email("non-existing") is None + assert users.try_get_user_by_name_or_email("email") is user + assert users.try_get_user_by_name_or_email("EMAIL") is user + assert users.try_get_user_by_name_or_email("name") is user + assert users.try_get_user_by_name_or_email("NAME") is user def test_get_user_by_name_or_email(user_factory): - user = user_factory(name='name', email='email') + user = user_factory(name="name", email="email") db.session.add(user) db.session.flush() with pytest.raises(users.UserNotFoundError): - assert users.get_user_by_name_or_email('non-existing') - assert users.get_user_by_name_or_email('email') is user - assert users.get_user_by_name_or_email('EMAIL') is user - assert users.get_user_by_name_or_email('name') is user - assert users.get_user_by_name_or_email('NAME') is user + assert users.get_user_by_name_or_email("non-existing") + assert users.get_user_by_name_or_email("email") is user + assert users.get_user_by_name_or_email("EMAIL") is user + assert users.get_user_by_name_or_email("name") is user + assert users.get_user_by_name_or_email("NAME") is user def test_create_user_for_first_user(fake_datetime): - with patch('szurubooru.func.users.update_user_name'), \ - patch('szurubooru.func.users.update_user_password'), \ - patch('szurubooru.func.users.update_user_email'), \ - fake_datetime('1997-01-01'): - user = users.create_user('name', 'password', 'email') + with patch("szurubooru.func.users.update_user_name"), patch( + "szurubooru.func.users.update_user_password" + ), patch("szurubooru.func.users.update_user_email"), fake_datetime( + "1997-01-01" + ): + user = users.create_user("name", "password", "email") assert user.creation_time == datetime(1997, 1, 1) assert user.last_login_time is None assert user.rank == model.User.RANK_ADMINISTRATOR - users.update_user_name.assert_called_once_with(user, 'name') - users.update_user_password.assert_called_once_with(user, 'password') - users.update_user_email.assert_called_once_with(user, 'email') + users.update_user_name.assert_called_once_with(user, "name") + users.update_user_password.assert_called_once_with(user, "password") + users.update_user_email.assert_called_once_with(user, "email") def test_create_user_for_subsequent_users(user_factory, config_injector): - config_injector({'default_rank': 'regular'}) + config_injector({"default_rank": "regular"}) db.session.add(user_factory()) db.session.flush() - with patch('szurubooru.func.users.update_user_name'), \ - patch('szurubooru.func.users.update_user_email'), \ - patch('szurubooru.func.users.update_user_password'): - user = users.create_user('name', 'password', 'email') + with patch("szurubooru.func.users.update_user_name"), patch( + "szurubooru.func.users.update_user_email" + ), patch("szurubooru.func.users.update_user_password"): + user = users.create_user("name", "password", "email") assert user.rank == model.User.RANK_REGULAR @@ -248,56 +265,58 @@ def test_update_user_name_with_empty_string(user_factory): def test_update_user_name_with_too_long_string(user_factory): user = user_factory() with pytest.raises(users.InvalidUserNameError): - users.update_user_name(user, 'a' * 300) + users.update_user_name(user, "a" * 300) def test_update_user_name_with_invalid_name(user_factory, config_injector): - config_injector({'user_name_regex': '^[a-z]+$'}) + config_injector({"user_name_regex": "^[a-z]+$"}) user = user_factory() with pytest.raises(users.InvalidUserNameError): - users.update_user_name(user, '0') + users.update_user_name(user, "0") def test_update_user_name_with_duplicate_name(user_factory, config_injector): - config_injector({'user_name_regex': '^[a-z]+$'}) + config_injector({"user_name_regex": "^[a-z]+$"}) user = user_factory() - existing_user = user_factory(name='dummy') + existing_user = user_factory(name="dummy") db.session.add(existing_user) db.session.flush() with pytest.raises(users.UserAlreadyExistsError): - users.update_user_name(user, 'dummy') + users.update_user_name(user, "dummy") def test_update_user_name_reusing_own_name(user_factory, config_injector): - config_injector({'user_name_regex': '^[a-z]+$'}) - user = user_factory(name='dummy') + config_injector({"user_name_regex": "^[a-z]+$"}) + user = user_factory(name="dummy") db.session.add(user) db.session.flush() - with patch('szurubooru.func.files.has'): + with patch("szurubooru.func.files.has"): files.has.return_value = False - users.update_user_name(user, 'dummy') + users.update_user_name(user, "dummy") db.session.flush() - assert users.try_get_user_by_name('dummy') is user + assert users.try_get_user_by_name("dummy") is user def test_update_user_name_for_new_user(user_factory, config_injector): - config_injector({'user_name_regex': '^[a-z]+$'}) + config_injector({"user_name_regex": "^[a-z]+$"}) user = user_factory() - with patch('szurubooru.func.files.has'): + with patch("szurubooru.func.files.has"): files.has.return_value = False - users.update_user_name(user, 'dummy') - assert user.name == 'dummy' + users.update_user_name(user, "dummy") + assert user.name == "dummy" def test_update_user_name_moves_avatar(user_factory, config_injector): - config_injector({'user_name_regex': '^[a-z]+$'}) - user = user_factory(name='old') - with patch('szurubooru.func.files.has'), \ - patch('szurubooru.func.files.move'): + config_injector({"user_name_regex": "^[a-z]+$"}) + user = user_factory(name="old") + with patch("szurubooru.func.files.has"), patch( + "szurubooru.func.files.move" + ): files.has.return_value = True - users.update_user_name(user, 'new') + users.update_user_name(user, "new") files.move.assert_called_once_with( - 'avatars/old.png', 'avatars/new.png') + "avatars/old.png", "avatars/new.png" + ) def test_update_user_password_with_empty_string(user_factory): @@ -307,72 +326,74 @@ def test_update_user_password_with_empty_string(user_factory): def test_update_user_password_with_invalid_string( - user_factory, config_injector): - config_injector({'password_regex': '^[a-z]+$'}) + user_factory, config_injector +): + config_injector({"password_regex": "^[a-z]+$"}) user = user_factory() with pytest.raises(users.InvalidPasswordError): - users.update_user_password(user, '0') + users.update_user_password(user, "0") def test_update_user_password(user_factory, config_injector): - config_injector({'password_regex': '^[a-z]+$'}) + config_injector({"password_regex": "^[a-z]+$"}) user = user_factory() - with patch('szurubooru.func.auth.create_password'), \ - patch('szurubooru.func.auth.get_password_hash'): - auth.create_password.return_value = 'salt' - auth.get_password_hash.return_value = ('hash', 3) - users.update_user_password(user, 'a') - assert user.password_salt == 'salt' - assert user.password_hash == 'hash' + with patch("szurubooru.func.auth.create_password"), patch( + "szurubooru.func.auth.get_password_hash" + ): + auth.create_password.return_value = "salt" + auth.get_password_hash.return_value = ("hash", 3) + users.update_user_password(user, "a") + assert user.password_salt == "salt" + assert user.password_hash == "hash" assert user.password_revision == 3 def test_update_user_email_with_too_long_string(user_factory): user = user_factory() with pytest.raises(users.InvalidEmailError): - users.update_user_email(user, 'a' * 300) + users.update_user_email(user, "a" * 300) def test_update_user_email_with_invalid_email(user_factory): user = user_factory() - with patch('szurubooru.func.util.is_valid_email'): + with patch("szurubooru.func.util.is_valid_email"): util.is_valid_email.return_value = False with pytest.raises(users.InvalidEmailError): - users.update_user_email(user, 'a') + users.update_user_email(user, "a") def test_update_user_email_with_empty_string(user_factory): user = user_factory() - with patch('szurubooru.func.util.is_valid_email'): + with patch("szurubooru.func.util.is_valid_email"): util.is_valid_email.return_value = True - users.update_user_email(user, '') + users.update_user_email(user, "") assert user.email is None def test_update_user_email(user_factory): user = user_factory() - with patch('szurubooru.func.util.is_valid_email'): + with patch("szurubooru.func.util.is_valid_email"): util.is_valid_email.return_value = True - users.update_user_email(user, 'a') - assert user.email == 'a' + users.update_user_email(user, "a") + assert user.email == "a" def test_update_user_rank_with_empty_string(user_factory): user = user_factory() auth_user = user_factory() with pytest.raises(users.InvalidRankError): - users.update_user_rank(user, '', auth_user) + users.update_user_rank(user, "", auth_user) def test_update_user_rank_with_invalid_string(user_factory): user = user_factory() auth_user = user_factory() with pytest.raises(users.InvalidRankError): - users.update_user_rank(user, 'invalid', auth_user) + users.update_user_rank(user, "invalid", auth_user) with pytest.raises(users.InvalidRankError): - users.update_user_rank(user, 'anonymous', auth_user) + users.update_user_rank(user, "anonymous", auth_user) with pytest.raises(users.InvalidRankError): - users.update_user_rank(user, 'nobody', auth_user) + users.update_user_rank(user, "nobody", auth_user) def test_update_user_rank_with_higher_rank_than_possible(user_factory): @@ -382,9 +403,9 @@ def test_update_user_rank_with_higher_rank_than_possible(user_factory): auth_user = user_factory() auth_user.rank = model.User.RANK_ANONYMOUS with pytest.raises(errors.AuthError): - users.update_user_rank(user, 'regular', auth_user) + users.update_user_rank(user, "regular", auth_user) with pytest.raises(errors.AuthError): - users.update_user_rank(auth_user, 'regular', auth_user) + users.update_user_rank(auth_user, "regular", auth_user) def test_update_user_rank(user_factory): @@ -393,8 +414,8 @@ def test_update_user_rank(user_factory): user = user_factory() auth_user = user_factory() auth_user.rank = model.User.RANK_ADMINISTRATOR - users.update_user_rank(user, 'regular', auth_user) - users.update_user_rank(auth_user, 'regular', auth_user) + users.update_user_rank(user, "regular", auth_user) + users.update_user_rank(auth_user, "regular", auth_user) assert user.rank == model.User.RANK_REGULAR assert auth_user.rank == model.User.RANK_REGULAR @@ -402,54 +423,57 @@ def test_update_user_rank(user_factory): def test_update_user_avatar_with_invalid_style(user_factory): user = user_factory() with pytest.raises(users.InvalidAvatarError): - users.update_user_avatar(user, 'invalid', b'') + users.update_user_avatar(user, "invalid", b"") def test_update_user_avatar_to_gravatar(user_factory): user = user_factory() - users.update_user_avatar(user, 'gravatar') + users.update_user_avatar(user, "gravatar") assert user.avatar_style == model.User.AVATAR_GRAVATAR def test_update_user_avatar_to_empty_manual(user_factory): user = user_factory() - with patch('szurubooru.func.files.has'), \ - pytest.raises(users.InvalidAvatarError): + with patch("szurubooru.func.files.has"), pytest.raises( + users.InvalidAvatarError + ): files.has.return_value = False - users.update_user_avatar(user, 'manual', b'') + users.update_user_avatar(user, "manual", b"") def test_update_user_avatar_to_previous_manual(user_factory): user = user_factory() - with patch('szurubooru.func.files.has'): + with patch("szurubooru.func.files.has"): files.has.return_value = True - users.update_user_avatar(user, 'manual', b'') + users.update_user_avatar(user, "manual", b"") def test_update_user_avatar_to_new_manual(user_factory, config_injector): config_injector( - {'thumbnails': {'avatar_width': 500, 'avatar_height': 500}}) + {"thumbnails": {"avatar_width": 500, "avatar_height": 500}} + ) user = user_factory() - with patch('szurubooru.func.files.save'): - users.update_user_avatar(user, 'manual', EMPTY_PIXEL) + with patch("szurubooru.func.files.save"): + users.update_user_avatar(user, "manual", EMPTY_PIXEL) assert user.avatar_style == model.User.AVATAR_MANUAL assert files.save.called def test_bump_user_login_time(user_factory, fake_datetime): user = user_factory() - with fake_datetime('1997-01-01'): + with fake_datetime("1997-01-01"): users.bump_user_login_time(user) assert user.last_login_time == datetime(1997, 1, 1) def test_reset_user_password(user_factory): - with patch('szurubooru.func.auth.create_password'), \ - patch('szurubooru.func.auth.get_password_hash'): + with patch("szurubooru.func.auth.create_password"), patch( + "szurubooru.func.auth.get_password_hash" + ): user = user_factory() - auth.create_password.return_value = 'salt' - auth.get_password_hash.return_value = ('hash', 3) + auth.create_password.return_value = "salt" + auth.get_password_hash.return_value = ("hash", 3) users.reset_user_password(user) - assert user.password_salt == 'salt' - assert user.password_hash == 'hash' + assert user.password_salt == "salt" + assert user.password_hash == "hash" assert user.password_revision == 3 diff --git a/server/szurubooru/tests/func/test_util.py b/server/szurubooru/tests/func/test_util.py index 24fe4e4..f42ba29 100644 --- a/server/szurubooru/tests/func/test_util.py +++ b/server/szurubooru/tests/func/test_util.py @@ -1,40 +1,47 @@ from datetime import datetime + import pytest + from szurubooru import errors from szurubooru.func import util - -dt = datetime # pylint: disable=invalid-name +dt = datetime def test_parsing_empty_date_time(): with pytest.raises(errors.ValidationError): - util.parse_time_range('') + util.parse_time_range("") -@pytest.mark.parametrize('output,input', [ - ((dt(1997, 1, 2, 0, 0, 0), dt(1997, 1, 2, 23, 59, 59)), 'today'), - ((dt(1997, 1, 1, 0, 0, 0), dt(1997, 1, 1, 23, 59, 59)), 'yesterday'), - ((dt(1999, 1, 1, 0, 0, 0), dt(1999, 12, 31, 23, 59, 59)), '1999'), - ((dt(1999, 2, 1, 0, 0, 0), dt(1999, 2, 28, 23, 59, 59)), '1999-2'), - ((dt(1999, 2, 1, 0, 0, 0), dt(1999, 2, 28, 23, 59, 59)), '1999-02'), - ((dt(1999, 2, 6, 0, 0, 0), dt(1999, 2, 6, 23, 59, 59)), '1999-2-6'), - ((dt(1999, 2, 6, 0, 0, 0), dt(1999, 2, 6, 23, 59, 59)), '1999-02-6'), - ((dt(1999, 2, 6, 0, 0, 0), dt(1999, 2, 6, 23, 59, 59)), '1999-2-06'), - ((dt(1999, 2, 6, 0, 0, 0), dt(1999, 2, 6, 23, 59, 59)), '1999-02-06'), -]) +@pytest.mark.parametrize( + "output,input", + [ + ((dt(1997, 1, 2, 0, 0, 0), dt(1997, 1, 2, 23, 59, 59)), "today"), + ((dt(1997, 1, 1, 0, 0, 0), dt(1997, 1, 1, 23, 59, 59)), "yesterday"), + ((dt(1999, 1, 1, 0, 0, 0), dt(1999, 12, 31, 23, 59, 59)), "1999"), + ((dt(1999, 2, 1, 0, 0, 0), dt(1999, 2, 28, 23, 59, 59)), "1999-2"), + ((dt(1999, 2, 1, 0, 0, 0), dt(1999, 2, 28, 23, 59, 59)), "1999-02"), + ((dt(1999, 2, 6, 0, 0, 0), dt(1999, 2, 6, 23, 59, 59)), "1999-2-6"), + ((dt(1999, 2, 6, 0, 0, 0), dt(1999, 2, 6, 23, 59, 59)), "1999-02-6"), + ((dt(1999, 2, 6, 0, 0, 0), dt(1999, 2, 6, 23, 59, 59)), "1999-2-06"), + ((dt(1999, 2, 6, 0, 0, 0), dt(1999, 2, 6, 23, 59, 59)), "1999-02-06"), + ], +) def test_parsing_date_time(fake_datetime, input, output): - with fake_datetime('1997-01-02 03:04:05'): + with fake_datetime("1997-01-02 03:04:05"): assert util.parse_time_range(input) == output -@pytest.mark.parametrize('input,output', [ - ([], []), - (['a', 'b', 'c'], ['a', 'b', 'c']), - (['a', 'b', 'a'], ['a', 'b']), - (['a', 'a', 'b'], ['a', 'b']), - (['a', 'A', 'b'], ['a', 'b']), - (['a', 'A', 'b', 'B'], ['a', 'b']), -]) +@pytest.mark.parametrize( + "input,output", + [ + ([], []), + (["a", "b", "c"], ["a", "b", "c"]), + (["a", "b", "a"], ["a", "b"]), + (["a", "a", "b"], ["a", "b"]), + (["a", "A", "b"], ["a", "b"]), + (["a", "A", "b", "B"], ["a", "b"]), + ], +) def test_icase_unique(input, output): assert util.icase_unique(input) == output diff --git a/server/szurubooru/tests/middleware/test_authenticator.py b/server/szurubooru/tests/middleware/test_authenticator.py index be21a93..9a4a3cc 100644 --- a/server/szurubooru/tests/middleware/test_authenticator.py +++ b/server/szurubooru/tests/middleware/test_authenticator.py @@ -1,7 +1,9 @@ from unittest.mock import patch + import pytest + from szurubooru import db -from szurubooru.func import auth, users, user_tokens +from szurubooru.func import auth, user_tokens, users from szurubooru.middleware import authenticator from szurubooru.rest import errors @@ -17,14 +19,12 @@ def test_process_request_bump_login(context_factory, user_factory): db.session.add(user) db.session.flush() ctx = context_factory( - headers={ - 'Authorization': 'Basic dGVzdFVzZXI6dGVzdFRva2Vu' - }, - params={ - 'bump-login': 'true' - }) - with patch('szurubooru.func.auth.is_valid_password'), \ - patch('szurubooru.func.users.get_user_by_name'): + headers={"Authorization": "Basic dGVzdFVzZXI6dGVzdFRva2Vu"}, + params={"bump-login": "true"}, + ) + with patch("szurubooru.func.auth.is_valid_password"), patch( + "szurubooru.func.users.get_user_by_name" + ): users.get_user_by_name.return_value = user auth.is_valid_password.return_value = True authenticator.process_request(ctx) @@ -32,20 +32,18 @@ def test_process_request_bump_login(context_factory, user_factory): def test_process_request_bump_login_with_token( - context_factory, user_token_factory): + context_factory, user_token_factory +): user_token = user_token_factory() db.session.add(user_token) db.session.flush() ctx = context_factory( - headers={ - 'Authorization': 'Token dGVzdFVzZXI6dGVzdFRva2Vu' - }, - params={ - 'bump-login': 'true' - }) - with patch('szurubooru.func.auth.is_valid_token'), \ - patch('szurubooru.func.users.get_user_by_name'), \ - patch('szurubooru.func.user_tokens.get_by_user_and_token'): + headers={"Authorization": "Token dGVzdFVzZXI6dGVzdFRva2Vu"}, + params={"bump-login": "true"}, + ) + with patch("szurubooru.func.auth.is_valid_token"), patch( + "szurubooru.func.users.get_user_by_name" + ), patch("szurubooru.func.user_tokens.get_by_user_and_token"): users.get_user_by_name.return_value = user_token.user user_tokens.get_by_user_and_token.return_value = user_token auth.is_valid_token.return_value = True @@ -57,11 +55,11 @@ def test_process_request_bump_login_with_token( def test_process_request_basic_auth_valid(context_factory, user_factory): user = user_factory() ctx = context_factory( - headers={ - 'Authorization': 'Basic dGVzdFVzZXI6dGVzdFBhc3N3b3Jk' - }) - with patch('szurubooru.func.auth.is_valid_password'), \ - patch('szurubooru.func.users.get_user_by_name'): + headers={"Authorization": "Basic dGVzdFVzZXI6dGVzdFBhc3N3b3Jk"} + ) + with patch("szurubooru.func.auth.is_valid_password"), patch( + "szurubooru.func.users.get_user_by_name" + ): users.get_user_by_name.return_value = user auth.is_valid_password.return_value = True authenticator.process_request(ctx) @@ -71,12 +69,11 @@ def test_process_request_basic_auth_valid(context_factory, user_factory): def test_process_request_token_auth_valid(context_factory, user_token_factory): user_token = user_token_factory() ctx = context_factory( - headers={ - 'Authorization': 'Token dGVzdFVzZXI6dGVzdFRva2Vu' - }) - with patch('szurubooru.func.auth.is_valid_token'), \ - patch('szurubooru.func.users.get_user_by_name'), \ - patch('szurubooru.func.user_tokens.get_by_user_and_token'): + headers={"Authorization": "Token dGVzdFVzZXI6dGVzdFRva2Vu"} + ) + with patch("szurubooru.func.auth.is_valid_token"), patch( + "szurubooru.func.users.get_user_by_name" + ), patch("szurubooru.func.user_tokens.get_by_user_and_token"): users.get_user_by_name.return_value = user_token.user user_tokens.get_by_user_and_token.return_value = user_token auth.is_valid_token.return_value = True @@ -85,9 +82,6 @@ def test_process_request_token_auth_valid(context_factory, user_token_factory): def test_process_request_bad_header(context_factory): - ctx = context_factory( - headers={ - 'Authorization': 'Secret SuperSecretValue' - }) + ctx = context_factory(headers={"Authorization": "Secret SuperSecretValue"}) with pytest.raises(errors.HttpBadRequest): authenticator.process_request(ctx) diff --git a/server/szurubooru/tests/model/test_comment.py b/server/szurubooru/tests/model/test_comment.py index ffd5189..fcbf176 100644 --- a/server/szurubooru/tests/model/test_comment.py +++ b/server/szurubooru/tests/model/test_comment.py @@ -1,4 +1,5 @@ from datetime import datetime + from szurubooru import db, model @@ -6,7 +7,7 @@ def test_saving_comment(user_factory, post_factory): user = user_factory() post = post_factory() comment = model.Comment() - comment.text = 'long text' * 1000 + comment.text = "long text" * 1000 comment.user = user comment.post = post comment.creation_time = datetime(1997, 1, 1) @@ -17,7 +18,7 @@ def test_saving_comment(user_factory, post_factory): db.session.refresh(comment) assert not db.session.dirty assert comment.user is not None and comment.user.user_id is not None - assert comment.text == 'long text' * 1000 + assert comment.text == "long text" * 1000 assert comment.creation_time == datetime(1997, 1, 1) assert comment.last_edit_time == datetime(1998, 1, 1) diff --git a/server/szurubooru/tests/model/test_pool.py b/server/szurubooru/tests/model/test_pool.py new file mode 100644 index 0000000..bec9560 --- /dev/null +++ b/server/szurubooru/tests/model/test_pool.py @@ -0,0 +1,97 @@ +from datetime import datetime + +import pytest + +from szurubooru import db, model + + +@pytest.fixture(autouse=True) +def inject_config(config_injector): + config_injector( + {"delete_source_files": False, "secret": "secret", "data_dir": ""} + ) + + +def test_saving_pool(pool_factory, post_factory): + post1 = post_factory() + post2 = post_factory() + pool = model.Pool() + pool.names = [model.PoolName("alias1", 0), model.PoolName("alias2", 1)] + pool.posts = [] + pool.category = model.PoolCategory("category") + pool.creation_time = datetime(1997, 1, 1) + pool.last_edit_time = datetime(1998, 1, 1) + db.session.add_all([pool, post1, post2]) + db.session.commit() + + assert pool.pool_id is not None + pool.posts.append(post1) + pool.posts.append(post2) + db.session.commit() + + pool = ( + db.session.query(model.Pool) + .join(model.PoolName) + .filter(model.PoolName.name == "alias1") + .one() + ) + assert [pool_name.name for pool_name in pool.names] == ["alias1", "alias2"] + assert pool.category.name == "category" + assert pool.creation_time == datetime(1997, 1, 1) + assert pool.last_edit_time == datetime(1998, 1, 1) + assert [post.post_id for post in pool.posts] == [1, 2] + + +def test_cascade_deletions(pool_factory, post_factory): + post1 = post_factory() + post2 = post_factory() + pool = model.Pool() + pool.names = [model.PoolName("alias1", 0), model.PoolName("alias2", 1)] + pool.posts = [] + pool.category = model.PoolCategory("category") + pool.creation_time = datetime(1997, 1, 1) + pool.last_edit_time = datetime(1998, 1, 1) + db.session.add_all([pool, post1, post2]) + db.session.commit() + + assert pool.pool_id is not None + pool.posts.append(post1) + pool.posts.append(post2) + db.session.commit() + + db.session.delete(pool) + db.session.commit() + assert db.session.query(model.Pool).count() == 0 + assert db.session.query(model.PoolName).count() == 0 + assert db.session.query(model.PoolPost).count() == 0 + assert db.session.query(model.PoolCategory).count() == 1 + assert db.session.query(model.Post).count() == 2 + + +def test_tracking_post_count(post_factory, pool_factory): + pool1 = pool_factory() + pool2 = pool_factory() + post1 = post_factory() + post2 = post_factory() + db.session.add_all([pool1, pool2, post1, post2]) + db.session.flush() + assert pool1.pool_id is not None + assert pool2.pool_id is not None + pool1.posts.append(post1) + pool2.posts.append(post1) + pool2.posts.append(post2) + db.session.commit() + assert len(post1.pools) == 2 + assert len(post2.pools) == 1 + assert pool1.post_count == 1 + assert pool2.post_count == 2 + db.session.delete(post1) + db.session.commit() + db.session.refresh(pool1) + db.session.refresh(pool2) + assert pool1.post_count == 0 + assert pool2.post_count == 1 + db.session.delete(post2) + db.session.commit() + db.session.refresh(pool2) + assert pool2.post_count == 0 diff --git a/server/szurubooru/tests/model/test_post.py b/server/szurubooru/tests/model/test_post.py index ee69146..f01455d 100644 --- a/server/szurubooru/tests/model/test_post.py +++ b/server/szurubooru/tests/model/test_post.py @@ -1,15 +1,15 @@ from datetime import datetime + import pytest + from szurubooru import db, model @pytest.fixture(autouse=True) def inject_config(config_injector): - config_injector({ - 'secret': 'secret', - 'data_dir': '', - 'delete_source_files': False - }) + config_injector( + {"secret": "secret", "data_dir": "", "delete_source_files": False} + ) def test_saving_post(post_factory, user_factory, tag_factory): @@ -19,12 +19,12 @@ def test_saving_post(post_factory, user_factory, tag_factory): related_post1 = post_factory() related_post2 = post_factory() post = model.Post() - post.safety = 'safety' - post.type = 'type' - post.checksum = 'deadbeef' + post.safety = "safety" + post.type = "type" + post.checksum = "deadbeef" post.creation_time = datetime(1997, 1, 1) post.last_edit_time = datetime(1998, 1, 1) - post.mime_type = 'application/whatever' + post.mime_type = "application/whatever" db.session.add_all([user, tag1, tag2, related_post1, related_post2, post]) post.user = user @@ -39,9 +39,9 @@ def test_saving_post(post_factory, user_factory, tag_factory): db.session.refresh(related_post2) assert not db.session.dirty assert post.user.user_id is not None - assert post.safety == 'safety' - assert post.type == 'type' - assert post.checksum == 'deadbeef' + assert post.safety == "safety" + assert post.type == "type" + assert post.checksum == "deadbeef" assert post.creation_time == datetime(1997, 1, 1) assert post.last_edit_time == datetime(1998, 1, 1) assert len(post.relations) == 2 @@ -50,9 +50,9 @@ def test_saving_post(post_factory, user_factory, tag_factory): assert len(related_post2.relations) == 0 -# pylint: disable=too-many-statements def test_cascade_deletions( - post_factory, user_factory, tag_factory, comment_factory): + post_factory, user_factory, tag_factory, comment_factory +): user = user_factory() tag1 = tag_factory() tag2 = tag_factory() @@ -60,8 +60,9 @@ def test_cascade_deletions( related_post2 = post_factory() post = post_factory() comment = comment_factory(post=post, user=user) - db.session.add_all([ - user, tag1, tag2, post, related_post1, related_post2, comment]) + db.session.add_all( + [user, tag1, tag2, post, related_post1, related_post2, comment] + ) db.session.flush() score = model.PostScore() @@ -79,9 +80,13 @@ def test_cascade_deletions( feature.time = datetime(1997, 1, 1) note = model.PostNote() note.post = post - note.polygon = '' - note.text = '' - db.session.add_all([score, favorite, feature, note]) + note.polygon = "" + note.text = "" + signature = model.PostSignature() + signature.post = post + signature.signature = b"testvalue" + signature.words = list(range(50)) + db.session.add_all([score, favorite, feature, note, signature]) db.session.flush() post.user = user @@ -107,6 +112,7 @@ def test_cascade_deletions( assert db.session.query(model.PostNote).count() == 1 assert db.session.query(model.PostFeature).count() == 1 assert db.session.query(model.PostFavorite).count() == 1 + assert db.session.query(model.PostSignature).count() == 1 assert db.session.query(model.Comment).count() == 1 db.session.delete(post) @@ -122,6 +128,7 @@ def test_cascade_deletions( assert db.session.query(model.PostNote).count() == 0 assert db.session.query(model.PostFeature).count() == 0 assert db.session.query(model.PostFavorite).count() == 0 + assert db.session.query(model.PostSignature).count() == 0 assert db.session.query(model.Comment).count() == 0 diff --git a/server/szurubooru/tests/model/test_tag.py b/server/szurubooru/tests/model/test_tag.py index b677eef..9332f0d 100644 --- a/server/szurubooru/tests/model/test_tag.py +++ b/server/szurubooru/tests/model/test_tag.py @@ -1,27 +1,27 @@ from datetime import datetime + import pytest + from szurubooru import db, model @pytest.fixture(autouse=True) def inject_config(config_injector): - config_injector({ - 'delete_source_files': False, - 'secret': 'secret', - 'data_dir': '' - }) + config_injector( + {"delete_source_files": False, "secret": "secret", "data_dir": ""} + ) def test_saving_tag(tag_factory): - sug1 = tag_factory(names=['sug1']) - sug2 = tag_factory(names=['sug2']) - imp1 = tag_factory(names=['imp1']) - imp2 = tag_factory(names=['imp2']) + sug1 = tag_factory(names=["sug1"]) + sug2 = tag_factory(names=["sug2"]) + imp1 = tag_factory(names=["imp1"]) + imp2 = tag_factory(names=["imp2"]) tag = model.Tag() - tag.names = [model.TagName('alias1', 0), model.TagName('alias2', 1)] + tag.names = [model.TagName("alias1", 0), model.TagName("alias2", 1)] tag.suggestions = [] tag.implications = [] - tag.category = model.TagCategory('category') + tag.category = model.TagCategory("category") tag.creation_time = datetime(1997, 1, 1) tag.last_edit_time = datetime(1998, 1, 1) db.session.add_all([tag, sug1, sug2, imp1, imp2]) @@ -39,31 +39,35 @@ def test_saving_tag(tag_factory): db.session.commit() tag = ( - db.session - .query(model.Tag) + db.session.query(model.Tag) .join(model.TagName) - .filter(model.TagName.name == 'alias1') - .one()) - assert [tag_name.name for tag_name in tag.names] == ['alias1', 'alias2'] - assert tag.category.name == 'category' + .filter(model.TagName.name == "alias1") + .one() + ) + assert [tag_name.name for tag_name in tag.names] == ["alias1", "alias2"] + assert tag.category.name == "category" assert tag.creation_time == datetime(1997, 1, 1) assert tag.last_edit_time == datetime(1998, 1, 1) - assert [relation.names[0].name for relation in tag.suggestions] \ - == ['sug1', 'sug2'] - assert [relation.names[0].name for relation in tag.implications] \ - == ['imp1', 'imp2'] + assert [relation.names[0].name for relation in tag.suggestions] == [ + "sug1", + "sug2", + ] + assert [relation.names[0].name for relation in tag.implications] == [ + "imp1", + "imp2", + ] def test_cascade_deletions(tag_factory): - sug1 = tag_factory(names=['sug1']) - sug2 = tag_factory(names=['sug2']) - imp1 = tag_factory(names=['imp1']) - imp2 = tag_factory(names=['imp2']) + sug1 = tag_factory(names=["sug1"]) + sug2 = tag_factory(names=["sug2"]) + imp1 = tag_factory(names=["imp1"]) + imp2 = tag_factory(names=["imp2"]) tag = model.Tag() - tag.names = [model.TagName('alias1', 0), model.TagName('alias2', 1)] + tag.names = [model.TagName("alias1", 0), model.TagName("alias2", 1)] tag.suggestions = [] tag.implications = [] - tag.category = model.TagCategory('category') + tag.category = model.TagCategory("category") tag.creation_time = datetime(1997, 1, 1) tag.last_edit_time = datetime(1998, 1, 1) tag.post_count = 1 diff --git a/server/szurubooru/tests/model/test_user.py b/server/szurubooru/tests/model/test_user.py index 08875fa..adc9dd7 100644 --- a/server/szurubooru/tests/model/test_user.py +++ b/server/szurubooru/tests/model/test_user.py @@ -1,25 +1,26 @@ from datetime import datetime + from szurubooru import db, model def test_saving_user(): user = model.User() - user.name = 'name' - user.password_salt = 'salt' - user.password_hash = 'hash' - user.email = 'email' - user.rank = 'rank' + user.name = "name" + user.password_salt = "salt" + user.password_hash = "hash" + user.email = "email" + user.rank = "rank" user.creation_time = datetime(1997, 1, 1) user.avatar_style = model.User.AVATAR_GRAVATAR db.session.add(user) db.session.flush() db.session.refresh(user) assert not db.session.dirty - assert user.name == 'name' - assert user.password_salt == 'salt' - assert user.password_hash == 'hash' - assert user.email == 'email' - assert user.rank == 'rank' + assert user.name == "name" + assert user.password_salt == "salt" + assert user.password_hash == "hash" + assert user.email == "email" + assert user.rank == "rank" assert user.creation_time == datetime(1997, 1, 1) assert user.avatar_style == model.User.AVATAR_GRAVATAR @@ -43,10 +44,12 @@ def test_comment_count(user_factory, comment_factory): db.session.add(user) db.session.flush() assert user.comment_count == 0 - db.session.add_all([ - comment_factory(user=user), - comment_factory(), - ]) + db.session.add_all( + [ + comment_factory(user=user), + comment_factory(), + ] + ) db.session.flush() db.session.refresh(user) assert user.comment_count == 1 @@ -60,10 +63,12 @@ def test_favorite_count(user_factory, post_factory): assert user1.comment_count == 0 post1 = post_factory() post2 = post_factory() - db.session.add_all([ - model.PostFavorite(post=post1, time=datetime.utcnow(), user=user1), - model.PostFavorite(post=post2, time=datetime.utcnow(), user=user2), - ]) + db.session.add_all( + [ + model.PostFavorite(post=post1, time=datetime.utcnow(), user=user1), + model.PostFavorite(post=post2, time=datetime.utcnow(), user=user2), + ] + ) db.session.flush() db.session.refresh(user1) assert user1.favorite_post_count == 1 @@ -78,12 +83,16 @@ def test_liked_post_count(user_factory, post_factory): assert user1.disliked_post_count == 0 post1 = post_factory() post2 = post_factory() - db.session.add_all([ - model.PostScore( - post=post1, time=datetime.utcnow(), user=user1, score=1), - model.PostScore( - post=post2, time=datetime.utcnow(), user=user2, score=1), - ]) + db.session.add_all( + [ + model.PostScore( + post=post1, time=datetime.utcnow(), user=user1, score=1 + ), + model.PostScore( + post=post2, time=datetime.utcnow(), user=user2, score=1 + ), + ] + ) db.session.flush() db.session.refresh(user1) assert user1.liked_post_count == 1 @@ -99,19 +108,22 @@ def test_disliked_post_count(user_factory, post_factory): assert user1.disliked_post_count == 0 post1 = post_factory() post2 = post_factory() - db.session.add_all([ - model.PostScore( - post=post1, time=datetime.utcnow(), user=user1, score=-1), - model.PostScore( - post=post2, time=datetime.utcnow(), user=user2, score=1), - ]) + db.session.add_all( + [ + model.PostScore( + post=post1, time=datetime.utcnow(), user=user1, score=-1 + ), + model.PostScore( + post=post2, time=datetime.utcnow(), user=user2, score=1 + ), + ] + ) db.session.flush() db.session.refresh(user1) assert user1.liked_post_count == 0 assert user1.disliked_post_count == 1 -# pylint: disable=too-many-statements def test_cascade_deletions(post_factory, user_factory, comment_factory): user = user_factory() @@ -148,10 +160,10 @@ def test_cascade_deletions(post_factory, user_factory, comment_factory): snapshot = model.Snapshot() snapshot.user = user snapshot.creation_time = datetime(1997, 1, 1) - snapshot.resource_type = '-' + snapshot.resource_type = "-" snapshot.resource_pkey = 1 - snapshot.resource_name = '-' - snapshot.operation = '-' + snapshot.resource_name = "-" + snapshot.operation = "-" db.session.add_all([user, post, comment, snapshot]) db.session.commit() diff --git a/server/szurubooru/tests/model/test_user_token.py b/server/szurubooru/tests/model/test_user_token.py index 0280082..cddb5cf 100644 --- a/server/szurubooru/tests/model/test_user_token.py +++ b/server/szurubooru/tests/model/test_user_token.py @@ -1,4 +1,5 @@ from datetime import datetime + from szurubooru import db @@ -9,6 +10,6 @@ def test_saving_user_token(user_token_factory): db.session.refresh(user_token) assert not db.session.dirty assert user_token.user is not None - assert user_token.token == 'dummy' + assert user_token.token == "dummy" assert user_token.enabled is True assert user_token.creation_time == datetime(1997, 1, 1) diff --git a/server/szurubooru/tests/rest/test_context.py b/server/szurubooru/tests/rest/test_context.py index 681d9d7..ec652b1 100644 --- a/server/szurubooru/tests/rest/test_context.py +++ b/server/szurubooru/tests/rest/test_context.py @@ -1,33 +1,38 @@ -# pylint: disable=unexpected-keyword-arg import unittest.mock + import pytest -from szurubooru import rest, errors + +from szurubooru import errors, rest from szurubooru.func import net def test_has_param(): - ctx = rest.Context(env={}, method=None, url=None, params={'key': 'value'}) - assert ctx.has_param('key') - assert not ctx.has_param('non-existing') + ctx = rest.Context(env={}, method=None, url=None, params={"key": "value"}) + assert ctx.has_param("key") + assert not ctx.has_param("non-existing") def test_get_file(): ctx = rest.Context( - env={}, method=None, url=None, files={'key': b'content'}) - assert ctx.get_file('key') == b'content' + env={}, method=None, url=None, files={"key": b"content"} + ) + assert ctx.get_file("key") == b"content" with pytest.raises(errors.ValidationError): - ctx.get_file('non-existing') + ctx.get_file("non-existing") def test_get_file_from_url(): - with unittest.mock.patch('szurubooru.func.net.download'): - net.download.return_value = b'content' + with unittest.mock.patch("szurubooru.func.net.download"): + net.download.return_value = b"content" ctx = rest.Context( - env={}, method=None, url=None, params={'keyUrl': 'example.com'}) - assert ctx.get_file('key') == b'content' - net.download.assert_called_once_with('example.com') + env={}, method=None, url=None, params={"keyUrl": "example.com"} + ) + assert ctx.get_file("key") == b"content" + net.download.assert_called_once_with( + "example.com", use_video_downloader=False + ) with pytest.raises(errors.ValidationError): - assert ctx.get_file('non-existing') + assert ctx.get_file("non-existing") def test_getting_list_parameter(): @@ -35,12 +40,13 @@ def test_getting_list_parameter(): env={}, method=None, url=None, - params={'key': 'value', 'list': ['1', '2', '3']}) - assert ctx.get_param_as_list('key') == ['value'] - assert ctx.get_param_as_list('list') == ['1', '2', '3'] + params={"key": "value", "list": ["1", "2", "3"]}, + ) + assert ctx.get_param_as_list("key") == ["value"] + assert ctx.get_param_as_list("list") == ["1", "2", "3"] with pytest.raises(errors.ValidationError): - ctx.get_param_as_list('non-existing') - assert ctx.get_param_as_list('non-existing', default=['def']) == ['def'] + ctx.get_param_as_list("non-existing") + assert ctx.get_param_as_list("non-existing", default=["def"]) == ["def"] def test_getting_string_parameter(): @@ -48,12 +54,13 @@ def test_getting_string_parameter(): env={}, method=None, url=None, - params={'key': 'value', 'list': ['1', '2', '3']}) - assert ctx.get_param_as_string('key') == 'value' - assert ctx.get_param_as_string('list') == '1,2,3' + params={"key": "value", "list": ["1", "2", "3"]}, + ) + assert ctx.get_param_as_string("key") == "value" + assert ctx.get_param_as_string("list") == "1,2,3" with pytest.raises(errors.ValidationError): - ctx.get_param_as_string('non-existing') - assert ctx.get_param_as_string('non-existing', default='x') == 'x' + ctx.get_param_as_string("non-existing") + assert ctx.get_param_as_string("non-existing", default="x") == "x" def test_getting_int_parameter(): @@ -61,55 +68,57 @@ def test_getting_int_parameter(): env={}, method=None, url=None, - params={'key': '50', 'err': 'invalid', 'list': [1, 2, 3]}) - assert ctx.get_param_as_int('key') == 50 + params={"key": "50", "err": "invalid", "list": [1, 2, 3]}, + ) + assert ctx.get_param_as_int("key") == 50 with pytest.raises(errors.ValidationError): - ctx.get_param_as_int('list') + ctx.get_param_as_int("list") with pytest.raises(errors.ValidationError): - ctx.get_param_as_int('non-existing') - assert ctx.get_param_as_int('non-existing', default=5) == 5 + ctx.get_param_as_int("non-existing") + assert ctx.get_param_as_int("non-existing", default=5) == 5 with pytest.raises(errors.ValidationError): - ctx.get_param_as_int('err') + ctx.get_param_as_int("err") with pytest.raises(errors.ValidationError): - assert ctx.get_param_as_int('key', min=50) == 50 - ctx.get_param_as_int('key', min=51) + assert ctx.get_param_as_int("key", min=50) == 50 + ctx.get_param_as_int("key", min=51) with pytest.raises(errors.ValidationError): - assert ctx.get_param_as_int('key', max=50) == 50 - ctx.get_param_as_int('key', max=49) + assert ctx.get_param_as_int("key", max=50) == 50 + ctx.get_param_as_int("key", max=49) def test_getting_bool_parameter(): def test(value): ctx = rest.Context( - env={}, method=None, url=None, params={'key': value}) - return ctx.get_param_as_bool('key') + env={}, method=None, url=None, params={"key": value} + ) + return ctx.get_param_as_bool("key") - assert test('1') is True - assert test('y') is True - assert test('yes') is True - assert test('yep') is True - assert test('yup') is True - assert test('yeah') is True - assert test('t') is True - assert test('true') is True - assert test('TRUE') is True + assert test("1") is True + assert test("y") is True + assert test("yes") is True + assert test("yep") is True + assert test("yup") is True + assert test("yeah") is True + assert test("t") is True + assert test("true") is True + assert test("TRUE") is True - assert test('0') is False - assert test('n') is False - assert test('no') is False - assert test('nope') is False - assert test('f') is False - assert test('false') is False - assert test('FALSE') is False + assert test("0") is False + assert test("n") is False + assert test("no") is False + assert test("nope") is False + assert test("f") is False + assert test("false") is False + assert test("FALSE") is False with pytest.raises(errors.ValidationError): - test('herp') + test("herp") with pytest.raises(errors.ValidationError): - test('2') + test("2") with pytest.raises(errors.ValidationError): - test(['1', '2']) + test(["1", "2"]) ctx = rest.Context(env={}, method=None, url=None) with pytest.raises(errors.ValidationError): - ctx.get_param_as_bool('non-existing') - assert ctx.get_param_as_bool('non-existing', default=True) is True + ctx.get_param_as_bool("non-existing") + assert ctx.get_param_as_bool("non-existing", default=True) is True diff --git a/server/szurubooru/tests/search/configs/test_comment_search_config.py b/server/szurubooru/tests/search/configs/test_comment_search_config.py index 109629b..d986c22 100644 --- a/server/szurubooru/tests/search/configs/test_comment_search_config.py +++ b/server/szurubooru/tests/search/configs/test_comment_search_config.py @@ -1,6 +1,7 @@ -# pylint: disable=redefined-outer-name from datetime import datetime + import pytest + from szurubooru import db, search @@ -13,22 +14,28 @@ def executor(): def verify_unpaged(executor): def verify(input, expected_comment_text): actual_count, actual_comments = executor.execute( - input, offset=0, limit=100) + input, offset=0, limit=100 + ) actual_comment_text = [c.text for c in actual_comments] assert actual_count == len(expected_comment_text) assert actual_comment_text == expected_comment_text + return verify -@pytest.mark.parametrize('input,expected_comment_text', [ - ('creation-time:2014', ['t2', 't1']), - ('creation-date:2014', ['t2', 't1']), -]) +@pytest.mark.parametrize( + "input,expected_comment_text", + [ + ("creation-time:2014", ["t2", "t1"]), + ("creation-date:2014", ["t2", "t1"]), + ], +) def test_filter_by_creation_time( - verify_unpaged, comment_factory, input, expected_comment_text): - comment1 = comment_factory(text='t1') - comment2 = comment_factory(text='t2') - comment3 = comment_factory(text='t3') + verify_unpaged, comment_factory, input, expected_comment_text +): + comment1 = comment_factory(text="t1") + comment2 = comment_factory(text="t2") + comment3 = comment_factory(text="t3") comment1.creation_time = datetime(2014, 1, 1) comment2.creation_time = datetime(2014, 6, 1) comment3.creation_time = datetime(2015, 1, 1) @@ -37,109 +44,121 @@ def test_filter_by_creation_time( verify_unpaged(input, expected_comment_text) -@pytest.mark.parametrize('input,expected_comment_text', [ - ('text:t1', ['t1']), - ('text:t2', ['t2']), - ('text:t1,t2', ['t1', 't2']), - ('text:t*', ['t1', 't2']), -]) +@pytest.mark.parametrize( + "input,expected_comment_text", + [ + ("text:t1", ["t1"]), + ("text:t2", ["t2"]), + ("text:t1,t2", ["t1", "t2"]), + ("text:t*", ["t1", "t2"]), + ], +) def test_filter_by_text( - verify_unpaged, comment_factory, input, expected_comment_text): - comment1 = comment_factory(text='t1') - comment2 = comment_factory(text='t2') + verify_unpaged, comment_factory, input, expected_comment_text +): + comment1 = comment_factory(text="t1") + comment2 = comment_factory(text="t2") db.session.add_all([comment1, comment2]) db.session.flush() verify_unpaged(input, expected_comment_text) -@pytest.mark.parametrize('input,expected_comment_text', [ - ('user:u1', ['t1']), - ('user:u2', ['t2']), - ('user:u1,u2', ['t2', 't1']), -]) +@pytest.mark.parametrize( + "input,expected_comment_text", + [ + ("user:u1", ["t1"]), + ("user:u2", ["t2"]), + ("user:u1,u2", ["t2", "t1"]), + ], +) def test_filter_by_user( - verify_unpaged, - comment_factory, - user_factory, - input, - expected_comment_text): - db.session.add(comment_factory(text='t2', user=user_factory(name='u2'))) - db.session.add(comment_factory(text='t1', user=user_factory(name='u1'))) + verify_unpaged, comment_factory, user_factory, input, expected_comment_text +): + db.session.add(comment_factory(text="t2", user=user_factory(name="u2"))) + db.session.add(comment_factory(text="t1", user=user_factory(name="u1"))) db.session.flush() verify_unpaged(input, expected_comment_text) -@pytest.mark.parametrize('input,expected_comment_text', [ - ('post:1', ['t1']), - ('post:2', ['t2']), - ('post:1,2', ['t1', 't2']), -]) +@pytest.mark.parametrize( + "input,expected_comment_text", + [ + ("post:1", ["t1"]), + ("post:2", ["t2"]), + ("post:1,2", ["t1", "t2"]), + ], +) def test_filter_by_post( - verify_unpaged, - comment_factory, - post_factory, - input, - expected_comment_text): - db.session.add(comment_factory(text='t1', post=post_factory(id=1))) - db.session.add(comment_factory(text='t2', post=post_factory(id=2))) + verify_unpaged, comment_factory, post_factory, input, expected_comment_text +): + db.session.add(comment_factory(text="t1", post=post_factory(id=1))) + db.session.add(comment_factory(text="t2", post=post_factory(id=2))) db.session.flush() verify_unpaged(input, expected_comment_text) -@pytest.mark.parametrize('input,expected_comment_text', [ - ('', ['t1', 't2']), - ('t1', ['t1']), - ('t2', ['t2']), - ('t1,t2', ['t1', 't2']), -]) +@pytest.mark.parametrize( + "input,expected_comment_text", + [ + ("", ["t1", "t2"]), + ("t1", ["t1"]), + ("t2", ["t2"]), + ("t1,t2", ["t1", "t2"]), + ], +) def test_anonymous( - verify_unpaged, comment_factory, input, expected_comment_text): - db.session.add(comment_factory(text='t1')) - db.session.add(comment_factory(text='t2')) + verify_unpaged, comment_factory, input, expected_comment_text +): + db.session.add(comment_factory(text="t1")) + db.session.add(comment_factory(text="t2")) db.session.flush() verify_unpaged(input, expected_comment_text) -@pytest.mark.parametrize('input,expected_comment_text', [ - ('sort:user', ['t1', 't2']), -]) +@pytest.mark.parametrize( + "input,expected_comment_text", + [ + ("sort:user", ["t1", "t2"]), + ], +) def test_sort_by_user( - verify_unpaged, - comment_factory, - user_factory, - input, - expected_comment_text): - db.session.add(comment_factory(text='t2', user=user_factory(name='u2'))) - db.session.add(comment_factory(text='t1', user=user_factory(name='u1'))) + verify_unpaged, comment_factory, user_factory, input, expected_comment_text +): + db.session.add(comment_factory(text="t2", user=user_factory(name="u2"))) + db.session.add(comment_factory(text="t1", user=user_factory(name="u1"))) db.session.flush() verify_unpaged(input, expected_comment_text) -@pytest.mark.parametrize('input,expected_comment_text', [ - ('sort:post', ['t2', 't1']), -]) +@pytest.mark.parametrize( + "input,expected_comment_text", + [ + ("sort:post", ["t2", "t1"]), + ], +) def test_sort_by_post( - verify_unpaged, - comment_factory, - post_factory, - input, - expected_comment_text): - db.session.add(comment_factory(text='t1', post=post_factory(id=1))) - db.session.add(comment_factory(text='t2', post=post_factory(id=2))) + verify_unpaged, comment_factory, post_factory, input, expected_comment_text +): + db.session.add(comment_factory(text="t1", post=post_factory(id=1))) + db.session.add(comment_factory(text="t2", post=post_factory(id=2))) db.session.flush() verify_unpaged(input, expected_comment_text) -@pytest.mark.parametrize('input,expected_comment_text', [ - ('', ['t3', 't2', 't1']), - ('sort:creation-date', ['t3', 't2', 't1']), - ('sort:creation-time', ['t3', 't2', 't1']), -]) +@pytest.mark.parametrize( + "input,expected_comment_text", + [ + ("", ["t3", "t2", "t1"]), + ("sort:creation-date", ["t3", "t2", "t1"]), + ("sort:creation-time", ["t3", "t2", "t1"]), + ], +) def test_sort_by_creation_time( - verify_unpaged, comment_factory, input, expected_comment_text): - comment1 = comment_factory(text='t1') - comment2 = comment_factory(text='t2') - comment3 = comment_factory(text='t3') + verify_unpaged, comment_factory, input, expected_comment_text +): + comment1 = comment_factory(text="t1") + comment2 = comment_factory(text="t2") + comment3 = comment_factory(text="t3") comment1.creation_time = datetime(1991, 1, 1) comment2.creation_time = datetime(1991, 1, 2) comment3.creation_time = datetime(1991, 1, 3) @@ -148,17 +167,21 @@ def test_sort_by_creation_time( verify_unpaged(input, expected_comment_text) -@pytest.mark.parametrize('input,expected_comment_text', [ - ('sort:last-edit-date', ['t3', 't2', 't1']), - ('sort:last-edit-time', ['t3', 't2', 't1']), - ('sort:edit-date', ['t3', 't2', 't1']), - ('sort:edit-time', ['t3', 't2', 't1']), -]) +@pytest.mark.parametrize( + "input,expected_comment_text", + [ + ("sort:last-edit-date", ["t3", "t2", "t1"]), + ("sort:last-edit-time", ["t3", "t2", "t1"]), + ("sort:edit-date", ["t3", "t2", "t1"]), + ("sort:edit-time", ["t3", "t2", "t1"]), + ], +) def test_sort_by_last_edit_time( - verify_unpaged, comment_factory, input, expected_comment_text): - comment1 = comment_factory(text='t1') - comment2 = comment_factory(text='t2') - comment3 = comment_factory(text='t3') + verify_unpaged, comment_factory, input, expected_comment_text +): + comment1 = comment_factory(text="t1") + comment2 = comment_factory(text="t2") + comment3 = comment_factory(text="t3") comment1.last_edit_time = datetime(1991, 1, 1) comment2.last_edit_time = datetime(1991, 1, 2) comment3.last_edit_time = datetime(1991, 1, 3) diff --git a/server/szurubooru/tests/search/configs/test_pool_search_config.py b/server/szurubooru/tests/search/configs/test_pool_search_config.py new file mode 100644 index 0000000..202635c --- /dev/null +++ b/server/szurubooru/tests/search/configs/test_pool_search_config.py @@ -0,0 +1,433 @@ +from datetime import datetime + +import pytest + +from szurubooru import db, errors, search + + +@pytest.fixture +def executor(): + return search.Executor(search.configs.PoolSearchConfig()) + + +@pytest.fixture +def verify_unpaged(executor): + def verify(input, expected_pool_names): + actual_count, actual_pools = executor.execute( + input, offset=0, limit=100 + ) + actual_pool_names = [u.names[0].name for u in actual_pools] + assert actual_count == len(expected_pool_names) + assert actual_pool_names == expected_pool_names + + return verify + + +@pytest.mark.parametrize( + "input,expected_pool_names", + [ + ("", ["t1", "t2"]), + ("t1", ["t1"]), + ("t2", ["t2"]), + ("t1,t2", ["t1", "t2"]), + ("T1,T2", ["t1", "t2"]), + ], +) +def test_filter_anonymous( + verify_unpaged, pool_factory, input, expected_pool_names +): + db.session.add(pool_factory(id=1, names=["t1"])) + db.session.add(pool_factory(id=2, names=["t2"])) + db.session.flush() + verify_unpaged(input, expected_pool_names) + + +@pytest.mark.parametrize( + "db_driver,input,expected_pool_names", + [ + (None, ",", None), + (None, "t1,", None), + (None, "t1,t2", ["t1", "t2"]), + (None, "t1\\,", []), + (None, "asd..asd", None), + (None, "asd\\..asd", []), + (None, "asd.\\.asd", []), + (None, "asd\\.\\.asd", []), + (None, "-", None), + (None, "\\-", ["-"]), + ( + None, + "--", + [ + "t1", + "t2", + "*", + "*asd*", + ":", + "asd:asd", + "\\", + "\\asd", + "-asd", + ], + ), + (None, "\\--", []), + ( + None, + "-\\-", + [ + "t1", + "t2", + "*", + "*asd*", + ":", + "asd:asd", + "\\", + "\\asd", + "-asd", + ], + ), + (None, "-*", []), + (None, "\\-*", ["-", "-asd"]), + (None, ":", None), + (None, "\\:", [":"]), + (None, "\\:asd", []), + (None, "*\\:*", [":", "asd:asd"]), + (None, "asd:asd", None), + (None, "asd\\:asd", ["asd:asd"]), + ( + None, + "*", + [ + "t1", + "t2", + "*", + "*asd*", + ":", + "asd:asd", + "\\", + "\\asd", + "-", + "-asd", + ], + ), + (None, "\\*", ["*"]), + (None, "\\", None), + (None, "\\asd", None), + ("psycopg2", "\\\\", ["\\"]), + ("psycopg2", "\\\\asd", ["\\asd"]), + ], +) +def test_escaping( + executor, pool_factory, input, expected_pool_names, db_driver +): + db.session.add_all( + [ + pool_factory(id=1, names=["t1"]), + pool_factory(id=2, names=["t2"]), + pool_factory(id=3, names=["*"]), + pool_factory(id=4, names=["*asd*"]), + pool_factory(id=5, names=[":"]), + pool_factory(id=6, names=["asd:asd"]), + pool_factory(id=7, names=["\\"]), + pool_factory(id=8, names=["\\asd"]), + pool_factory(id=9, names=["-"]), + pool_factory(id=10, names=["-asd"]), + ] + ) + 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) + else: + actual_count, actual_pools = executor.execute( + input, offset=0, limit=100 + ) + actual_pool_names = [u.names[0].name for u in actual_pools] + assert actual_count == len(expected_pool_names) + assert sorted(actual_pool_names) == sorted(expected_pool_names) + + +def test_filter_anonymous_starting_with_colon(verify_unpaged, pool_factory): + db.session.add(pool_factory(id=1, names=[":t"])) + db.session.flush() + with pytest.raises(errors.SearchError): + verify_unpaged(":t", [":t"]) + verify_unpaged("\\:t", [":t"]) + + +@pytest.mark.parametrize( + "input,expected_pool_names", + [ + ("name:pool1", ["pool1"]), + ("name:pool2", ["pool2"]), + ("name:none", []), + ("name:", []), + ("name:*1", ["pool1"]), + ("name:*2", ["pool2"]), + ("name:*", ["pool1", "pool2", "pool3", "pool4"]), + ("name:p*", ["pool1", "pool2", "pool3", "pool4"]), + ("name:*o*", ["pool1", "pool2", "pool3", "pool4"]), + ("name:*!*", []), + ("name:!*", []), + ("name:*!", []), + ("-name:pool1", ["pool2", "pool3", "pool4"]), + ("-name:pool2", ["pool1", "pool3", "pool4"]), + ("name:pool1,pool2", ["pool1", "pool2"]), + ("-name:pool1,pool3", ["pool2", "pool4"]), + ("name:pool4", ["pool4"]), + ("name:pool5", ["pool4"]), + ("name:pool4,pool5", ["pool4"]), + ], +) +def test_filter_by_name( + verify_unpaged, pool_factory, input, expected_pool_names +): + db.session.add(pool_factory(id=1, names=["pool1"])) + db.session.add(pool_factory(id=2, names=["pool2"])) + db.session.add(pool_factory(id=3, names=["pool3"])) + db.session.add(pool_factory(id=4, names=["pool4", "pool5", "pool6"])) + db.session.flush() + verify_unpaged(input, expected_pool_names) + + +@pytest.mark.parametrize( + "input,expected_pool_names", + [ + ("category:cat1", ["t1", "t2"]), + ("category:cat2", ["t3"]), + ("category:cat1,cat2", ["t1", "t2", "t3"]), + ], +) +def test_filter_by_category( + verify_unpaged, + pool_factory, + pool_category_factory, + input, + expected_pool_names, +): + cat1 = pool_category_factory(name="cat1") + cat2 = pool_category_factory(name="cat2") + pool1 = pool_factory(id=1, names=["t1"], category=cat1) + pool2 = pool_factory(id=2, names=["t2"], category=cat1) + pool3 = pool_factory(id=3, names=["t3"], category=cat2) + db.session.add_all([pool1, pool2, pool3]) + db.session.flush() + verify_unpaged(input, expected_pool_names) + + +@pytest.mark.parametrize( + "input,expected_pool_names", + [ + ("creation-time:2014", ["t1", "t2"]), + ("creation-date:2014", ["t1", "t2"]), + ("-creation-time:2014", ["t3"]), + ("-creation-date:2014", ["t3"]), + ("creation-time:2014..2014-06", ["t1", "t2"]), + ("creation-time:2014-06..2015-01-01", ["t2", "t3"]), + ("creation-time:2014-06..", ["t2", "t3"]), + ("creation-time:..2014-06", ["t1", "t2"]), + ("-creation-time:2014..2014-06", ["t3"]), + ("-creation-time:2014-06..2015-01-01", ["t1"]), + ("creation-date:2014..2014-06", ["t1", "t2"]), + ("creation-date:2014-06..2015-01-01", ["t2", "t3"]), + ("creation-date:2014-06..", ["t2", "t3"]), + ("creation-date:..2014-06", ["t1", "t2"]), + ("-creation-date:2014..2014-06", ["t3"]), + ("-creation-date:2014-06..2015-01-01", ["t1"]), + ("creation-time:2014-01,2015", ["t1", "t3"]), + ("creation-date:2014-01,2015", ["t1", "t3"]), + ("-creation-time:2014-01,2015", ["t2"]), + ("-creation-date:2014-01,2015", ["t2"]), + ], +) +def test_filter_by_creation_time( + verify_unpaged, pool_factory, input, expected_pool_names +): + pool1 = pool_factory(id=1, names=["t1"]) + pool2 = pool_factory(id=2, names=["t2"]) + pool3 = pool_factory(id=3, names=["t3"]) + pool1.creation_time = datetime(2014, 1, 1) + pool2.creation_time = datetime(2014, 6, 1) + pool3.creation_time = datetime(2015, 1, 1) + db.session.add_all([pool1, pool2, pool3]) + db.session.flush() + verify_unpaged(input, expected_pool_names) + + +@pytest.mark.parametrize( + "input,expected_pool_names", + [ + ("last-edit-date:2014", ["t1", "t3"]), + ("last-edit-time:2014", ["t1", "t3"]), + ("edit-date:2014", ["t1", "t3"]), + ("edit-time:2014", ["t1", "t3"]), + ], +) +def test_filter_by_edit_time( + verify_unpaged, pool_factory, input, expected_pool_names +): + pool1 = pool_factory(id=1, names=["t1"]) + pool2 = pool_factory(id=2, names=["t2"]) + pool3 = pool_factory(id=3, names=["t3"]) + pool1.last_edit_time = datetime(2014, 1, 1) + pool2.last_edit_time = datetime(2015, 1, 1) + pool3.last_edit_time = datetime(2014, 1, 1) + db.session.add_all([pool1, pool2, pool3]) + db.session.flush() + verify_unpaged(input, expected_pool_names) + + +@pytest.mark.parametrize( + "input,expected_pool_names", + [ + ("post-count:2", ["t1"]), + ("post-count:1", ["t2"]), + ("post-count:1..", ["t1", "t2"]), + ("post-count-min:1", ["t1", "t2"]), + ("post-count:..1", ["t2"]), + ("post-count-max:1", ["t2"]), + ], +) +def test_filter_by_post_count( + verify_unpaged, pool_factory, post_factory, input, expected_pool_names +): + post1 = post_factory(id=1) + post2 = post_factory(id=2) + pool1 = pool_factory(id=1, names=["t1"]) + pool2 = pool_factory(id=2, names=["t2"]) + db.session.add_all([post1, post2, pool1, pool2]) + pool1.posts.append(post1) + pool1.posts.append(post2) + pool2.posts.append(post1) + db.session.flush() + verify_unpaged(input, expected_pool_names) + + +@pytest.mark.parametrize( + "input", + [ + "post-count:..", + "post-count:asd", + "post-count:asd,1", + "post-count:1,asd", + "post-count:asd..1", + "post-count:1..asd", + ], +) +def test_filter_by_invalid_input(executor, input): + with pytest.raises(errors.SearchError): + executor.execute(input, offset=0, limit=100) + + +@pytest.mark.parametrize( + "input,expected_pool_names", + [ + ("", ["t1", "t2"]), + ("sort:name", ["t1", "t2"]), + ("-sort:name", ["t2", "t1"]), + ("sort:name,asc", ["t1", "t2"]), + ("sort:name,desc", ["t2", "t1"]), + ("-sort:name,asc", ["t2", "t1"]), + ("-sort:name,desc", ["t1", "t2"]), + ], +) +def test_sort_by_name( + verify_unpaged, pool_factory, input, expected_pool_names +): + db.session.add(pool_factory(id=2, names=["t2"])) + db.session.add(pool_factory(id=1, names=["t1"])) + db.session.flush() + verify_unpaged(input, expected_pool_names) + + +@pytest.mark.parametrize( + "input,expected_pool_names", + [ + ("", ["t1", "t2", "t3"]), + ("sort:creation-date", ["t3", "t2", "t1"]), + ("sort:creation-time", ["t3", "t2", "t1"]), + ], +) +def test_sort_by_creation_time( + verify_unpaged, pool_factory, input, expected_pool_names +): + pool1 = pool_factory(id=1, names=["t1"]) + pool2 = pool_factory(id=2, names=["t2"]) + pool3 = pool_factory(id=3, names=["t3"]) + pool1.creation_time = datetime(1991, 1, 1) + pool2.creation_time = datetime(1991, 1, 2) + pool3.creation_time = datetime(1991, 1, 3) + db.session.add_all([pool3, pool1, pool2]) + db.session.flush() + verify_unpaged(input, expected_pool_names) + + +@pytest.mark.parametrize( + "input,expected_pool_names", + [ + ("", ["t1", "t2", "t3"]), + ("sort:last-edit-date", ["t3", "t2", "t1"]), + ("sort:last-edit-time", ["t3", "t2", "t1"]), + ("sort:edit-date", ["t3", "t2", "t1"]), + ("sort:edit-time", ["t3", "t2", "t1"]), + ], +) +def test_sort_by_last_edit_time( + verify_unpaged, pool_factory, input, expected_pool_names +): + pool1 = pool_factory(id=1, names=["t1"]) + pool2 = pool_factory(id=2, names=["t2"]) + pool3 = pool_factory(id=3, names=["t3"]) + pool1.last_edit_time = datetime(1991, 1, 1) + pool2.last_edit_time = datetime(1991, 1, 2) + pool3.last_edit_time = datetime(1991, 1, 3) + db.session.add_all([pool3, pool1, pool2]) + db.session.flush() + verify_unpaged(input, expected_pool_names) + + +@pytest.mark.parametrize( + "input,expected_pool_names", + [ + ("sort:post-count", ["t2", "t1"]), + ], +) +def test_sort_by_post_count( + verify_unpaged, pool_factory, post_factory, input, expected_pool_names +): + post1 = post_factory(id=1) + post2 = post_factory(id=2) + pool1 = pool_factory(id=1, names=["t1"]) + pool2 = pool_factory(id=2, names=["t2"]) + db.session.add_all([post1, post2, pool1, pool2]) + pool1.posts.append(post1) + pool2.posts.append(post1) + pool2.posts.append(post2) + db.session.flush() + verify_unpaged(input, expected_pool_names) + + +@pytest.mark.parametrize( + "input,expected_pool_names", + [ + ("sort:category", ["t3", "t1", "t2"]), + ], +) +def test_sort_by_category( + verify_unpaged, + pool_factory, + pool_category_factory, + input, + expected_pool_names, +): + cat1 = pool_category_factory(name="cat1") + cat2 = pool_category_factory(name="cat2") + pool1 = pool_factory(id=1, names=["t1"], category=cat2) + pool2 = pool_factory(id=2, names=["t2"], category=cat2) + pool3 = pool_factory(id=3, names=["t3"], category=cat1) + db.session.add_all([pool1, pool2, pool3]) + db.session.flush() + verify_unpaged(input, expected_pool_names) 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 dd9b98f..84e1601 100644 --- a/server/szurubooru/tests/search/configs/test_post_search_config.py +++ b/server/szurubooru/tests/search/configs/test_post_search_config.py @@ -1,16 +1,17 @@ -# pylint: disable=redefined-outer-name from datetime import datetime + import pytest -from szurubooru import db, model, errors, search + +from szurubooru import db, errors, model, search @pytest.fixture def fav_factory(user_factory): def factory(post, user=None): return model.PostFavorite( - post=post, - user=user or user_factory(), - time=datetime.utcnow()) + post=post, user=user or user_factory(), time=datetime.utcnow() + ) + return factory @@ -21,14 +22,17 @@ def score_factory(user_factory): post=post, user=user or user_factory(), time=datetime.utcnow(), - score=score) + score=score, + ) + return factory @pytest.fixture def note_factory(): - def factory(text='...'): - return model.PostNote(polygon='...', text=text) + def factory(text="..."): + return model.PostNote(polygon="...", text=text) + return factory @@ -37,11 +41,10 @@ def feature_factory(user_factory): def factory(post=None): if post: return model.PostFeature( - time=datetime.utcnow(), - user=user_factory(), - post=post) - return model.PostFeature( - time=datetime.utcnow(), user=user_factory()) + time=datetime.utcnow(), user=user_factory(), post=post + ) + return model.PostFeature(time=datetime.utcnow(), user=user_factory()) + return factory @@ -58,6 +61,7 @@ def auth_executor(executor, user_factory): db.session.flush() executor.config.user = auth_user return auth_user + return wrapper @@ -65,15 +69,16 @@ def auth_executor(executor, user_factory): def verify_unpaged(executor): def verify(input, expected_post_ids, test_order=False): actual_count, actual_posts = executor.execute( - input, offset=0, limit=100) + input, offset=0, limit=100 + ) actual_post_ids = list([p.post_id for p in actual_posts]) if not test_order: actual_post_ids = sorted(actual_post_ids) expected_post_ids = sorted(expected_post_ids) assert actual_post_ids == expected_post_ids assert actual_count == len(expected_post_ids) - return verify + return verify @pytest.fixture def verify_around(executor): @@ -84,11 +89,14 @@ def verify_around(executor): return verify -@pytest.mark.parametrize('input,expected_post_ids', [ - ('id:1', [1]), - ('id:3', [3]), - ('id:1,3', [1, 3]), -]) +@pytest.mark.parametrize( + "input,expected_post_ids", + [ + ("id:1", [1]), + ("id:3", [3]), + ("id:1,3", [1, 3]), + ], +) def test_filter_by_id(verify_unpaged, post_factory, input, expected_post_ids): post1 = post_factory(id=1) post2 = post_factory(id=2) @@ -98,35 +106,43 @@ def test_filter_by_id(verify_unpaged, post_factory, input, expected_post_ids): verify_unpaged(input, expected_post_ids) -@pytest.mark.parametrize('input,expected_post_ids', [ - ('tag:t1', [1]), - ('tag:t2', [2]), - ('tag:t1,t2', [1, 2]), - ('tag:t4a', [4]), - ('tag:t4b', [4]), -]) +@pytest.mark.parametrize( + "input,expected_post_ids", + [ + ("tag:t1", [1]), + ("tag:t2", [2]), + ("tag:t1,t2", [1, 2]), + ("tag:t4a", [4]), + ("tag:t4b", [4]), + ], +) def test_filter_by_tag( - verify_unpaged, post_factory, tag_factory, input, expected_post_ids): + verify_unpaged, post_factory, tag_factory, input, expected_post_ids +): post1 = post_factory(id=1) post2 = post_factory(id=2) post3 = post_factory(id=3) post4 = post_factory(id=4) - post1.tags = [tag_factory(names=['t1'])] - post2.tags = [tag_factory(names=['t2'])] - post3.tags = [tag_factory(names=['t3'])] - post4.tags = [tag_factory(names=['t4a', 't4b'])] + post1.tags = [tag_factory(names=["t1"])] + post2.tags = [tag_factory(names=["t2"])] + post3.tags = [tag_factory(names=["t3"])] + post4.tags = [tag_factory(names=["t4a", "t4b"])] db.session.add_all([post1, post2, post3, post4]) db.session.flush() verify_unpaged(input, expected_post_ids) -@pytest.mark.parametrize('input,expected_post_ids', [ - ('score:1', [1]), - ('score:3', [3]), - ('score:1,3', [1, 3]), -]) +@pytest.mark.parametrize( + "input,expected_post_ids", + [ + ("score:1", [1]), + ("score:3", [3]), + ("score:1,3", [1, 3]), + ], +) def test_filter_by_score( - verify_unpaged, post_factory, user_factory, input, expected_post_ids): + verify_unpaged, post_factory, user_factory, input, expected_post_ids +): post1 = post_factory(id=1) post2 = post_factory(id=2) post3 = post_factory(id=3) @@ -136,96 +152,123 @@ def test_filter_by_score( score=post.post_id, time=datetime.utcnow(), post=post, - user=user_factory())) + user=user_factory(), + ) + ) db.session.add_all([post1, post2, post3]) db.session.flush() verify_unpaged(input, expected_post_ids) -@pytest.mark.parametrize('input,expected_post_ids', [ - ('uploader:', [4]), - ('uploader:u1', [1]), - ('uploader:u3', [3]), - ('uploader:u1,u3', [1, 3]), - ('upload:', [4]), - ('upload:u1', [1]), - ('upload:u3', [3]), - ('upload:u1,u3', [1, 3]), - ('submit:', [4]), - ('submit:u1', [1]), - ('submit:u3', [3]), - ('submit:u1,u3', [1, 3]), -]) +@pytest.mark.parametrize( + "input,expected_post_ids", + [ + ("uploader:", [4]), + ("uploader:u1", [1]), + ("uploader:u3", [3]), + ("uploader:u1,u3", [1, 3]), + ("upload:", [4]), + ("upload:u1", [1]), + ("upload:u3", [3]), + ("upload:u1,u3", [1, 3]), + ("submit:", [4]), + ("submit:u1", [1]), + ("submit:u3", [3]), + ("submit:u1,u3", [1, 3]), + ], +) def test_filter_by_uploader( - verify_unpaged, post_factory, user_factory, input, expected_post_ids): + verify_unpaged, post_factory, user_factory, input, expected_post_ids +): post1 = post_factory(id=1) post2 = post_factory(id=2) post3 = post_factory(id=3) post4 = post_factory(id=4) - post1.user = user_factory(name='u1') - post2.user = user_factory(name='u2') - post3.user = user_factory(name='u3') + post1.user = user_factory(name="u1") + post2.user = user_factory(name="u2") + post3.user = user_factory(name="u3") db.session.add_all([post1, post2, post3, post4]) db.session.flush() verify_unpaged(input, expected_post_ids) -@pytest.mark.parametrize('input,expected_post_ids', [ - ('comment:u1', [1]), - ('comment:u3', [3]), - ('comment:u1,u3', [1, 3]), -]) +@pytest.mark.parametrize( + "input,expected_post_ids", + [ + ("comment:u1", [1]), + ("comment:u3", [3]), + ("comment:u1,u3", [1, 3]), + ], +) def test_filter_by_commenter( - verify_unpaged, - post_factory, - user_factory, - comment_factory, - input, - expected_post_ids): + verify_unpaged, + post_factory, + user_factory, + comment_factory, + input, + expected_post_ids, +): post1 = post_factory(id=1) post2 = post_factory(id=2) post3 = post_factory(id=3) - db.session.add_all([ - comment_factory(post=post1, user=user_factory(name='u1')), - comment_factory(post=post2, user=user_factory(name='u2')), - comment_factory(post=post3, user=user_factory(name='u3')), - post1, post2, post3, - ]) + db.session.add_all( + [ + comment_factory(post=post1, user=user_factory(name="u1")), + comment_factory(post=post2, user=user_factory(name="u2")), + comment_factory(post=post3, user=user_factory(name="u3")), + post1, + post2, + post3, + ] + ) db.session.flush() verify_unpaged(input, expected_post_ids) -@pytest.mark.parametrize('input,expected_post_ids', [ - ('fav:u1', [1]), - ('fav:u3', [3]), - ('fav:u1,u3', [1, 3]), -]) +@pytest.mark.parametrize( + "input,expected_post_ids", + [ + ("fav:u1", [1]), + ("fav:u3", [3]), + ("fav:u1,u3", [1, 3]), + ], +) def test_filter_by_favorite( - verify_unpaged, - post_factory, - user_factory, - fav_factory, - input, - expected_post_ids): + verify_unpaged, + post_factory, + user_factory, + fav_factory, + input, + expected_post_ids, +): post1 = post_factory(id=1) post2 = post_factory(id=2) post3 = post_factory(id=3) - db.session.add_all([ - fav_factory(post=post1, user=user_factory(name='u1')), - fav_factory(post=post2, user=user_factory(name='u2')), - fav_factory(post=post3, user=user_factory(name='u3')), - post1, post2, post3]) + db.session.add_all( + [ + fav_factory(post=post1, user=user_factory(name="u1")), + fav_factory(post=post2, user=user_factory(name="u2")), + fav_factory(post=post3, user=user_factory(name="u3")), + post1, + post2, + post3, + ] + ) db.session.flush() verify_unpaged(input, expected_post_ids) -@pytest.mark.parametrize('input,expected_post_ids', [ - ('tag-count:1', [1]), - ('tag-count:3', [3]), - ('tag-count:1,3', [1, 3]), -]) +@pytest.mark.parametrize( + "input,expected_post_ids", + [ + ("tag-count:1", [1]), + ("tag-count:3", [3]), + ("tag-count:1,3", [1, 3]), + ], +) def test_filter_by_tag_count( - verify_unpaged, post_factory, tag_factory, input, expected_post_ids): + verify_unpaged, post_factory, tag_factory, input, expected_post_ids +): post1 = post_factory(id=1) post2 = post_factory(id=2) post3 = post_factory(id=3) @@ -237,61 +280,79 @@ def test_filter_by_tag_count( verify_unpaged(input, expected_post_ids) -@pytest.mark.parametrize('input,expected_post_ids', [ - ('comment-count:1', [1]), - ('comment-count:3', [3]), - ('comment-count:1,3', [1, 3]), -]) +@pytest.mark.parametrize( + "input,expected_post_ids", + [ + ("comment-count:1", [1]), + ("comment-count:3", [3]), + ("comment-count:1,3", [1, 3]), + ], +) def test_filter_by_comment_count( - verify_unpaged, - post_factory, - comment_factory, - input, - expected_post_ids): + verify_unpaged, post_factory, comment_factory, input, expected_post_ids +): post1 = post_factory(id=1) post2 = post_factory(id=2) post3 = post_factory(id=3) - db.session.add_all([ - comment_factory(post=post1), - comment_factory(post=post2), - comment_factory(post=post2), - comment_factory(post=post3), - comment_factory(post=post3), - comment_factory(post=post3), - post1, post2, post3]) + db.session.add_all( + [ + comment_factory(post=post1), + comment_factory(post=post2), + comment_factory(post=post2), + comment_factory(post=post3), + comment_factory(post=post3), + comment_factory(post=post3), + post1, + post2, + post3, + ] + ) db.session.flush() verify_unpaged(input, expected_post_ids) -@pytest.mark.parametrize('input,expected_post_ids', [ - ('fav-count:1', [1]), - ('fav-count:3', [3]), - ('fav-count:1,3', [1, 3]), -]) +@pytest.mark.parametrize( + "input,expected_post_ids", + [ + ("fav-count:1", [1]), + ("fav-count:3", [3]), + ("fav-count:1,3", [1, 3]), + ], +) def test_filter_by_favorite_count( - verify_unpaged, post_factory, fav_factory, input, expected_post_ids): + verify_unpaged, post_factory, fav_factory, input, expected_post_ids +): post1 = post_factory(id=1) post2 = post_factory(id=2) post3 = post_factory(id=3) - db.session.add_all([ - fav_factory(post=post1), - fav_factory(post=post2), - fav_factory(post=post2), - fav_factory(post=post3), - fav_factory(post=post3), - fav_factory(post=post3), - post1, post2, post3]) + db.session.add_all( + [ + fav_factory(post=post1), + fav_factory(post=post2), + fav_factory(post=post2), + fav_factory(post=post3), + fav_factory(post=post3), + fav_factory(post=post3), + post1, + post2, + post3, + ] + ) db.session.flush() verify_unpaged(input, expected_post_ids) -@pytest.mark.parametrize('input,expected_post_ids', [ - ('note-count:1', [1]), - ('note-count:3', [3]), - ('note-count:1,3', [1, 3]), -]) +@pytest.mark.parametrize( + "input,expected_post_ids", + [ + ("note-count:1", [1]), + ("note-count:3", [3]), + ("note-count:1,3", [1, 3]), + ], +) def test_filter_by_note_count( - verify_unpaged, post_factory, note_factory, input, expected_post_ids): + verify_unpaged, post_factory, note_factory, input, expected_post_ids +): post1 = post_factory(id=1) post2 = post_factory(id=2) post3 = post_factory(id=3) @@ -303,36 +364,40 @@ def test_filter_by_note_count( verify_unpaged(input, expected_post_ids) -@pytest.mark.parametrize('input,expected_post_ids', [ - ('note-text:*', [1, 2, 3]), - ('note-text:text2', [2]), - ('note-text:text3*', [3]), - ('note-text:text3a,text2', [2, 3]), -]) +@pytest.mark.parametrize( + "input,expected_post_ids", + [ + ("note-text:*", [1, 2, 3]), + ("note-text:text2", [2]), + ("note-text:text3*", [3]), + ("note-text:text3a,text2", [2, 3]), + ], +) def test_filter_by_note_text( - verify_unpaged, post_factory, note_factory, input, expected_post_ids): + verify_unpaged, post_factory, note_factory, input, expected_post_ids +): post1 = post_factory(id=1) post2 = post_factory(id=2) post3 = post_factory(id=3) - post1.notes = [note_factory(text='text1')] - post2.notes = [note_factory(text='text2'), note_factory(text='text2')] - post3.notes = [note_factory(text='text3a'), note_factory(text='text3b')] + post1.notes = [note_factory(text="text1")] + post2.notes = [note_factory(text="text2"), note_factory(text="text2")] + post3.notes = [note_factory(text="text3a"), note_factory(text="text3b")] db.session.add_all([post1, post2, post3]) db.session.flush() verify_unpaged(input, expected_post_ids) -@pytest.mark.parametrize('input,expected_post_ids', [ - ('feature-count:1', [1]), - ('feature-count:3', [3]), - ('feature-count:1,3', [1, 3]), -]) +@pytest.mark.parametrize( + "input,expected_post_ids", + [ + ("feature-count:1", [1]), + ("feature-count:3", [3]), + ("feature-count:1,3", [1, 3]), + ], +) def test_filter_by_feature_count( - verify_unpaged, - post_factory, - feature_factory, - input, - expected_post_ids): + verify_unpaged, post_factory, feature_factory, input, expected_post_ids +): post1 = post_factory(id=1) post2 = post_factory(id=2) post3 = post_factory(id=3) @@ -344,18 +409,22 @@ def test_filter_by_feature_count( verify_unpaged(input, expected_post_ids) -@pytest.mark.parametrize('input,expected_post_ids', [ - ('type:image', [1]), - ('type:anim', [2]), - ('type:animation', [2]), - ('type:gif', [2]), - ('type:video', [3]), - ('type:webm', [3]), - ('type:flash', [4]), - ('type:swf', [4]), -]) +@pytest.mark.parametrize( + "input,expected_post_ids", + [ + ("type:image", [1]), + ("type:anim", [2]), + ("type:animation", [2]), + ("type:gif", [2]), + ("type:video", [3]), + ("type:webm", [3]), + ("type:flash", [4]), + ("type:swf", [4]), + ], +) def test_filter_by_type( - verify_unpaged, post_factory, input, expected_post_ids): + verify_unpaged, post_factory, input, expected_post_ids +): post1 = post_factory(id=1) post2 = post_factory(id=2) post3 = post_factory(id=3) @@ -369,14 +438,18 @@ def test_filter_by_type( verify_unpaged(input, expected_post_ids) -@pytest.mark.parametrize('input,expected_post_ids', [ - ('safety:safe', [1]), - ('safety:sketchy', [2]), - ('safety:questionable', [2]), - ('safety:unsafe', [3]), -]) +@pytest.mark.parametrize( + "input,expected_post_ids", + [ + ("safety:safe", [1]), + ("safety:sketchy", [2]), + ("safety:questionable", [2]), + ("safety:unsafe", [3]), + ], +) def test_filter_by_safety( - verify_unpaged, post_factory, input, expected_post_ids): + verify_unpaged, post_factory, input, expected_post_ids +): post1 = post_factory(id=1) post2 = post_factory(id=2) post3 = post_factory(id=3) @@ -390,34 +463,42 @@ def test_filter_by_safety( def test_filter_by_invalid_type(executor): with pytest.raises(errors.SearchError): - executor.execute('type:invalid', offset=0, limit=100) + executor.execute("type:invalid", offset=0, limit=100) -@pytest.mark.parametrize('input,expected_post_ids', [ - ('content-checksum:checksum1', [1]), - ('content-checksum:checksum3', [3]), - ('content-checksum:checksum1,checksum3', [1, 3]), -]) +@pytest.mark.parametrize( + "input,expected_post_ids", + [ + ("content-checksum:checksum1", [1]), + ("content-checksum:checksum3", [3]), + ("content-checksum:checksum1,checksum3", [1, 3]), + ], +) def test_filter_by_content_checksum( - verify_unpaged, post_factory, input, expected_post_ids): + verify_unpaged, post_factory, input, expected_post_ids +): post1 = post_factory(id=1) post2 = post_factory(id=2) post3 = post_factory(id=3) - post1.checksum = 'checksum1' - post2.checksum = 'checksum2' - post3.checksum = 'checksum3' + post1.checksum = "checksum1" + post2.checksum = "checksum2" + post3.checksum = "checksum3" db.session.add_all([post1, post2, post3]) db.session.flush() verify_unpaged(input, expected_post_ids) -@pytest.mark.parametrize('input,expected_post_ids', [ - ('file-size:100', [1]), - ('file-size:102', [3]), - ('file-size:100,102', [1, 3]), -]) +@pytest.mark.parametrize( + "input,expected_post_ids", + [ + ("file-size:100", [1]), + ("file-size:102", [3]), + ("file-size:100,102", [1, 3]), + ], +) def test_filter_by_file_size( - verify_unpaged, post_factory, input, expected_post_ids): + verify_unpaged, post_factory, input, expected_post_ids +): post1 = post_factory(id=1) post2 = post_factory(id=2) post3 = post_factory(id=3) @@ -429,25 +510,29 @@ def test_filter_by_file_size( verify_unpaged(input, expected_post_ids) -@pytest.mark.parametrize('input,expected_post_ids', [ - ('image-width:100', [1]), - ('image-width:200', [2]), - ('image-width:100,300', [1, 3]), - ('image-height:200', [1]), - ('image-height:100', [2]), - ('image-height:200,300', [1, 3]), - ('image-area:20000', [1, 2]), - ('image-area:90000', [3]), - ('image-area:20000,90000', [1, 2, 3]), - ('image-ar:1', [3]), - ('image-ar:..0.9', [1, 4]), - ('image-ar:1.1..', [2]), - ('image-ar:1/1..1/1', [3]), - ('image-ar:1:1..1:1', [3]), - ('image-ar:0.62..0.63', [4]), -]) +@pytest.mark.parametrize( + "input,expected_post_ids", + [ + ("image-width:100", [1]), + ("image-width:200", [2]), + ("image-width:100,300", [1, 3]), + ("image-height:200", [1]), + ("image-height:100", [2]), + ("image-height:200,300", [1, 3]), + ("image-area:20000", [1, 2]), + ("image-area:90000", [3]), + ("image-area:20000,90000", [1, 2, 3]), + ("image-ar:1", [3]), + ("image-ar:..0.9", [1, 4]), + ("image-ar:1.1..", [2]), + ("image-ar:1/1..1/1", [3]), + ("image-ar:1:1..1:1", [3]), + ("image-ar:0.62..0.63", [4]), + ], +) def test_filter_by_image_size( - verify_unpaged, post_factory, input, expected_post_ids): + verify_unpaged, post_factory, input, expected_post_ids +): post1 = post_factory(id=1) post2 = post_factory(id=2) post3 = post_factory(id=3) @@ -467,25 +552,29 @@ def test_filter_by_image_size( def test_filter_by_invalid_aspect_ratio(executor): with pytest.raises(errors.SearchError): - executor.execute('image-ar:1:1:1', offset=0, limit=100) + executor.execute("image-ar:1:1:1", offset=0, limit=100) -@pytest.mark.parametrize('input,expected_post_ids', [ - ('creation-date:2014', [1]), - ('creation-date:2016', [3]), - ('creation-date:2014,2016', [1, 3]), - ('creation-time:2014', [1]), - ('creation-time:2016', [3]), - ('creation-time:2014,2016', [1, 3]), - ('date:2014', [1]), - ('date:2016', [3]), - ('date:2014,2016', [1, 3]), - ('time:2014', [1]), - ('time:2016', [3]), - ('time:2014,2016', [1, 3]), -]) +@pytest.mark.parametrize( + "input,expected_post_ids", + [ + ("creation-date:2014", [1]), + ("creation-date:2016", [3]), + ("creation-date:2014,2016", [1, 3]), + ("creation-time:2014", [1]), + ("creation-time:2016", [3]), + ("creation-time:2014,2016", [1, 3]), + ("date:2014", [1]), + ("date:2016", [3]), + ("date:2014,2016", [1, 3]), + ("time:2014", [1]), + ("time:2016", [3]), + ("time:2014,2016", [1, 3]), + ], +) def test_filter_by_creation_time( - verify_unpaged, post_factory, input, expected_post_ids): + verify_unpaged, post_factory, input, expected_post_ids +): post1 = post_factory(id=1) post2 = post_factory(id=2) post3 = post_factory(id=3) @@ -497,22 +586,26 @@ def test_filter_by_creation_time( verify_unpaged(input, expected_post_ids) -@pytest.mark.parametrize('input,expected_post_ids', [ - ('last-edit-date:2014', [1]), - ('last-edit-date:2016', [3]), - ('last-edit-date:2014,2016', [1, 3]), - ('last-edit-time:2014', [1]), - ('last-edit-time:2016', [3]), - ('last-edit-time:2014,2016', [1, 3]), - ('edit-date:2014', [1]), - ('edit-date:2016', [3]), - ('edit-date:2014,2016', [1, 3]), - ('edit-time:2014', [1]), - ('edit-time:2016', [3]), - ('edit-time:2014,2016', [1, 3]), -]) +@pytest.mark.parametrize( + "input,expected_post_ids", + [ + ("last-edit-date:2014", [1]), + ("last-edit-date:2016", [3]), + ("last-edit-date:2014,2016", [1, 3]), + ("last-edit-time:2014", [1]), + ("last-edit-time:2016", [3]), + ("last-edit-time:2014,2016", [1, 3]), + ("edit-date:2014", [1]), + ("edit-date:2016", [3]), + ("edit-date:2014,2016", [1, 3]), + ("edit-time:2014", [1]), + ("edit-time:2016", [3]), + ("edit-time:2014,2016", [1, 3]), + ], +) def test_filter_by_last_edit_time( - verify_unpaged, post_factory, input, expected_post_ids): + verify_unpaged, post_factory, input, expected_post_ids +): post1 = post_factory(id=1) post2 = post_factory(id=2) post3 = post_factory(id=3) @@ -524,20 +617,20 @@ def test_filter_by_last_edit_time( verify_unpaged(input, expected_post_ids) -@pytest.mark.parametrize('input,expected_post_ids', [ - ('comment-date:2014', [1]), - ('comment-date:2016', [3]), - ('comment-date:2014,2016', [1, 3]), - ('comment-time:2014', [1]), - ('comment-time:2016', [3]), - ('comment-time:2014,2016', [1, 3]), -]) +@pytest.mark.parametrize( + "input,expected_post_ids", + [ + ("comment-date:2014", [1]), + ("comment-date:2016", [3]), + ("comment-date:2014,2016", [1, 3]), + ("comment-time:2014", [1]), + ("comment-time:2016", [3]), + ("comment-time:2014,2016", [1, 3]), + ], +) def test_filter_by_comment_date( - verify_unpaged, - post_factory, - comment_factory, - input, - expected_post_ids): + verify_unpaged, post_factory, comment_factory, input, expected_post_ids +): post1 = post_factory(id=1) post2 = post_factory(id=2) post3 = post_factory(id=3) @@ -552,16 +645,20 @@ def test_filter_by_comment_date( verify_unpaged(input, expected_post_ids) -@pytest.mark.parametrize('input,expected_post_ids', [ - ('fav-date:2014', [1]), - ('fav-date:2016', [3]), - ('fav-date:2014,2016', [1, 3]), - ('fav-time:2014', [1]), - ('fav-time:2016', [3]), - ('fav-time:2014,2016', [1, 3]), -]) +@pytest.mark.parametrize( + "input,expected_post_ids", + [ + ("fav-date:2014", [1]), + ("fav-date:2016", [3]), + ("fav-date:2014,2016", [1, 3]), + ("fav-time:2014", [1]), + ("fav-time:2016", [3]), + ("fav-time:2014,2016", [1, 3]), + ], +) def test_filter_by_fav_date( - verify_unpaged, post_factory, fav_factory, input, expected_post_ids): + verify_unpaged, post_factory, fav_factory, input, expected_post_ids +): post1 = post_factory(id=1) post2 = post_factory(id=2) post3 = post_factory(id=3) @@ -576,20 +673,20 @@ def test_filter_by_fav_date( verify_unpaged(input, expected_post_ids) -@pytest.mark.parametrize('input,expected_post_ids', [ - ('feature-date:2014', [1]), - ('feature-date:2016', [3]), - ('feature-date:2014,2016', [1, 3]), - ('feature-time:2014', [1]), - ('feature-time:2016', [3]), - ('feature-time:2014,2016', [1, 3]), -]) +@pytest.mark.parametrize( + "input,expected_post_ids", + [ + ("feature-date:2014", [1]), + ("feature-date:2016", [3]), + ("feature-date:2014,2016", [1, 3]), + ("feature-time:2014", [1]), + ("feature-time:2016", [3]), + ("feature-time:2014,2016", [1, 3]), + ], +) def test_filter_by_feature_date( - verify_unpaged, - post_factory, - feature_factory, - input, - expected_post_ids): + verify_unpaged, post_factory, feature_factory, input, expected_post_ids +): post1 = post_factory(id=1) post2 = post_factory(id=2) post3 = post_factory(id=3) @@ -604,37 +701,40 @@ def test_filter_by_feature_date( verify_unpaged(input, expected_post_ids) -@pytest.mark.parametrize('input', [ - 'sort:random', - 'sort:id', - 'sort:score', - 'sort:tag-count', - 'sort:comment-count', - 'sort:fav-count', - 'sort:note-count', - 'sort:feature-count', - 'sort:file-size', - 'sort:image-width', - 'sort:width', - 'sort:image-height', - 'sort:height', - 'sort:image-area', - 'sort:area', - 'sort:creation-date', - 'sort:creation-time', - 'sort:date', - 'sort:time', - 'sort:last-edit-date', - 'sort:last-edit-time', - 'sort:edit-date', - 'sort:edit-time', - 'sort:comment-date', - 'sort:comment-time', - 'sort:fav-date', - 'sort:fav-time', - 'sort:feature-date', - 'sort:feature-time', -]) +@pytest.mark.parametrize( + "input", + [ + "sort:random", + "sort:id", + "sort:score", + "sort:tag-count", + "sort:comment-count", + "sort:fav-count", + "sort:note-count", + "sort:feature-count", + "sort:file-size", + "sort:image-width", + "sort:width", + "sort:image-height", + "sort:height", + "sort:image-area", + "sort:area", + "sort:creation-date", + "sort:creation-time", + "sort:date", + "sort:time", + "sort:last-edit-date", + "sort:last-edit-time", + "sort:edit-date", + "sort:edit-time", + "sort:comment-date", + "sort:comment-time", + "sort:fav-date", + "sort:fav-time", + "sort:feature-date", + "sort:feature-time", + ], +) def test_sort_tokens(verify_unpaged, post_factory, input): post1 = post_factory(id=1) post2 = post_factory(id=2) @@ -644,137 +744,152 @@ def test_sort_tokens(verify_unpaged, post_factory, input): verify_unpaged(input, [1, 2, 3]) -@pytest.mark.parametrize('input,expected_post_ids', [ - ('', [1, 2, 3, 4]), - ('t1', [1]), - ('t2', [2]), - ('t1,t2', [1, 2]), - ('t4a', [4]), - ('t4b', [4]), -]) +@pytest.mark.parametrize( + "input,expected_post_ids", + [ + ("", [1, 2, 3, 4]), + ("t1", [1]), + ("t2", [2]), + ("t1,t2", [1, 2]), + ("t4a", [4]), + ("t4b", [4]), + ], +) def test_anonymous( - verify_unpaged, post_factory, tag_factory, input, expected_post_ids): + verify_unpaged, post_factory, tag_factory, input, expected_post_ids +): post1 = post_factory(id=1) post2 = post_factory(id=2) post3 = post_factory(id=3) post4 = post_factory(id=4) - post1.tags = [tag_factory(names=['t1'])] - post2.tags = [tag_factory(names=['t2'])] - post3.tags = [tag_factory(names=['t3'])] - post4.tags = [tag_factory(names=['t4a', 't4b'])] + post1.tags = [tag_factory(names=["t1"])] + post2.tags = [tag_factory(names=["t2"])] + post3.tags = [tag_factory(names=["t3"])] + post4.tags = [tag_factory(names=["t4a", "t4b"])] db.session.add_all([post1, post2, post3, post4]) db.session.flush() verify_unpaged(input, expected_post_ids) def test_own_liked( - auth_executor, - post_factory, - score_factory, - user_factory, - verify_unpaged): + auth_executor, post_factory, score_factory, user_factory, verify_unpaged +): auth_user = auth_executor() post1 = post_factory(id=1) post2 = post_factory(id=2) post3 = post_factory(id=3) - db.session.add_all([ - score_factory(post=post1, user=auth_user, score=1), - score_factory(post=post2, user=user_factory(name='dummy'), score=1), - score_factory(post=post3, user=auth_user, score=-1), - post1, post2, post3, - ]) + db.session.add_all( + [ + score_factory(post=post1, user=auth_user, score=1), + score_factory( + post=post2, user=user_factory(name="dummy"), score=1 + ), + score_factory(post=post3, user=auth_user, score=-1), + post1, + post2, + post3, + ] + ) db.session.flush() - verify_unpaged('special:liked', [1]) - verify_unpaged('-special:liked', [2, 3]) + verify_unpaged("special:liked", [1]) + verify_unpaged("-special:liked", [2, 3]) def test_own_disliked( - auth_executor, - post_factory, - score_factory, - user_factory, - verify_unpaged): + auth_executor, post_factory, score_factory, user_factory, verify_unpaged +): auth_user = auth_executor() post1 = post_factory(id=1) post2 = post_factory(id=2) post3 = post_factory(id=3) - db.session.add_all([ - score_factory(post=post1, user=auth_user, score=-1), - score_factory(post=post2, user=user_factory(name='dummy'), score=-1), - score_factory(post=post3, user=auth_user, score=1), - post1, post2, post3, - ]) + db.session.add_all( + [ + score_factory(post=post1, user=auth_user, score=-1), + score_factory( + post=post2, user=user_factory(name="dummy"), score=-1 + ), + score_factory(post=post3, user=auth_user, score=1), + post1, + post2, + post3, + ] + ) db.session.flush() - verify_unpaged('special:disliked', [1]) - verify_unpaged('-special:disliked', [2, 3]) + verify_unpaged("special:disliked", [1]) + verify_unpaged("-special:disliked", [2, 3]) -@pytest.mark.parametrize('input', [ - 'liked:x', - 'disliked:x', -]) +@pytest.mark.parametrize( + "input", + [ + "liked:x", + "disliked:x", + ], +) def test_someones_score(executor, input): with pytest.raises(errors.SearchError): executor.execute(input, offset=0, limit=100) def test_own_fav( - auth_executor, - post_factory, - fav_factory, - user_factory, - verify_unpaged): + auth_executor, post_factory, fav_factory, user_factory, verify_unpaged +): auth_user = auth_executor() post1 = post_factory(id=1) post2 = post_factory(id=2) - db.session.add_all([ - fav_factory(post=post1, user=auth_user), - fav_factory(post=post2, user=user_factory(name='unrelated')), - post1, post2, - ]) + db.session.add_all( + [ + fav_factory(post=post1, user=auth_user), + fav_factory(post=post2, user=user_factory(name="unrelated")), + post1, + post2, + ] + ) db.session.flush() - verify_unpaged('special:fav', [1]) - verify_unpaged('-special:fav', [2]) + verify_unpaged("special:fav", [1]) + verify_unpaged("-special:fav", [2]) def test_tumbleweed( - post_factory, - fav_factory, - comment_factory, - score_factory, - verify_unpaged): + post_factory, fav_factory, comment_factory, score_factory, verify_unpaged +): post1 = post_factory(id=1) post2 = post_factory(id=2) post3 = post_factory(id=3) post4 = post_factory(id=4) - db.session.add_all([ - comment_factory(post=post1), - score_factory(post=post2), - fav_factory(post=post3), - post1, post2, post3, post4, - ]) + db.session.add_all( + [ + comment_factory(post=post1), + score_factory(post=post2), + fav_factory(post=post3), + post1, + post2, + post3, + post4, + ] + ) db.session.flush() - verify_unpaged('special:tumbleweed', [4]) - verify_unpaged('-special:tumbleweed', [1, 2, 3]) + verify_unpaged("special:tumbleweed", [4]) + verify_unpaged("-special:tumbleweed", [1, 2, 3]) -@pytest.mark.parametrize('input,expected_post_ids', [ - ('sort:id,asc metric-a:1..3', [1, 2, 3]), - ('sort:id,asc metric-a-min:2', [2, 3]), - ('sort:id,asc metric-a:1.5..', [2, 3]), - ('sort:id,asc metric-a:1..3 metric-b:2..', [2]), - ('sort:id,asc c metric-a:3..', [3]), - ('sort:id,asc metric-b:..2', [1, 2]), - ('sort:id,asc metric-b:..1.9', [1]), - ('sort:metric-a', [1, 2, 3, 5, 4]), - ('sort:metric-a,desc', [3, 2, 1, 5, 4]), - ('metric-a:1..3 metric-b:1..3 sort:metric-b,desc', [2, 1]), - ('metric-a:1..3 sort:metric-b,desc', [2, 1, 3]), - ('metric-a:2..3 metric-b:1..3 sort:metric-b,desc', [2]), - ('metric-a:2..3 sort:metric-b,desc', [2, 3]), - ('sort:id,asc metric:a', [1, 2, 3]), - ('sort:id,asc -metric:a', [4, 5]), - ('sort:id,asc metric:a -metric:b', [3]), +@pytest.mark.parametrize("input,expected_post_ids", [ + ("sort:id,asc metric-a:1..3", [1, 2, 3]), + ("sort:id,asc metric-a-min:2", [2, 3]), + ("sort:id,asc metric-a:1.5..", [2, 3]), + ("sort:id,asc metric-a:1..3 metric-b:2..", [2]), + ("sort:id,asc c metric-a:3..", [3]), + ("sort:id,asc metric-b:..2", [1, 2]), + ("sort:id,asc metric-b:..1.9", [1]), + ("sort:metric-a", [1, 2, 3, 5, 4]), + ("sort:metric-a,desc", [3, 2, 1, 5, 4]), + ("metric-a:1..3 metric-b:1..3 sort:metric-b,desc", [2, 1]), + ("metric-a:1..3 sort:metric-b,desc", [2, 1, 3]), + ("metric-a:2..3 metric-b:1..3 sort:metric-b,desc", [2]), + ("metric-a:2..3 sort:metric-b,desc", [2, 3]), + ("sort:id,asc metric:a", [1, 2, 3]), + ("sort:id,asc -metric:a", [4, 5]), + ("sort:id,asc metric:a -metric:b", [3]), ]) def test_metrics( input, @@ -785,9 +900,9 @@ def test_metrics( post_metric_factory, post_metric_range_factory, verify_unpaged): - tag_a = tag_factory(names=['a']) - tag_b = tag_factory(names=['b']) - tag_c = tag_factory(names=['c']) + tag_a = tag_factory(names=["a"]) + tag_b = tag_factory(names=["b"]) + tag_c = tag_factory(names=["c"]) post1 = post_factory(id=1, tags=[tag_a, tag_b, tag_c]) post2 = post_factory(id=2, tags=[tag_a, tag_b, tag_c]) post3 = post_factory(id=3, tags=[tag_a, tag_b, tag_c]) @@ -813,15 +928,15 @@ def test_metrics( verify_unpaged(input, expected_post_ids, True) -@pytest.mark.parametrize('input,expected_prev_id,expected_next_id', [ - ('', 3, 1), # default order is actually descending - ('sort:id,asc', 1, 3), - ('sort:id,desc', 3, 1), - ('sort:tag-count,asc', 1, 3), - ('sort:tag-count,desc', 3, 1), - ('metric-a:0..2 sort:metric-a', 3, 1), - ('metric-a:0..2 sort:metric-a,desc', 1, 3), - ('sort:metric-b', 3, 1), +@pytest.mark.parametrize("input,expected_prev_id,expected_next_id", [ + ("", 3, 1), # default order is actually descending + ("sort:id,asc", 1, 3), + ("sort:id,desc", 3, 1), + ("sort:tag-count,asc", 1, 3), + ("sort:tag-count,desc", 3, 1), + ("metric-a:0..2 sort:metric-a", 3, 1), + ("metric-a:0..2 sort:metric-a,desc", 1, 3), + ("sort:metric-b", 3, 1), ]) def test_around_query( input, @@ -832,10 +947,10 @@ def test_around_query( metric_factory, post_metric_factory, verify_around): - tag_a = tag_factory(names=['a']) - tag_b = tag_factory(names=['b']) - tag_c = tag_factory(names=['c']) - tag_d = tag_factory(names=['d']) + tag_a = tag_factory(names=["a"]) + tag_b = tag_factory(names=["b"]) + tag_c = tag_factory(names=["c"]) + tag_d = tag_factory(names=["d"]) post1 = post_factory(id=1, tags=[tag_a]) post2 = post_factory(id=2, tags=[tag_a, tag_b]) post3 = post_factory(id=3, tags=[tag_a, tag_b, tag_c]) 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 8ea107f..8175b73 100644 --- a/server/szurubooru/tests/search/configs/test_tag_search_config.py +++ b/server/szurubooru/tests/search/configs/test_tag_search_config.py @@ -1,6 +1,7 @@ -# pylint: disable=redefined-outer-name from datetime import datetime + import pytest + from szurubooru import db, errors, search @@ -13,180 +14,239 @@ def executor(): def verify_unpaged(executor): def verify(input, expected_tag_names): actual_count, actual_tags = executor.execute( - input, offset=0, limit=100) + input, offset=0, limit=100 + ) actual_tag_names = [u.names[0].name for u in actual_tags] assert actual_count == len(expected_tag_names) assert actual_tag_names == expected_tag_names + return verify -@pytest.mark.parametrize('input,expected_tag_names', [ - ('', ['t1', 't2']), - ('t1', ['t1']), - ('t2', ['t2']), - ('t1,t2', ['t1', 't2']), - ('T1,T2', ['t1', 't2']), -]) +@pytest.mark.parametrize( + "input,expected_tag_names", + [ + ("", ["t1", "t2"]), + ("t1", ["t1"]), + ("t2", ["t2"]), + ("t1,t2", ["t1", "t2"]), + ("T1,T2", ["t1", "t2"]), + ], +) def test_filter_anonymous( - verify_unpaged, tag_factory, input, expected_tag_names): - db.session.add(tag_factory(names=['t1'])) - db.session.add(tag_factory(names=['t2'])) + verify_unpaged, tag_factory, input, expected_tag_names +): + db.session.add(tag_factory(names=["t1"])) + db.session.add(tag_factory(names=["t2"])) db.session.flush() verify_unpaged(input, expected_tag_names) -@pytest.mark.parametrize('db_driver,input,expected_tag_names', [ - (None, ',', None), - (None, 't1,', None), - (None, 't1,t2', ['t1', 't2']), - (None, 't1\\,', []), - (None, 'asd..asd', None), - (None, 'asd\\..asd', []), - (None, 'asd.\\.asd', []), - (None, 'asd\\.\\.asd', []), - (None, '-', None), - (None, '\\-', ['-']), - (None, '--', [ - 't1', 't2', '*', '*asd*', ':', 'asd:asd', '\\', '\\asd', '-asd', - ]), - (None, '\\--', []), - (None, '-\\-', [ - 't1', 't2', '*', '*asd*', ':', 'asd:asd', '\\', '\\asd', '-asd', - ]), - (None, '-*', []), - (None, '\\-*', ['-', '-asd']), - (None, ':', None), - (None, '\\:', [':']), - (None, '\\:asd', []), - (None, '*\\:*', [':', 'asd:asd']), - (None, 'asd:asd', None), - (None, 'asd\\:asd', ['asd:asd']), - (None, '*', [ - 't1', 't2', '*', '*asd*', ':', 'asd:asd', '\\', '\\asd', '-', '-asd' - ]), - (None, '\\*', ['*']), - (None, '\\', None), - (None, '\\asd', None), - ('psycopg2', '\\\\', ['\\']), - ('psycopg2', '\\\\asd', ['\\asd']), -]) -def test_escaping( - executor, tag_factory, input, expected_tag_names, db_driver): - db.session.add_all([ - tag_factory(names=['t1']), - tag_factory(names=['t2']), - tag_factory(names=['*']), - tag_factory(names=['*asd*']), - tag_factory(names=[':']), - tag_factory(names=['asd:asd']), - tag_factory(names=['\\']), - tag_factory(names=['\\asd']), - tag_factory(names=['-']), - tag_factory(names=['-asd']) - ]) +@pytest.mark.parametrize( + "db_driver,input,expected_tag_names", + [ + (None, ",", None), + (None, "t1,", None), + (None, "t1,t2", ["t1", "t2"]), + (None, "t1\\,", []), + (None, "asd..asd", None), + (None, "asd\\..asd", []), + (None, "asd.\\.asd", []), + (None, "asd\\.\\.asd", []), + (None, "-", None), + (None, "\\-", ["-"]), + ( + None, + "--", + [ + "t1", + "t2", + "*", + "*asd*", + ":", + "asd:asd", + "\\", + "\\asd", + "-asd", + ], + ), + (None, "\\--", []), + ( + None, + "-\\-", + [ + "t1", + "t2", + "*", + "*asd*", + ":", + "asd:asd", + "\\", + "\\asd", + "-asd", + ], + ), + (None, "-*", []), + (None, "\\-*", ["-", "-asd"]), + (None, ":", None), + (None, "\\:", [":"]), + (None, "\\:asd", []), + (None, "*\\:*", [":", "asd:asd"]), + (None, "asd:asd", None), + (None, "asd\\:asd", ["asd:asd"]), + ( + None, + "*", + [ + "t1", + "t2", + "*", + "*asd*", + ":", + "asd:asd", + "\\", + "\\asd", + "-", + "-asd", + ], + ), + (None, "\\*", ["*"]), + (None, "\\", None), + (None, "\\asd", None), + ("psycopg2", "\\\\", ["\\"]), + ("psycopg2", "\\\\asd", ["\\asd"]), + ], +) +def test_escaping(executor, tag_factory, input, expected_tag_names, db_driver): + db.session.add_all( + [ + tag_factory(names=["t1"]), + tag_factory(names=["t2"]), + tag_factory(names=["*"]), + tag_factory(names=["*asd*"]), + tag_factory(names=[":"]), + tag_factory(names=["asd:asd"]), + tag_factory(names=["\\"]), + tag_factory(names=["\\asd"]), + tag_factory(names=["-"]), + tag_factory(names=["-asd"]), + ] + ) db.session.flush() - if db_driver: - if db.sessionmaker.kw['bind'].driver != db_driver: - pytest.xfail() + 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) else: actual_count, actual_tags = executor.execute( - input, offset=0, limit=100) + input, offset=0, limit=100 + ) actual_tag_names = [u.names[0].name for u in actual_tags] assert actual_count == len(expected_tag_names) assert sorted(actual_tag_names) == sorted(expected_tag_names) def test_filter_anonymous_starting_with_colon(verify_unpaged, tag_factory): - db.session.add(tag_factory(names=[':t'])) + db.session.add(tag_factory(names=[":t"])) db.session.flush() with pytest.raises(errors.SearchError): - verify_unpaged(':t', [':t']) - verify_unpaged('\\:t', [':t']) + verify_unpaged(":t", [":t"]) + verify_unpaged("\\:t", [":t"]) -@pytest.mark.parametrize('input,expected_tag_names', [ - ('name:tag1', ['tag1']), - ('name:tag2', ['tag2']), - ('name:none', []), - ('name:', []), - ('name:*1', ['tag1']), - ('name:*2', ['tag2']), - ('name:*', ['tag1', 'tag2', 'tag3', 'tag4']), - ('name:t*', ['tag1', 'tag2', 'tag3', 'tag4']), - ('name:*a*', ['tag1', 'tag2', 'tag3', 'tag4']), - ('name:*!*', []), - ('name:!*', []), - ('name:*!', []), - ('-name:tag1', ['tag2', 'tag3', 'tag4']), - ('-name:tag2', ['tag1', 'tag3', 'tag4']), - ('name:tag1,tag2', ['tag1', 'tag2']), - ('-name:tag1,tag3', ['tag2', 'tag4']), - ('name:tag4', ['tag4']), - ('name:tag5', ['tag4']), - ('name:tag4,tag5', ['tag4']), -]) +@pytest.mark.parametrize( + "input,expected_tag_names", + [ + ("name:tag1", ["tag1"]), + ("name:tag2", ["tag2"]), + ("name:none", []), + ("name:", []), + ("name:*1", ["tag1"]), + ("name:*2", ["tag2"]), + ("name:*", ["tag1", "tag2", "tag3", "tag4"]), + ("name:t*", ["tag1", "tag2", "tag3", "tag4"]), + ("name:*a*", ["tag1", "tag2", "tag3", "tag4"]), + ("name:*!*", []), + ("name:!*", []), + ("name:*!", []), + ("-name:tag1", ["tag2", "tag3", "tag4"]), + ("-name:tag2", ["tag1", "tag3", "tag4"]), + ("name:tag1,tag2", ["tag1", "tag2"]), + ("-name:tag1,tag3", ["tag2", "tag4"]), + ("name:tag4", ["tag4"]), + ("name:tag5", ["tag4"]), + ("name:tag4,tag5", ["tag4"]), + ], +) def test_filter_by_name( - verify_unpaged, tag_factory, input, expected_tag_names): - db.session.add(tag_factory(names=['tag1'])) - db.session.add(tag_factory(names=['tag2'])) - db.session.add(tag_factory(names=['tag3'])) - db.session.add(tag_factory(names=['tag4', 'tag5', 'tag6'])) + verify_unpaged, tag_factory, input, expected_tag_names +): + db.session.add(tag_factory(names=["tag1"])) + db.session.add(tag_factory(names=["tag2"])) + db.session.add(tag_factory(names=["tag3"])) + db.session.add(tag_factory(names=["tag4", "tag5", "tag6"])) db.session.flush() verify_unpaged(input, expected_tag_names) -@pytest.mark.parametrize('input,expected_tag_names', [ - ('category:cat1', ['t1', 't2']), - ('category:cat2', ['t3']), - ('category:cat1,cat2', ['t1', 't2', 't3']), -]) +@pytest.mark.parametrize( + "input,expected_tag_names", + [ + ("category:cat1", ["t1", "t2"]), + ("category:cat2", ["t3"]), + ("category:cat1,cat2", ["t1", "t2", "t3"]), + ], +) def test_filter_by_category( - verify_unpaged, - tag_factory, - tag_category_factory, - input, - expected_tag_names): - 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) + verify_unpaged, + tag_factory, + tag_category_factory, + input, + expected_tag_names, +): + 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) db.session.add_all([tag1, tag2, tag3]) db.session.flush() verify_unpaged(input, expected_tag_names) -@pytest.mark.parametrize('input,expected_tag_names', [ - ('creation-time:2014', ['t1', 't2']), - ('creation-date:2014', ['t1', 't2']), - ('-creation-time:2014', ['t3']), - ('-creation-date:2014', ['t3']), - ('creation-time:2014..2014-06', ['t1', 't2']), - ('creation-time:2014-06..2015-01-01', ['t2', 't3']), - ('creation-time:2014-06..', ['t2', 't3']), - ('creation-time:..2014-06', ['t1', 't2']), - ('-creation-time:2014..2014-06', ['t3']), - ('-creation-time:2014-06..2015-01-01', ['t1']), - ('creation-date:2014..2014-06', ['t1', 't2']), - ('creation-date:2014-06..2015-01-01', ['t2', 't3']), - ('creation-date:2014-06..', ['t2', 't3']), - ('creation-date:..2014-06', ['t1', 't2']), - ('-creation-date:2014..2014-06', ['t3']), - ('-creation-date:2014-06..2015-01-01', ['t1']), - ('creation-time:2014-01,2015', ['t1', 't3']), - ('creation-date:2014-01,2015', ['t1', 't3']), - ('-creation-time:2014-01,2015', ['t2']), - ('-creation-date:2014-01,2015', ['t2']), -]) +@pytest.mark.parametrize( + "input,expected_tag_names", + [ + ("creation-time:2014", ["t1", "t2"]), + ("creation-date:2014", ["t1", "t2"]), + ("-creation-time:2014", ["t3"]), + ("-creation-date:2014", ["t3"]), + ("creation-time:2014..2014-06", ["t1", "t2"]), + ("creation-time:2014-06..2015-01-01", ["t2", "t3"]), + ("creation-time:2014-06..", ["t2", "t3"]), + ("creation-time:..2014-06", ["t1", "t2"]), + ("-creation-time:2014..2014-06", ["t3"]), + ("-creation-time:2014-06..2015-01-01", ["t1"]), + ("creation-date:2014..2014-06", ["t1", "t2"]), + ("creation-date:2014-06..2015-01-01", ["t2", "t3"]), + ("creation-date:2014-06..", ["t2", "t3"]), + ("creation-date:..2014-06", ["t1", "t2"]), + ("-creation-date:2014..2014-06", ["t3"]), + ("-creation-date:2014-06..2015-01-01", ["t1"]), + ("creation-time:2014-01,2015", ["t1", "t3"]), + ("creation-date:2014-01,2015", ["t1", "t3"]), + ("-creation-time:2014-01,2015", ["t2"]), + ("-creation-date:2014-01,2015", ["t2"]), + ], +) def test_filter_by_creation_time( - verify_unpaged, tag_factory, input, expected_tag_names): - tag1 = tag_factory(names=['t1']) - tag2 = tag_factory(names=['t2']) - tag3 = tag_factory(names=['t3']) + verify_unpaged, tag_factory, input, expected_tag_names +): + tag1 = tag_factory(names=["t1"]) + tag2 = tag_factory(names=["t2"]) + tag3 = tag_factory(names=["t3"]) tag1.creation_time = datetime(2014, 1, 1) tag2.creation_time = datetime(2014, 6, 1) tag3.creation_time = datetime(2015, 1, 1) @@ -195,17 +255,21 @@ def test_filter_by_creation_time( verify_unpaged(input, expected_tag_names) -@pytest.mark.parametrize('input,expected_tag_names', [ - ('last-edit-date:2014', ['t1', 't3']), - ('last-edit-time:2014', ['t1', 't3']), - ('edit-date:2014', ['t1', 't3']), - ('edit-time:2014', ['t1', 't3']), -]) +@pytest.mark.parametrize( + "input,expected_tag_names", + [ + ("last-edit-date:2014", ["t1", "t3"]), + ("last-edit-time:2014", ["t1", "t3"]), + ("edit-date:2014", ["t1", "t3"]), + ("edit-time:2014", ["t1", "t3"]), + ], +) def test_filter_by_edit_time( - verify_unpaged, tag_factory, input, expected_tag_names): - tag1 = tag_factory(names=['t1']) - tag2 = tag_factory(names=['t2']) - tag3 = tag_factory(names=['t3']) + verify_unpaged, tag_factory, input, expected_tag_names +): + tag1 = tag_factory(names=["t1"]) + tag2 = tag_factory(names=["t2"]) + tag3 = tag_factory(names=["t3"]) tag1.last_edit_time = datetime(2014, 1, 1) tag2.last_edit_time = datetime(2015, 1, 1) tag3.last_edit_time = datetime(2014, 1, 1) @@ -214,24 +278,28 @@ def test_filter_by_edit_time( verify_unpaged(input, expected_tag_names) -@pytest.mark.parametrize('input,expected_tag_names', [ - ('post-count:2', ['t1']), - ('post-count:1', ['t2']), - ('post-count:1..', ['t1', 't2']), - ('post-count-min:1', ['t1', 't2']), - ('post-count:..1', ['t2']), - ('post-count-max:1', ['t2']), - ('usage-count:2', ['t1']), - ('usage-count:1', ['t2']), - ('usages:2', ['t1']), - ('usages:1', ['t2']), -]) +@pytest.mark.parametrize( + "input,expected_tag_names", + [ + ("post-count:2", ["t1"]), + ("post-count:1", ["t2"]), + ("post-count:1..", ["t1", "t2"]), + ("post-count-min:1", ["t1", "t2"]), + ("post-count:..1", ["t2"]), + ("post-count-max:1", ["t2"]), + ("usage-count:2", ["t1"]), + ("usage-count:1", ["t2"]), + ("usages:2", ["t1"]), + ("usages:1", ["t2"]), + ], +) def test_filter_by_post_count( - verify_unpaged, tag_factory, post_factory, input, expected_tag_names): + verify_unpaged, tag_factory, post_factory, input, expected_tag_names +): post1 = post_factory() post2 = post_factory() - tag1 = tag_factory(names=['t1']) - tag2 = tag_factory(names=['t2']) + tag1 = tag_factory(names=["t1"]) + tag2 = tag_factory(names=["t2"]) db.session.add_all([post1, post2, tag1, tag2]) post1.tags.append(tag1) post1.tags.append(tag2) @@ -240,31 +308,38 @@ def test_filter_by_post_count( verify_unpaged(input, expected_tag_names) -@pytest.mark.parametrize('input', [ - 'post-count:..', - 'post-count:asd', - 'post-count:asd,1', - 'post-count:1,asd', - 'post-count:asd..1', - 'post-count:1..asd', -]) +@pytest.mark.parametrize( + "input", + [ + "post-count:..", + "post-count:asd", + "post-count:asd,1", + "post-count:1,asd", + "post-count:asd..1", + "post-count:1..asd", + ], +) def test_filter_by_invalid_input(executor, input): with pytest.raises(errors.SearchError): executor.execute(input, offset=0, limit=100) -@pytest.mark.parametrize('input,expected_tag_names', [ - ('suggestion-count:2', ['t1']), - ('suggestion-count:1', ['t2']), - ('suggestion-count:0', ['sug1', 'sug2', 'sug3']), -]) +@pytest.mark.parametrize( + "input,expected_tag_names", + [ + ("suggestion-count:2", ["t1"]), + ("suggestion-count:1", ["t2"]), + ("suggestion-count:0", ["sug1", "sug2", "sug3"]), + ], +) def test_filter_by_suggestion_count( - verify_unpaged, tag_factory, input, expected_tag_names): - sug1 = tag_factory(names=['sug1']) - sug2 = tag_factory(names=['sug2']) - sug3 = tag_factory(names=['sug3']) - tag1 = tag_factory(names=['t1']) - tag2 = tag_factory(names=['t2']) + verify_unpaged, tag_factory, input, expected_tag_names +): + sug1 = tag_factory(names=["sug1"]) + sug2 = tag_factory(names=["sug2"]) + sug3 = tag_factory(names=["sug3"]) + tag1 = tag_factory(names=["t1"]) + tag2 = tag_factory(names=["t2"]) db.session.add_all([sug1, sug3, tag2, sug2, tag1]) tag1.suggestions.append(sug1) tag1.suggestions.append(sug2) @@ -273,18 +348,22 @@ def test_filter_by_suggestion_count( verify_unpaged(input, expected_tag_names) -@pytest.mark.parametrize('input,expected_tag_names', [ - ('implication-count:2', ['t1']), - ('implication-count:1', ['t2']), - ('implication-count:0', ['sug1', 'sug2', 'sug3']), -]) +@pytest.mark.parametrize( + "input,expected_tag_names", + [ + ("implication-count:2", ["t1"]), + ("implication-count:1", ["t2"]), + ("implication-count:0", ["sug1", "sug2", "sug3"]), + ], +) def test_filter_by_implication_count( - verify_unpaged, tag_factory, input, expected_tag_names): - sug1 = tag_factory(names=['sug1']) - sug2 = tag_factory(names=['sug2']) - sug3 = tag_factory(names=['sug3']) - tag1 = tag_factory(names=['t1']) - tag2 = tag_factory(names=['t2']) + verify_unpaged, tag_factory, input, expected_tag_names +): + sug1 = tag_factory(names=["sug1"]) + sug2 = tag_factory(names=["sug2"]) + sug3 = tag_factory(names=["sug3"]) + tag1 = tag_factory(names=["t1"]) + tag2 = tag_factory(names=["t2"]) db.session.add_all([sug1, sug3, tag2, sug2, tag1]) tag1.implications.append(sug1) tag1.implications.append(sug2) @@ -293,32 +372,39 @@ def test_filter_by_implication_count( verify_unpaged(input, expected_tag_names) -@pytest.mark.parametrize('input,expected_tag_names', [ - ('', ['t1', 't2']), - ('sort:name', ['t1', 't2']), - ('-sort:name', ['t2', 't1']), - ('sort:name,asc', ['t1', 't2']), - ('sort:name,desc', ['t2', 't1']), - ('-sort:name,asc', ['t2', 't1']), - ('-sort:name,desc', ['t1', 't2']), -]) +@pytest.mark.parametrize( + "input,expected_tag_names", + [ + ("", ["t1", "t2"]), + ("sort:name", ["t1", "t2"]), + ("-sort:name", ["t2", "t1"]), + ("sort:name,asc", ["t1", "t2"]), + ("sort:name,desc", ["t2", "t1"]), + ("-sort:name,asc", ["t2", "t1"]), + ("-sort:name,desc", ["t1", "t2"]), + ], +) def test_sort_by_name(verify_unpaged, tag_factory, input, expected_tag_names): - db.session.add(tag_factory(names=['t2'])) - db.session.add(tag_factory(names=['t1'])) + db.session.add(tag_factory(names=["t2"])) + db.session.add(tag_factory(names=["t1"])) db.session.flush() verify_unpaged(input, expected_tag_names) -@pytest.mark.parametrize('input,expected_tag_names', [ - ('', ['t1', 't2', 't3']), - ('sort:creation-date', ['t3', 't2', 't1']), - ('sort:creation-time', ['t3', 't2', 't1']), -]) +@pytest.mark.parametrize( + "input,expected_tag_names", + [ + ("", ["t1", "t2", "t3"]), + ("sort:creation-date", ["t3", "t2", "t1"]), + ("sort:creation-time", ["t3", "t2", "t1"]), + ], +) def test_sort_by_creation_time( - verify_unpaged, tag_factory, input, expected_tag_names): - tag1 = tag_factory(names=['t1']) - tag2 = tag_factory(names=['t2']) - tag3 = tag_factory(names=['t3']) + verify_unpaged, tag_factory, input, expected_tag_names +): + tag1 = tag_factory(names=["t1"]) + tag2 = tag_factory(names=["t2"]) + tag3 = tag_factory(names=["t3"]) tag1.creation_time = datetime(1991, 1, 1) tag2.creation_time = datetime(1991, 1, 2) tag3.creation_time = datetime(1991, 1, 3) @@ -327,18 +413,22 @@ def test_sort_by_creation_time( verify_unpaged(input, expected_tag_names) -@pytest.mark.parametrize('input,expected_tag_names', [ - ('', ['t1', 't2', 't3']), - ('sort:last-edit-date', ['t3', 't2', 't1']), - ('sort:last-edit-time', ['t3', 't2', 't1']), - ('sort:edit-date', ['t3', 't2', 't1']), - ('sort:edit-time', ['t3', 't2', 't1']), -]) +@pytest.mark.parametrize( + "input,expected_tag_names", + [ + ("", ["t1", "t2", "t3"]), + ("sort:last-edit-date", ["t3", "t2", "t1"]), + ("sort:last-edit-time", ["t3", "t2", "t1"]), + ("sort:edit-date", ["t3", "t2", "t1"]), + ("sort:edit-time", ["t3", "t2", "t1"]), + ], +) def test_sort_by_last_edit_time( - verify_unpaged, tag_factory, input, expected_tag_names): - tag1 = tag_factory(names=['t1']) - tag2 = tag_factory(names=['t2']) - tag3 = tag_factory(names=['t3']) + verify_unpaged, tag_factory, input, expected_tag_names +): + tag1 = tag_factory(names=["t1"]) + tag2 = tag_factory(names=["t2"]) + tag3 = tag_factory(names=["t3"]) tag1.last_edit_time = datetime(1991, 1, 1) tag2.last_edit_time = datetime(1991, 1, 2) tag3.last_edit_time = datetime(1991, 1, 3) @@ -347,17 +437,21 @@ def test_sort_by_last_edit_time( verify_unpaged(input, expected_tag_names) -@pytest.mark.parametrize('input,expected_tag_names', [ - ('sort:post-count', ['t2', 't1']), - ('sort:usage-count', ['t2', 't1']), - ('sort:usages', ['t2', 't1']), -]) +@pytest.mark.parametrize( + "input,expected_tag_names", + [ + ("sort:post-count", ["t2", "t1"]), + ("sort:usage-count", ["t2", "t1"]), + ("sort:usages", ["t2", "t1"]), + ], +) def test_sort_by_post_count( - verify_unpaged, tag_factory, post_factory, input, expected_tag_names): + verify_unpaged, tag_factory, post_factory, input, expected_tag_names +): post1 = post_factory() post2 = post_factory() - tag1 = tag_factory(names=['t1']) - tag2 = tag_factory(names=['t2']) + tag1 = tag_factory(names=["t1"]) + tag2 = tag_factory(names=["t2"]) db.session.add_all([post1, post2, tag1, tag2]) post1.tags.append(tag1) post1.tags.append(tag2) @@ -366,16 +460,20 @@ def test_sort_by_post_count( verify_unpaged(input, expected_tag_names) -@pytest.mark.parametrize('input,expected_tag_names', [ - ('sort:suggestion-count', ['t1', 't2', 'sug1', 'sug2', 'sug3']), -]) +@pytest.mark.parametrize( + "input,expected_tag_names", + [ + ("sort:suggestion-count", ["t1", "t2", "sug1", "sug2", "sug3"]), + ], +) def test_sort_by_suggestion_count( - verify_unpaged, tag_factory, input, expected_tag_names): - sug1 = tag_factory(names=['sug1']) - sug2 = tag_factory(names=['sug2']) - sug3 = tag_factory(names=['sug3']) - tag1 = tag_factory(names=['t1']) - tag2 = tag_factory(names=['t2']) + verify_unpaged, tag_factory, input, expected_tag_names +): + sug1 = tag_factory(names=["sug1"]) + sug2 = tag_factory(names=["sug2"]) + sug3 = tag_factory(names=["sug3"]) + tag1 = tag_factory(names=["t1"]) + tag2 = tag_factory(names=["t2"]) db.session.add_all([sug1, sug3, tag2, sug2, tag1]) tag1.suggestions.append(sug1) tag1.suggestions.append(sug2) @@ -384,16 +482,20 @@ def test_sort_by_suggestion_count( verify_unpaged(input, expected_tag_names) -@pytest.mark.parametrize('input,expected_tag_names', [ - ('sort:implication-count', ['t1', 't2', 'sug1', 'sug2', 'sug3']), -]) +@pytest.mark.parametrize( + "input,expected_tag_names", + [ + ("sort:implication-count", ["t1", "t2", "sug1", "sug2", "sug3"]), + ], +) def test_sort_by_implication_count( - verify_unpaged, tag_factory, input, expected_tag_names): - sug1 = tag_factory(names=['sug1']) - sug2 = tag_factory(names=['sug2']) - sug3 = tag_factory(names=['sug3']) - tag1 = tag_factory(names=['t1']) - tag2 = tag_factory(names=['t2']) + verify_unpaged, tag_factory, input, expected_tag_names +): + sug1 = tag_factory(names=["sug1"]) + sug2 = tag_factory(names=["sug2"]) + sug3 = tag_factory(names=["sug3"]) + tag1 = tag_factory(names=["t1"]) + tag2 = tag_factory(names=["t2"]) db.session.add_all([sug1, sug3, tag2, sug2, tag1]) tag1.implications.append(sug1) tag1.implications.append(sug2) @@ -402,20 +504,24 @@ def test_sort_by_implication_count( verify_unpaged(input, expected_tag_names) -@pytest.mark.parametrize('input,expected_tag_names', [ - ('sort:category', ['t3', 't1', 't2']), -]) +@pytest.mark.parametrize( + "input,expected_tag_names", + [ + ("sort:category", ["t3", "t1", "t2"]), + ], +) def test_sort_by_category( - verify_unpaged, - tag_factory, - tag_category_factory, - input, - expected_tag_names): - cat1 = tag_category_factory(name='cat1') - cat2 = tag_category_factory(name='cat2') - tag1 = tag_factory(names=['t1'], category=cat2) - tag2 = tag_factory(names=['t2'], category=cat2) - tag3 = tag_factory(names=['t3'], category=cat1) + verify_unpaged, + tag_factory, + tag_category_factory, + input, + expected_tag_names, +): + cat1 = tag_category_factory(name="cat1") + cat2 = tag_category_factory(name="cat2") + tag1 = tag_factory(names=["t1"], category=cat2) + tag2 = tag_factory(names=["t2"], category=cat2) + tag3 = tag_factory(names=["t3"], category=cat1) db.session.add_all([tag1, tag2, tag3]) db.session.flush() verify_unpaged(input, expected_tag_names) diff --git a/server/szurubooru/tests/search/configs/test_user_search_config.py b/server/szurubooru/tests/search/configs/test_user_search_config.py index c4d9402..485ab1c 100644 --- a/server/szurubooru/tests/search/configs/test_user_search_config.py +++ b/server/szurubooru/tests/search/configs/test_user_search_config.py @@ -1,6 +1,7 @@ -# pylint: disable=redefined-outer-name from datetime import datetime + import pytest + from szurubooru import db, errors, search @@ -13,42 +14,48 @@ def executor(): def verify_unpaged(executor): def verify(input, expected_user_names): actual_count, actual_users = executor.execute( - input, offset=0, limit=100) + input, offset=0, limit=100 + ) actual_user_names = [u.name for u in actual_users] assert actual_count == len(expected_user_names) assert actual_user_names == expected_user_names + return verify -@pytest.mark.parametrize('input,expected_user_names', [ - ('creation-time:2014', ['u1', 'u2']), - ('creation-date:2014', ['u1', 'u2']), - ('-creation-time:2014', ['u3']), - ('-creation-date:2014', ['u3']), - ('creation-time:2014..2014-06', ['u1', 'u2']), - ('creation-time:2014-06..2015-01-01', ['u2', 'u3']), - ('creation-time:2014-06..', ['u2', 'u3']), - ('creation-time:..2014-06', ['u1', 'u2']), - ('creation-time-min:2014-06', ['u2', 'u3']), - ('creation-time-max:2014-06', ['u1', 'u2']), - ('-creation-time:2014..2014-06', ['u3']), - ('-creation-time:2014-06..2015-01-01', ['u1']), - ('creation-date:2014..2014-06', ['u1', 'u2']), - ('creation-date:2014-06..2015-01-01', ['u2', 'u3']), - ('creation-date:2014-06..', ['u2', 'u3']), - ('creation-date:..2014-06', ['u1', 'u2']), - ('-creation-date:2014..2014-06', ['u3']), - ('-creation-date:2014-06..2015-01-01', ['u1']), - ('creation-time:2014-01,2015', ['u1', 'u3']), - ('creation-date:2014-01,2015', ['u1', 'u3']), - ('-creation-time:2014-01,2015', ['u2']), - ('-creation-date:2014-01,2015', ['u2']), -]) +@pytest.mark.parametrize( + "input,expected_user_names", + [ + ("creation-time:2014", ["u1", "u2"]), + ("creation-date:2014", ["u1", "u2"]), + ("-creation-time:2014", ["u3"]), + ("-creation-date:2014", ["u3"]), + ("creation-time:2014..2014-06", ["u1", "u2"]), + ("creation-time:2014-06..2015-01-01", ["u2", "u3"]), + ("creation-time:2014-06..", ["u2", "u3"]), + ("creation-time:..2014-06", ["u1", "u2"]), + ("creation-time-min:2014-06", ["u2", "u3"]), + ("creation-time-max:2014-06", ["u1", "u2"]), + ("-creation-time:2014..2014-06", ["u3"]), + ("-creation-time:2014-06..2015-01-01", ["u1"]), + ("creation-date:2014..2014-06", ["u1", "u2"]), + ("creation-date:2014-06..2015-01-01", ["u2", "u3"]), + ("creation-date:2014-06..", ["u2", "u3"]), + ("creation-date:..2014-06", ["u1", "u2"]), + ("-creation-date:2014..2014-06", ["u3"]), + ("-creation-date:2014-06..2015-01-01", ["u1"]), + ("creation-time:2014-01,2015", ["u1", "u3"]), + ("creation-date:2014-01,2015", ["u1", "u3"]), + ("-creation-time:2014-01,2015", ["u2"]), + ("-creation-date:2014-01,2015", ["u2"]), + ], +) def test_filter_by_creation_time( - verify_unpaged, input, expected_user_names, user_factory): - user1 = user_factory(name='u1') - user2 = user_factory(name='u2') - user3 = user_factory(name='u3') + verify_unpaged, input, expected_user_names, user_factory +): + user1 = user_factory(name="u1") + user2 = user_factory(name="u2") + user3 = user_factory(name="u3") user1.creation_time = datetime(2014, 1, 1) user2.creation_time = datetime(2014, 6, 1) user3.creation_time = datetime(2015, 1, 1) @@ -57,59 +64,67 @@ def test_filter_by_creation_time( verify_unpaged(input, expected_user_names) -@pytest.mark.parametrize('input,expected_user_names', [ - ('name:user1', ['user1']), - ('name:user2', ['user2']), - ('name:none', []), - ('name:', []), - ('name:*1', ['user1']), - ('name:*2', ['user2']), - ('name:*', ['user1', 'user2', 'user3']), - ('name:u*', ['user1', 'user2', 'user3']), - ('name:*ser*', ['user1', 'user2', 'user3']), - ('name:*zer*', []), - ('name:zer*', []), - ('name:*zer', []), - ('-name:user1', ['user2', 'user3']), - ('-name:user2', ['user1', 'user3']), - ('name:user1,user2', ['user1', 'user2']), - ('-name:user1,user3', ['user2']), -]) +@pytest.mark.parametrize( + "input,expected_user_names", + [ + ("name:user1", ["user1"]), + ("name:user2", ["user2"]), + ("name:none", []), + ("name:", []), + ("name:*1", ["user1"]), + ("name:*2", ["user2"]), + ("name:*", ["user1", "user2", "user3"]), + ("name:u*", ["user1", "user2", "user3"]), + ("name:*ser*", ["user1", "user2", "user3"]), + ("name:*zer*", []), + ("name:zer*", []), + ("name:*zer", []), + ("-name:user1", ["user2", "user3"]), + ("-name:user2", ["user1", "user3"]), + ("name:user1,user2", ["user1", "user2"]), + ("-name:user1,user3", ["user2"]), + ], +) def test_filter_by_name( - verify_unpaged, input, expected_user_names, user_factory): - db.session.add(user_factory(name='user1')) - db.session.add(user_factory(name='user2')) - db.session.add(user_factory(name='user3')) + verify_unpaged, input, expected_user_names, user_factory +): + db.session.add(user_factory(name="user1")) + db.session.add(user_factory(name="user2")) + db.session.add(user_factory(name="user3")) db.session.flush() verify_unpaged(input, expected_user_names) -@pytest.mark.parametrize('input,expected_user_names', [ - ('name:u1', ['u1']), - ('name:u2*', ['u2..']), - ('name:u1,u3..x', ['u1', 'u3..x']), - ('name:u2..', None), - ('name:*..*', None), - ('name:u3..x', None), - ('name:*..x', None), - ('name:u2\\..', ['u2..']), - ('name:*\\..*', ['u2..', 'u3..x']), - ('name:u3\\..x', ['u3..x']), - ('name:*\\..x', ['u3..x']), - ('name:u2.\\.', ['u2..']), - ('name:*.\\.*', ['u2..', 'u3..x']), - ('name:u3.\\.x', ['u3..x']), - ('name:*.\\.x', ['u3..x']), - ('name:u2\\.\\.', ['u2..']), - ('name:*\\.\\.*', ['u2..', 'u3..x']), - ('name:u3\\.\\.x', ['u3..x']), - ('name:*\\.\\.x', ['u3..x']), -]) +@pytest.mark.parametrize( + "input,expected_user_names", + [ + ("name:u1", ["u1"]), + ("name:u2*", ["u2.."]), + ("name:u1,u3..x", ["u1", "u3..x"]), + ("name:u2..", None), + ("name:*..*", None), + ("name:u3..x", None), + ("name:*..x", None), + ("name:u2\\..", ["u2.."]), + ("name:*\\..*", ["u2..", "u3..x"]), + ("name:u3\\..x", ["u3..x"]), + ("name:*\\..x", ["u3..x"]), + ("name:u2.\\.", ["u2.."]), + ("name:*.\\.*", ["u2..", "u3..x"]), + ("name:u3.\\.x", ["u3..x"]), + ("name:*.\\.x", ["u3..x"]), + ("name:u2\\.\\.", ["u2.."]), + ("name:*\\.\\.*", ["u2..", "u3..x"]), + ("name:u3\\.\\.x", ["u3..x"]), + ("name:*\\.\\.x", ["u3..x"]), + ], +) def test_filter_by_name_that_looks_like_range( - verify_unpaged, input, expected_user_names, user_factory): - db.session.add(user_factory(name='u1')) - db.session.add(user_factory(name='u2..')) - db.session.add(user_factory(name='u3..x')) + verify_unpaged, input, expected_user_names, user_factory +): + db.session.add(user_factory(name="u1")) + db.session.add(user_factory(name="u2..")) + db.session.add(user_factory(name="u3..x")) db.session.flush() if not expected_user_names: with pytest.raises(errors.SearchError): @@ -118,30 +133,36 @@ def test_filter_by_name_that_looks_like_range( verify_unpaged(input, expected_user_names) -@pytest.mark.parametrize('input,expected_user_names', [ - ('', ['u1', 'u2']), - ('u1', ['u1']), - ('u2', ['u2']), - ('u1,u2', ['u1', 'u2']), -]) -def test_anonymous( - verify_unpaged, input, expected_user_names, user_factory): - db.session.add(user_factory(name='u1')) - db.session.add(user_factory(name='u2')) +@pytest.mark.parametrize( + "input,expected_user_names", + [ + ("", ["u1", "u2"]), + ("u1", ["u1"]), + ("u2", ["u2"]), + ("u1,u2", ["u1", "u2"]), + ], +) +def test_anonymous(verify_unpaged, input, expected_user_names, user_factory): + db.session.add(user_factory(name="u1")) + db.session.add(user_factory(name="u2")) db.session.flush() verify_unpaged(input, expected_user_names) -@pytest.mark.parametrize('input,expected_user_names', [ - ('creation-time:2014 u1', ['u1']), - ('creation-time:2014 u2', ['u2']), - ('creation-time:2016 u2', []), -]) +@pytest.mark.parametrize( + "input,expected_user_names", + [ + ("creation-time:2014 u1", ["u1"]), + ("creation-time:2014 u2", ["u2"]), + ("creation-time:2016 u2", []), + ], +) def test_combining_tokens( - verify_unpaged, input, expected_user_names, user_factory): - user1 = user_factory(name='u1') - user2 = user_factory(name='u2') - user3 = user_factory(name='u3') + verify_unpaged, input, expected_user_names, user_factory +): + user1 = user_factory(name="u1") + user2 = user_factory(name="u2") + user3 = user_factory(name="u3") user1.creation_time = datetime(2014, 1, 1) user2.creation_time = datetime(2014, 6, 1) user3.creation_time = datetime(2015, 1, 1) @@ -151,61 +172,77 @@ def test_combining_tokens( @pytest.mark.parametrize( - 'offset,limit,expected_total_count,expected_user_names', [ - (0, 1, 2, ['u1']), - (1, 1, 2, ['u2']), + "offset,limit,expected_total_count,expected_user_names", + [ + (0, 1, 2, ["u1"]), + (1, 1, 2, ["u2"]), (2, 1, 2, []), (-1, 1, 2, []), - (-1, 2, 2, ['u1']), - (0, 2, 2, ['u1', 'u2']), + (-1, 2, 2, ["u1"]), + (0, 2, 2, ["u1", "u2"]), (3, 1, 2, []), (0, 0, 2, []), - ]) + ], +) def test_paging( - executor, user_factory, offset, limit, - expected_total_count, expected_user_names): - db.session.add(user_factory(name='u1')) - db.session.add(user_factory(name='u2')) + executor, + user_factory, + offset, + limit, + expected_total_count, + expected_user_names, +): + db.session.add(user_factory(name="u1")) + db.session.add(user_factory(name="u2")) db.session.flush() actual_count, actual_users = executor.execute( - '', offset=offset, limit=limit) + "", offset=offset, limit=limit + ) actual_user_names = [u.name for u in actual_users] assert actual_count == expected_total_count assert actual_user_names == expected_user_names -@pytest.mark.parametrize('input,expected_user_names', [ - ('', ['u1', 'u2']), - ('sort:name', ['u1', 'u2']), - ('-sort:name', ['u2', 'u1']), - ('sort:name,asc', ['u1', 'u2']), - ('sort:name,desc', ['u2', 'u1']), - ('-sort:name,asc', ['u2', 'u1']), - ('-sort:name,desc', ['u1', 'u2']), -]) +@pytest.mark.parametrize( + "input,expected_user_names", + [ + ("", ["u1", "u2"]), + ("sort:name", ["u1", "u2"]), + ("-sort:name", ["u2", "u1"]), + ("sort:name,asc", ["u1", "u2"]), + ("sort:name,desc", ["u2", "u1"]), + ("-sort:name,asc", ["u2", "u1"]), + ("-sort:name,desc", ["u1", "u2"]), + ], +) def test_sort_by_name( - verify_unpaged, input, expected_user_names, user_factory): - db.session.add(user_factory(name='u2')) - db.session.add(user_factory(name='u1')) + verify_unpaged, input, expected_user_names, user_factory +): + db.session.add(user_factory(name="u2")) + db.session.add(user_factory(name="u1")) db.session.flush() verify_unpaged(input, expected_user_names) -@pytest.mark.parametrize('input,expected_user_names', [ - ('', ['u1', 'u2', 'u3']), - ('sort:creation-date', ['u3', 'u2', 'u1']), - ('sort:creation-time', ['u3', 'u2', 'u1']), - ('-sort:creation-date', ['u1', 'u2', 'u3']), - ('sort:creation-date,asc', ['u1', 'u2', 'u3']), - ('sort:creation-date,desc', ['u3', 'u2', 'u1']), - ('-sort:creation-date,asc', ['u3', 'u2', 'u1']), - ('-sort:creation-date,desc', ['u1', 'u2', 'u3']), -]) +@pytest.mark.parametrize( + "input,expected_user_names", + [ + ("", ["u1", "u2", "u3"]), + ("sort:creation-date", ["u3", "u2", "u1"]), + ("sort:creation-time", ["u3", "u2", "u1"]), + ("-sort:creation-date", ["u1", "u2", "u3"]), + ("sort:creation-date,asc", ["u1", "u2", "u3"]), + ("sort:creation-date,desc", ["u3", "u2", "u1"]), + ("-sort:creation-date,asc", ["u3", "u2", "u1"]), + ("-sort:creation-date,desc", ["u1", "u2", "u3"]), + ], +) def test_sort_by_creation_time( - verify_unpaged, input, expected_user_names, user_factory): - user1 = user_factory(name='u1') - user2 = user_factory(name='u2') - user3 = user_factory(name='u3') + verify_unpaged, input, expected_user_names, user_factory +): + user1 = user_factory(name="u1") + user2 = user_factory(name="u2") + user3 = user_factory(name="u3") user1.creation_time = datetime(1991, 1, 1) user2.creation_time = datetime(1991, 1, 2) user3.creation_time = datetime(1991, 1, 3) @@ -214,18 +251,22 @@ def test_sort_by_creation_time( verify_unpaged(input, expected_user_names) -@pytest.mark.parametrize('input,expected_user_names', [ - ('', ['u1', 'u2', 'u3']), - ('sort:last-login-date', ['u3', 'u2', 'u1']), - ('sort:last-login-time', ['u3', 'u2', 'u1']), - ('sort:login-date', ['u3', 'u2', 'u1']), - ('sort:login-time', ['u3', 'u2', 'u1']), -]) +@pytest.mark.parametrize( + "input,expected_user_names", + [ + ("", ["u1", "u2", "u3"]), + ("sort:last-login-date", ["u3", "u2", "u1"]), + ("sort:last-login-time", ["u3", "u2", "u1"]), + ("sort:login-date", ["u3", "u2", "u1"]), + ("sort:login-time", ["u3", "u2", "u1"]), + ], +) def test_sort_by_last_login_time( - verify_unpaged, input, expected_user_names, user_factory): - user1 = user_factory(name='u1') - user2 = user_factory(name='u2') - user3 = user_factory(name='u3') + verify_unpaged, input, expected_user_names, user_factory +): + user1 = user_factory(name="u1") + user2 = user_factory(name="u2") + user3 = user_factory(name="u3") user1.last_login_time = datetime(1991, 1, 1) user2.last_login_time = datetime(1991, 1, 2) user3.last_login_time = datetime(1991, 1, 3) @@ -235,39 +276,43 @@ def test_sort_by_last_login_time( def test_random_sort(executor, user_factory): - user1 = user_factory(name='u1') - user2 = user_factory(name='u2') - user3 = user_factory(name='u3') + user1 = user_factory(name="u1") + user2 = user_factory(name="u2") + user3 = user_factory(name="u3") db.session.add_all([user3, user1, user2]) db.session.flush() actual_count, actual_users = executor.execute( - 'sort:random', offset=0, limit=100) + "sort:random", offset=0, limit=100 + ) actual_user_names = [u.name for u in actual_users] assert actual_count == 3 assert len(actual_user_names) == 3 - assert 'u1' in actual_user_names - assert 'u2' in actual_user_names - assert 'u3' in actual_user_names + assert "u1" in actual_user_names + assert "u2" in actual_user_names + assert "u3" in actual_user_names -@pytest.mark.parametrize('input,expected_error', [ - ('creation-date:..', errors.SearchError), - ('creation-date-min:..', errors.ValidationError), - ('creation-date-min:..2014-01-01', errors.ValidationError), - ('creation-date-min:2014-01-01..', errors.ValidationError), - ('creation-date-max:..2014-01-01', errors.ValidationError), - ('creation-date-max:2014-01-01..', errors.ValidationError), - ('creation-date-max:yesterday,today', errors.ValidationError), - ('creation-date:bad..', errors.ValidationError), - ('creation-date:..bad', errors.ValidationError), - ('creation-date:bad..bad', errors.ValidationError), - ('sort:', errors.SearchError), - ('sort:nam', errors.SearchError), - ('sort:name,as', errors.SearchError), - ('sort:name,asc,desc', errors.SearchError), - ('bad:x', errors.SearchError), - ('special:unsupported', errors.SearchError), -]) +@pytest.mark.parametrize( + "input,expected_error", + [ + ("creation-date:..", errors.SearchError), + ("creation-date-min:..", errors.ValidationError), + ("creation-date-min:..2014-01-01", errors.ValidationError), + ("creation-date-min:2014-01-01..", errors.ValidationError), + ("creation-date-max:..2014-01-01", errors.ValidationError), + ("creation-date-max:2014-01-01..", errors.ValidationError), + ("creation-date-max:yesterday,today", errors.ValidationError), + ("creation-date:bad..", errors.ValidationError), + ("creation-date:..bad", errors.ValidationError), + ("creation-date:bad..bad", errors.ValidationError), + ("sort:", errors.SearchError), + ("sort:nam", errors.SearchError), + ("sort:name,as", errors.SearchError), + ("sort:name,asc,desc", errors.SearchError), + ("bad:x", errors.SearchError), + ("special:unsupported", errors.SearchError), + ], +) def test_bad_tokens(executor, input, expected_error): with pytest.raises(expected_error): executor.execute(input, offset=0, limit=100) diff --git a/server/szurubooru/tests/search/test_executor.py b/server/szurubooru/tests/search/test_executor.py index e1b2dac..4530bee 100644 --- a/server/szurubooru/tests/search/test_executor.py +++ b/server/szurubooru/tests/search/test_executor.py @@ -1,23 +1,27 @@ import unittest.mock + import pytest + from szurubooru import search from szurubooru.func import cache def test_retrieving_from_cache(): config = unittest.mock.MagicMock() - with unittest.mock.patch('szurubooru.func.cache.has'), \ - unittest.mock.patch('szurubooru.func.cache.get'): + with unittest.mock.patch("szurubooru.func.cache.has"), unittest.mock.patch( + "szurubooru.func.cache.get" + ): cache.has.side_effect = lambda *args: True executor = search.Executor(config) - executor.execute('test:whatever', 1, 10) + executor.execute("test:whatever", 1, 10) assert cache.get.called def test_putting_equivalent_queries_into_cache(): config = search.configs.PostSearchConfig() - with unittest.mock.patch('szurubooru.func.cache.has'), \ - unittest.mock.patch('szurubooru.func.cache.put'): + with unittest.mock.patch("szurubooru.func.cache.has"), unittest.mock.patch( + "szurubooru.func.cache.put" + ): hashes = [] def appender(key, _value): @@ -26,20 +30,21 @@ def test_putting_equivalent_queries_into_cache(): cache.has.side_effect = lambda *args: False cache.put.side_effect = appender executor = search.Executor(config) - executor.execute('safety:safe test', 1, 10) - executor.execute('safety:safe test', 1, 10) - executor.execute('safety:safe test ', 1, 10) - executor.execute(' safety:safe test', 1, 10) - executor.execute(' SAFETY:safe test', 1, 10) - executor.execute('test safety:safe', 1, 10) + executor.execute("safety:safe test", 1, 10) + executor.execute("safety:safe test", 1, 10) + executor.execute("safety:safe test ", 1, 10) + executor.execute(" safety:safe test", 1, 10) + executor.execute(" SAFETY:safe test", 1, 10) + executor.execute("test safety:safe", 1, 10) assert len(hashes) == 6 assert len(set(hashes)) == 1 def test_putting_non_equivalent_queries_into_cache(): config = search.configs.PostSearchConfig() - with unittest.mock.patch('szurubooru.func.cache.has'), \ - unittest.mock.patch('szurubooru.func.cache.put'): + with unittest.mock.patch("szurubooru.func.cache.has"), unittest.mock.patch( + "szurubooru.func.cache.put" + ): hashes = [] def appender(key, _value): @@ -49,42 +54,42 @@ def test_putting_non_equivalent_queries_into_cache(): cache.put.side_effect = appender executor = search.Executor(config) args = [ - ('', 1, 10), - ('creation-time:2016', 1, 10), - ('creation-time:2015', 1, 10), - ('creation-time:2016-01', 1, 10), - ('creation-time:2016-02', 1, 10), - ('creation-time:2016-01-01', 1, 10), - ('creation-time:2016-01-02', 1, 10), - ('tag-count:1,3', 1, 10), - ('tag-count:1,2', 1, 10), - ('tag-count:1', 1, 10), - ('tag-count:1..3', 1, 10), - ('tag-count:1..4', 1, 10), - ('tag-count:2..3', 1, 10), - ('tag-count:1..', 1, 10), - ('tag-count:2..', 1, 10), - ('tag-count:..3', 1, 10), - ('tag-count:..4', 1, 10), - ('-tag-count:1..3', 1, 10), - ('-tag-count:1..4', 1, 10), - ('-tag-count:2..3', 1, 10), - ('-tag-count:1..', 1, 10), - ('-tag-count:2..', 1, 10), - ('-tag-count:..3', 1, 10), - ('-tag-count:..4', 1, 10), - ('safety:safe', 1, 10), - ('safety:safe', 1, 20), - ('safety:safe', 2, 10), - ('safety:sketchy', 1, 10), - ('safety:safe test', 1, 10), - ('-safety:safe', 1, 10), - ('-safety:safe', 1, 20), - ('-safety:safe', 2, 10), - ('-safety:sketchy', 1, 10), - ('-safety:safe test', 1, 10), - ('safety:safe -test', 1, 10), - ('-test', 1, 10), + ("", 1, 10), + ("creation-time:2016", 1, 10), + ("creation-time:2015", 1, 10), + ("creation-time:2016-01", 1, 10), + ("creation-time:2016-02", 1, 10), + ("creation-time:2016-01-01", 1, 10), + ("creation-time:2016-01-02", 1, 10), + ("tag-count:1,3", 1, 10), + ("tag-count:1,2", 1, 10), + ("tag-count:1", 1, 10), + ("tag-count:1..3", 1, 10), + ("tag-count:1..4", 1, 10), + ("tag-count:2..3", 1, 10), + ("tag-count:1..", 1, 10), + ("tag-count:2..", 1, 10), + ("tag-count:..3", 1, 10), + ("tag-count:..4", 1, 10), + ("-tag-count:1..3", 1, 10), + ("-tag-count:1..4", 1, 10), + ("-tag-count:2..3", 1, 10), + ("-tag-count:1..", 1, 10), + ("-tag-count:2..", 1, 10), + ("-tag-count:..3", 1, 10), + ("-tag-count:..4", 1, 10), + ("safety:safe", 1, 10), + ("safety:safe", 1, 20), + ("safety:safe", 2, 10), + ("safety:sketchy", 1, 10), + ("safety:safe test", 1, 10), + ("-safety:safe", 1, 10), + ("-safety:safe", 1, 20), + ("-safety:safe", 2, 10), + ("-safety:sketchy", 1, 10), + ("-safety:safe test", 1, 10), + ("safety:safe -test", 1, 10), + ("-test", 1, 10), ] for arg in args: executor.execute(*arg) @@ -92,18 +97,22 @@ def test_putting_non_equivalent_queries_into_cache(): assert len(set(hashes)) == len(args) -@pytest.mark.parametrize('input', [ - 'special:fav', - 'special:liked', - 'special:disliked', - '-special:fav', - '-special:liked', - '-special:disliked', -]) +@pytest.mark.parametrize( + "input", + [ + "special:fav", + "special:liked", + "special:disliked", + "-special:fav", + "-special:liked", + "-special:disliked", + ], +) def test_putting_auth_dependent_queries_into_cache(user_factory, input): config = search.configs.PostSearchConfig() - with unittest.mock.patch('szurubooru.func.cache.has'), \ - unittest.mock.patch('szurubooru.func.cache.put'): + with unittest.mock.patch("szurubooru.func.cache.has"), unittest.mock.patch( + "szurubooru.func.cache.put" + ): hashes = [] def appender(key, _value): diff --git a/server/wait-for-es b/server/wait-for-es deleted file mode 100755 index 6855c75..0000000 --- a/server/wait-for-es +++ /dev/null @@ -1,33 +0,0 @@ -#!/usr/bin/env python3 -''' -Docker helper script. Blocks until the ElasticSearch service is ready. -''' -import logging -import time -import elasticsearch -from szurubooru import config, errors - - -def main(): - print('Looking for ElasticSearch connection...') - logging.basicConfig(level=logging.ERROR) - es = elasticsearch.Elasticsearch([{ - 'host': config.config['elasticsearch']['host'], - 'port': config.config['elasticsearch']['port'], - }]) - - TIMEOUT = 30 - DELAY = 0.1 - for _ in range(int(TIMEOUT / DELAY)): - try: - es.cluster.health(wait_for_status='yellow') - print('Connected to ElasticSearch!') - return - except Exception: - time.sleep(DELAY) - pass - raise errors.ThirdPartyError('Error connecting to ElasticSearch') - - -if __name__ == '__main__': - main() |