From 3edc07b7f851ed5d07574c62da1ff8320cccd69b Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 29 Jul 2020 21:41:35 +0000 Subject: [PATCH 1/6] client/build: bump elliptic from 6.4.0 to 6.5.3 Bumps [elliptic](https://github.com/indutny/elliptic) from 6.4.0 to 6.5.3. - [Release notes](https://github.com/indutny/elliptic/releases) - [Commits](https://github.com/indutny/elliptic/compare/v6.4.0...v6.5.3) Signed-off-by: dependabot[bot] --- client/package-lock.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/client/package-lock.json b/client/package-lock.json index 448ed784..bf1040d0 100644 --- a/client/package-lock.json +++ b/client/package-lock.json @@ -1761,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", From b74492974df42cc40be0221b7d08ffa0819dba46 Mon Sep 17 00:00:00 2001 From: Shyam Sunder Date: Thu, 13 Aug 2020 12:36:21 -0400 Subject: [PATCH 2/6] doc/developer-utils: added helper script for easily creating szurubooru migrations --- .../create-alembic-migration.sh | 28 +++++++++++++++++++ 1 file changed, 28 insertions(+) create mode 100755 doc/developer-utils/create-alembic-migration.sh diff --git a/doc/developer-utils/create-alembic-migration.sh b/doc/developer-utils/create-alembic-migration.sh new file mode 100755 index 00000000..1e884a39 --- /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 From 4595f9a2aa1d39f815a62cff2328bc61b0f1a9ed Mon Sep 17 00:00:00 2001 From: Shyam Sunder Date: Thu, 13 Aug 2020 19:14:14 -0400 Subject: [PATCH 3/6] server: API support for webhooks Closes #339 --- doc/API.md | 10 +++ server/config.yaml.dist | 11 +++- server/szurubooru/facade.py | 5 ++ server/szurubooru/func/net.py | 24 +++++++ server/szurubooru/func/snapshots.py | 13 +++- .../tests/api/test_pool_category_updating.py | 2 +- .../tests/api/test_pool_creating.py | 2 +- .../tests/api/test_pool_deleting.py | 23 +++---- .../tests/api/test_post_creating.py | 12 +++- .../tests/api/test_post_featuring.py | 8 ++- .../tests/api/test_tag_category_updating.py | 2 +- .../szurubooru/tests/api/test_tag_creating.py | 2 +- .../szurubooru/tests/api/test_tag_deleting.py | 21 +++--- server/szurubooru/tests/func/test_net.py | 45 +++++++++++++ .../szurubooru/tests/func/test_snapshots.py | 64 ++++++++++--------- 15 files changed, 182 insertions(+), 62 deletions(-) diff --git a/doc/API.md b/doc/API.md index 51084c06..db96bb10 100644 --- a/doc/API.md +++ b/doc/API.md @@ -13,6 +13,7 @@ - [Error handling](#error-handling) - [Field selecting](#field-selecting) - [Versioning](#versioning) + - [Webhooks](#webhooks) 2. [API reference](#api-reference) @@ -276,6 +277,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 diff --git a/server/config.yaml.dist b/server/config.yaml.dist index 937c1387..e3799a35 100644 --- a/server/config.yaml.dist +++ b/server/config.yaml.dist @@ -39,8 +39,9 @@ smtp: user: # example: bot pass: # example: groovy123 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 + # 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 @@ -58,6 +59,12 @@ pool_category_name_regex: ^[^\s%+#/]+$ 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: diff --git a/server/szurubooru/facade.py b/server/szurubooru/facade.py index f1a9a072..ecf34c74 100644 --- a/server/szurubooru/facade.py +++ b/server/szurubooru/facade.py @@ -94,6 +94,11 @@ def validate_config() -> None: 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") + if config.config["smtp"]["host"]: if not config.config["smtp"]["port"]: raise errors.ConfigError("SMTP host is set but port is not set") diff --git a/server/szurubooru/func/net.py b/server/szurubooru/func/net.py index ddac65c8..bb43dddb 100644 --- a/server/szurubooru/func/net.py +++ b/server/szurubooru/func/net.py @@ -1,7 +1,10 @@ +import json import logging import os +import urllib.error import urllib.request from tempfile import NamedTemporaryFile +from typing import Any, Dict, List from youtube_dl import YoutubeDL from youtube_dl.utils import YoutubeDLError @@ -58,3 +61,24 @@ def _youtube_dl_wrapper(url: str) -> bytes: raise errors.ThirdPartyError( "Error downloading video %s (file could not be saved)" % (url) ) + + +def post_to_webhooks(payload: Dict[str, Any]) -> List[int]: + return_list = [] + for webhook in config.config["webhooks"] or []: + 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_list.append(res.status) + except urllib.error.URLError as e: + logger.error(f"Unable to call webhook {webhook}: {str(e)}") + return_list.append(400) + return return_list diff --git a/server/szurubooru/func/snapshots.py b/server/szurubooru/func/snapshots.py index b34b7022..afb26ea5 100644 --- a/server/szurubooru/func/snapshots.py +++ b/server/szurubooru/func/snapshots.py @@ -4,7 +4,7 @@ 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]: @@ -93,6 +93,13 @@ def serialize_snapshot( } +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: @@ -116,6 +123,7 @@ 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) def modify(entity: model.Base, auth_user: Optional[model.User]) -> None: @@ -147,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: @@ -155,6 +164,7 @@ 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( @@ -174,3 +184,4 @@ def merge( ) = 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/tests/api/test_pool_category_updating.py b/server/szurubooru/tests/api/test_pool_category_updating.py index d934e603..c13112f4 100644 --- a/server/szurubooru/tests/api/test_pool_category_updating.py +++ b/server/szurubooru/tests/api/test_pool_category_updating.py @@ -67,7 +67,7 @@ def test_omitting_optional_field( 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}}, diff --git a/server/szurubooru/tests/api/test_pool_creating.py b/server/szurubooru/tests/api/test_pool_creating.py index a9f1473f..4fd89396 100644 --- a/server/szurubooru/tests/api/test_pool_creating.py +++ b/server/szurubooru/tests/api/test_pool_creating.py @@ -70,7 +70,7 @@ def test_omitting_optional_field( 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( diff --git a/server/szurubooru/tests/api/test_pool_deleting.py b/server/szurubooru/tests/api/test_pool_deleting.py index 387aca10..5c5adcc1 100644 --- a/server/szurubooru/tests/api/test_pool_deleting.py +++ b/server/szurubooru/tests/api/test_pool_deleting.py @@ -34,17 +34,18 @@ def test_deleting_used( pool.posts.append(post) db.session.add_all([pool, post]) db.session.commit() - 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 == [] + 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): diff --git a/server/szurubooru/tests/api/test_post_creating.py b/server/szurubooru/tests/api/test_post_creating.py index 4e05cf11..e2db0eba 100644 --- a/server/szurubooru/tests/api/test_post_creating.py +++ b/server/szurubooru/tests/api/test_post_creating.py @@ -143,7 +143,9 @@ def test_anonymous_uploads( with patch("szurubooru.func.posts.serialize_post"), patch( "szurubooru.func.posts.create_post" - ), patch("szurubooru.func.posts.update_post_source"): + ), patch("szurubooru.func.posts.update_post_source"), patch( + "szurubooru.func.snapshots._post_to_webhooks" + ): config_injector( { "privileges": { @@ -181,6 +183,8 @@ def test_creating_from_url_saves_source( "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( { @@ -223,6 +227,8 @@ def test_creating_from_url_with_source_specified( "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( { @@ -334,7 +340,7 @@ def test_errors_not_spending_ids( # successful request 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( @@ -357,7 +363,7 @@ def test_errors_not_spending_ids( # successful request 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( diff --git a/server/szurubooru/tests/api/test_post_featuring.py b/server/szurubooru/tests/api/test_post_featuring.py index e1f8cef8..d83d4b6b 100644 --- a/server/szurubooru/tests/api/test_post_featuring.py +++ b/server/szurubooru/tests/api/test_post_featuring.py @@ -55,7 +55,9 @@ def test_trying_to_feature_the_same_post_twice( ): 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}, @@ -80,7 +82,9 @@ def test_featuring_one_post_after_another( 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 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( diff --git a/server/szurubooru/tests/api/test_tag_category_updating.py b/server/szurubooru/tests/api/test_tag_category_updating.py index 0d593900..6c32737b 100644 --- a/server/szurubooru/tests/api/test_tag_category_updating.py +++ b/server/szurubooru/tests/api/test_tag_category_updating.py @@ -65,7 +65,7 @@ def test_omitting_optional_field( del params[field] 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}}, diff --git a/server/szurubooru/tests/api/test_tag_creating.py b/server/szurubooru/tests/api/test_tag_creating.py index 648b3178..2f4264db 100644 --- a/server/szurubooru/tests/api/test_tag_creating.py +++ b/server/szurubooru/tests/api/test_tag_creating.py @@ -71,7 +71,7 @@ def test_omitting_optional_field( del params[field] 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( diff --git a/server/szurubooru/tests/api/test_tag_deleting.py b/server/szurubooru/tests/api/test_tag_deleting.py index b45ef556..59a19f8c 100644 --- a/server/szurubooru/tests/api/test_tag_deleting.py +++ b/server/szurubooru/tests/api/test_tag_deleting.py @@ -34,16 +34,17 @@ def test_deleting_used( 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): diff --git a/server/szurubooru/tests/func/test_net.py b/server/szurubooru/tests/func/test_net.py index 58e16287..8539a343 100644 --- a/server/szurubooru/tests/func/test_net.py +++ b/server/szurubooru/tests/func/test_net.py @@ -1,3 +1,6 @@ +from datetime import datetime +from unittest.mock import patch + import pytest from szurubooru import errors @@ -99,3 +102,45 @@ def test_video_download(url, expected_sha1): 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): + config_injector({"webhooks": [webhook]}) + res = net.post_to_webhooks({"test_arg": "test_value"}) + assert len(res) == 1 + assert res[0] == status_code + + +def test_multiple_webhooks(config_injector): + config_injector( + { + "webhooks": [ + "https://postman-echo.com/post", + "https://postman-echo.com/post", + ] + } + ) + res = net.post_to_webhooks({"test_arg": "test_value"}) + assert len(res) == 2 + assert res[0] == 200 + assert res[1] == 200 + + +def test_malformed_webhooks(config_injector): + config_injector({"webhooks": ["malformed_url"]}) + with pytest.raises(ValueError): + net.post_to_webhooks({"test_arg": "test_value"}) diff --git a/server/szurubooru/tests/func/test_snapshots.py b/server/szurubooru/tests/func/test_snapshots.py index b4dda1cc..da935307 100644 --- a/server/szurubooru/tests/func/test_snapshots.py +++ b/server/szurubooru/tests/func/test_snapshots.py @@ -132,7 +132,9 @@ def test_create(tag_factory, user_factory): tag = tag_factory(names=["dummy"]) db.session.add(tag) db.session.flush() - with patch("szurubooru.func.snapshots.get_tag_snapshot"): + 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() @@ -156,29 +158,30 @@ def test_modify_saves_non_empty_diffs(post_factory, user_factory): post.source = "new source" post.notes = [model.PostNote(polygon=[(0, 0), (0, 1), (1, 1)], text="new")] db.session.flush() - 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", + 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"} + ], + }, }, - "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): @@ -195,7 +198,9 @@ def test_delete(tag_factory, user_factory): tag = tag_factory(names=["dummy"]) db.session.add(tag) db.session.flush() - with patch("szurubooru.func.snapshots.get_tag_snapshot"): + 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() @@ -210,8 +215,9 @@ def test_merge(tag_factory, user_factory): 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"] From 74c97efdef3154004692b943ef70713d48357dff Mon Sep 17 00:00:00 2001 From: Shyam Sunder Date: Sat, 22 Aug 2020 10:17:59 -0400 Subject: [PATCH 4/6] client/search: fix autocomplete for composite queries Fixes #342 --- client/js/controls/auto_complete_control.js | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/client/js/controls/auto_complete_control.js b/client/js/controls/auto_complete_control.js index 6e9bf3dd..fb3792fc 100644 --- a/client/js/controls/auto_complete_control.js +++ b/client/js/controls/auto_complete_control.js @@ -62,13 +62,16 @@ class AutoCompleteControl { 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(); + prefix + result.toString() + delimiter + suffix.trimLeft(); if (!addSpace) { this._sourceInputNode.value = this._sourceInputNode.value.trim(); } From 3e69edc11748d7835ec9d878b22c6834f553cb47 Mon Sep 17 00:00:00 2001 From: Shyam Sunder Date: Sat, 22 Aug 2020 22:08:52 -0400 Subject: [PATCH 5/6] dev/pre-commit: move pytest hook to 'push' stage --- .pre-commit-config.yaml | 1 + 1 file changed, 1 insertion(+) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 0b791742..2b35471c 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -75,6 +75,7 @@ repos: files: server/szurubooru/ exclude: server/szurubooru/migrations/ pass_filenames: false + stages: [push] - id: pytest-cov name: pytest From 1bbcaf11f7b064f2a40245b79d392cbbf0324484 Mon Sep 17 00:00:00 2001 From: Shyam Sunder Date: Sun, 23 Aug 2020 13:03:23 -0400 Subject: [PATCH 6/6] client/posts: add tag implications when autocompleting mass tag inputs Closes #334. This solution should function similar to single post tagging. Implications are automatically added but this also allows for them to review and manually remove any unwanted implications. --- client/css/post-list-view.styl | 2 +- client/js/views/posts_header_view.js | 27 ++++++++++++++++++++++----- 2 files changed, 23 insertions(+), 6 deletions(-) diff --git a/client/css/post-list-view.styl b/client/css/post-list-view.styl index 5297bb0c..8d4989b3 100644 --- a/client/css/post-list-view.styl +++ b/client/css/post-list-view.styl @@ -182,7 +182,7 @@ .hint display: none input[name=tag] - width: 12em + width: 24em @media (max-width: 1000px) display: block width: 100% diff --git a/client/js/views/posts_header_view.js b/client/js/views/posts_header_view.js index 81546a7e..7cbddf3d 100644 --- a/client/js/views/posts_header_view.js +++ b/client/js/views/posts_header_view.js @@ -6,6 +6,7 @@ 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 template = views.getTemplate("posts-header"); @@ -74,11 +75,27 @@ class BulkTagEditor extends BulkEditor { this._autoCompleteControl = new TagAutoCompleteControl( this._inputNode, { - confirm: (tag) => - this._autoCompleteControl.replaceSelectedText( - tag.names[0], - false - ), + 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) =>