From d30a68567eaf44eec4f140eb493d7de819d44161 Mon Sep 17 00:00:00 2001 From: WithoutPants <53250216+WithoutPants@users.noreply.github.com> Date: Tue, 30 Mar 2021 12:33:57 +1100 Subject: [PATCH 01/66] Update libraries and regenerate yarn.lock (#1231) * Update and regenerate yarn.lock * Remove eslint check for react function order --- ui/v2.5/.eslintrc.json | 1 + ui/v2.5/yarn.lock | 5328 +++++++++++++++++----------------------- 2 files changed, 2215 insertions(+), 3114 deletions(-) diff --git a/ui/v2.5/.eslintrc.json b/ui/v2.5/.eslintrc.json index c99b31cc3..32597b3e3 100644 --- a/ui/v2.5/.eslintrc.json +++ b/ui/v2.5/.eslintrc.json @@ -63,6 +63,7 @@ "react/destructuring-assignment": "off", "react/require-default-props": "off", "react/jsx-props-no-spreading": "off", + "react/sort-comp": "off", "react/style-prop-object": ["error", { "allow": ["FormattedNumber"] }], diff --git a/ui/v2.5/yarn.lock b/ui/v2.5/yarn.lock index bdcfb738b..fad7ebb00 100644 --- a/ui/v2.5/yarn.lock +++ b/ui/v2.5/yarn.lock @@ -2,41 +2,22 @@ # yarn lockfile v1 -"@apollo/client@^3.1.3": - version "3.2.5" - resolved "https://registry.yarnpkg.com/@apollo/client/-/client-3.2.5.tgz#24e0a6faa1d231ab44807af237c6227410c75c4d" - integrity sha512-zpruxnFMz6K94gs2pqc3sidzFDbQpKT5D6P/J/I9s8ekHZ5eczgnRp6pqXC86Bh7+44j/btpmOT0kwiboyqTnA== - dependencies: - "@graphql-typed-document-node/core" "^3.0.0" - "@types/zen-observable" "^0.8.0" - "@wry/context" "^0.5.2" - "@wry/equality" "^0.2.0" - fast-json-stable-stringify "^2.0.0" - graphql-tag "^2.11.0" - hoist-non-react-statics "^3.3.2" - optimism "^0.13.0" - prop-types "^15.7.2" - symbol-observable "^2.0.0" - ts-invariant "^0.4.4" - tslib "^1.10.0" - zen-observable "^0.8.14" - -"@apollo/client@^3.2.5", "@apollo/client@^3.3.7": - version "3.3.7" - resolved "https://registry.yarnpkg.com/@apollo/client/-/client-3.3.7.tgz#f15bf961dc0c2bee37a47bf86b8881fdc6183810" - integrity sha512-Cb0OqqvlehlRHtHIXRIS/Pe5WYU4hHl1FznXTRSxBAN42WmBUM3zy/Unvw183RdWMyV6Kc2pFKOEuaG1K7JTAQ== +"@apollo/client@^3.1.3", "@apollo/client@^3.2.5", "@apollo/client@^3.3.7": + version "3.3.12" + resolved "https://registry.yarnpkg.com/@apollo/client/-/client-3.3.12.tgz#e723161617c479812ac425803a2b6a7c1b2466dd" + integrity sha512-1wLVqRpujzbLRWmFPnRCDK65xapOe2txY0sTI+BaqEbumMUVNS3vxojT6hRHf9ODFEK+F6MLrud2HGx0mB3eQw== dependencies: "@graphql-typed-document-node/core" "^3.0.0" "@types/zen-observable" "^0.8.0" "@wry/context" "^0.5.2" "@wry/equality" "^0.3.0" fast-json-stable-stringify "^2.0.0" - graphql-tag "^2.11.0" + graphql-tag "^2.12.0" hoist-non-react-statics "^3.3.2" optimism "^0.14.0" prop-types "^15.7.2" symbol-observable "^2.0.0" - ts-invariant "^0.6.0" + ts-invariant "^0.6.2" tslib "^1.10.0" zen-observable "^0.8.14" @@ -47,31 +28,33 @@ dependencies: tslib "~2.0.1" -"@babel/code-frame@7.10.4", "@babel/code-frame@^7.0.0", "@babel/code-frame@^7.10.4", "@babel/code-frame@^7.5.5": +"@babel/code-frame@7.10.4": version "7.10.4" resolved "https://registry.yarnpkg.com/@babel/code-frame/-/code-frame-7.10.4.tgz#168da1a36e90da68ae8d49c0f1b48c7c6249213a" integrity sha512-vG6SvB6oYEhvgisZNFRmRCUkLz11c7rp+tbNTynGqc6mS1d5ATd/sGyV6W0KZZnXRKMTzZDRgQT3Ou9jhpAfUg== dependencies: "@babel/highlight" "^7.10.4" -"@babel/code-frame@^7.12.13": +"@babel/code-frame@7.12.11": + version "7.12.11" + resolved "https://registry.yarnpkg.com/@babel/code-frame/-/code-frame-7.12.11.tgz#f4ad435aa263db935b8f10f2c552d23fb716a63f" + integrity sha512-Zt1yodBx1UcyiePMSkWnU4hPqhwq7hGi2nFL1LeA3EUl+q2LQx16MISgJ0+z7dnmgvP9QtIleuETGOiOH1RcIw== + dependencies: + "@babel/highlight" "^7.10.4" + +"@babel/code-frame@^7.0.0", "@babel/code-frame@^7.10.4", "@babel/code-frame@^7.12.13", "@babel/code-frame@^7.5.5": version "7.12.13" resolved "https://registry.yarnpkg.com/@babel/code-frame/-/code-frame-7.12.13.tgz#dcfc826beef65e75c50e21d3837d7d95798dd658" integrity sha512-HV1Cm0Q3ZrpCR93tkWOYiuYIgLxZXZFVG2VgK+MBWjUqZTundupbfx2aXarXuw5Ko5aMcjtJgbSs4vUGBS5v6g== dependencies: "@babel/highlight" "^7.12.13" -"@babel/compat-data@^7.12.1": - version "7.12.1" - resolved "https://registry.yarnpkg.com/@babel/compat-data/-/compat-data-7.12.1.tgz#d7386a689aa0ddf06255005b4b991988021101a0" - integrity sha512-725AQupWJZ8ba0jbKceeFblZTY90McUBWMwHhkFQ9q1zKPJ95GUktljFcgcsIVwRnTnRKlcYzfiNImg5G9m6ZQ== +"@babel/compat-data@^7.12.1", "@babel/compat-data@^7.13.0", "@babel/compat-data@^7.13.12", "@babel/compat-data@^7.13.8": + version "7.13.12" + resolved "https://registry.yarnpkg.com/@babel/compat-data/-/compat-data-7.13.12.tgz#a8a5ccac19c200f9dd49624cac6e19d7be1236a1" + integrity sha512-3eJJ841uKxeV8dcN/2yGEUy+RfgQspPEgQat85umsE1rotuquQ2AbIub4S6j7c50a2d+4myc+zSlnXeIHrOnhQ== -"@babel/compat-data@^7.13.0", "@babel/compat-data@^7.13.8": - version "7.13.8" - resolved "https://registry.yarnpkg.com/@babel/compat-data/-/compat-data-7.13.8.tgz#5b783b9808f15cef71547f1b691f34f8ff6003a6" - integrity sha512-EaI33z19T4qN3xLXsGf48M2cDqa6ei9tPZlfLdb2HC+e/cFtREiRd8hdSqDbwdLB0/+gLwqJmCYASH0z2bUdog== - -"@babel/core@7.12.3", "@babel/core@>=7.9.0", "@babel/core@^7.0.0", "@babel/core@^7.1.0", "@babel/core@^7.7.5", "@babel/core@^7.8.4", "@babel/core@^7.9.0": +"@babel/core@7.12.3": version "7.12.3" resolved "https://registry.yarnpkg.com/@babel/core/-/core-7.12.3.tgz#1b436884e1e3bff6fb1328dc02b208759de92ad8" integrity sha512-0qXcZYKZp3/6N2jKYVxZv0aNCsxTSVCiK72DTiTYZAu7sjg73W0/aynWjMbiGd87EQL4WyA8reiJVh92AVla9g== @@ -93,17 +76,17 @@ semver "^5.4.1" source-map "^0.5.0" -"@babel/core@^7.12.3": - version "7.13.8" - resolved "https://registry.yarnpkg.com/@babel/core/-/core-7.13.8.tgz#c191d9c5871788a591d69ea1dc03e5843a3680fb" - integrity sha512-oYapIySGw1zGhEFRd6lzWNLWFX2s5dA/jm+Pw/+59ZdXtjyIuwlXbrId22Md0rgZVop+aVoqow2riXhBLNyuQg== +"@babel/core@>=7.9.0", "@babel/core@^7.0.0", "@babel/core@^7.1.0", "@babel/core@^7.12.3", "@babel/core@^7.7.5", "@babel/core@^7.8.4", "@babel/core@^7.9.0": + version "7.13.10" + resolved "https://registry.yarnpkg.com/@babel/core/-/core-7.13.10.tgz#07de050bbd8193fcd8a3c27918c0890613a94559" + integrity sha512-bfIYcT0BdKeAZrovpMqX2Mx5NrgAckGbwT982AkdS5GNfn3KMGiprlBAtmBcFZRUmpaufS6WZFP8trvx8ptFDw== dependencies: "@babel/code-frame" "^7.12.13" - "@babel/generator" "^7.13.0" - "@babel/helper-compilation-targets" "^7.13.8" + "@babel/generator" "^7.13.9" + "@babel/helper-compilation-targets" "^7.13.10" "@babel/helper-module-transforms" "^7.13.0" - "@babel/helpers" "^7.13.0" - "@babel/parser" "^7.13.4" + "@babel/helpers" "^7.13.10" + "@babel/parser" "^7.13.10" "@babel/template" "^7.12.13" "@babel/traverse" "^7.13.0" "@babel/types" "^7.13.0" @@ -115,16 +98,7 @@ semver "^6.3.0" source-map "^0.5.0" -"@babel/generator@^7.12.1", "@babel/generator@^7.5.0": - version "7.12.1" - resolved "https://registry.yarnpkg.com/@babel/generator/-/generator-7.12.1.tgz#0d70be32bdaa03d7c51c8597dda76e0df1f15468" - integrity sha512-DB+6rafIdc9o72Yc3/Ph5h+6hUjeOp66pF0naQBgUFFuPqzQwIlPTm3xZR7YNvduIMtkDIj2t21LSQwnbCrXvg== - dependencies: - "@babel/types" "^7.12.1" - jsesc "^2.5.1" - source-map "^0.5.0" - -"@babel/generator@^7.13.0": +"@babel/generator@^7.12.1", "@babel/generator@^7.12.13", "@babel/generator@^7.13.0", "@babel/generator@^7.13.9", "@babel/generator@^7.5.0": version "7.13.9" resolved "https://registry.yarnpkg.com/@babel/generator/-/generator-7.13.9.tgz#3a7aa96f9efb8e2be42d38d80e2ceb4c64d8de39" integrity sha512-mHOOmY0Axl/JCTkxTU6Lf5sWOg/v8nUa+Xkt4zMTftX0wqmb6Sh7J8gvcehBw7q0AhrhAR+FDacKjCZ2X8K+Sw== @@ -133,28 +107,13 @@ jsesc "^2.5.1" source-map "^0.5.0" -"@babel/helper-annotate-as-pure@^7.10.4": - version "7.10.4" - resolved "https://registry.yarnpkg.com/@babel/helper-annotate-as-pure/-/helper-annotate-as-pure-7.10.4.tgz#5bf0d495a3f757ac3bda48b5bf3b3ba309c72ba3" - integrity sha512-XQlqKQP4vXFB7BN8fEEerrmYvHp3fK/rBkRFz9jaJbzK0B1DSfej9Kc7ZzE8Z/OnId1jpJdNAZ3BFQjWG68rcA== - dependencies: - "@babel/types" "^7.10.4" - -"@babel/helper-annotate-as-pure@^7.12.13": +"@babel/helper-annotate-as-pure@^7.10.4", "@babel/helper-annotate-as-pure@^7.12.13": version "7.12.13" resolved "https://registry.yarnpkg.com/@babel/helper-annotate-as-pure/-/helper-annotate-as-pure-7.12.13.tgz#0f58e86dfc4bb3b1fcd7db806570e177d439b6ab" integrity sha512-7YXfX5wQ5aYM/BOlbSccHDbuXXFPxeoUmfWtz8le2yTkTZc+BxsiEnENFoi2SlmA8ewDkG2LgIMIVzzn2h8kfw== dependencies: "@babel/types" "^7.12.13" -"@babel/helper-builder-binary-assignment-operator-visitor@^7.10.4": - version "7.10.4" - resolved "https://registry.yarnpkg.com/@babel/helper-builder-binary-assignment-operator-visitor/-/helper-builder-binary-assignment-operator-visitor-7.10.4.tgz#bb0b75f31bf98cbf9ff143c1ae578b87274ae1a3" - integrity sha512-L0zGlFrGWZK4PbT8AszSfLTM5sDU1+Az/En9VrdT8/LmEiJt4zXt+Jve9DCAnQcbqDhCI+29y/L93mrDzddCcg== - dependencies: - "@babel/helper-explode-assignable-expression" "^7.10.4" - "@babel/types" "^7.10.4" - "@babel/helper-builder-binary-assignment-operator-visitor@^7.12.13": version "7.12.13" resolved "https://registry.yarnpkg.com/@babel/helper-builder-binary-assignment-operator-visitor/-/helper-builder-binary-assignment-operator-visitor-7.12.13.tgz#6bc20361c88b0a74d05137a65cac8d3cbf6f61fc" @@ -163,58 +122,20 @@ "@babel/helper-explode-assignable-expression" "^7.12.13" "@babel/types" "^7.12.13" -"@babel/helper-builder-react-jsx-experimental@^7.12.1": - version "7.12.4" - resolved "https://registry.yarnpkg.com/@babel/helper-builder-react-jsx-experimental/-/helper-builder-react-jsx-experimental-7.12.4.tgz#55fc1ead5242caa0ca2875dcb8eed6d311e50f48" - integrity sha512-AjEa0jrQqNk7eDQOo0pTfUOwQBMF+xVqrausQwT9/rTKy0g04ggFNaJpaE09IQMn9yExluigWMJcj0WC7bq+Og== - dependencies: - "@babel/helper-annotate-as-pure" "^7.10.4" - "@babel/helper-module-imports" "^7.12.1" - "@babel/types" "^7.12.1" - -"@babel/helper-builder-react-jsx@^7.10.4": - version "7.10.4" - resolved "https://registry.yarnpkg.com/@babel/helper-builder-react-jsx/-/helper-builder-react-jsx-7.10.4.tgz#8095cddbff858e6fa9c326daee54a2f2732c1d5d" - integrity sha512-5nPcIZ7+KKDxT1427oBivl9V9YTal7qk0diccnh7RrcgrT/pGFOjgGw1dgryyx1GvHEpXVfoDF6Ak3rTiWh8Rg== - dependencies: - "@babel/helper-annotate-as-pure" "^7.10.4" - "@babel/types" "^7.10.4" - -"@babel/helper-compilation-targets@^7.12.1": - version "7.12.1" - resolved "https://registry.yarnpkg.com/@babel/helper-compilation-targets/-/helper-compilation-targets-7.12.1.tgz#310e352888fbdbdd8577be8dfdd2afb9e7adcf50" - integrity sha512-jtBEif7jsPwP27GPHs06v4WBV0KrE8a/P7n0N0sSvHn2hwUCYnolP/CLmz51IzAW4NlN+HuoBtb9QcwnRo9F/g== - dependencies: - "@babel/compat-data" "^7.12.1" - "@babel/helper-validator-option" "^7.12.1" - browserslist "^4.12.0" - semver "^5.5.0" - -"@babel/helper-compilation-targets@^7.13.0", "@babel/helper-compilation-targets@^7.13.8": - version "7.13.8" - resolved "https://registry.yarnpkg.com/@babel/helper-compilation-targets/-/helper-compilation-targets-7.13.8.tgz#02bdb22783439afb11b2f009814bdd88384bd468" - integrity sha512-pBljUGC1y3xKLn1nrx2eAhurLMA8OqBtBP/JwG4U8skN7kf8/aqwwxpV1N6T0e7r6+7uNitIa/fUxPFagSXp3A== +"@babel/helper-compilation-targets@^7.12.1", "@babel/helper-compilation-targets@^7.13.0", "@babel/helper-compilation-targets@^7.13.10", "@babel/helper-compilation-targets@^7.13.8": + version "7.13.10" + resolved "https://registry.yarnpkg.com/@babel/helper-compilation-targets/-/helper-compilation-targets-7.13.10.tgz#1310a1678cb8427c07a753750da4f8ce442bdd0c" + integrity sha512-/Xju7Qg1GQO4mHZ/Kcs6Au7gfafgZnwm+a7sy/ow/tV1sHeraRUHbjdat8/UvDor4Tez+siGKDk6zIKtCPKVJA== dependencies: "@babel/compat-data" "^7.13.8" "@babel/helper-validator-option" "^7.12.17" browserslist "^4.14.5" semver "^6.3.0" -"@babel/helper-create-class-features-plugin@^7.12.1": - version "7.12.1" - resolved "https://registry.yarnpkg.com/@babel/helper-create-class-features-plugin/-/helper-create-class-features-plugin-7.12.1.tgz#3c45998f431edd4a9214c5f1d3ad1448a6137f6e" - integrity sha512-hkL++rWeta/OVOBTRJc9a5Azh5mt5WgZUGAKMD8JM141YsE08K//bp1unBBieO6rUKkIPyUE0USQ30jAy3Sk1w== - dependencies: - "@babel/helper-function-name" "^7.10.4" - "@babel/helper-member-expression-to-functions" "^7.12.1" - "@babel/helper-optimise-call-expression" "^7.10.4" - "@babel/helper-replace-supers" "^7.12.1" - "@babel/helper-split-export-declaration" "^7.10.4" - -"@babel/helper-create-class-features-plugin@^7.13.0": - version "7.13.8" - resolved "https://registry.yarnpkg.com/@babel/helper-create-class-features-plugin/-/helper-create-class-features-plugin-7.13.8.tgz#0367bd0a7505156ce018ca464f7ac91ba58c1a04" - integrity sha512-qioaRrKHQbn4hkRKDHbnuQ6kAxmmOF+kzKGnIfxPK4j2rckSJCpKzr/SSTlohSCiE3uAQpNDJ9FIh4baeE8W+w== +"@babel/helper-create-class-features-plugin@^7.12.1", "@babel/helper-create-class-features-plugin@^7.13.0": + version "7.13.11" + resolved "https://registry.yarnpkg.com/@babel/helper-create-class-features-plugin/-/helper-create-class-features-plugin-7.13.11.tgz#30d30a005bca2c953f5653fc25091a492177f4f6" + integrity sha512-ays0I7XYq9xbjCSvT+EvysLgfc3tOkwCULHjrnscGT3A9qD4sk3wXnJ3of0MAWsWGjdinFvajHU2smYuqXKMrw== dependencies: "@babel/helper-function-name" "^7.12.13" "@babel/helper-member-expression-to-functions" "^7.13.0" @@ -222,15 +143,6 @@ "@babel/helper-replace-supers" "^7.13.0" "@babel/helper-split-export-declaration" "^7.12.13" -"@babel/helper-create-regexp-features-plugin@^7.12.1": - version "7.12.1" - resolved "https://registry.yarnpkg.com/@babel/helper-create-regexp-features-plugin/-/helper-create-regexp-features-plugin-7.12.1.tgz#18b1302d4677f9dc4740fe8c9ed96680e29d37e8" - integrity sha512-rsZ4LGvFTZnzdNZR5HZdmJVuXK8834R5QkF3WvcnBhrlVtF0HSIUC6zbreL9MgjTywhKokn8RIYRiq99+DLAxA== - dependencies: - "@babel/helper-annotate-as-pure" "^7.10.4" - "@babel/helper-regex" "^7.10.4" - regexpu-core "^4.7.1" - "@babel/helper-create-regexp-features-plugin@^7.12.13": version "7.12.17" resolved "https://registry.yarnpkg.com/@babel/helper-create-regexp-features-plugin/-/helper-create-regexp-features-plugin-7.12.17.tgz#a2ac87e9e319269ac655b8d4415e94d38d663cb7" @@ -239,19 +151,10 @@ "@babel/helper-annotate-as-pure" "^7.12.13" regexpu-core "^4.7.1" -"@babel/helper-define-map@^7.10.4": - version "7.10.5" - resolved "https://registry.yarnpkg.com/@babel/helper-define-map/-/helper-define-map-7.10.5.tgz#b53c10db78a640800152692b13393147acb9bb30" - integrity sha512-fMw4kgFB720aQFXSVaXr79pjjcW5puTCM16+rECJ/plGS+zByelE8l9nCpV1GibxTnFVmUuYG9U8wYfQHdzOEQ== - dependencies: - "@babel/helper-function-name" "^7.10.4" - "@babel/types" "^7.10.5" - lodash "^4.17.19" - -"@babel/helper-define-polyfill-provider@^0.1.4": - version "0.1.4" - resolved "https://registry.yarnpkg.com/@babel/helper-define-polyfill-provider/-/helper-define-polyfill-provider-0.1.4.tgz#b618b087c6a0328127e5d53576df818bcee2b15f" - integrity sha512-K5V2GaQZ1gpB+FTXM4AFVG2p1zzhm67n9wrQCJYNzvuLzQybhJyftW7qeDd2uUxPDNdl5Rkon1rOAeUeNDZ28Q== +"@babel/helper-define-polyfill-provider@^0.1.5": + version "0.1.5" + resolved "https://registry.yarnpkg.com/@babel/helper-define-polyfill-provider/-/helper-define-polyfill-provider-0.1.5.tgz#3c2f91b7971b9fc11fe779c945c014065dea340e" + integrity sha512-nXuzCSwlJ/WKr8qxzW816gwyT6VZgiJG17zR40fou70yfAcqjoNyTLl/DQ+FExw5Hx5KNqshmN8Ldl/r2N7cTg== dependencies: "@babel/helper-compilation-targets" "^7.13.0" "@babel/helper-module-imports" "^7.12.13" @@ -262,13 +165,6 @@ resolve "^1.14.2" semver "^6.1.2" -"@babel/helper-explode-assignable-expression@^7.10.4": - version "7.12.1" - resolved "https://registry.yarnpkg.com/@babel/helper-explode-assignable-expression/-/helper-explode-assignable-expression-7.12.1.tgz#8006a466695c4ad86a2a5f2fb15b5f2c31ad5633" - integrity sha512-dmUwH8XmlrUpVqgtZ737tK88v07l840z9j3OEhCLwKTkjlvKpfqXVIZ0wpK3aeOxspwGrf/5AP5qLx4rO3w5rA== - dependencies: - "@babel/types" "^7.12.1" - "@babel/helper-explode-assignable-expression@^7.12.13": version "7.13.0" resolved "https://registry.yarnpkg.com/@babel/helper-explode-assignable-expression/-/helper-explode-assignable-expression-7.13.0.tgz#17b5c59ff473d9f956f40ef570cf3a76ca12657f" @@ -276,15 +172,6 @@ dependencies: "@babel/types" "^7.13.0" -"@babel/helper-function-name@^7.10.4": - version "7.10.4" - resolved "https://registry.yarnpkg.com/@babel/helper-function-name/-/helper-function-name-7.10.4.tgz#d2d3b20c59ad8c47112fa7d2a94bc09d5ef82f1a" - integrity sha512-YdaSyz1n8gY44EmN7x44zBn9zQ1Ry2Y+3GTA+3vH6Mizke1Vw0aWDM66FOYEPw8//qKkmqOckrGgTYa+6sceqQ== - dependencies: - "@babel/helper-get-function-arity" "^7.10.4" - "@babel/template" "^7.10.4" - "@babel/types" "^7.10.4" - "@babel/helper-function-name@^7.12.13": version "7.12.13" resolved "https://registry.yarnpkg.com/@babel/helper-function-name/-/helper-function-name-7.12.13.tgz#93ad656db3c3c2232559fd7b2c3dbdcbe0eb377a" @@ -294,13 +181,6 @@ "@babel/template" "^7.12.13" "@babel/types" "^7.12.13" -"@babel/helper-get-function-arity@^7.10.4": - version "7.10.4" - resolved "https://registry.yarnpkg.com/@babel/helper-get-function-arity/-/helper-get-function-arity-7.10.4.tgz#98c1cbea0e2332f33f9a4661b8ce1505b2c19ba2" - integrity sha512-EkN3YDB+SRDgiIUnNgcmiD361ti+AVbL3f3Henf6dqqUyr5dMsorno0lJWJuLhDhkI5sYEpgj6y9kB8AOU1I2A== - dependencies: - "@babel/types" "^7.10.4" - "@babel/helper-get-function-arity@^7.12.13": version "7.12.13" resolved "https://registry.yarnpkg.com/@babel/helper-get-function-arity/-/helper-get-function-arity-7.12.13.tgz#bc63451d403a3b3082b97e1d8b3fe5bd4091e583" @@ -308,13 +188,6 @@ dependencies: "@babel/types" "^7.12.13" -"@babel/helper-hoist-variables@^7.10.4": - version "7.10.4" - resolved "https://registry.yarnpkg.com/@babel/helper-hoist-variables/-/helper-hoist-variables-7.10.4.tgz#d49b001d1d5a68ca5e6604dda01a6297f7c9381e" - integrity sha512-wljroF5PgCk2juF69kanHVs6vrLwIPNp6DLD+Lrl3hoQ3PpPPikaDRNFA+0t81NOoMt2DL6WW/mdU8k4k6ZzuA== - dependencies: - "@babel/types" "^7.10.4" - "@babel/helper-hoist-variables@^7.13.0": version "7.13.0" resolved "https://registry.yarnpkg.com/@babel/helper-hoist-variables/-/helper-hoist-variables-7.13.0.tgz#5d5882e855b5c5eda91e0cadc26c6e7a2c8593d8" @@ -323,77 +196,33 @@ "@babel/traverse" "^7.13.0" "@babel/types" "^7.13.0" -"@babel/helper-member-expression-to-functions@^7.12.1": - version "7.12.1" - resolved "https://registry.yarnpkg.com/@babel/helper-member-expression-to-functions/-/helper-member-expression-to-functions-7.12.1.tgz#fba0f2fcff3fba00e6ecb664bb5e6e26e2d6165c" - integrity sha512-k0CIe3tXUKTRSoEx1LQEPFU9vRQfqHtl+kf8eNnDqb4AUJEy5pz6aIiog+YWtVm2jpggjS1laH68bPsR+KWWPQ== +"@babel/helper-member-expression-to-functions@^7.13.0", "@babel/helper-member-expression-to-functions@^7.13.12": + version "7.13.12" + resolved "https://registry.yarnpkg.com/@babel/helper-member-expression-to-functions/-/helper-member-expression-to-functions-7.13.12.tgz#dfe368f26d426a07299d8d6513821768216e6d72" + integrity sha512-48ql1CLL59aKbU94Y88Xgb2VFy7a95ykGRbJJaaVv+LX5U8wFpLfiGXJJGUozsmA1oEh/o5Bp60Voq7ACyA/Sw== dependencies: - "@babel/types" "^7.12.1" + "@babel/types" "^7.13.12" -"@babel/helper-member-expression-to-functions@^7.13.0": - version "7.13.0" - resolved "https://registry.yarnpkg.com/@babel/helper-member-expression-to-functions/-/helper-member-expression-to-functions-7.13.0.tgz#6aa4bb678e0f8c22f58cdb79451d30494461b091" - integrity sha512-yvRf8Ivk62JwisqV1rFRMxiSMDGnN6KH1/mDMmIrij4jztpQNRoHqqMG3U6apYbGRPJpgPalhva9Yd06HlUxJQ== +"@babel/helper-module-imports@^7.0.0", "@babel/helper-module-imports@^7.12.1", "@babel/helper-module-imports@^7.12.13", "@babel/helper-module-imports@^7.13.12": + version "7.13.12" + resolved "https://registry.yarnpkg.com/@babel/helper-module-imports/-/helper-module-imports-7.13.12.tgz#c6a369a6f3621cb25da014078684da9196b61977" + integrity sha512-4cVvR2/1B693IuOvSI20xqqa/+bl7lqAMR59R4iu39R9aOX8/JoYY1sFaNvUMyMBGnHdwvJgUrzNLoUZxXypxA== dependencies: - "@babel/types" "^7.13.0" + "@babel/types" "^7.13.12" -"@babel/helper-module-imports@^7.0.0", "@babel/helper-module-imports@^7.12.1": - version "7.12.1" - resolved "https://registry.yarnpkg.com/@babel/helper-module-imports/-/helper-module-imports-7.12.1.tgz#1644c01591a15a2f084dd6d092d9430eb1d1216c" - integrity sha512-ZeC1TlMSvikvJNy1v/wPIazCu3NdOwgYZLIkmIyAsGhqkNpiDoQQRmaCK8YP4Pq3GPTLPV9WXaPCJKvx06JxKA== +"@babel/helper-module-transforms@^7.12.1", "@babel/helper-module-transforms@^7.13.0": + version "7.13.12" + resolved "https://registry.yarnpkg.com/@babel/helper-module-transforms/-/helper-module-transforms-7.13.12.tgz#600e58350490828d82282631a1422268e982ba96" + integrity sha512-7zVQqMO3V+K4JOOj40kxiCrMf6xlQAkewBB0eu2b03OO/Q21ZutOzjpfD79A5gtE/2OWi1nv625MrDlGlkbknQ== dependencies: - "@babel/types" "^7.12.1" - -"@babel/helper-module-imports@^7.12.13": - version "7.12.13" - resolved "https://registry.yarnpkg.com/@babel/helper-module-imports/-/helper-module-imports-7.12.13.tgz#ec67e4404f41750463e455cc3203f6a32e93fcb0" - integrity sha512-NGmfvRp9Rqxy0uHSSVP+SRIW1q31a7Ji10cLBcqSDUngGentY4FRiHOFZFE1CLU5eiL0oE8reH7Tg1y99TDM/g== - dependencies: - "@babel/types" "^7.12.13" - -"@babel/helper-module-imports@^7.7.0": - version "7.12.5" - resolved "https://registry.yarnpkg.com/@babel/helper-module-imports/-/helper-module-imports-7.12.5.tgz#1bfc0229f794988f76ed0a4d4e90860850b54dfb" - integrity sha512-SR713Ogqg6++uexFRORf/+nPXMmWIn80TALu0uaFb+iQIUoR7bOC7zBWyzBs5b3tBBJXuyD0cRu1F15GyzjOWA== - dependencies: - "@babel/types" "^7.12.5" - -"@babel/helper-module-transforms@^7.12.1": - version "7.12.1" - resolved "https://registry.yarnpkg.com/@babel/helper-module-transforms/-/helper-module-transforms-7.12.1.tgz#7954fec71f5b32c48e4b303b437c34453fd7247c" - integrity sha512-QQzehgFAZ2bbISiCpmVGfiGux8YVFXQ0abBic2Envhej22DVXV9nCFaS5hIQbkyo1AdGb+gNME2TSh3hYJVV/w== - dependencies: - "@babel/helper-module-imports" "^7.12.1" - "@babel/helper-replace-supers" "^7.12.1" - "@babel/helper-simple-access" "^7.12.1" - "@babel/helper-split-export-declaration" "^7.11.0" - "@babel/helper-validator-identifier" "^7.10.4" - "@babel/template" "^7.10.4" - "@babel/traverse" "^7.12.1" - "@babel/types" "^7.12.1" - lodash "^4.17.19" - -"@babel/helper-module-transforms@^7.13.0": - version "7.13.0" - resolved "https://registry.yarnpkg.com/@babel/helper-module-transforms/-/helper-module-transforms-7.13.0.tgz#42eb4bd8eea68bab46751212c357bfed8b40f6f1" - integrity sha512-Ls8/VBwH577+pw7Ku1QkUWIyRRNHpYlts7+qSqBBFCW3I8QteB9DxfcZ5YJpOwH6Ihe/wn8ch7fMGOP1OhEIvw== - dependencies: - "@babel/helper-module-imports" "^7.12.13" - "@babel/helper-replace-supers" "^7.13.0" - "@babel/helper-simple-access" "^7.12.13" + "@babel/helper-module-imports" "^7.13.12" + "@babel/helper-replace-supers" "^7.13.12" + "@babel/helper-simple-access" "^7.13.12" "@babel/helper-split-export-declaration" "^7.12.13" "@babel/helper-validator-identifier" "^7.12.11" "@babel/template" "^7.12.13" "@babel/traverse" "^7.13.0" - "@babel/types" "^7.13.0" - lodash "^4.17.19" - -"@babel/helper-optimise-call-expression@^7.10.4": - version "7.10.4" - resolved "https://registry.yarnpkg.com/@babel/helper-optimise-call-expression/-/helper-optimise-call-expression-7.10.4.tgz#50dc96413d594f995a77905905b05893cd779673" - integrity sha512-n3UGKY4VXwXThEiKrgRAoVPBMqeoPgHVqiHZOanAJCG9nQUL2pLRQirUzl0ioKclHGpGqRgIOkgcIJaIWLpygg== - dependencies: - "@babel/types" "^7.10.4" + "@babel/types" "^7.13.12" "@babel/helper-optimise-call-expression@^7.12.13": version "7.12.13" @@ -402,32 +231,11 @@ dependencies: "@babel/types" "^7.12.13" -"@babel/helper-plugin-utils@^7.0.0", "@babel/helper-plugin-utils@^7.10.4", "@babel/helper-plugin-utils@^7.8.0", "@babel/helper-plugin-utils@^7.8.3": - version "7.10.4" - resolved "https://registry.yarnpkg.com/@babel/helper-plugin-utils/-/helper-plugin-utils-7.10.4.tgz#2f75a831269d4f677de49986dff59927533cf375" - integrity sha512-O4KCvQA6lLiMU9l2eawBPMf1xPP8xPfB3iEQw150hOVTqj/rfXz0ThTb4HEzqQfs2Bmo5Ay8BzxfzVtBrr9dVg== - -"@babel/helper-plugin-utils@^7.12.13", "@babel/helper-plugin-utils@^7.13.0": +"@babel/helper-plugin-utils@^7.0.0", "@babel/helper-plugin-utils@^7.10.4", "@babel/helper-plugin-utils@^7.12.13", "@babel/helper-plugin-utils@^7.13.0", "@babel/helper-plugin-utils@^7.8.0", "@babel/helper-plugin-utils@^7.8.3": version "7.13.0" resolved "https://registry.yarnpkg.com/@babel/helper-plugin-utils/-/helper-plugin-utils-7.13.0.tgz#806526ce125aed03373bc416a828321e3a6a33af" integrity sha512-ZPafIPSwzUlAoWT8DKs1W2VyF2gOWthGd5NGFMsBcMMol+ZhK+EQY/e6V96poa6PA/Bh+C9plWN0hXO1uB8AfQ== -"@babel/helper-regex@^7.10.4": - version "7.10.5" - resolved "https://registry.yarnpkg.com/@babel/helper-regex/-/helper-regex-7.10.5.tgz#32dfbb79899073c415557053a19bd055aae50ae0" - integrity sha512-68kdUAzDrljqBrio7DYAEgCoJHxppJOERHOgOrDN7WjOzP0ZQ1LsSDRXcemzVZaLvjaJsJEESb6qt+znNuENDg== - dependencies: - lodash "^4.17.19" - -"@babel/helper-remap-async-to-generator@^7.12.1": - version "7.12.1" - resolved "https://registry.yarnpkg.com/@babel/helper-remap-async-to-generator/-/helper-remap-async-to-generator-7.12.1.tgz#8c4dbbf916314f6047dc05e6a2217074238347fd" - integrity sha512-9d0KQCRM8clMPcDwo8SevNs+/9a8yWVVmaE80FGJcEP8N1qToREmWEGnBn8BUlJhYRFz6fqxeRL1sl5Ogsed7A== - dependencies: - "@babel/helper-annotate-as-pure" "^7.10.4" - "@babel/helper-wrap-function" "^7.10.4" - "@babel/types" "^7.12.1" - "@babel/helper-remap-async-to-generator@^7.13.0": version "7.13.0" resolved "https://registry.yarnpkg.com/@babel/helper-remap-async-to-generator/-/helper-remap-async-to-generator-7.13.0.tgz#376a760d9f7b4b2077a9dd05aa9c3927cadb2209" @@ -437,39 +245,22 @@ "@babel/helper-wrap-function" "^7.13.0" "@babel/types" "^7.13.0" -"@babel/helper-replace-supers@^7.12.1": - version "7.12.1" - resolved "https://registry.yarnpkg.com/@babel/helper-replace-supers/-/helper-replace-supers-7.12.1.tgz#f15c9cc897439281891e11d5ce12562ac0cf3fa9" - integrity sha512-zJjTvtNJnCFsCXVi5rUInstLd/EIVNmIKA1Q9ynESmMBWPWd+7sdR+G4/wdu+Mppfep0XLyG2m7EBPvjCeFyrw== +"@babel/helper-replace-supers@^7.12.13", "@babel/helper-replace-supers@^7.13.0", "@babel/helper-replace-supers@^7.13.12": + version "7.13.12" + resolved "https://registry.yarnpkg.com/@babel/helper-replace-supers/-/helper-replace-supers-7.13.12.tgz#6442f4c1ad912502481a564a7386de0c77ff3804" + integrity sha512-Gz1eiX+4yDO8mT+heB94aLVNCL+rbuT2xy4YfyNqu8F+OI6vMvJK891qGBTqL9Uc8wxEvRW92Id6G7sDen3fFw== dependencies: - "@babel/helper-member-expression-to-functions" "^7.12.1" - "@babel/helper-optimise-call-expression" "^7.10.4" - "@babel/traverse" "^7.12.1" - "@babel/types" "^7.12.1" - -"@babel/helper-replace-supers@^7.12.13", "@babel/helper-replace-supers@^7.13.0": - version "7.13.0" - resolved "https://registry.yarnpkg.com/@babel/helper-replace-supers/-/helper-replace-supers-7.13.0.tgz#6034b7b51943094cb41627848cb219cb02be1d24" - integrity sha512-Segd5me1+Pz+rmN/NFBOplMbZG3SqRJOBlY+mA0SxAv6rjj7zJqr1AVr3SfzUVTLCv7ZLU5FycOM/SBGuLPbZw== - dependencies: - "@babel/helper-member-expression-to-functions" "^7.13.0" + "@babel/helper-member-expression-to-functions" "^7.13.12" "@babel/helper-optimise-call-expression" "^7.12.13" "@babel/traverse" "^7.13.0" - "@babel/types" "^7.13.0" + "@babel/types" "^7.13.12" -"@babel/helper-simple-access@^7.12.1": - version "7.12.1" - resolved "https://registry.yarnpkg.com/@babel/helper-simple-access/-/helper-simple-access-7.12.1.tgz#32427e5aa61547d38eb1e6eaf5fd1426fdad9136" - integrity sha512-OxBp7pMrjVewSSC8fXDFrHrBcJATOOFssZwv16F3/6Xtc138GHybBfPbm9kfiqQHKhYQrlamWILwlDCeyMFEaA== +"@babel/helper-simple-access@^7.12.13", "@babel/helper-simple-access@^7.13.12": + version "7.13.12" + resolved "https://registry.yarnpkg.com/@babel/helper-simple-access/-/helper-simple-access-7.13.12.tgz#dd6c538afb61819d205a012c31792a39c7a5eaf6" + integrity sha512-7FEjbrx5SL9cWvXioDbnlYTppcZGuCY6ow3/D5vMggb2Ywgu4dMrpTJX0JdQAIcRRUElOIxF3yEooa9gUb9ZbA== dependencies: - "@babel/types" "^7.12.1" - -"@babel/helper-simple-access@^7.12.13": - version "7.12.13" - resolved "https://registry.yarnpkg.com/@babel/helper-simple-access/-/helper-simple-access-7.12.13.tgz#8478bcc5cacf6aa1672b251c1d2dde5ccd61a6c4" - integrity sha512-0ski5dyYIHEfwpWGx5GPWhH35j342JaflmCeQmsPWcrOQDtCN6C1zKAVRFVbK53lPW2c9TsuLLSUDf0tIGJ5hA== - dependencies: - "@babel/types" "^7.12.13" + "@babel/types" "^7.13.12" "@babel/helper-skip-transparent-expression-wrappers@^7.12.1": version "7.12.1" @@ -478,13 +269,6 @@ dependencies: "@babel/types" "^7.12.1" -"@babel/helper-split-export-declaration@^7.10.4", "@babel/helper-split-export-declaration@^7.11.0": - version "7.11.0" - resolved "https://registry.yarnpkg.com/@babel/helper-split-export-declaration/-/helper-split-export-declaration-7.11.0.tgz#f8a491244acf6a676158ac42072911ba83ad099f" - integrity sha512-74Vejvp6mHkGE+m+k5vHY93FX2cAtrw1zXrZXRlG4l410Nm9PxfEiVTn1PjDPV5SnmieiueY4AFg2xqhNFuuZg== - dependencies: - "@babel/types" "^7.11.0" - "@babel/helper-split-export-declaration@^7.12.13": version "7.12.13" resolved "https://registry.yarnpkg.com/@babel/helper-split-export-declaration/-/helper-split-export-declaration-7.12.13.tgz#e9430be00baf3e88b0e13e6f9d4eaf2136372b05" @@ -492,36 +276,16 @@ dependencies: "@babel/types" "^7.12.13" -"@babel/helper-validator-identifier@^7.10.4": - version "7.10.4" - resolved "https://registry.yarnpkg.com/@babel/helper-validator-identifier/-/helper-validator-identifier-7.10.4.tgz#a78c7a7251e01f616512d31b10adcf52ada5e0d2" - integrity sha512-3U9y+43hz7ZM+rzG24Qe2mufW5KhvFg/NhnNph+i9mgCtdTCtMJuI1TMkrIUiK7Ix4PYlRF9I5dhqaLYA/ADXw== - "@babel/helper-validator-identifier@^7.12.11": version "7.12.11" resolved "https://registry.yarnpkg.com/@babel/helper-validator-identifier/-/helper-validator-identifier-7.12.11.tgz#c9a1f021917dcb5ccf0d4e453e399022981fc9ed" integrity sha512-np/lG3uARFybkoHokJUmf1QfEvRVCPbmQeUQpKow5cQ3xWrV9i3rUHodKDJPQfTVX61qKi+UdYk8kik84n7XOw== -"@babel/helper-validator-option@^7.12.1": - version "7.12.1" - resolved "https://registry.yarnpkg.com/@babel/helper-validator-option/-/helper-validator-option-7.12.1.tgz#175567380c3e77d60ff98a54bb015fe78f2178d9" - integrity sha512-YpJabsXlJVWP0USHjnC/AQDTLlZERbON577YUVO/wLpqyj6HAtVYnWaQaN0iUN+1/tWn3c+uKKXjRut5115Y2A== - -"@babel/helper-validator-option@^7.12.17": +"@babel/helper-validator-option@^7.12.1", "@babel/helper-validator-option@^7.12.17": version "7.12.17" resolved "https://registry.yarnpkg.com/@babel/helper-validator-option/-/helper-validator-option-7.12.17.tgz#d1fbf012e1a79b7eebbfdc6d270baaf8d9eb9831" integrity sha512-TopkMDmLzq8ngChwRlyjR6raKD6gMSae4JdYDB8bByKreQgG0RBTuKe9LRxW3wFtUnjxOPRKBDwEH6Mg5KeDfw== -"@babel/helper-wrap-function@^7.10.4": - version "7.12.3" - resolved "https://registry.yarnpkg.com/@babel/helper-wrap-function/-/helper-wrap-function-7.12.3.tgz#3332339fc4d1fbbf1c27d7958c27d34708e990d9" - integrity sha512-Cvb8IuJDln3rs6tzjW3Y8UeelAOdnpB8xtQ4sme2MSZ9wOxrbThporC0y/EtE16VAtoyEfLM404Xr1e0OOp+ow== - dependencies: - "@babel/helper-function-name" "^7.10.4" - "@babel/template" "^7.10.4" - "@babel/traverse" "^7.10.4" - "@babel/types" "^7.10.4" - "@babel/helper-wrap-function@^7.13.0": version "7.13.0" resolved "https://registry.yarnpkg.com/@babel/helper-wrap-function/-/helper-wrap-function-7.13.0.tgz#bdb5c66fda8526ec235ab894ad53a1235c79fcc4" @@ -532,67 +296,44 @@ "@babel/traverse" "^7.13.0" "@babel/types" "^7.13.0" -"@babel/helpers@^7.12.1": - version "7.12.1" - resolved "https://registry.yarnpkg.com/@babel/helpers/-/helpers-7.12.1.tgz#8a8261c1d438ec18cb890434df4ec768734c1e79" - integrity sha512-9JoDSBGoWtmbay98efmT2+mySkwjzeFeAL9BuWNoVQpkPFQF8SIIFUfY5os9u8wVzglzoiPRSW7cuJmBDUt43g== - dependencies: - "@babel/template" "^7.10.4" - "@babel/traverse" "^7.12.1" - "@babel/types" "^7.12.1" - -"@babel/helpers@^7.13.0": - version "7.13.0" - resolved "https://registry.yarnpkg.com/@babel/helpers/-/helpers-7.13.0.tgz#7647ae57377b4f0408bf4f8a7af01c42e41badc0" - integrity sha512-aan1MeFPxFacZeSz6Ld7YZo5aPuqnKlD7+HZY75xQsueczFccP9A7V05+oe0XpLwHK3oLorPe9eaAUljL7WEaQ== +"@babel/helpers@^7.12.1", "@babel/helpers@^7.13.10": + version "7.13.10" + resolved "https://registry.yarnpkg.com/@babel/helpers/-/helpers-7.13.10.tgz#fd8e2ba7488533cdeac45cc158e9ebca5e3c7df8" + integrity sha512-4VO883+MWPDUVRF3PhiLBUFHoX/bsLTGFpFK/HqvvfBZz2D57u9XzPVNFVBTc0PW/CWR9BXTOKt8NF4DInUHcQ== dependencies: "@babel/template" "^7.12.13" "@babel/traverse" "^7.13.0" "@babel/types" "^7.13.0" -"@babel/highlight@^7.10.4": - version "7.10.4" - resolved "https://registry.yarnpkg.com/@babel/highlight/-/highlight-7.10.4.tgz#7d1bdfd65753538fabe6c38596cdb76d9ac60143" - integrity sha512-i6rgnR/YgPEQzZZnbTHHuZdlE8qyoBNalD6F+q4vAFlcMEcqmkoG+mPqJYJCo63qPf74+Y1UZsl3l6f7/RIkmA== - dependencies: - "@babel/helper-validator-identifier" "^7.10.4" - chalk "^2.0.0" - js-tokens "^4.0.0" - -"@babel/highlight@^7.12.13": - version "7.13.8" - resolved "https://registry.yarnpkg.com/@babel/highlight/-/highlight-7.13.8.tgz#10b2dac78526424dfc1f47650d0e415dfd9dc481" - integrity sha512-4vrIhfJyfNf+lCtXC2ck1rKSzDwciqF7IWFhXXrSOUC2O5DrVp+w4c6ed4AllTxhTkUP5x2tYj41VaxdVMMRDw== +"@babel/highlight@^7.10.4", "@babel/highlight@^7.12.13": + version "7.13.10" + resolved "https://registry.yarnpkg.com/@babel/highlight/-/highlight-7.13.10.tgz#a8b2a66148f5b27d666b15d81774347a731d52d1" + integrity sha512-5aPpe5XQPzflQrFwL1/QoeHkP2MsA4JCntcXHRhEsdsfPVkvPi2w7Qix4iV7t5S/oC9OodGrggd8aco1g3SZFg== dependencies: "@babel/helper-validator-identifier" "^7.12.11" chalk "^2.0.0" js-tokens "^4.0.0" -"@babel/parser@7.11.5": - version "7.11.5" - resolved "https://registry.yarnpkg.com/@babel/parser/-/parser-7.11.5.tgz#c7ff6303df71080ec7a4f5b8c003c58f1cf51037" - integrity sha512-X9rD8qqm695vgmeaQ4fvz/o3+Wk4ZzQvSHkDBgpYKxpD4qTAUm88ZKtHkVqIOsYFFbIQ6wQYhC6q7pjqVK0E0Q== +"@babel/parser@7.12.16": + version "7.12.16" + resolved "https://registry.yarnpkg.com/@babel/parser/-/parser-7.12.16.tgz#cc31257419d2c3189d394081635703f549fc1ed4" + integrity sha512-c/+u9cqV6F0+4Hpq01jnJO+GLp2DdT63ppz9Xa+6cHaajM9VFzK/iDXiKK65YtpeVwu+ctfS6iqlMqRgQRzeCw== -"@babel/parser@^7.0.0", "@babel/parser@^7.1.0", "@babel/parser@^7.10.4", "@babel/parser@^7.12.1", "@babel/parser@^7.12.3", "@babel/parser@^7.7.0": - version "7.12.3" - resolved "https://registry.yarnpkg.com/@babel/parser/-/parser-7.12.3.tgz#a305415ebe7a6c7023b40b5122a0662d928334cd" - integrity sha512-kFsOS0IbsuhO5ojF8Hc8z/8vEIOkylVBrjiZUbLTE3XFe0Qi+uu6HjzQixkFaqr0ZPAMZcBVxEwmsnsLPZ2Xsw== +"@babel/parser@^7.0.0", "@babel/parser@^7.1.0", "@babel/parser@^7.12.13", "@babel/parser@^7.12.3", "@babel/parser@^7.13.0", "@babel/parser@^7.13.10", "@babel/parser@^7.7.0": + version "7.13.12" + resolved "https://registry.yarnpkg.com/@babel/parser/-/parser-7.13.12.tgz#ba320059420774394d3b0c0233ba40e4250b81d1" + integrity sha512-4T7Pb244rxH24yR116LAuJ+adxXXnHhZaLJjegJVKSdoNCe4x1eDBaud5YIcQFcqzsaD5BHvJw5BQ0AZapdCRw== -"@babel/parser@^7.12.13", "@babel/parser@^7.13.0", "@babel/parser@^7.13.4": - version "7.13.9" - resolved "https://registry.yarnpkg.com/@babel/parser/-/parser-7.13.9.tgz#ca34cb95e1c2dd126863a84465ae8ef66114be99" - integrity sha512-nEUfRiARCcaVo3ny3ZQjURjHQZUo/JkEw7rLlSZy/psWGnvwXFtPcr6jb7Yb41DVW5LTe6KRq9LGleRNsg1Frw== - -"@babel/plugin-proposal-async-generator-functions@^7.12.1": - version "7.12.1" - resolved "https://registry.yarnpkg.com/@babel/plugin-proposal-async-generator-functions/-/plugin-proposal-async-generator-functions-7.12.1.tgz#dc6c1170e27d8aca99ff65f4925bd06b1c90550e" - integrity sha512-d+/o30tJxFxrA1lhzJqiUcEJdI6jKlNregCv5bASeGf2Q4MXmnwH7viDo7nhx1/ohf09oaH8j1GVYG/e3Yqk6A== +"@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining@^7.13.12": + version "7.13.12" + resolved "https://registry.yarnpkg.com/@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining/-/plugin-bugfix-v8-spread-parameters-in-optional-chaining-7.13.12.tgz#a3484d84d0b549f3fc916b99ee4783f26fabad2a" + integrity sha512-d0u3zWKcoZf379fOeJdr1a5WPDny4aOFZ6hlfKivgK0LY7ZxNfoaHL2fWwdGtHyVvra38FC+HVYkO+byfSA8AQ== dependencies: - "@babel/helper-plugin-utils" "^7.10.4" - "@babel/helper-remap-async-to-generator" "^7.12.1" - "@babel/plugin-syntax-async-generators" "^7.8.0" + "@babel/helper-plugin-utils" "^7.13.0" + "@babel/helper-skip-transparent-expression-wrappers" "^7.12.1" + "@babel/plugin-proposal-optional-chaining" "^7.13.12" -"@babel/plugin-proposal-async-generator-functions@^7.13.8": +"@babel/plugin-proposal-async-generator-functions@^7.12.1", "@babel/plugin-proposal-async-generator-functions@^7.13.8": version "7.13.8" resolved "https://registry.yarnpkg.com/@babel/plugin-proposal-async-generator-functions/-/plugin-proposal-async-generator-functions-7.13.8.tgz#87aacb574b3bc4b5603f6fe41458d72a5a2ec4b1" integrity sha512-rPBnhj+WgoSmgq+4gQUtXx/vOcU+UYtjy1AA/aeD61Hwj410fwYyqfUcRP3lR8ucgliVJL/G7sXcNUecC75IXA== @@ -601,7 +342,7 @@ "@babel/helper-remap-async-to-generator" "^7.13.0" "@babel/plugin-syntax-async-generators" "^7.8.4" -"@babel/plugin-proposal-class-properties@7.12.1", "@babel/plugin-proposal-class-properties@^7.0.0", "@babel/plugin-proposal-class-properties@^7.12.1": +"@babel/plugin-proposal-class-properties@7.12.1": version "7.12.1" resolved "https://registry.yarnpkg.com/@babel/plugin-proposal-class-properties/-/plugin-proposal-class-properties-7.12.1.tgz#a082ff541f2a29a4821065b8add9346c0c16e5de" integrity sha512-cKp3dlQsFsEs5CWKnN7BnSHOd0EOW8EKpEjkoz1pO2E5KzIDNV9Ros1b0CnmbVgAGXJubOYVBOGCT1OmJwOI7w== @@ -609,7 +350,7 @@ "@babel/helper-create-class-features-plugin" "^7.12.1" "@babel/helper-plugin-utils" "^7.10.4" -"@babel/plugin-proposal-class-properties@^7.13.0": +"@babel/plugin-proposal-class-properties@^7.0.0", "@babel/plugin-proposal-class-properties@^7.12.1", "@babel/plugin-proposal-class-properties@^7.13.0": version "7.13.0" resolved "https://registry.yarnpkg.com/@babel/plugin-proposal-class-properties/-/plugin-proposal-class-properties-7.13.0.tgz#146376000b94efd001e57a40a88a525afaab9f37" integrity sha512-KnTDjFNC1g+45ka0myZNvSBFLhNCLN+GeGYLDEA8Oq7MZ6yMgfLoIRh86GRT0FjtJhZw8JyUskP9uvj5pHM9Zg== @@ -626,15 +367,7 @@ "@babel/helper-plugin-utils" "^7.10.4" "@babel/plugin-syntax-decorators" "^7.12.1" -"@babel/plugin-proposal-dynamic-import@^7.12.1": - version "7.12.1" - resolved "https://registry.yarnpkg.com/@babel/plugin-proposal-dynamic-import/-/plugin-proposal-dynamic-import-7.12.1.tgz#43eb5c2a3487ecd98c5c8ea8b5fdb69a2749b2dc" - integrity sha512-a4rhUSZFuq5W8/OO8H7BL5zspjnc1FLd9hlOxIK/f7qG4a0qsqk8uvF/ywgBA8/OmjsapjpvaEOYItfGG1qIvQ== - dependencies: - "@babel/helper-plugin-utils" "^7.10.4" - "@babel/plugin-syntax-dynamic-import" "^7.8.0" - -"@babel/plugin-proposal-dynamic-import@^7.13.8": +"@babel/plugin-proposal-dynamic-import@^7.12.1", "@babel/plugin-proposal-dynamic-import@^7.13.8": version "7.13.8" resolved "https://registry.yarnpkg.com/@babel/plugin-proposal-dynamic-import/-/plugin-proposal-dynamic-import-7.13.8.tgz#876a1f6966e1dec332e8c9451afda3bebcdf2e1d" integrity sha512-ONWKj0H6+wIRCkZi9zSbZtE/r73uOhMVHh256ys0UzfM7I3d4n+spZNWjOnJv2gzopumP2Wxi186vI8N0Y2JyQ== @@ -642,15 +375,7 @@ "@babel/helper-plugin-utils" "^7.13.0" "@babel/plugin-syntax-dynamic-import" "^7.8.3" -"@babel/plugin-proposal-export-namespace-from@^7.12.1": - version "7.12.1" - resolved "https://registry.yarnpkg.com/@babel/plugin-proposal-export-namespace-from/-/plugin-proposal-export-namespace-from-7.12.1.tgz#8b9b8f376b2d88f5dd774e4d24a5cc2e3679b6d4" - integrity sha512-6CThGf0irEkzujYS5LQcjBx8j/4aQGiVv7J9+2f7pGfxqyKh3WnmVJYW3hdrQjyksErMGBPQrCnHfOtna+WLbw== - dependencies: - "@babel/helper-plugin-utils" "^7.10.4" - "@babel/plugin-syntax-export-namespace-from" "^7.8.3" - -"@babel/plugin-proposal-export-namespace-from@^7.12.13": +"@babel/plugin-proposal-export-namespace-from@^7.12.1", "@babel/plugin-proposal-export-namespace-from@^7.12.13": version "7.12.13" resolved "https://registry.yarnpkg.com/@babel/plugin-proposal-export-namespace-from/-/plugin-proposal-export-namespace-from-7.12.13.tgz#393be47a4acd03fa2af6e3cde9b06e33de1b446d" integrity sha512-INAgtFo4OnLN3Y/j0VwAgw3HDXcDtX+C/erMvWzuV9v71r7urb6iyMXu7eM9IgLr1ElLlOkaHjJ0SbCmdOQ3Iw== @@ -658,15 +383,7 @@ "@babel/helper-plugin-utils" "^7.12.13" "@babel/plugin-syntax-export-namespace-from" "^7.8.3" -"@babel/plugin-proposal-json-strings@^7.12.1": - version "7.12.1" - resolved "https://registry.yarnpkg.com/@babel/plugin-proposal-json-strings/-/plugin-proposal-json-strings-7.12.1.tgz#d45423b517714eedd5621a9dfdc03fa9f4eb241c" - integrity sha512-GoLDUi6U9ZLzlSda2Df++VSqDJg3CG+dR0+iWsv6XRw1rEq+zwt4DirM9yrxW6XWaTpmai1cWJLMfM8qQJf+yw== - dependencies: - "@babel/helper-plugin-utils" "^7.10.4" - "@babel/plugin-syntax-json-strings" "^7.8.0" - -"@babel/plugin-proposal-json-strings@^7.13.8": +"@babel/plugin-proposal-json-strings@^7.12.1", "@babel/plugin-proposal-json-strings@^7.13.8": version "7.13.8" resolved "https://registry.yarnpkg.com/@babel/plugin-proposal-json-strings/-/plugin-proposal-json-strings-7.13.8.tgz#bf1fb362547075afda3634ed31571c5901afef7b" integrity sha512-w4zOPKUFPX1mgvTmL/fcEqy34hrQ1CRcGxdphBc6snDnnqJ47EZDIyop6IwXzAC8G916hsIuXB2ZMBCExC5k7Q== @@ -674,15 +391,7 @@ "@babel/helper-plugin-utils" "^7.13.0" "@babel/plugin-syntax-json-strings" "^7.8.3" -"@babel/plugin-proposal-logical-assignment-operators@^7.12.1": - version "7.12.1" - resolved "https://registry.yarnpkg.com/@babel/plugin-proposal-logical-assignment-operators/-/plugin-proposal-logical-assignment-operators-7.12.1.tgz#f2c490d36e1b3c9659241034a5d2cd50263a2751" - integrity sha512-k8ZmVv0JU+4gcUGeCDZOGd0lCIamU/sMtIiX3UWnUc5yzgq6YUGyEolNYD+MLYKfSzgECPcqetVcJP9Afe/aCA== - dependencies: - "@babel/helper-plugin-utils" "^7.10.4" - "@babel/plugin-syntax-logical-assignment-operators" "^7.10.4" - -"@babel/plugin-proposal-logical-assignment-operators@^7.13.8": +"@babel/plugin-proposal-logical-assignment-operators@^7.12.1", "@babel/plugin-proposal-logical-assignment-operators@^7.13.8": version "7.13.8" resolved "https://registry.yarnpkg.com/@babel/plugin-proposal-logical-assignment-operators/-/plugin-proposal-logical-assignment-operators-7.13.8.tgz#93fa78d63857c40ce3c8c3315220fd00bfbb4e1a" integrity sha512-aul6znYB4N4HGweImqKn59Su9RS8lbUIqxtXTOcAGtNIDczoEFv+l1EhmX8rUBp3G1jMjKJm8m0jXVp63ZpS4A== @@ -690,7 +399,7 @@ "@babel/helper-plugin-utils" "^7.13.0" "@babel/plugin-syntax-logical-assignment-operators" "^7.10.4" -"@babel/plugin-proposal-nullish-coalescing-operator@7.12.1", "@babel/plugin-proposal-nullish-coalescing-operator@^7.12.1": +"@babel/plugin-proposal-nullish-coalescing-operator@7.12.1": version "7.12.1" resolved "https://registry.yarnpkg.com/@babel/plugin-proposal-nullish-coalescing-operator/-/plugin-proposal-nullish-coalescing-operator-7.12.1.tgz#3ed4fff31c015e7f3f1467f190dbe545cd7b046c" integrity sha512-nZY0ESiaQDI1y96+jk6VxMOaL4LPo/QDHBqL+SF3/vl6dHkTwHlOI8L4ZwuRBHgakRBw5zsVylel7QPbbGuYgg== @@ -698,7 +407,7 @@ "@babel/helper-plugin-utils" "^7.10.4" "@babel/plugin-syntax-nullish-coalescing-operator" "^7.8.0" -"@babel/plugin-proposal-nullish-coalescing-operator@^7.13.8": +"@babel/plugin-proposal-nullish-coalescing-operator@^7.12.1", "@babel/plugin-proposal-nullish-coalescing-operator@^7.13.8": version "7.13.8" resolved "https://registry.yarnpkg.com/@babel/plugin-proposal-nullish-coalescing-operator/-/plugin-proposal-nullish-coalescing-operator-7.13.8.tgz#3730a31dafd3c10d8ccd10648ed80a2ac5472ef3" integrity sha512-iePlDPBn//UhxExyS9KyeYU7RM9WScAG+D3Hhno0PLJebAEpDZMocbDe64eqynhNAnwz/vZoL/q/QB2T1OH39A== @@ -706,7 +415,7 @@ "@babel/helper-plugin-utils" "^7.13.0" "@babel/plugin-syntax-nullish-coalescing-operator" "^7.8.3" -"@babel/plugin-proposal-numeric-separator@7.12.1", "@babel/plugin-proposal-numeric-separator@^7.12.1": +"@babel/plugin-proposal-numeric-separator@7.12.1": version "7.12.1" resolved "https://registry.yarnpkg.com/@babel/plugin-proposal-numeric-separator/-/plugin-proposal-numeric-separator-7.12.1.tgz#0e2c6774c4ce48be412119b4d693ac777f7685a6" integrity sha512-MR7Ok+Af3OhNTCxYVjJZHS0t97ydnJZt/DbR4WISO39iDnhiD8XHrY12xuSJ90FFEGjir0Fzyyn7g/zY6hxbxA== @@ -714,7 +423,7 @@ "@babel/helper-plugin-utils" "^7.10.4" "@babel/plugin-syntax-numeric-separator" "^7.10.4" -"@babel/plugin-proposal-numeric-separator@^7.12.13": +"@babel/plugin-proposal-numeric-separator@^7.12.1", "@babel/plugin-proposal-numeric-separator@^7.12.13": version "7.12.13" resolved "https://registry.yarnpkg.com/@babel/plugin-proposal-numeric-separator/-/plugin-proposal-numeric-separator-7.12.13.tgz#bd9da3188e787b5120b4f9d465a8261ce67ed1db" integrity sha512-O1jFia9R8BUCl3ZGB7eitaAPu62TXJRHn7rh+ojNERCFyqRwJMTmhz+tJ+k0CwI6CLjX/ee4qW74FSqlq9I35w== @@ -722,16 +431,7 @@ "@babel/helper-plugin-utils" "^7.12.13" "@babel/plugin-syntax-numeric-separator" "^7.10.4" -"@babel/plugin-proposal-object-rest-spread@^7.0.0", "@babel/plugin-proposal-object-rest-spread@^7.12.1": - version "7.12.1" - resolved "https://registry.yarnpkg.com/@babel/plugin-proposal-object-rest-spread/-/plugin-proposal-object-rest-spread-7.12.1.tgz#def9bd03cea0f9b72283dac0ec22d289c7691069" - integrity sha512-s6SowJIjzlhx8o7lsFx5zmY4At6CTtDvgNQDdPzkBQucle58A6b/TTeEBYtyDgmcXjUTM+vE8YOGHZzzbc/ioA== - dependencies: - "@babel/helper-plugin-utils" "^7.10.4" - "@babel/plugin-syntax-object-rest-spread" "^7.8.0" - "@babel/plugin-transform-parameters" "^7.12.1" - -"@babel/plugin-proposal-object-rest-spread@^7.13.8": +"@babel/plugin-proposal-object-rest-spread@^7.0.0", "@babel/plugin-proposal-object-rest-spread@^7.12.1", "@babel/plugin-proposal-object-rest-spread@^7.13.8": version "7.13.8" resolved "https://registry.yarnpkg.com/@babel/plugin-proposal-object-rest-spread/-/plugin-proposal-object-rest-spread-7.13.8.tgz#5d210a4d727d6ce3b18f9de82cc99a3964eed60a" integrity sha512-DhB2EuB1Ih7S3/IRX5AFVgZ16k3EzfRbq97CxAVI1KSYcW+lexV8VZb7G7L8zuPVSdQMRn0kiBpf/Yzu9ZKH0g== @@ -742,15 +442,7 @@ "@babel/plugin-syntax-object-rest-spread" "^7.8.3" "@babel/plugin-transform-parameters" "^7.13.0" -"@babel/plugin-proposal-optional-catch-binding@^7.12.1": - version "7.12.1" - resolved "https://registry.yarnpkg.com/@babel/plugin-proposal-optional-catch-binding/-/plugin-proposal-optional-catch-binding-7.12.1.tgz#ccc2421af64d3aae50b558a71cede929a5ab2942" - integrity sha512-hFvIjgprh9mMw5v42sJWLI1lzU5L2sznP805zeT6rySVRA0Y18StRhDqhSxlap0oVgItRsB6WSROp4YnJTJz0g== - dependencies: - "@babel/helper-plugin-utils" "^7.10.4" - "@babel/plugin-syntax-optional-catch-binding" "^7.8.0" - -"@babel/plugin-proposal-optional-catch-binding@^7.13.8": +"@babel/plugin-proposal-optional-catch-binding@^7.12.1", "@babel/plugin-proposal-optional-catch-binding@^7.13.8": version "7.13.8" resolved "https://registry.yarnpkg.com/@babel/plugin-proposal-optional-catch-binding/-/plugin-proposal-optional-catch-binding-7.13.8.tgz#3ad6bd5901506ea996fc31bdcf3ccfa2bed71107" integrity sha512-0wS/4DUF1CuTmGo+NiaHfHcVSeSLj5S3e6RivPTg/2k3wOv3jO35tZ6/ZWsQhQMvdgI7CwphjQa/ccarLymHVA== @@ -758,7 +450,7 @@ "@babel/helper-plugin-utils" "^7.13.0" "@babel/plugin-syntax-optional-catch-binding" "^7.8.3" -"@babel/plugin-proposal-optional-chaining@7.12.1", "@babel/plugin-proposal-optional-chaining@^7.12.1": +"@babel/plugin-proposal-optional-chaining@7.12.1": version "7.12.1" resolved "https://registry.yarnpkg.com/@babel/plugin-proposal-optional-chaining/-/plugin-proposal-optional-chaining-7.12.1.tgz#cce122203fc8a32794296fc377c6dedaf4363797" integrity sha512-c2uRpY6WzaVDzynVY9liyykS+kVU+WRZPMPYpkelXH8KBt1oXoI89kPbZKKG/jDT5UK92FTW2fZkZaJhdiBabw== @@ -767,24 +459,16 @@ "@babel/helper-skip-transparent-expression-wrappers" "^7.12.1" "@babel/plugin-syntax-optional-chaining" "^7.8.0" -"@babel/plugin-proposal-optional-chaining@^7.13.8": - version "7.13.8" - resolved "https://registry.yarnpkg.com/@babel/plugin-proposal-optional-chaining/-/plugin-proposal-optional-chaining-7.13.8.tgz#e39df93efe7e7e621841babc197982e140e90756" - integrity sha512-hpbBwbTgd7Cz1QryvwJZRo1U0k1q8uyBmeXOSQUjdg/A2TASkhR/rz7AyqZ/kS8kbpsNA80rOYbxySBJAqmhhQ== +"@babel/plugin-proposal-optional-chaining@^7.12.1", "@babel/plugin-proposal-optional-chaining@^7.13.12": + version "7.13.12" + resolved "https://registry.yarnpkg.com/@babel/plugin-proposal-optional-chaining/-/plugin-proposal-optional-chaining-7.13.12.tgz#ba9feb601d422e0adea6760c2bd6bbb7bfec4866" + integrity sha512-fcEdKOkIB7Tf4IxrgEVeFC4zeJSTr78no9wTdBuZZbqF64kzllU0ybo2zrzm7gUQfxGhBgq4E39oRs8Zx/RMYQ== dependencies: "@babel/helper-plugin-utils" "^7.13.0" "@babel/helper-skip-transparent-expression-wrappers" "^7.12.1" "@babel/plugin-syntax-optional-chaining" "^7.8.3" -"@babel/plugin-proposal-private-methods@^7.12.1": - version "7.12.1" - resolved "https://registry.yarnpkg.com/@babel/plugin-proposal-private-methods/-/plugin-proposal-private-methods-7.12.1.tgz#86814f6e7a21374c980c10d38b4493e703f4a389" - integrity sha512-mwZ1phvH7/NHK6Kf8LP7MYDogGV+DKB1mryFOEwx5EBNQrosvIczzZFTUmWaeujd5xT6G1ELYWUz3CutMhjE1w== - dependencies: - "@babel/helper-create-class-features-plugin" "^7.12.1" - "@babel/helper-plugin-utils" "^7.10.4" - -"@babel/plugin-proposal-private-methods@^7.13.0": +"@babel/plugin-proposal-private-methods@^7.12.1", "@babel/plugin-proposal-private-methods@^7.13.0": version "7.13.0" resolved "https://registry.yarnpkg.com/@babel/plugin-proposal-private-methods/-/plugin-proposal-private-methods-7.13.0.tgz#04bd4c6d40f6e6bbfa2f57e2d8094bad900ef787" integrity sha512-MXyyKQd9inhx1kDYPkFRVOBXQ20ES8Pto3T7UZ92xj2mY0EVD8oAVzeyYuVfy/mxAdTSIayOvg+aVzcHV2bn6Q== @@ -792,15 +476,7 @@ "@babel/helper-create-class-features-plugin" "^7.13.0" "@babel/helper-plugin-utils" "^7.13.0" -"@babel/plugin-proposal-unicode-property-regex@^7.12.1", "@babel/plugin-proposal-unicode-property-regex@^7.4.4": - version "7.12.1" - resolved "https://registry.yarnpkg.com/@babel/plugin-proposal-unicode-property-regex/-/plugin-proposal-unicode-property-regex-7.12.1.tgz#2a183958d417765b9eae334f47758e5d6a82e072" - integrity sha512-MYq+l+PvHuw/rKUz1at/vb6nCnQ2gmJBNaM62z0OgH7B2W1D9pvkpYtlti9bGtizNIU1K3zm4bZF9F91efVY0w== - dependencies: - "@babel/helper-create-regexp-features-plugin" "^7.12.1" - "@babel/helper-plugin-utils" "^7.10.4" - -"@babel/plugin-proposal-unicode-property-regex@^7.12.13": +"@babel/plugin-proposal-unicode-property-regex@^7.12.1", "@babel/plugin-proposal-unicode-property-regex@^7.12.13", "@babel/plugin-proposal-unicode-property-regex@^7.4.4": version "7.12.13" resolved "https://registry.yarnpkg.com/@babel/plugin-proposal-unicode-property-regex/-/plugin-proposal-unicode-property-regex-7.12.13.tgz#bebde51339be829c17aaaaced18641deb62b39ba" integrity sha512-XyJmZidNfofEkqFV5VC/bLabGmO5QzenPO/YOfGuEbgU+2sSwMmio3YLb4WtBgcmmdwZHyVyv8on77IUjQ5Gvg== @@ -822,14 +498,7 @@ dependencies: "@babel/helper-plugin-utils" "^7.8.0" -"@babel/plugin-syntax-class-properties@^7.0.0", "@babel/plugin-syntax-class-properties@^7.12.1", "@babel/plugin-syntax-class-properties@^7.8.3": - version "7.12.1" - resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-class-properties/-/plugin-syntax-class-properties-7.12.1.tgz#bcb297c5366e79bebadef509549cd93b04f19978" - integrity sha512-U40A76x5gTwmESz+qiqssqmeEsKvcSyvtgktrm0uzcARAmM9I1jR221f6Oq+GmHrcD+LvZDag1UTOTe2fL3TeA== - dependencies: - "@babel/helper-plugin-utils" "^7.10.4" - -"@babel/plugin-syntax-class-properties@^7.12.13": +"@babel/plugin-syntax-class-properties@^7.0.0", "@babel/plugin-syntax-class-properties@^7.12.1", "@babel/plugin-syntax-class-properties@^7.12.13", "@babel/plugin-syntax-class-properties@^7.8.3": version "7.12.13" resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-class-properties/-/plugin-syntax-class-properties-7.12.13.tgz#b5c987274c4a3a82b89714796931a6b53544ae10" integrity sha512-fm4idjKla0YahUNgFNLCB0qySdsoPiZP3iQE3rky0mBUtMZ23yDJ9SJdg6dXTSDnulOVqiF3Hgr9nbXvXTQZYA== @@ -837,11 +506,11 @@ "@babel/helper-plugin-utils" "^7.12.13" "@babel/plugin-syntax-decorators@^7.12.1": - version "7.12.1" - resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-decorators/-/plugin-syntax-decorators-7.12.1.tgz#81a8b535b284476c41be6de06853a8802b98c5dd" - integrity sha512-ir9YW5daRrTYiy9UJ2TzdNIJEZu8KclVzDcfSt4iEmOtwQ4llPtWInNKJyKnVXp1vE4bbVd5S31M/im3mYMO1w== + version "7.12.13" + resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-decorators/-/plugin-syntax-decorators-7.12.13.tgz#fac829bf3c7ef4a1bc916257b403e58c6bdaf648" + integrity sha512-Rw6aIXGuqDLr6/LoBBYE57nKOzQpz/aDkKlMqEwH+Vp0MXbG6H/TfRjaY343LKxzAKAMXIHsQ8JzaZKuDZ9MwA== dependencies: - "@babel/helper-plugin-utils" "^7.10.4" + "@babel/helper-plugin-utils" "^7.12.13" "@babel/plugin-syntax-dynamic-import@^7.8.0", "@babel/plugin-syntax-dynamic-import@^7.8.3": version "7.8.3" @@ -857,12 +526,12 @@ dependencies: "@babel/helper-plugin-utils" "^7.8.3" -"@babel/plugin-syntax-flow@^7.0.0", "@babel/plugin-syntax-flow@^7.12.1": - version "7.12.1" - resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-flow/-/plugin-syntax-flow-7.12.1.tgz#a77670d9abe6d63e8acadf4c31bb1eb5a506bbdd" - integrity sha512-1lBLLmtxrwpm4VKmtVFselI/P3pX+G63fAtUUt6b2Nzgao77KNDwyuRt90Mj2/9pKobtt68FdvjfqohZjg/FCA== +"@babel/plugin-syntax-flow@^7.0.0", "@babel/plugin-syntax-flow@^7.12.1", "@babel/plugin-syntax-flow@^7.12.13": + version "7.12.13" + resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-flow/-/plugin-syntax-flow-7.12.13.tgz#5df9962503c0a9c918381c929d51d4d6949e7e86" + integrity sha512-J/RYxnlSLXZLVR7wTRsozxKT8qbsx1mNKJzXEEjQ0Kjx1ZACcyHgbanNWNCFtc36IzuWhYWPpvJFFoexoOWFmA== dependencies: - "@babel/helper-plugin-utils" "^7.10.4" + "@babel/helper-plugin-utils" "^7.12.13" "@babel/plugin-syntax-import-meta@^7.8.3": version "7.10.4" @@ -878,14 +547,7 @@ dependencies: "@babel/helper-plugin-utils" "^7.8.0" -"@babel/plugin-syntax-jsx@^7.0.0", "@babel/plugin-syntax-jsx@^7.12.1": - version "7.12.1" - resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-jsx/-/plugin-syntax-jsx-7.12.1.tgz#9d9d357cc818aa7ae7935917c1257f67677a0926" - integrity sha512-1yRi7yAtB0ETgxdY9ti/p2TivUxJkTdhu/ZbF9MshVGqOx1TdB3b7xCXs49Fupgg50N45KcAsRP/ZqWjs9SRjg== - dependencies: - "@babel/helper-plugin-utils" "^7.10.4" - -"@babel/plugin-syntax-jsx@^7.12.13": +"@babel/plugin-syntax-jsx@^7.0.0", "@babel/plugin-syntax-jsx@^7.12.13": version "7.12.13" resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-jsx/-/plugin-syntax-jsx-7.12.13.tgz#044fb81ebad6698fe62c478875575bcbb9b70f15" integrity sha512-d4HM23Q1K7oq/SLNmG6mRt85l2csmQ0cHRaxRXjKW0YFdEXqlZ5kzFQKH5Uc3rDJECgu+yCRgPkG04Mm98R/1g== @@ -934,51 +596,28 @@ dependencies: "@babel/helper-plugin-utils" "^7.8.0" -"@babel/plugin-syntax-top-level-await@^7.12.1": - version "7.12.1" - resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-top-level-await/-/plugin-syntax-top-level-await-7.12.1.tgz#dd6c0b357ac1bb142d98537450a319625d13d2a0" - integrity sha512-i7ooMZFS+a/Om0crxZodrTzNEPJHZrlMVGMTEpFAj6rYY/bKCddB0Dk/YxfPuYXOopuhKk/e1jV6h+WUU9XN3A== - dependencies: - "@babel/helper-plugin-utils" "^7.10.4" - -"@babel/plugin-syntax-top-level-await@^7.12.13": +"@babel/plugin-syntax-top-level-await@^7.12.1", "@babel/plugin-syntax-top-level-await@^7.12.13", "@babel/plugin-syntax-top-level-await@^7.8.3": version "7.12.13" resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-top-level-await/-/plugin-syntax-top-level-await-7.12.13.tgz#c5f0fa6e249f5b739727f923540cf7a806130178" integrity sha512-A81F9pDwyS7yM//KwbCSDqy3Uj4NMIurtplxphWxoYtNPov7cJsDkAFNNyVlIZ3jwGycVsurZ+LtOA8gZ376iQ== dependencies: "@babel/helper-plugin-utils" "^7.12.13" -"@babel/plugin-syntax-typescript@^7.12.1": - version "7.12.1" - resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-typescript/-/plugin-syntax-typescript-7.12.1.tgz#460ba9d77077653803c3dd2e673f76d66b4029e5" - integrity sha512-UZNEcCY+4Dp9yYRCAHrHDU+9ZXLYaY9MgBXSRLkB9WjYFRR6quJBumfVrEkUxrePPBwFcpWfNKXqVRQQtm7mMA== +"@babel/plugin-syntax-typescript@^7.12.13": + version "7.12.13" + resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-typescript/-/plugin-syntax-typescript-7.12.13.tgz#9dff111ca64154cef0f4dc52cf843d9f12ce4474" + integrity sha512-cHP3u1JiUiG2LFDKbXnwVad81GvfyIOmCD6HIEId6ojrY0Drfy2q1jw7BwN7dE84+kTnBjLkXoL3IEy/3JPu2w== dependencies: - "@babel/helper-plugin-utils" "^7.10.4" + "@babel/helper-plugin-utils" "^7.12.13" -"@babel/plugin-transform-arrow-functions@^7.0.0", "@babel/plugin-transform-arrow-functions@^7.12.1": - version "7.12.1" - resolved "https://registry.yarnpkg.com/@babel/plugin-transform-arrow-functions/-/plugin-transform-arrow-functions-7.12.1.tgz#8083ffc86ac8e777fbe24b5967c4b2521f3cb2b3" - integrity sha512-5QB50qyN44fzzz4/qxDPQMBCTHgxg3n0xRBLJUmBlLoU/sFvxVWGZF/ZUfMVDQuJUKXaBhbupxIzIfZ6Fwk/0A== - dependencies: - "@babel/helper-plugin-utils" "^7.10.4" - -"@babel/plugin-transform-arrow-functions@^7.13.0": +"@babel/plugin-transform-arrow-functions@^7.0.0", "@babel/plugin-transform-arrow-functions@^7.12.1", "@babel/plugin-transform-arrow-functions@^7.13.0": version "7.13.0" resolved "https://registry.yarnpkg.com/@babel/plugin-transform-arrow-functions/-/plugin-transform-arrow-functions-7.13.0.tgz#10a59bebad52d637a027afa692e8d5ceff5e3dae" integrity sha512-96lgJagobeVmazXFaDrbmCLQxBysKu7U6Do3mLsx27gf5Dk85ezysrs2BZUpXD703U/Su1xTBDxxar2oa4jAGg== dependencies: "@babel/helper-plugin-utils" "^7.13.0" -"@babel/plugin-transform-async-to-generator@^7.12.1": - version "7.12.1" - resolved "https://registry.yarnpkg.com/@babel/plugin-transform-async-to-generator/-/plugin-transform-async-to-generator-7.12.1.tgz#3849a49cc2a22e9743cbd6b52926d30337229af1" - integrity sha512-SDtqoEcarK1DFlRJ1hHRY5HvJUj5kX4qmtpMAm2QnhOlyuMC4TMdCRgW6WXpv93rZeYNeLP22y8Aq2dbcDRM1A== - dependencies: - "@babel/helper-module-imports" "^7.12.1" - "@babel/helper-plugin-utils" "^7.10.4" - "@babel/helper-remap-async-to-generator" "^7.12.1" - -"@babel/plugin-transform-async-to-generator@^7.13.0": +"@babel/plugin-transform-async-to-generator@^7.12.1", "@babel/plugin-transform-async-to-generator@^7.13.0": version "7.13.0" resolved "https://registry.yarnpkg.com/@babel/plugin-transform-async-to-generator/-/plugin-transform-async-to-generator-7.13.0.tgz#8e112bf6771b82bf1e974e5e26806c5c99aa516f" integrity sha512-3j6E004Dx0K3eGmhxVJxwwI89CTJrce7lg3UrtFuDAVQ/2+SJ/h/aSFOeE6/n0WB1GsOffsJp6MnPQNQ8nmwhg== @@ -987,49 +626,21 @@ "@babel/helper-plugin-utils" "^7.13.0" "@babel/helper-remap-async-to-generator" "^7.13.0" -"@babel/plugin-transform-block-scoped-functions@^7.0.0", "@babel/plugin-transform-block-scoped-functions@^7.12.1": - version "7.12.1" - resolved "https://registry.yarnpkg.com/@babel/plugin-transform-block-scoped-functions/-/plugin-transform-block-scoped-functions-7.12.1.tgz#f2a1a365bde2b7112e0a6ded9067fdd7c07905d9" - integrity sha512-5OpxfuYnSgPalRpo8EWGPzIYf0lHBWORCkj5M0oLBwHdlux9Ri36QqGW3/LR13RSVOAoUUMzoPI/jpE4ABcHoA== - dependencies: - "@babel/helper-plugin-utils" "^7.10.4" - -"@babel/plugin-transform-block-scoped-functions@^7.12.13": +"@babel/plugin-transform-block-scoped-functions@^7.0.0", "@babel/plugin-transform-block-scoped-functions@^7.12.1", "@babel/plugin-transform-block-scoped-functions@^7.12.13": version "7.12.13" resolved "https://registry.yarnpkg.com/@babel/plugin-transform-block-scoped-functions/-/plugin-transform-block-scoped-functions-7.12.13.tgz#a9bf1836f2a39b4eb6cf09967739de29ea4bf4c4" integrity sha512-zNyFqbc3kI/fVpqwfqkg6RvBgFpC4J18aKKMmv7KdQ/1GgREapSJAykLMVNwfRGO3BtHj3YQZl8kxCXPcVMVeg== dependencies: "@babel/helper-plugin-utils" "^7.12.13" -"@babel/plugin-transform-block-scoping@^7.0.0", "@babel/plugin-transform-block-scoping@^7.12.1": - version "7.12.1" - resolved "https://registry.yarnpkg.com/@babel/plugin-transform-block-scoping/-/plugin-transform-block-scoping-7.12.1.tgz#f0ee727874b42a208a48a586b84c3d222c2bbef1" - integrity sha512-zJyAC9sZdE60r1nVQHblcfCj29Dh2Y0DOvlMkcqSo0ckqjiCwNiUezUKw+RjOCwGfpLRwnAeQ2XlLpsnGkvv9w== - dependencies: - "@babel/helper-plugin-utils" "^7.10.4" - -"@babel/plugin-transform-block-scoping@^7.12.13": +"@babel/plugin-transform-block-scoping@^7.0.0", "@babel/plugin-transform-block-scoping@^7.12.1", "@babel/plugin-transform-block-scoping@^7.12.13": version "7.12.13" resolved "https://registry.yarnpkg.com/@babel/plugin-transform-block-scoping/-/plugin-transform-block-scoping-7.12.13.tgz#f36e55076d06f41dfd78557ea039c1b581642e61" integrity sha512-Pxwe0iqWJX4fOOM2kEZeUuAxHMWb9nK+9oh5d11bsLoB0xMg+mkDpt0eYuDZB7ETrY9bbcVlKUGTOGWy7BHsMQ== dependencies: "@babel/helper-plugin-utils" "^7.12.13" -"@babel/plugin-transform-classes@^7.0.0", "@babel/plugin-transform-classes@^7.12.1": - version "7.12.1" - resolved "https://registry.yarnpkg.com/@babel/plugin-transform-classes/-/plugin-transform-classes-7.12.1.tgz#65e650fcaddd3d88ddce67c0f834a3d436a32db6" - integrity sha512-/74xkA7bVdzQTBeSUhLLJgYIcxw/dpEpCdRDiHgPJ3Mv6uC11UhjpOhl72CgqbBCmt1qtssCyB2xnJm1+PFjog== - dependencies: - "@babel/helper-annotate-as-pure" "^7.10.4" - "@babel/helper-define-map" "^7.10.4" - "@babel/helper-function-name" "^7.10.4" - "@babel/helper-optimise-call-expression" "^7.10.4" - "@babel/helper-plugin-utils" "^7.10.4" - "@babel/helper-replace-supers" "^7.12.1" - "@babel/helper-split-export-declaration" "^7.10.4" - globals "^11.1.0" - -"@babel/plugin-transform-classes@^7.13.0": +"@babel/plugin-transform-classes@^7.0.0", "@babel/plugin-transform-classes@^7.12.1", "@babel/plugin-transform-classes@^7.13.0": version "7.13.0" resolved "https://registry.yarnpkg.com/@babel/plugin-transform-classes/-/plugin-transform-classes-7.13.0.tgz#0265155075c42918bf4d3a4053134176ad9b533b" integrity sha512-9BtHCPUARyVH1oXGcSJD3YpsqRLROJx5ZNP6tN5vnk17N0SVf9WCtf8Nuh1CFmgByKKAIMstitKduoCmsaDK5g== @@ -1042,43 +653,21 @@ "@babel/helper-split-export-declaration" "^7.12.13" globals "^11.1.0" -"@babel/plugin-transform-computed-properties@^7.0.0", "@babel/plugin-transform-computed-properties@^7.12.1": - version "7.12.1" - resolved "https://registry.yarnpkg.com/@babel/plugin-transform-computed-properties/-/plugin-transform-computed-properties-7.12.1.tgz#d68cf6c9b7f838a8a4144badbe97541ea0904852" - integrity sha512-vVUOYpPWB7BkgUWPo4C44mUQHpTZXakEqFjbv8rQMg7TC6S6ZhGZ3otQcRH6u7+adSlE5i0sp63eMC/XGffrzg== - dependencies: - "@babel/helper-plugin-utils" "^7.10.4" - -"@babel/plugin-transform-computed-properties@^7.13.0": +"@babel/plugin-transform-computed-properties@^7.0.0", "@babel/plugin-transform-computed-properties@^7.12.1", "@babel/plugin-transform-computed-properties@^7.13.0": version "7.13.0" resolved "https://registry.yarnpkg.com/@babel/plugin-transform-computed-properties/-/plugin-transform-computed-properties-7.13.0.tgz#845c6e8b9bb55376b1fa0b92ef0bdc8ea06644ed" integrity sha512-RRqTYTeZkZAz8WbieLTvKUEUxZlUTdmL5KGMyZj7FnMfLNKV4+r5549aORG/mgojRmFlQMJDUupwAMiF2Q7OUg== dependencies: "@babel/helper-plugin-utils" "^7.13.0" -"@babel/plugin-transform-destructuring@^7.0.0", "@babel/plugin-transform-destructuring@^7.12.1": - version "7.12.1" - resolved "https://registry.yarnpkg.com/@babel/plugin-transform-destructuring/-/plugin-transform-destructuring-7.12.1.tgz#b9a570fe0d0a8d460116413cb4f97e8e08b2f847" - integrity sha512-fRMYFKuzi/rSiYb2uRLiUENJOKq4Gnl+6qOv5f8z0TZXg3llUwUhsNNwrwaT/6dUhJTzNpBr+CUvEWBtfNY1cw== - dependencies: - "@babel/helper-plugin-utils" "^7.10.4" - -"@babel/plugin-transform-destructuring@^7.13.0": +"@babel/plugin-transform-destructuring@^7.0.0", "@babel/plugin-transform-destructuring@^7.12.1", "@babel/plugin-transform-destructuring@^7.13.0": version "7.13.0" resolved "https://registry.yarnpkg.com/@babel/plugin-transform-destructuring/-/plugin-transform-destructuring-7.13.0.tgz#c5dce270014d4e1ebb1d806116694c12b7028963" integrity sha512-zym5em7tePoNT9s964c0/KU3JPPnuq7VhIxPRefJ4/s82cD+q1mgKfuGRDMCPL0HTyKz4dISuQlCusfgCJ86HA== dependencies: "@babel/helper-plugin-utils" "^7.13.0" -"@babel/plugin-transform-dotall-regex@^7.12.1", "@babel/plugin-transform-dotall-regex@^7.4.4": - version "7.12.1" - resolved "https://registry.yarnpkg.com/@babel/plugin-transform-dotall-regex/-/plugin-transform-dotall-regex-7.12.1.tgz#a1d16c14862817b6409c0a678d6f9373ca9cd975" - integrity sha512-B2pXeRKoLszfEW7J4Hg9LoFaWEbr/kzo3teWHmtFCszjRNa/b40f9mfeqZsIDLLt/FjwQ6pz/Gdlwy85xNckBA== - dependencies: - "@babel/helper-create-regexp-features-plugin" "^7.12.1" - "@babel/helper-plugin-utils" "^7.10.4" - -"@babel/plugin-transform-dotall-regex@^7.12.13": +"@babel/plugin-transform-dotall-regex@^7.12.1", "@babel/plugin-transform-dotall-regex@^7.12.13", "@babel/plugin-transform-dotall-regex@^7.4.4": version "7.12.13" resolved "https://registry.yarnpkg.com/@babel/plugin-transform-dotall-regex/-/plugin-transform-dotall-regex-7.12.13.tgz#3f1601cc29905bfcb67f53910f197aeafebb25ad" integrity sha512-foDrozE65ZFdUC2OfgeOCrEPTxdB3yjqxpXh8CH+ipd9CHd4s/iq81kcUpyH8ACGNEPdFqbtzfgzbT/ZGlbDeQ== @@ -1086,29 +675,14 @@ "@babel/helper-create-regexp-features-plugin" "^7.12.13" "@babel/helper-plugin-utils" "^7.12.13" -"@babel/plugin-transform-duplicate-keys@^7.12.1": - version "7.12.1" - resolved "https://registry.yarnpkg.com/@babel/plugin-transform-duplicate-keys/-/plugin-transform-duplicate-keys-7.12.1.tgz#745661baba295ac06e686822797a69fbaa2ca228" - integrity sha512-iRght0T0HztAb/CazveUpUQrZY+aGKKaWXMJ4uf9YJtqxSUe09j3wteztCUDRHs+SRAL7yMuFqUsLoAKKzgXjw== - dependencies: - "@babel/helper-plugin-utils" "^7.10.4" - -"@babel/plugin-transform-duplicate-keys@^7.12.13": +"@babel/plugin-transform-duplicate-keys@^7.12.1", "@babel/plugin-transform-duplicate-keys@^7.12.13": version "7.12.13" resolved "https://registry.yarnpkg.com/@babel/plugin-transform-duplicate-keys/-/plugin-transform-duplicate-keys-7.12.13.tgz#6f06b87a8b803fd928e54b81c258f0a0033904de" integrity sha512-NfADJiiHdhLBW3pulJlJI2NB0t4cci4WTZ8FtdIuNc2+8pslXdPtRRAEWqUY+m9kNOk2eRYbTAOipAxlrOcwwQ== dependencies: "@babel/helper-plugin-utils" "^7.12.13" -"@babel/plugin-transform-exponentiation-operator@^7.12.1": - version "7.12.1" - resolved "https://registry.yarnpkg.com/@babel/plugin-transform-exponentiation-operator/-/plugin-transform-exponentiation-operator-7.12.1.tgz#b0f2ed356ba1be1428ecaf128ff8a24f02830ae0" - integrity sha512-7tqwy2bv48q+c1EHbXK0Zx3KXd2RVQp6OC7PbwFNt/dPTAV3Lu5sWtWuAj8owr5wqtWnqHfl2/mJlUmqkChKug== - dependencies: - "@babel/helper-builder-binary-assignment-operator-visitor" "^7.10.4" - "@babel/helper-plugin-utils" "^7.10.4" - -"@babel/plugin-transform-exponentiation-operator@^7.12.13": +"@babel/plugin-transform-exponentiation-operator@^7.12.1", "@babel/plugin-transform-exponentiation-operator@^7.12.13": version "7.12.13" resolved "https://registry.yarnpkg.com/@babel/plugin-transform-exponentiation-operator/-/plugin-transform-exponentiation-operator-7.12.13.tgz#4d52390b9a273e651e4aba6aee49ef40e80cd0a1" integrity sha512-fbUelkM1apvqez/yYx1/oICVnGo2KM5s63mhGylrmXUxK/IAXSIf87QIxVfZldWf4QsOafY6vV3bX8aMHSvNrA== @@ -1116,7 +690,7 @@ "@babel/helper-builder-binary-assignment-operator-visitor" "^7.12.13" "@babel/helper-plugin-utils" "^7.12.13" -"@babel/plugin-transform-flow-strip-types@7.12.1", "@babel/plugin-transform-flow-strip-types@^7.0.0": +"@babel/plugin-transform-flow-strip-types@7.12.1": version "7.12.1" resolved "https://registry.yarnpkg.com/@babel/plugin-transform-flow-strip-types/-/plugin-transform-flow-strip-types-7.12.1.tgz#8430decfa7eb2aea5414ed4a3fa6e1652b7d77c4" integrity sha512-8hAtkmsQb36yMmEtk2JZ9JnVyDSnDOdlB+0nEGzIDLuK4yR3JcEjfuFPYkdEPSh8Id+rAMeBEn+X0iVEyho6Hg== @@ -1124,29 +698,22 @@ "@babel/helper-plugin-utils" "^7.10.4" "@babel/plugin-syntax-flow" "^7.12.1" -"@babel/plugin-transform-for-of@^7.0.0", "@babel/plugin-transform-for-of@^7.12.1": - version "7.12.1" - resolved "https://registry.yarnpkg.com/@babel/plugin-transform-for-of/-/plugin-transform-for-of-7.12.1.tgz#07640f28867ed16f9511c99c888291f560921cfa" - integrity sha512-Zaeq10naAsuHo7heQvyV0ptj4dlZJwZgNAtBYBnu5nNKJoW62m0zKcIEyVECrUKErkUkg6ajMy4ZfnVZciSBhg== +"@babel/plugin-transform-flow-strip-types@^7.0.0": + version "7.13.0" + resolved "https://registry.yarnpkg.com/@babel/plugin-transform-flow-strip-types/-/plugin-transform-flow-strip-types-7.13.0.tgz#58177a48c209971e8234e99906cb6bd1122addd3" + integrity sha512-EXAGFMJgSX8gxWD7PZtW/P6M+z74jpx3wm/+9pn+c2dOawPpBkUX7BrfyPvo6ZpXbgRIEuwgwDb/MGlKvu2pOg== dependencies: - "@babel/helper-plugin-utils" "^7.10.4" + "@babel/helper-plugin-utils" "^7.13.0" + "@babel/plugin-syntax-flow" "^7.12.13" -"@babel/plugin-transform-for-of@^7.13.0": +"@babel/plugin-transform-for-of@^7.0.0", "@babel/plugin-transform-for-of@^7.12.1", "@babel/plugin-transform-for-of@^7.13.0": version "7.13.0" resolved "https://registry.yarnpkg.com/@babel/plugin-transform-for-of/-/plugin-transform-for-of-7.13.0.tgz#c799f881a8091ac26b54867a845c3e97d2696062" integrity sha512-IHKT00mwUVYE0zzbkDgNRP6SRzvfGCYsOxIRz8KsiaaHCcT9BWIkO+H9QRJseHBLOGBZkHUdHiqj6r0POsdytg== dependencies: "@babel/helper-plugin-utils" "^7.13.0" -"@babel/plugin-transform-function-name@^7.0.0", "@babel/plugin-transform-function-name@^7.12.1": - version "7.12.1" - resolved "https://registry.yarnpkg.com/@babel/plugin-transform-function-name/-/plugin-transform-function-name-7.12.1.tgz#2ec76258c70fe08c6d7da154003a480620eba667" - integrity sha512-JF3UgJUILoFrFMEnOJLJkRHSk6LUSXLmEFsA23aR2O5CSLUxbeUX1IZ1YQ7Sn0aXb601Ncwjx73a+FVqgcljVw== - dependencies: - "@babel/helper-function-name" "^7.10.4" - "@babel/helper-plugin-utils" "^7.10.4" - -"@babel/plugin-transform-function-name@^7.12.13": +"@babel/plugin-transform-function-name@^7.0.0", "@babel/plugin-transform-function-name@^7.12.1", "@babel/plugin-transform-function-name@^7.12.13": version "7.12.13" resolved "https://registry.yarnpkg.com/@babel/plugin-transform-function-name/-/plugin-transform-function-name-7.12.13.tgz#bb024452f9aaed861d374c8e7a24252ce3a50051" integrity sha512-6K7gZycG0cmIwwF7uMK/ZqeCikCGVBdyP2J5SKNCXO5EOHcqi+z7Jwf8AmyDNcBgxET8DrEtCt/mPKPyAzXyqQ== @@ -1154,44 +721,21 @@ "@babel/helper-function-name" "^7.12.13" "@babel/helper-plugin-utils" "^7.12.13" -"@babel/plugin-transform-literals@^7.0.0", "@babel/plugin-transform-literals@^7.12.1": - version "7.12.1" - resolved "https://registry.yarnpkg.com/@babel/plugin-transform-literals/-/plugin-transform-literals-7.12.1.tgz#d73b803a26b37017ddf9d3bb8f4dc58bfb806f57" - integrity sha512-+PxVGA+2Ag6uGgL0A5f+9rklOnnMccwEBzwYFL3EUaKuiyVnUipyXncFcfjSkbimLrODoqki1U9XxZzTvfN7IQ== - dependencies: - "@babel/helper-plugin-utils" "^7.10.4" - -"@babel/plugin-transform-literals@^7.12.13": +"@babel/plugin-transform-literals@^7.0.0", "@babel/plugin-transform-literals@^7.12.1", "@babel/plugin-transform-literals@^7.12.13": version "7.12.13" resolved "https://registry.yarnpkg.com/@babel/plugin-transform-literals/-/plugin-transform-literals-7.12.13.tgz#2ca45bafe4a820197cf315794a4d26560fe4bdb9" integrity sha512-FW+WPjSR7hiUxMcKqyNjP05tQ2kmBCdpEpZHY1ARm96tGQCCBvXKnpjILtDplUnJ/eHZ0lALLM+d2lMFSpYJrQ== dependencies: "@babel/helper-plugin-utils" "^7.12.13" -"@babel/plugin-transform-member-expression-literals@^7.0.0", "@babel/plugin-transform-member-expression-literals@^7.12.1": - version "7.12.1" - resolved "https://registry.yarnpkg.com/@babel/plugin-transform-member-expression-literals/-/plugin-transform-member-expression-literals-7.12.1.tgz#496038602daf1514a64d43d8e17cbb2755e0c3ad" - integrity sha512-1sxePl6z9ad0gFMB9KqmYofk34flq62aqMt9NqliS/7hPEpURUCMbyHXrMPlo282iY7nAvUB1aQd5mg79UD9Jg== - dependencies: - "@babel/helper-plugin-utils" "^7.10.4" - -"@babel/plugin-transform-member-expression-literals@^7.12.13": +"@babel/plugin-transform-member-expression-literals@^7.0.0", "@babel/plugin-transform-member-expression-literals@^7.12.1", "@babel/plugin-transform-member-expression-literals@^7.12.13": version "7.12.13" resolved "https://registry.yarnpkg.com/@babel/plugin-transform-member-expression-literals/-/plugin-transform-member-expression-literals-7.12.13.tgz#5ffa66cd59b9e191314c9f1f803b938e8c081e40" integrity sha512-kxLkOsg8yir4YeEPHLuO2tXP9R/gTjpuTOjshqSpELUN3ZAg2jfDnKUvzzJxObun38sw3wm4Uu69sX/zA7iRvg== dependencies: "@babel/helper-plugin-utils" "^7.12.13" -"@babel/plugin-transform-modules-amd@^7.12.1": - version "7.12.1" - resolved "https://registry.yarnpkg.com/@babel/plugin-transform-modules-amd/-/plugin-transform-modules-amd-7.12.1.tgz#3154300b026185666eebb0c0ed7f8415fefcf6f9" - integrity sha512-tDW8hMkzad5oDtzsB70HIQQRBiTKrhfgwC/KkJeGsaNFTdWhKNt/BiE8c5yj19XiGyrxpbkOfH87qkNg1YGlOQ== - dependencies: - "@babel/helper-module-transforms" "^7.12.1" - "@babel/helper-plugin-utils" "^7.10.4" - babel-plugin-dynamic-import-node "^2.3.3" - -"@babel/plugin-transform-modules-amd@^7.13.0": +"@babel/plugin-transform-modules-amd@^7.12.1", "@babel/plugin-transform-modules-amd@^7.13.0": version "7.13.0" resolved "https://registry.yarnpkg.com/@babel/plugin-transform-modules-amd/-/plugin-transform-modules-amd-7.13.0.tgz#19f511d60e3d8753cc5a6d4e775d3a5184866cc3" integrity sha512-EKy/E2NHhY/6Vw5d1k3rgoobftcNUmp9fGjb9XZwQLtTctsRBOTRO7RHHxfIky1ogMN5BxN7p9uMA3SzPfotMQ== @@ -1200,17 +744,7 @@ "@babel/helper-plugin-utils" "^7.13.0" babel-plugin-dynamic-import-node "^2.3.3" -"@babel/plugin-transform-modules-commonjs@^7.0.0", "@babel/plugin-transform-modules-commonjs@^7.12.1": - version "7.12.1" - resolved "https://registry.yarnpkg.com/@babel/plugin-transform-modules-commonjs/-/plugin-transform-modules-commonjs-7.12.1.tgz#fa403124542636c786cf9b460a0ffbb48a86e648" - integrity sha512-dY789wq6l0uLY8py9c1B48V8mVL5gZh/+PQ5ZPrylPYsnAvnEMjqsUXkuoDVPeVK+0VyGar+D08107LzDQ6pag== - dependencies: - "@babel/helper-module-transforms" "^7.12.1" - "@babel/helper-plugin-utils" "^7.10.4" - "@babel/helper-simple-access" "^7.12.1" - babel-plugin-dynamic-import-node "^2.3.3" - -"@babel/plugin-transform-modules-commonjs@^7.13.8": +"@babel/plugin-transform-modules-commonjs@^7.0.0", "@babel/plugin-transform-modules-commonjs@^7.12.1", "@babel/plugin-transform-modules-commonjs@^7.13.8": version "7.13.8" resolved "https://registry.yarnpkg.com/@babel/plugin-transform-modules-commonjs/-/plugin-transform-modules-commonjs-7.13.8.tgz#7b01ad7c2dcf2275b06fa1781e00d13d420b3e1b" integrity sha512-9QiOx4MEGglfYZ4XOnU79OHr6vIWUakIj9b4mioN8eQIoEh+pf5p/zEB36JpDFWA12nNMiRf7bfoRvl9Rn79Bw== @@ -1220,18 +754,7 @@ "@babel/helper-simple-access" "^7.12.13" babel-plugin-dynamic-import-node "^2.3.3" -"@babel/plugin-transform-modules-systemjs@^7.12.1": - version "7.12.1" - resolved "https://registry.yarnpkg.com/@babel/plugin-transform-modules-systemjs/-/plugin-transform-modules-systemjs-7.12.1.tgz#663fea620d593c93f214a464cd399bf6dc683086" - integrity sha512-Hn7cVvOavVh8yvW6fLwveFqSnd7rbQN3zJvoPNyNaQSvgfKmDBO9U1YL9+PCXGRlZD9tNdWTy5ACKqMuzyn32Q== - dependencies: - "@babel/helper-hoist-variables" "^7.10.4" - "@babel/helper-module-transforms" "^7.12.1" - "@babel/helper-plugin-utils" "^7.10.4" - "@babel/helper-validator-identifier" "^7.10.4" - babel-plugin-dynamic-import-node "^2.3.3" - -"@babel/plugin-transform-modules-systemjs@^7.13.8": +"@babel/plugin-transform-modules-systemjs@^7.12.1", "@babel/plugin-transform-modules-systemjs@^7.13.8": version "7.13.8" resolved "https://registry.yarnpkg.com/@babel/plugin-transform-modules-systemjs/-/plugin-transform-modules-systemjs-7.13.8.tgz#6d066ee2bff3c7b3d60bf28dec169ad993831ae3" integrity sha512-hwqctPYjhM6cWvVIlOIe27jCIBgHCsdH2xCJVAYQm7V5yTMoilbVMi9f6wKg0rpQAOn6ZG4AOyvCqFF/hUh6+A== @@ -1242,15 +765,7 @@ "@babel/helper-validator-identifier" "^7.12.11" babel-plugin-dynamic-import-node "^2.3.3" -"@babel/plugin-transform-modules-umd@^7.12.1": - version "7.12.1" - resolved "https://registry.yarnpkg.com/@babel/plugin-transform-modules-umd/-/plugin-transform-modules-umd-7.12.1.tgz#eb5a218d6b1c68f3d6217b8fa2cc82fec6547902" - integrity sha512-aEIubCS0KHKM0zUos5fIoQm+AZUMt1ZvMpqz0/H5qAQ7vWylr9+PLYurT+Ic7ID/bKLd4q8hDovaG3Zch2uz5Q== - dependencies: - "@babel/helper-module-transforms" "^7.12.1" - "@babel/helper-plugin-utils" "^7.10.4" - -"@babel/plugin-transform-modules-umd@^7.13.0": +"@babel/plugin-transform-modules-umd@^7.12.1", "@babel/plugin-transform-modules-umd@^7.13.0": version "7.13.0" resolved "https://registry.yarnpkg.com/@babel/plugin-transform-modules-umd/-/plugin-transform-modules-umd-7.13.0.tgz#8a3d96a97d199705b9fd021580082af81c06e70b" integrity sha512-D/ILzAh6uyvkWjKKyFE/W0FzWwasv6vPTSqPcjxFqn6QpX3u8DjRVliq4F2BamO2Wee/om06Vyy+vPkNrd4wxw== @@ -1258,43 +773,21 @@ "@babel/helper-module-transforms" "^7.13.0" "@babel/helper-plugin-utils" "^7.13.0" -"@babel/plugin-transform-named-capturing-groups-regex@^7.12.1": - version "7.12.1" - resolved "https://registry.yarnpkg.com/@babel/plugin-transform-named-capturing-groups-regex/-/plugin-transform-named-capturing-groups-regex-7.12.1.tgz#b407f5c96be0d9f5f88467497fa82b30ac3e8753" - integrity sha512-tB43uQ62RHcoDp9v2Nsf+dSM8sbNodbEicbQNA53zHz8pWUhsgHSJCGpt7daXxRydjb0KnfmB+ChXOv3oADp1Q== - dependencies: - "@babel/helper-create-regexp-features-plugin" "^7.12.1" - -"@babel/plugin-transform-named-capturing-groups-regex@^7.12.13": +"@babel/plugin-transform-named-capturing-groups-regex@^7.12.1", "@babel/plugin-transform-named-capturing-groups-regex@^7.12.13": version "7.12.13" resolved "https://registry.yarnpkg.com/@babel/plugin-transform-named-capturing-groups-regex/-/plugin-transform-named-capturing-groups-regex-7.12.13.tgz#2213725a5f5bbbe364b50c3ba5998c9599c5c9d9" integrity sha512-Xsm8P2hr5hAxyYblrfACXpQKdQbx4m2df9/ZZSQ8MAhsadw06+jW7s9zsSw6he+mJZXRlVMyEnVktJo4zjk1WA== dependencies: "@babel/helper-create-regexp-features-plugin" "^7.12.13" -"@babel/plugin-transform-new-target@^7.12.1": - version "7.12.1" - resolved "https://registry.yarnpkg.com/@babel/plugin-transform-new-target/-/plugin-transform-new-target-7.12.1.tgz#80073f02ee1bb2d365c3416490e085c95759dec0" - integrity sha512-+eW/VLcUL5L9IvJH7rT1sT0CzkdUTvPrXC2PXTn/7z7tXLBuKvezYbGdxD5WMRoyvyaujOq2fWoKl869heKjhw== - dependencies: - "@babel/helper-plugin-utils" "^7.10.4" - -"@babel/plugin-transform-new-target@^7.12.13": +"@babel/plugin-transform-new-target@^7.12.1", "@babel/plugin-transform-new-target@^7.12.13": version "7.12.13" resolved "https://registry.yarnpkg.com/@babel/plugin-transform-new-target/-/plugin-transform-new-target-7.12.13.tgz#e22d8c3af24b150dd528cbd6e685e799bf1c351c" integrity sha512-/KY2hbLxrG5GTQ9zzZSc3xWiOy379pIETEhbtzwZcw9rvuaVV4Fqy7BYGYOWZnaoXIQYbbJ0ziXLa/sKcGCYEQ== dependencies: "@babel/helper-plugin-utils" "^7.12.13" -"@babel/plugin-transform-object-super@^7.0.0", "@babel/plugin-transform-object-super@^7.12.1": - version "7.12.1" - resolved "https://registry.yarnpkg.com/@babel/plugin-transform-object-super/-/plugin-transform-object-super-7.12.1.tgz#4ea08696b8d2e65841d0c7706482b048bed1066e" - integrity sha512-AvypiGJH9hsquNUn+RXVcBdeE3KHPZexWRdimhuV59cSoOt5kFBmqlByorAeUlGG2CJWd0U+4ZtNKga/TB0cAw== - dependencies: - "@babel/helper-plugin-utils" "^7.10.4" - "@babel/helper-replace-supers" "^7.12.1" - -"@babel/plugin-transform-object-super@^7.12.13": +"@babel/plugin-transform-object-super@^7.0.0", "@babel/plugin-transform-object-super@^7.12.1", "@babel/plugin-transform-object-super@^7.12.13": version "7.12.13" resolved "https://registry.yarnpkg.com/@babel/plugin-transform-object-super/-/plugin-transform-object-super-7.12.13.tgz#b4416a2d63b8f7be314f3d349bd55a9c1b5171f7" integrity sha512-JzYIcj3XtYspZDV8j9ulnoMPZZnF/Cj0LUxPOjR89BdBVx+zYJI9MdMIlUZjbXDX+6YVeS6I3e8op+qQ3BYBoQ== @@ -1302,28 +795,14 @@ "@babel/helper-plugin-utils" "^7.12.13" "@babel/helper-replace-supers" "^7.12.13" -"@babel/plugin-transform-parameters@^7.0.0", "@babel/plugin-transform-parameters@^7.12.1": - version "7.12.1" - resolved "https://registry.yarnpkg.com/@babel/plugin-transform-parameters/-/plugin-transform-parameters-7.12.1.tgz#d2e963b038771650c922eff593799c96d853255d" - integrity sha512-xq9C5EQhdPK23ZeCdMxl8bbRnAgHFrw5EOC3KJUsSylZqdkCaFEXxGSBuTSObOpiiHHNyb82es8M1QYgfQGfNg== - dependencies: - "@babel/helper-plugin-utils" "^7.10.4" - -"@babel/plugin-transform-parameters@^7.13.0": +"@babel/plugin-transform-parameters@^7.0.0", "@babel/plugin-transform-parameters@^7.12.1", "@babel/plugin-transform-parameters@^7.13.0": version "7.13.0" resolved "https://registry.yarnpkg.com/@babel/plugin-transform-parameters/-/plugin-transform-parameters-7.13.0.tgz#8fa7603e3097f9c0b7ca1a4821bc2fb52e9e5007" integrity sha512-Jt8k/h/mIwE2JFEOb3lURoY5C85ETcYPnbuAJ96zRBzh1XHtQZfs62ChZ6EP22QlC8c7Xqr9q+e1SU5qttwwjw== dependencies: "@babel/helper-plugin-utils" "^7.13.0" -"@babel/plugin-transform-property-literals@^7.0.0", "@babel/plugin-transform-property-literals@^7.12.1": - version "7.12.1" - resolved "https://registry.yarnpkg.com/@babel/plugin-transform-property-literals/-/plugin-transform-property-literals-7.12.1.tgz#41bc81200d730abb4456ab8b3fbd5537b59adecd" - integrity sha512-6MTCR/mZ1MQS+AwZLplX4cEySjCpnIF26ToWo942nqn8hXSm7McaHQNeGx/pt7suI1TWOWMfa/NgBhiqSnX0cQ== - dependencies: - "@babel/helper-plugin-utils" "^7.10.4" - -"@babel/plugin-transform-property-literals@^7.12.13": +"@babel/plugin-transform-property-literals@^7.0.0", "@babel/plugin-transform-property-literals@^7.12.1", "@babel/plugin-transform-property-literals@^7.12.13": version "7.12.13" resolved "https://registry.yarnpkg.com/@babel/plugin-transform-property-literals/-/plugin-transform-property-literals-7.12.13.tgz#4e6a9e37864d8f1b3bc0e2dce7bf8857db8b1a81" integrity sha512-nqVigwVan+lR+g8Fj8Exl0UQX2kymtjcWfMOYM1vTYEKujeyv2SkMgazf2qNcK7l4SDiKyTA/nHCPqL4e2zo1A== @@ -1331,36 +810,27 @@ "@babel/helper-plugin-utils" "^7.12.13" "@babel/plugin-transform-react-constant-elements@^7.12.1": - version "7.12.13" - resolved "https://registry.yarnpkg.com/@babel/plugin-transform-react-constant-elements/-/plugin-transform-react-constant-elements-7.12.13.tgz#f8ee56888545d53d80f766b3cc1563ab2c241f92" - integrity sha512-qmzKVTn46Upvtxv8LQoQ8mTCdUC83AOVQIQm57e9oekLT5cmK9GOMOfcWhe8jMNx4UJXn/UDhVZ/7lGofVNeDQ== + version "7.13.10" + resolved "https://registry.yarnpkg.com/@babel/plugin-transform-react-constant-elements/-/plugin-transform-react-constant-elements-7.13.10.tgz#5d3de8a8ee53f4612e728f4f17b8c9125f8019e5" + integrity sha512-E+aCW9j7mLq01tOuGV08YzLBt+vSyr4bOPT75B6WrAlrUfmOYOZ/yWk847EH0dv0xXiCihWLEmlX//O30YhpIw== dependencies: - "@babel/helper-plugin-utils" "^7.12.13" + "@babel/helper-plugin-utils" "^7.13.0" -"@babel/plugin-transform-react-display-name@7.12.1", "@babel/plugin-transform-react-display-name@^7.0.0", "@babel/plugin-transform-react-display-name@^7.12.1": +"@babel/plugin-transform-react-display-name@7.12.1": version "7.12.1" resolved "https://registry.yarnpkg.com/@babel/plugin-transform-react-display-name/-/plugin-transform-react-display-name-7.12.1.tgz#1cbcd0c3b1d6648c55374a22fc9b6b7e5341c00d" integrity sha512-cAzB+UzBIrekfYxyLlFqf/OagTvHLcVBb5vpouzkYkBclRPraiygVnafvAoipErZLI8ANv8Ecn6E/m5qPXD26w== dependencies: "@babel/helper-plugin-utils" "^7.10.4" -"@babel/plugin-transform-react-display-name@^7.12.13": +"@babel/plugin-transform-react-display-name@^7.0.0", "@babel/plugin-transform-react-display-name@^7.12.1", "@babel/plugin-transform-react-display-name@^7.12.13": version "7.12.13" resolved "https://registry.yarnpkg.com/@babel/plugin-transform-react-display-name/-/plugin-transform-react-display-name-7.12.13.tgz#c28effd771b276f4647411c9733dbb2d2da954bd" integrity sha512-MprESJzI9O5VnJZrL7gg1MpdqmiFcUv41Jc7SahxYsNP2kDkFqClxxTZq+1Qv4AFCamm+GXMRDQINNn+qrxmiA== dependencies: "@babel/helper-plugin-utils" "^7.12.13" -"@babel/plugin-transform-react-jsx-development@^7.12.1": - version "7.12.1" - resolved "https://registry.yarnpkg.com/@babel/plugin-transform-react-jsx-development/-/plugin-transform-react-jsx-development-7.12.1.tgz#0b8f8cd531dcf7991f1e5f2c10a2a4f1cfc78e36" - integrity sha512-IilcGWdN1yNgEGOrB96jbTplRh+V2Pz1EoEwsKsHfX1a/L40cUYuD71Zepa7C+ujv7kJIxnDftWeZbKNEqZjCQ== - dependencies: - "@babel/helper-builder-react-jsx-experimental" "^7.12.1" - "@babel/helper-plugin-utils" "^7.10.4" - "@babel/plugin-syntax-jsx" "^7.12.1" - -"@babel/plugin-transform-react-jsx-development@^7.12.12": +"@babel/plugin-transform-react-jsx-development@^7.12.1", "@babel/plugin-transform-react-jsx-development@^7.12.12": version "7.12.17" resolved "https://registry.yarnpkg.com/@babel/plugin-transform-react-jsx-development/-/plugin-transform-react-jsx-development-7.12.17.tgz#f510c0fa7cd7234153539f9a362ced41a5ca1447" integrity sha512-BPjYV86SVuOaudFhsJR1zjgxxOhJDt6JHNoD48DxWEIxUCAMjV1ys6DYw4SDYZh0b1QsS2vfIA9t/ZsQGsDOUQ== @@ -1368,39 +838,29 @@ "@babel/plugin-transform-react-jsx" "^7.12.17" "@babel/plugin-transform-react-jsx-self@^7.12.1": - version "7.12.1" - resolved "https://registry.yarnpkg.com/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.12.1.tgz#ef43cbca2a14f1bd17807dbe4376ff89d714cf28" - integrity sha512-FbpL0ieNWiiBB5tCldX17EtXgmzeEZjFrix72rQYeq9X6nUK38HCaxexzVQrZWXanxKJPKVVIU37gFjEQYkPkA== + version "7.12.13" + resolved "https://registry.yarnpkg.com/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.12.13.tgz#422d99d122d592acab9c35ea22a6cfd9bf189f60" + integrity sha512-FXYw98TTJ125GVCCkFLZXlZ1qGcsYqNQhVBQcZjyrwf8FEUtVfKIoidnO8S0q+KBQpDYNTmiGo1gn67Vti04lQ== dependencies: - "@babel/helper-plugin-utils" "^7.10.4" + "@babel/helper-plugin-utils" "^7.12.13" "@babel/plugin-transform-react-jsx-source@^7.12.1": - version "7.12.1" - resolved "https://registry.yarnpkg.com/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.12.1.tgz#d07de6863f468da0809edcf79a1aa8ce2a82a26b" - integrity sha512-keQ5kBfjJNRc6zZN1/nVHCd6LLIHq4aUKcVnvE/2l+ZZROSbqoiGFRtT5t3Is89XJxBQaP7NLZX2jgGHdZvvFQ== + version "7.12.13" + resolved "https://registry.yarnpkg.com/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.12.13.tgz#051d76126bee5c9a6aa3ba37be2f6c1698856bcb" + integrity sha512-O5JJi6fyfih0WfDgIJXksSPhGP/G0fQpfxYy87sDc+1sFmsCS6wr3aAn+whbzkhbjtq4VMqLRaSzR6IsshIC0Q== dependencies: - "@babel/helper-plugin-utils" "^7.10.4" + "@babel/helper-plugin-utils" "^7.12.13" -"@babel/plugin-transform-react-jsx@^7.0.0", "@babel/plugin-transform-react-jsx@^7.12.1": - version "7.12.1" - resolved "https://registry.yarnpkg.com/@babel/plugin-transform-react-jsx/-/plugin-transform-react-jsx-7.12.1.tgz#c2d96c77c2b0e4362cc4e77a43ce7c2539d478cb" - integrity sha512-RmKejwnT0T0QzQUzcbP5p1VWlpnP8QHtdhEtLG55ZDQnJNalbF3eeDyu3dnGKvGzFIQiBzFhBYTwvv435p9Xpw== - dependencies: - "@babel/helper-builder-react-jsx" "^7.10.4" - "@babel/helper-builder-react-jsx-experimental" "^7.12.1" - "@babel/helper-plugin-utils" "^7.10.4" - "@babel/plugin-syntax-jsx" "^7.12.1" - -"@babel/plugin-transform-react-jsx@^7.12.13", "@babel/plugin-transform-react-jsx@^7.12.17": - version "7.12.17" - resolved "https://registry.yarnpkg.com/@babel/plugin-transform-react-jsx/-/plugin-transform-react-jsx-7.12.17.tgz#dd2c1299f5e26de584939892de3cfc1807a38f24" - integrity sha512-mwaVNcXV+l6qJOuRhpdTEj8sT/Z0owAVWf9QujTZ0d2ye9X/K+MTOTSizcgKOj18PGnTc/7g1I4+cIUjsKhBcw== +"@babel/plugin-transform-react-jsx@^7.0.0", "@babel/plugin-transform-react-jsx@^7.12.1", "@babel/plugin-transform-react-jsx@^7.12.13", "@babel/plugin-transform-react-jsx@^7.12.17": + version "7.13.12" + resolved "https://registry.yarnpkg.com/@babel/plugin-transform-react-jsx/-/plugin-transform-react-jsx-7.13.12.tgz#1df5dfaf0f4b784b43e96da6f28d630e775f68b3" + integrity sha512-jcEI2UqIcpCqB5U5DRxIl0tQEProI2gcu+g8VTIqxLO5Iidojb4d77q+fwGseCvd8af/lJ9masp4QWzBXFE2xA== dependencies: "@babel/helper-annotate-as-pure" "^7.12.13" - "@babel/helper-module-imports" "^7.12.13" - "@babel/helper-plugin-utils" "^7.12.13" + "@babel/helper-module-imports" "^7.13.12" + "@babel/helper-plugin-utils" "^7.13.0" "@babel/plugin-syntax-jsx" "^7.12.13" - "@babel/types" "^7.12.17" + "@babel/types" "^7.13.12" "@babel/plugin-transform-react-pure-annotations@^7.12.1": version "7.12.1" @@ -1410,28 +870,14 @@ "@babel/helper-annotate-as-pure" "^7.10.4" "@babel/helper-plugin-utils" "^7.10.4" -"@babel/plugin-transform-regenerator@^7.12.1": - version "7.12.1" - resolved "https://registry.yarnpkg.com/@babel/plugin-transform-regenerator/-/plugin-transform-regenerator-7.12.1.tgz#5f0a28d842f6462281f06a964e88ba8d7ab49753" - integrity sha512-gYrHqs5itw6i4PflFX3OdBPMQdPbF4bj2REIUxlMRUFk0/ZOAIpDFuViuxPjUL7YC8UPnf+XG7/utJvqXdPKng== - dependencies: - regenerator-transform "^0.14.2" - -"@babel/plugin-transform-regenerator@^7.12.13": +"@babel/plugin-transform-regenerator@^7.12.1", "@babel/plugin-transform-regenerator@^7.12.13": version "7.12.13" resolved "https://registry.yarnpkg.com/@babel/plugin-transform-regenerator/-/plugin-transform-regenerator-7.12.13.tgz#b628bcc9c85260ac1aeb05b45bde25210194a2f5" integrity sha512-lxb2ZAvSLyJ2PEe47hoGWPmW22v7CtSl9jW8mingV4H2sEX/JOcrAj2nPuGWi56ERUm2bUpjKzONAuT6HCn2EA== dependencies: regenerator-transform "^0.14.2" -"@babel/plugin-transform-reserved-words@^7.12.1": - version "7.12.1" - resolved "https://registry.yarnpkg.com/@babel/plugin-transform-reserved-words/-/plugin-transform-reserved-words-7.12.1.tgz#6fdfc8cc7edcc42b36a7c12188c6787c873adcd8" - integrity sha512-pOnUfhyPKvZpVyBHhSBoX8vfA09b7r00Pmm1sH+29ae2hMTKVmSp4Ztsr8KBKjLjx17H0eJqaRC3bR2iThM54A== - dependencies: - "@babel/helper-plugin-utils" "^7.10.4" - -"@babel/plugin-transform-reserved-words@^7.12.13": +"@babel/plugin-transform-reserved-words@^7.12.1", "@babel/plugin-transform-reserved-words@^7.12.13": version "7.12.13" resolved "https://registry.yarnpkg.com/@babel/plugin-transform-reserved-words/-/plugin-transform-reserved-words-7.12.13.tgz#7d9988d4f06e0fe697ea1d9803188aa18b472695" integrity sha512-xhUPzDXxZN1QfiOy/I5tyye+TRz6lA7z6xaT4CLOjPRMVg1ldRf0LHw0TDBpYL4vG78556WuHdyO9oi5UmzZBg== @@ -1448,29 +894,14 @@ resolve "^1.8.1" semver "^5.5.1" -"@babel/plugin-transform-shorthand-properties@^7.0.0", "@babel/plugin-transform-shorthand-properties@^7.12.1": - version "7.12.1" - resolved "https://registry.yarnpkg.com/@babel/plugin-transform-shorthand-properties/-/plugin-transform-shorthand-properties-7.12.1.tgz#0bf9cac5550fce0cfdf043420f661d645fdc75e3" - integrity sha512-GFZS3c/MhX1OusqB1MZ1ct2xRzX5ppQh2JU1h2Pnfk88HtFTM+TWQqJNfwkmxtPQtb/s1tk87oENfXJlx7rSDw== - dependencies: - "@babel/helper-plugin-utils" "^7.10.4" - -"@babel/plugin-transform-shorthand-properties@^7.12.13": +"@babel/plugin-transform-shorthand-properties@^7.0.0", "@babel/plugin-transform-shorthand-properties@^7.12.1", "@babel/plugin-transform-shorthand-properties@^7.12.13": version "7.12.13" resolved "https://registry.yarnpkg.com/@babel/plugin-transform-shorthand-properties/-/plugin-transform-shorthand-properties-7.12.13.tgz#db755732b70c539d504c6390d9ce90fe64aff7ad" integrity sha512-xpL49pqPnLtf0tVluuqvzWIgLEhuPpZzvs2yabUHSKRNlN7ScYU7aMlmavOeyXJZKgZKQRBlh8rHbKiJDraTSw== dependencies: "@babel/helper-plugin-utils" "^7.12.13" -"@babel/plugin-transform-spread@^7.0.0", "@babel/plugin-transform-spread@^7.12.1": - version "7.12.1" - resolved "https://registry.yarnpkg.com/@babel/plugin-transform-spread/-/plugin-transform-spread-7.12.1.tgz#527f9f311be4ec7fdc2b79bb89f7bf884b3e1e1e" - integrity sha512-vuLp8CP0BE18zVYjsEBZ5xoCecMK6LBMMxYzJnh01rxQRvhNhH1csMMmBfNo5tGpGO+NhdSNW2mzIvBu3K1fng== - dependencies: - "@babel/helper-plugin-utils" "^7.10.4" - "@babel/helper-skip-transparent-expression-wrappers" "^7.12.1" - -"@babel/plugin-transform-spread@^7.13.0": +"@babel/plugin-transform-spread@^7.0.0", "@babel/plugin-transform-spread@^7.12.1", "@babel/plugin-transform-spread@^7.13.0": version "7.13.0" resolved "https://registry.yarnpkg.com/@babel/plugin-transform-spread/-/plugin-transform-spread-7.13.0.tgz#84887710e273c1815ace7ae459f6f42a5d31d5fd" integrity sha512-V6vkiXijjzYeFmQTr3dBxPtZYLPcUfY34DebOU27jIl2M/Y8Egm52Hw82CSjjPqd54GTlJs5x+CR7HeNr24ckg== @@ -1478,43 +909,21 @@ "@babel/helper-plugin-utils" "^7.13.0" "@babel/helper-skip-transparent-expression-wrappers" "^7.12.1" -"@babel/plugin-transform-sticky-regex@^7.12.1": - version "7.12.1" - resolved "https://registry.yarnpkg.com/@babel/plugin-transform-sticky-regex/-/plugin-transform-sticky-regex-7.12.1.tgz#5c24cf50de396d30e99afc8d1c700e8bce0f5caf" - integrity sha512-CiUgKQ3AGVk7kveIaPEET1jNDhZZEl1RPMWdTBE1799bdz++SwqDHStmxfCtDfBhQgCl38YRiSnrMuUMZIWSUQ== - dependencies: - "@babel/helper-plugin-utils" "^7.10.4" - "@babel/helper-regex" "^7.10.4" - -"@babel/plugin-transform-sticky-regex@^7.12.13": +"@babel/plugin-transform-sticky-regex@^7.12.1", "@babel/plugin-transform-sticky-regex@^7.12.13": version "7.12.13" resolved "https://registry.yarnpkg.com/@babel/plugin-transform-sticky-regex/-/plugin-transform-sticky-regex-7.12.13.tgz#760ffd936face73f860ae646fb86ee82f3d06d1f" integrity sha512-Jc3JSaaWT8+fr7GRvQP02fKDsYk4K/lYwWq38r/UGfaxo89ajud321NH28KRQ7xy1Ybc0VUE5Pz8psjNNDUglg== dependencies: "@babel/helper-plugin-utils" "^7.12.13" -"@babel/plugin-transform-template-literals@^7.0.0", "@babel/plugin-transform-template-literals@^7.12.1": - version "7.12.1" - resolved "https://registry.yarnpkg.com/@babel/plugin-transform-template-literals/-/plugin-transform-template-literals-7.12.1.tgz#b43ece6ed9a79c0c71119f576d299ef09d942843" - integrity sha512-b4Zx3KHi+taXB1dVRBhVJtEPi9h1THCeKmae2qP0YdUHIFhVjtpqqNfxeVAa1xeHVhAy4SbHxEwx5cltAu5apw== - dependencies: - "@babel/helper-plugin-utils" "^7.10.4" - -"@babel/plugin-transform-template-literals@^7.13.0": +"@babel/plugin-transform-template-literals@^7.0.0", "@babel/plugin-transform-template-literals@^7.12.1", "@babel/plugin-transform-template-literals@^7.13.0": version "7.13.0" resolved "https://registry.yarnpkg.com/@babel/plugin-transform-template-literals/-/plugin-transform-template-literals-7.13.0.tgz#a36049127977ad94438dee7443598d1cefdf409d" integrity sha512-d67umW6nlfmr1iehCcBv69eSUSySk1EsIS8aTDX4Xo9qajAh6mYtcl4kJrBkGXuxZPEgVr7RVfAvNW6YQkd4Mw== dependencies: "@babel/helper-plugin-utils" "^7.13.0" -"@babel/plugin-transform-typeof-symbol@^7.12.1": - version "7.12.1" - resolved "https://registry.yarnpkg.com/@babel/plugin-transform-typeof-symbol/-/plugin-transform-typeof-symbol-7.12.1.tgz#9ca6be343d42512fbc2e68236a82ae64bc7af78a" - integrity sha512-EPGgpGy+O5Kg5pJFNDKuxt9RdmTgj5sgrus2XVeMp/ZIbOESadgILUbm50SNpghOh3/6yrbsH+NB5+WJTmsA7Q== - dependencies: - "@babel/helper-plugin-utils" "^7.10.4" - -"@babel/plugin-transform-typeof-symbol@^7.12.13": +"@babel/plugin-transform-typeof-symbol@^7.12.1", "@babel/plugin-transform-typeof-symbol@^7.12.13": version "7.12.13" resolved "https://registry.yarnpkg.com/@babel/plugin-transform-typeof-symbol/-/plugin-transform-typeof-symbol-7.12.13.tgz#785dd67a1f2ea579d9c2be722de8c84cb85f5a7f" integrity sha512-eKv/LmUJpMnu4npgfvs3LiHhJua5fo/CysENxa45YCQXZwKnGCQKAg87bvoqSW1fFT+HA32l03Qxsm8ouTY3ZQ== @@ -1522,37 +931,22 @@ "@babel/helper-plugin-utils" "^7.12.13" "@babel/plugin-transform-typescript@^7.12.1": - version "7.12.1" - resolved "https://registry.yarnpkg.com/@babel/plugin-transform-typescript/-/plugin-transform-typescript-7.12.1.tgz#d92cc0af504d510e26a754a7dbc2e5c8cd9c7ab4" - integrity sha512-VrsBByqAIntM+EYMqSm59SiMEf7qkmI9dqMt6RbD/wlwueWmYcI0FFK5Fj47pP6DRZm+3teXjosKlwcZJ5lIMw== + version "7.13.0" + resolved "https://registry.yarnpkg.com/@babel/plugin-transform-typescript/-/plugin-transform-typescript-7.13.0.tgz#4a498e1f3600342d2a9e61f60131018f55774853" + integrity sha512-elQEwluzaU8R8dbVuW2Q2Y8Nznf7hnjM7+DSCd14Lo5fF63C9qNLbwZYbmZrtV9/ySpSUpkRpQXvJb6xyu4hCQ== dependencies: - "@babel/helper-create-class-features-plugin" "^7.12.1" - "@babel/helper-plugin-utils" "^7.10.4" - "@babel/plugin-syntax-typescript" "^7.12.1" + "@babel/helper-create-class-features-plugin" "^7.13.0" + "@babel/helper-plugin-utils" "^7.13.0" + "@babel/plugin-syntax-typescript" "^7.12.13" -"@babel/plugin-transform-unicode-escapes@^7.12.1": - version "7.12.1" - resolved "https://registry.yarnpkg.com/@babel/plugin-transform-unicode-escapes/-/plugin-transform-unicode-escapes-7.12.1.tgz#5232b9f81ccb07070b7c3c36c67a1b78f1845709" - integrity sha512-I8gNHJLIc7GdApm7wkVnStWssPNbSRMPtgHdmH3sRM1zopz09UWPS4x5V4n1yz/MIWTVnJ9sp6IkuXdWM4w+2Q== - dependencies: - "@babel/helper-plugin-utils" "^7.10.4" - -"@babel/plugin-transform-unicode-escapes@^7.12.13": +"@babel/plugin-transform-unicode-escapes@^7.12.1", "@babel/plugin-transform-unicode-escapes@^7.12.13": version "7.12.13" resolved "https://registry.yarnpkg.com/@babel/plugin-transform-unicode-escapes/-/plugin-transform-unicode-escapes-7.12.13.tgz#840ced3b816d3b5127dd1d12dcedc5dead1a5e74" integrity sha512-0bHEkdwJ/sN/ikBHfSmOXPypN/beiGqjo+o4/5K+vxEFNPRPdImhviPakMKG4x96l85emoa0Z6cDflsdBusZbw== dependencies: "@babel/helper-plugin-utils" "^7.12.13" -"@babel/plugin-transform-unicode-regex@^7.12.1": - version "7.12.1" - resolved "https://registry.yarnpkg.com/@babel/plugin-transform-unicode-regex/-/plugin-transform-unicode-regex-7.12.1.tgz#cc9661f61390db5c65e3febaccefd5c6ac3faecb" - integrity sha512-SqH4ClNngh/zGwHZOOQMTD+e8FGWexILV+ePMyiDJttAWRh5dhDL8rcl5lSgU3Huiq6Zn6pWTMvdPAb21Dwdyg== - dependencies: - "@babel/helper-create-regexp-features-plugin" "^7.12.1" - "@babel/helper-plugin-utils" "^7.10.4" - -"@babel/plugin-transform-unicode-regex@^7.12.13": +"@babel/plugin-transform-unicode-regex@^7.12.1", "@babel/plugin-transform-unicode-regex@^7.12.13": version "7.12.13" resolved "https://registry.yarnpkg.com/@babel/plugin-transform-unicode-regex/-/plugin-transform-unicode-regex-7.12.13.tgz#b52521685804e155b1202e83fc188d34bb70f5ac" integrity sha512-mDRzSNY7/zopwisPZ5kM9XKCfhchqIYwAKRERtEnhYscZB79VRekuRSoYbN0+KVe3y8+q1h6A4svXtP7N+UoCA== @@ -1560,7 +954,7 @@ "@babel/helper-create-regexp-features-plugin" "^7.12.13" "@babel/helper-plugin-utils" "^7.12.13" -"@babel/preset-env@7.12.1", "@babel/preset-env@^7.8.4": +"@babel/preset-env@7.12.1": version "7.12.1" resolved "https://registry.yarnpkg.com/@babel/preset-env/-/preset-env-7.12.1.tgz#9c7e5ca82a19efc865384bb4989148d2ee5d7ac2" integrity sha512-H8kxXmtPaAGT7TyBvSSkoSTUK6RHh61So05SyEbpmr0MCZrsNYn7mGMzzeYoOUCdHzww61k8XBft2TaES+xPLg== @@ -1632,15 +1026,16 @@ core-js-compat "^3.6.2" semver "^5.5.0" -"@babel/preset-env@^7.12.1": - version "7.13.9" - resolved "https://registry.yarnpkg.com/@babel/preset-env/-/preset-env-7.13.9.tgz#3ee5f233316b10d066d7f379c6d1e13a96853654" - integrity sha512-mcsHUlh2rIhViqMG823JpscLMesRt3QbMsv1+jhopXEb3W2wXvQ9QoiOlZI9ZbR3XqPtaFpZwEZKYqGJnGMZTQ== +"@babel/preset-env@^7.12.1", "@babel/preset-env@^7.8.4": + version "7.13.12" + resolved "https://registry.yarnpkg.com/@babel/preset-env/-/preset-env-7.13.12.tgz#6dff470478290582ac282fb77780eadf32480237" + integrity sha512-JzElc6jk3Ko6zuZgBtjOd01pf9yYDEIH8BcqVuYIuOkzOwDesoa/Nz4gIo4lBG6K861KTV9TvIgmFuT6ytOaAA== dependencies: - "@babel/compat-data" "^7.13.8" - "@babel/helper-compilation-targets" "^7.13.8" + "@babel/compat-data" "^7.13.12" + "@babel/helper-compilation-targets" "^7.13.10" "@babel/helper-plugin-utils" "^7.13.0" "@babel/helper-validator-option" "^7.12.17" + "@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining" "^7.13.12" "@babel/plugin-proposal-async-generator-functions" "^7.13.8" "@babel/plugin-proposal-class-properties" "^7.13.0" "@babel/plugin-proposal-dynamic-import" "^7.13.8" @@ -1651,7 +1046,7 @@ "@babel/plugin-proposal-numeric-separator" "^7.12.13" "@babel/plugin-proposal-object-rest-spread" "^7.13.8" "@babel/plugin-proposal-optional-catch-binding" "^7.13.8" - "@babel/plugin-proposal-optional-chaining" "^7.13.8" + "@babel/plugin-proposal-optional-chaining" "^7.13.12" "@babel/plugin-proposal-private-methods" "^7.13.0" "@babel/plugin-proposal-unicode-property-regex" "^7.12.13" "@babel/plugin-syntax-async-generators" "^7.8.4" @@ -1699,7 +1094,7 @@ "@babel/plugin-transform-unicode-escapes" "^7.12.13" "@babel/plugin-transform-unicode-regex" "^7.12.13" "@babel/preset-modules" "^0.1.4" - "@babel/types" "^7.13.0" + "@babel/types" "^7.13.12" babel-plugin-polyfill-corejs2 "^0.1.4" babel-plugin-polyfill-corejs3 "^0.1.3" babel-plugin-polyfill-regenerator "^0.1.2" @@ -1750,44 +1145,28 @@ "@babel/plugin-transform-typescript" "^7.12.1" "@babel/runtime-corejs3@^7.10.2": - version "7.12.1" - resolved "https://registry.yarnpkg.com/@babel/runtime-corejs3/-/runtime-corejs3-7.12.1.tgz#51b9092befbeeed938335a109dbe0df51451e9dc" - integrity sha512-umhPIcMrlBZ2aTWlWjUseW9LjQKxi1dpFlQS8DzsxB//5K+u6GLTC/JliPKHsd5kJVPIU6X/Hy0YvWOYPcMxBw== + version "7.13.10" + resolved "https://registry.yarnpkg.com/@babel/runtime-corejs3/-/runtime-corejs3-7.13.10.tgz#14c3f4c85de22ba88e8e86685d13e8861a82fe86" + integrity sha512-x/XYVQ1h684pp1mJwOV4CyvqZXqbc8CMsMGUnAbuc82ZCdv1U63w5RSUzgDSXQHG5Rps/kiksH6g2D5BuaKyXg== dependencies: core-js-pure "^3.0.0" regenerator-runtime "^0.13.4" -"@babel/runtime@7.12.1", "@babel/runtime@^7.0.0", "@babel/runtime@^7.1.2", "@babel/runtime@^7.10.2", "@babel/runtime@^7.11.2", "@babel/runtime@^7.12.1", "@babel/runtime@^7.4.2", "@babel/runtime@^7.4.4", "@babel/runtime@^7.5.5", "@babel/runtime@^7.6.3", "@babel/runtime@^7.7.2", "@babel/runtime@^7.8.4", "@babel/runtime@^7.8.7": +"@babel/runtime@7.12.1": version "7.12.1" resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.12.1.tgz#b4116a6b6711d010b2dad3b7b6e43bf1b9954740" integrity sha512-J5AIf3vPj3UwXaAzb5j1xM4WAQDX3EMgemF8rjCP3SoW09LfRKAXQKt6CoVYl230P6iWdRcBbnLDDdnqWxZSCA== dependencies: regenerator-runtime "^0.13.4" -"@babel/runtime@^7.10.5": - version "7.13.7" - resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.13.7.tgz#d494e39d198ee9ca04f4dcb76d25d9d7a1dc961a" - integrity sha512-h+ilqoX998mRVM5FtB5ijRuHUDVt5l3yfoOi2uh18Z/O3hvyaHQ39NpxVkCIG5yFs+mLq/ewFp8Bss6zmWv6ZA== +"@babel/runtime@^7.0.0", "@babel/runtime@^7.1.2", "@babel/runtime@^7.10.2", "@babel/runtime@^7.10.5", "@babel/runtime@^7.11.2", "@babel/runtime@^7.12.0", "@babel/runtime@^7.12.1", "@babel/runtime@^7.12.5", "@babel/runtime@^7.4.2", "@babel/runtime@^7.5.5", "@babel/runtime@^7.6.3", "@babel/runtime@^7.7.2", "@babel/runtime@^7.8.4", "@babel/runtime@^7.8.7": + version "7.13.10" + resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.13.10.tgz#47d42a57b6095f4468da440388fdbad8bebf0d7d" + integrity sha512-4QPkjJq6Ns3V/RgpEahRk+AGfL0eO6RHHtTWoNNr5mO49G6B5+X6d6THgWEAvTrznU5xYpbAlVKRYcsCgh/Akw== dependencies: regenerator-runtime "^0.13.4" -"@babel/runtime@^7.12.5": - version "7.12.5" - resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.12.5.tgz#410e7e487441e1b360c29be715d870d9b985882e" - integrity sha512-plcc+hbExy3McchJCEQG3knOsuh3HH+Prx1P6cLIkET/0dLuQDEnrT+s27Axgc9bqfsmNUNHfscgMUdBpC9xfg== - dependencies: - regenerator-runtime "^0.13.4" - -"@babel/template@^7.10.4", "@babel/template@^7.3.3": - version "7.10.4" - resolved "https://registry.yarnpkg.com/@babel/template/-/template-7.10.4.tgz#3251996c4200ebc71d1a8fc405fba940f36ba278" - integrity sha512-ZCjD27cGJFUB6nmCB1Enki3r+L5kJveX9pq1SvAUKoICy6CZ9yD8xO086YXdYhvNjBdnekm4ZnaP5yC8Cs/1tA== - dependencies: - "@babel/code-frame" "^7.10.4" - "@babel/parser" "^7.10.4" - "@babel/types" "^7.10.4" - -"@babel/template@^7.12.13": +"@babel/template@^7.10.4", "@babel/template@^7.12.13", "@babel/template@^7.3.3": version "7.12.13" resolved "https://registry.yarnpkg.com/@babel/template/-/template-7.12.13.tgz#530265be8a2589dbb37523844c5bcb55947fb327" integrity sha512-/7xxiGA57xMo/P2GVvdEumr8ONhFOhfgq2ihK3h1e6THqzTAkHbkXgB0xI9yeTfIUoH3+oAeHhqm/I43OTbbjA== @@ -1796,22 +1175,22 @@ "@babel/parser" "^7.12.13" "@babel/types" "^7.12.13" -"@babel/traverse@7.12.1", "@babel/traverse@^7.0.0", "@babel/traverse@^7.1.0", "@babel/traverse@^7.10.4", "@babel/traverse@^7.12.1", "@babel/traverse@^7.7.0": - version "7.12.1" - resolved "https://registry.yarnpkg.com/@babel/traverse/-/traverse-7.12.1.tgz#941395e0c5cc86d5d3e75caa095d3924526f0c1e" - integrity sha512-MA3WPoRt1ZHo2ZmoGKNqi20YnPt0B1S0GTZEPhhd+hw2KGUzBlHuVunj6K4sNuK+reEvyiPwtp0cpaqLzJDmAw== +"@babel/traverse@7.12.13": + version "7.12.13" + resolved "https://registry.yarnpkg.com/@babel/traverse/-/traverse-7.12.13.tgz#689f0e4b4c08587ad26622832632735fb8c4e0c0" + integrity sha512-3Zb4w7eE/OslI0fTp8c7b286/cQps3+vdLW3UcwC8VSJC6GbKn55aeVVu2QJNuCDoeKyptLOFrPq8WqZZBodyA== dependencies: - "@babel/code-frame" "^7.10.4" - "@babel/generator" "^7.12.1" - "@babel/helper-function-name" "^7.10.4" - "@babel/helper-split-export-declaration" "^7.11.0" - "@babel/parser" "^7.12.1" - "@babel/types" "^7.12.1" + "@babel/code-frame" "^7.12.13" + "@babel/generator" "^7.12.13" + "@babel/helper-function-name" "^7.12.13" + "@babel/helper-split-export-declaration" "^7.12.13" + "@babel/parser" "^7.12.13" + "@babel/types" "^7.12.13" debug "^4.1.0" globals "^11.1.0" lodash "^4.17.19" -"@babel/traverse@^7.13.0": +"@babel/traverse@^7.0.0", "@babel/traverse@^7.1.0", "@babel/traverse@^7.12.1", "@babel/traverse@^7.13.0", "@babel/traverse@^7.7.0": version "7.13.0" resolved "https://registry.yarnpkg.com/@babel/traverse/-/traverse-7.13.0.tgz#6d95752475f86ee7ded06536de309a65fc8966cc" integrity sha512-xys5xi5JEhzC3RzEmSGrs/b3pJW/o87SypZ+G/PhaE7uqVQNv/jlmVIBXuoh5atqQ434LfXV+sf23Oxj0bchJQ== @@ -1826,28 +1205,19 @@ globals "^11.1.0" lodash "^4.17.19" -"@babel/types@7.12.1", "@babel/types@^7.0.0", "@babel/types@^7.10.4", "@babel/types@^7.10.5", "@babel/types@^7.11.0", "@babel/types@^7.12.1", "@babel/types@^7.3.0", "@babel/types@^7.3.3", "@babel/types@^7.4.4", "@babel/types@^7.7.0", "@babel/types@^7.9.5": - version "7.12.1" - resolved "https://registry.yarnpkg.com/@babel/types/-/types-7.12.1.tgz#e109d9ab99a8de735be287ee3d6a9947a190c4ae" - integrity sha512-BzSY3NJBKM4kyatSOWh3D/JJ2O3CVzBybHWxtgxnggaxEuaSTTDqeiSb/xk9lrkw2Tbqyivw5ZU4rT+EfznQsA== - dependencies: - "@babel/helper-validator-identifier" "^7.10.4" - lodash "^4.17.19" - to-fast-properties "^2.0.0" - -"@babel/types@^7.12.13", "@babel/types@^7.12.17", "@babel/types@^7.12.6", "@babel/types@^7.13.0": - version "7.13.0" - resolved "https://registry.yarnpkg.com/@babel/types/-/types-7.13.0.tgz#74424d2816f0171b4100f0ab34e9a374efdf7f80" - integrity sha512-hE+HE8rnG1Z6Wzo+MhaKE5lM5eMx71T4EHJgku2E3xIfaULhDcxiiRxUYgwX8qwP1BBSlag+TdGOt6JAidIZTA== +"@babel/types@7.12.13": + version "7.12.13" + resolved "https://registry.yarnpkg.com/@babel/types/-/types-7.12.13.tgz#8be1aa8f2c876da11a9cf650c0ecf656913ad611" + integrity sha512-oKrdZTld2im1z8bDwTOQvUbxKwE+854zc16qWZQlcTqMN00pWxHQ4ZeOq0yDMnisOpRykH2/5Qqcrk/OlbAjiQ== dependencies: "@babel/helper-validator-identifier" "^7.12.11" lodash "^4.17.19" to-fast-properties "^2.0.0" -"@babel/types@^7.12.5": - version "7.12.12" - resolved "https://registry.yarnpkg.com/@babel/types/-/types-7.12.12.tgz#4608a6ec313abbd87afa55004d373ad04a96c299" - integrity sha512-lnIX7piTxOH22xE7fDXDbSHg9MM1/6ORnafpJmov5rs0kX5g4BZxeXNJLXsMRiO0U5Rb8/FvMS6xlTnTHvxonQ== +"@babel/types@^7.0.0", "@babel/types@^7.12.1", "@babel/types@^7.12.13", "@babel/types@^7.12.6", "@babel/types@^7.13.0", "@babel/types@^7.13.12", "@babel/types@^7.3.0", "@babel/types@^7.3.3", "@babel/types@^7.4.4", "@babel/types@^7.7.0", "@babel/types@^7.9.5": + version "7.13.12" + resolved "https://registry.yarnpkg.com/@babel/types/-/types-7.13.12.tgz#edbf99208ef48852acdff1c8a681a1e4ade580cd" + integrity sha512-K4nY2xFN4QMvQwkQ+zmBDp6ANMbVNw6BbxWmYA4qNjhR9W+Lj/8ky5MEY2Me5r+B2c6/v6F53oMndG+f9s3IiA== dependencies: "@babel/helper-validator-identifier" "^7.12.11" lodash "^4.17.19" @@ -1876,24 +1246,6 @@ resolved "https://registry.yarnpkg.com/@csstools/normalize.css/-/normalize.css-10.1.0.tgz#f0950bba18819512d42f7197e56c518aa491cf18" integrity sha512-ij4wRiunFfaJxjB0BdrYHIH8FxBJpOwNPhhAcunlmPdXudL1WQV1qoP9un6JsEBAgQH+7UXyyjh0g7jTxXK6tg== -"@emotion/babel-plugin@^11.0.0": - version "11.1.2" - resolved "https://registry.yarnpkg.com/@emotion/babel-plugin/-/babel-plugin-11.1.2.tgz#68fe1aa3130099161036858c64ee92056c6730b7" - integrity sha512-Nz1k7b11dWw8Nw4Z1R99A9mlB6C6rRsCtZnwNUOj4NsoZdrO2f2A/83ST7htJORD5zpOiLKY59aJN23092949w== - dependencies: - "@babel/helper-module-imports" "^7.7.0" - "@babel/plugin-syntax-jsx" "^7.12.1" - "@babel/runtime" "^7.7.2" - "@emotion/hash" "^0.8.0" - "@emotion/memoize" "^0.7.5" - "@emotion/serialize" "^1.0.0" - babel-plugin-macros "^2.6.1" - convert-source-map "^1.5.0" - escape-string-regexp "^4.0.0" - find-root "^1.1.0" - source-map "^0.5.7" - stylis "^4.0.3" - "@emotion/cache@^11.0.0", "@emotion/cache@^11.1.3": version "11.1.3" resolved "https://registry.yarnpkg.com/@emotion/cache/-/cache-11.1.3.tgz#c7683a9484bcd38d5562f2b9947873cf66829afd" @@ -1905,31 +1257,20 @@ "@emotion/weak-memoize" "^0.2.5" stylis "^4.0.3" -"@emotion/css@^11.0.0": - version "11.1.3" - resolved "https://registry.yarnpkg.com/@emotion/css/-/css-11.1.3.tgz#9ed44478b19e5d281ccbbd46d74d123d59be793f" - integrity sha512-RSQP59qtCNTf5NWD6xM08xsQdCZmVYnX/panPYvB6LQAPKQB6GL49Njf0EMbS3CyDtrlWsBcmqBtysFvfWT3rA== - dependencies: - "@emotion/babel-plugin" "^11.0.0" - "@emotion/cache" "^11.1.3" - "@emotion/serialize" "^1.0.0" - "@emotion/sheet" "^1.0.0" - "@emotion/utils" "^1.0.0" - "@emotion/hash@^0.8.0": version "0.8.0" resolved "https://registry.yarnpkg.com/@emotion/hash/-/hash-0.8.0.tgz#bbbff68978fefdbe68ccb533bc8cbe1d1afb5413" integrity sha512-kBJtf7PH6aWwZ6fka3zQ0p6SBYzx4fl1LoZXE2RrnYST9Xljm7WfKJrU4g/Xr3Beg72MLrp1AWNUmuYJTL7Cow== -"@emotion/memoize@^0.7.4", "@emotion/memoize@^0.7.5": +"@emotion/memoize@^0.7.4": version "0.7.5" resolved "https://registry.yarnpkg.com/@emotion/memoize/-/memoize-0.7.5.tgz#2c40f81449a4e554e9fc6396910ed4843ec2be50" integrity sha512-igX9a37DR2ZPGYtV6suZ6whr8pTFtyHL3K/oLUotxpSVO2ASaprmAe2Dkq7tBo7CRY7MMDrAa9nuQP9/YG8FxQ== "@emotion/react@^11.1.1": - version "11.1.4" - resolved "https://registry.yarnpkg.com/@emotion/react/-/react-11.1.4.tgz#ddee4247627ff7dd7d0c6ae52f1cfd6b420357d2" - integrity sha512-9gkhrW8UjV4IGRnEe4/aGPkUxoGS23aD9Vu6JCGfEDyBYL+nGkkRBoMFGAzCT9qFdyUvQp4UUtErbKWxq/JS4A== + version "11.1.5" + resolved "https://registry.yarnpkg.com/@emotion/react/-/react-11.1.5.tgz#15e78f9822894cdc296e6f4e0688bac8120dfe66" + integrity sha512-xfnZ9NJEv9SU9K2sxXM06lzjK245xSeHRpUh67eARBm3PBHjjKIZlfWZ7UQvD0Obvw6ZKjlC79uHrlzFYpOB/Q== dependencies: "@babel/runtime" "^7.7.2" "@emotion/cache" "^11.1.3" @@ -1940,9 +1281,9 @@ hoist-non-react-statics "^3.3.1" "@emotion/serialize@^1.0.0": - version "1.0.0" - resolved "https://registry.yarnpkg.com/@emotion/serialize/-/serialize-1.0.0.tgz#1a61f4f037cf39995c97fc80ebe99abc7b191ca9" - integrity sha512-zt1gm4rhdo5Sry8QpCOpopIUIKU+mUSpV9WNmFILUraatm5dttNEaYzUWWSboSMUE6PtN2j1cAsuvcugfdI3mw== + version "1.0.1" + resolved "https://registry.yarnpkg.com/@emotion/serialize/-/serialize-1.0.1.tgz#322cdebfdbb5a88946f17006548191859b9b0855" + integrity sha512-TXlKs5sgUKhFlszp/rg4lIAZd7UUSmJpwaf9/lAEFcUh2vPi32i7x4wk7O8TN8L8v2Ol8k0CxnhRBY0zQalTxA== dependencies: "@emotion/hash" "^0.8.0" "@emotion/memoize" "^0.7.4" @@ -1980,10 +1321,10 @@ ts-node "^9" tslib "^2" -"@eslint/eslintrc@^0.2.1": - version "0.2.1" - resolved "https://registry.yarnpkg.com/@eslint/eslintrc/-/eslintrc-0.2.1.tgz#f72069c330461a06684d119384435e12a5d76e3c" - integrity sha512-XRUeBZ5zBWLYgSANMpThFddrZZkEbGHgUdt5UJjZfnlN9BGCiUBrf+nvbRupSjMvqzwnQN0qwCmOxITt1cfywA== +"@eslint/eslintrc@^0.4.0": + version "0.4.0" + resolved "https://registry.yarnpkg.com/@eslint/eslintrc/-/eslintrc-0.4.0.tgz#99cc0a0584d72f1df38b900fb062ba995f395547" + integrity sha512-2ZPCc+uNbjV5ERJr+aKSPRwZgKd2z11x0EgLvb1PURmUrn9QNRXFqje0Ldq454PfAVyaJYyrDvvIKSFP4NnBog== dependencies: ajv "^6.12.4" debug "^4.1.1" @@ -1992,170 +1333,135 @@ ignore "^4.0.6" import-fresh "^3.2.1" js-yaml "^3.13.1" - lodash "^4.17.19" minimatch "^3.0.4" strip-json-comments "^3.1.1" -"@eslint/eslintrc@^0.3.0": - version "0.3.0" - resolved "https://registry.yarnpkg.com/@eslint/eslintrc/-/eslintrc-0.3.0.tgz#d736d6963d7003b6514e6324bec9c602ac340318" - integrity sha512-1JTKgrOKAHVivSvOYw+sJOunkBjUOvjqWk1DPja7ZFhIS2mX/4EgTT8M7eTK9jrKhL/FvXXEbQwIs3pg1xp3dg== - dependencies: - ajv "^6.12.4" - debug "^4.1.1" - espree "^7.3.0" - globals "^12.1.0" - ignore "^4.0.6" - import-fresh "^3.2.1" - js-yaml "^3.13.1" - lodash "^4.17.20" - minimatch "^3.0.4" - strip-json-comments "^3.1.1" - -"@formatjs/ecma402-abstract@1.5.1": - version "1.5.1" - resolved "https://registry.yarnpkg.com/@formatjs/ecma402-abstract/-/ecma402-abstract-1.5.1.tgz#629324d2bfdc570ed210fec7700ce20bbd872bed" - integrity sha512-io9XhgIpEbc6jSdn4QVnJeFaUzy6gS5fGiIRCUJ7QKqCNp69JS8EJPW8gCtvwz+JQtx2SJvhaMJbzz3rGkTXBA== +"@formatjs/ecma402-abstract@1.4.0": + version "1.4.0" + resolved "https://registry.yarnpkg.com/@formatjs/ecma402-abstract/-/ecma402-abstract-1.4.0.tgz#ac6c17a8fffac43c6d68c849a7b732626d32654c" + integrity sha512-Mv027hcLFjE45K8UJ8PjRpdDGfR0aManEFj1KzoN8zXNveHGEygpZGfFf/FTTMl+QEVSrPAUlyxaCApvmv47AQ== dependencies: tslib "^2.0.1" -"@formatjs/ecma402-abstract@1.5.2": - version "1.5.2" - resolved "https://registry.yarnpkg.com/@formatjs/ecma402-abstract/-/ecma402-abstract-1.5.2.tgz#6c20c24f814ebf8e9dd46e34310a67895853a931" - integrity sha512-rscxoLyIwH2x+l15Z4eD580ioO3CkFVoWDLgDtgiOnWzDzpL5EigDRg9V4mINb8W6bQRT1xnCxiRwvw3bgvqrA== +"@formatjs/ecma402-abstract@1.5.0": + version "1.5.0" + resolved "https://registry.yarnpkg.com/@formatjs/ecma402-abstract/-/ecma402-abstract-1.5.0.tgz#759c8f11ff45e96f8fb58741e7fbdb41096d5ddd" + integrity sha512-wXv36yo+mfWllweN0Fq7sUs7PUiNopn7I0JpLTe3hGu6ZMR4CV7LqK1llhB18pndwpKoafQKb1et2DCJAOW20Q== dependencies: tslib "^2.0.1" -"@formatjs/ecma402-abstract@^1.2.6": - version "1.2.6" - resolved "https://registry.yarnpkg.com/@formatjs/ecma402-abstract/-/ecma402-abstract-1.2.6.tgz#8929525dcb49af31f348fcb4789263743872b57f" - integrity sha512-5/wUWuOFEaZeHhDvc+xgkFZxjYcij9/CjyUHngTfWhx+NwYqo8/xusVYv9SnTYDHAahek3OSCP93tzcv4M+7Xw== +"@formatjs/ecma402-abstract@1.6.3": + version "1.6.3" + resolved "https://registry.yarnpkg.com/@formatjs/ecma402-abstract/-/ecma402-abstract-1.6.3.tgz#f82bd2cf3aa8aaa0f12f9339902942b8d4b96912" + integrity sha512-7ijswObmYXabVy5GvcpKG29jbyJ9rGtFdRBdmdQvoDmMo0PwlOl/L08GtrjA4YWLAZ0j2owb2YrRLGNAvLBk+Q== dependencies: - tslib "^2.0.1" + tslib "^2.1.0" -"@formatjs/intl-datetimeformat@3.2.4": - version "3.2.4" - resolved "https://registry.yarnpkg.com/@formatjs/intl-datetimeformat/-/intl-datetimeformat-3.2.4.tgz#082df22e68b065b9bf297bfa25b6692640af2044" - integrity sha512-gcwO+GitSavAixx7Q6qB8CQY8k4ioVSe2y6VaBiv7fMCCRMHjNzDRXXBe87Nikux4va2V25APPX7bR6+h9g4Zw== +"@formatjs/intl-displaynames@4.0.11": + version "4.0.11" + resolved "https://registry.yarnpkg.com/@formatjs/intl-displaynames/-/intl-displaynames-4.0.11.tgz#7872625234c15f6e9ab91473a6de1ab26def1fda" + integrity sha512-e3917+HmXStxb2fNP3sOr3R1DMALdWrUteBb3nerA2AKa12mXwmL0lDavrdltwZWqF7/Egh8fF/esB0Z/fqOgQ== dependencies: - "@formatjs/ecma402-abstract" "1.5.1" - tslib "^2.0.1" + "@formatjs/ecma402-abstract" "1.6.3" + tslib "^2.1.0" -"@formatjs/intl-displaynames@4.0.4": - version "4.0.4" - resolved "https://registry.yarnpkg.com/@formatjs/intl-displaynames/-/intl-displaynames-4.0.4.tgz#a3ef3d243a1cc6df51128ca3faa208969ab5fe57" - integrity sha512-oNeLM0vZDFNZSqrz70XhxbMGtjfQ7T/UUcA9K4DvjWX6vmgCbpw5rdwEddhTotY3EmTyUJueK+14e2gIwfCbBA== - dependencies: - "@formatjs/ecma402-abstract" "1.5.1" - tslib "^2.0.1" - -"@formatjs/intl-getcanonicallocales@1.5.3", "@formatjs/intl-getcanonicallocales@^1.5.3": - version "1.5.3" - resolved "https://registry.yarnpkg.com/@formatjs/intl-getcanonicallocales/-/intl-getcanonicallocales-1.5.3.tgz#b5978462340da1502502c3fde1c4abccff8f3b8e" - integrity sha512-QVBnSPZ32Y80wkXbf36hP9VbyklbOb8edppxFcgO9Lbd47zagllw65Y81QOHEn/j11JcTn2OhW0vea95LHvQmA== +"@formatjs/intl-getcanonicallocales@1.5.7", "@formatjs/intl-getcanonicallocales@^1.5.3": + version "1.5.7" + resolved "https://registry.yarnpkg.com/@formatjs/intl-getcanonicallocales/-/intl-getcanonicallocales-1.5.7.tgz#ec59cf2bcf54ab56a133cc6c277612b1535adfdd" + integrity sha512-raPV3Dw7CBC9kPvKdgxkVGgwzYBsQDDG9qXGWblpj/zR+ZJ6Q2V+Co5jZhrviy6lq3qaM2T1Itc0ibvvil1tBw== dependencies: cldr-core "38" - tslib "^2.0.1" + tslib "^2.1.0" -"@formatjs/intl-listformat@5.0.4": - version "5.0.4" - resolved "https://registry.yarnpkg.com/@formatjs/intl-listformat/-/intl-listformat-5.0.4.tgz#32b43257a4757ceab93d469c94f0fd067b302668" - integrity sha512-0DQ2NF1PmO3+mvZp4V/SPNk7kUaLDcZR3eWbN8cGvSafWOrcv1iEcTXOd8ow8u9OA0gBTWwgPDcQFn7W0mU8kw== +"@formatjs/intl-listformat@5.0.12": + version "5.0.12" + resolved "https://registry.yarnpkg.com/@formatjs/intl-listformat/-/intl-listformat-5.0.12.tgz#da0daa1988bc753be915e5361b7c237a3bca314e" + integrity sha512-xWAndG73lqJ1+ar6SljCpM9nUsi2YoZfKi45F2YZRSxtUx4JbWYkhpbroOwxjCQ8ppZFoPc2mlLZjhPZiTyG7g== dependencies: - "@formatjs/ecma402-abstract" "1.5.1" - tslib "^2.0.1" + "@formatjs/ecma402-abstract" "1.6.3" + tslib "^2.1.0" "@formatjs/intl-locale@^2.4.14": - version "2.4.14" - resolved "https://registry.yarnpkg.com/@formatjs/intl-locale/-/intl-locale-2.4.14.tgz#9852678ee1ba3214e75f2e21fd0010d06e998d93" - integrity sha512-BWjAx+1kiN2VvQvx2L41cv8gr40mBDA78PKhVKLq+cPeAp8lwMmnGWUYr1sUXNew31N1acb6fqNJUD5sBGB/wQ== + version "2.4.20" + resolved "https://registry.yarnpkg.com/@formatjs/intl-locale/-/intl-locale-2.4.20.tgz#c86c4427b1b626ae622fbccbfc59bed67b026125" + integrity sha512-ZrVFxKab+W6jFP6WEYsNW0b7IlGYnCS20fdLN6u0LwPCPYRP5oqHBl0FFVD2+aNnQ1T/21Aol54fCr5LdN/49Q== dependencies: - "@formatjs/ecma402-abstract" "1.5.2" - "@formatjs/intl-getcanonicallocales" "1.5.3" + "@formatjs/ecma402-abstract" "1.6.3" + "@formatjs/intl-getcanonicallocales" "1.5.7" cldr-core "38" - tslib "^2.0.1" + tslib "^2.1.0" "@formatjs/intl-numberformat@^5.5.2": - version "5.7.1" - resolved "https://registry.yarnpkg.com/@formatjs/intl-numberformat/-/intl-numberformat-5.7.1.tgz#9f0ad165ee8f9aa4aaec6a0268dd4523922a6fc0" - integrity sha512-wVkzeqIAxfibB7zekX4xJbHrVfqy6zik2xd4f0zhD4UAn/JfxSin4nFfY35VNr7R0ZtvPqrKBnbddEpyXRF+Zw== + version "5.7.6" + resolved "https://registry.yarnpkg.com/@formatjs/intl-numberformat/-/intl-numberformat-5.7.6.tgz#630206bb0acefd2d508ccf4f82367c6875cad611" + integrity sha512-ZlZfYtvbVHYZY5OG3RXizoCwxKxEKOrzEe2YOw9wbzoxF3PmFn0SAgojCFGLyNXkkR6xVxlylhbuOPf1dkIVNg== dependencies: - "@formatjs/ecma402-abstract" "^1.2.6" + "@formatjs/ecma402-abstract" "1.4.0" tslib "^2.0.1" "@formatjs/intl-numberformat@^6.1.3": - version "6.1.3" - resolved "https://registry.yarnpkg.com/@formatjs/intl-numberformat/-/intl-numberformat-6.1.3.tgz#ce5f313a503c6ae90620fce26a84ff4b6db9abc2" - integrity sha512-bLVEt4G7IfacLpiBKg2VfHKnPrzWOWX/jxEi+OIUk6M2bEfYj8Vi1nYGb8+D7ZHCbuj/L7+lnlL4bItPTXT8JA== + version "6.2.4" + resolved "https://registry.yarnpkg.com/@formatjs/intl-numberformat/-/intl-numberformat-6.2.4.tgz#e0e71bdc0d5239851118c85cbe54a59ea977f2cb" + integrity sha512-C82K7GbR06jjJKendLEZKTdTAtgrSkehXS6bX9snOL/nY9BIQYEeFJY/VBFz228jVAFyTsYCiL5toKeyPsfKXA== dependencies: - "@formatjs/ecma402-abstract" "1.5.1" - tslib "^2.0.1" + "@formatjs/ecma402-abstract" "1.6.3" + tslib "^2.1.0" "@formatjs/intl-pluralrules@^4.0.6": - version "4.0.6" - resolved "https://registry.yarnpkg.com/@formatjs/intl-pluralrules/-/intl-pluralrules-4.0.6.tgz#bab69e68b122089daa39f57072978a560f955176" - integrity sha512-/7Hjg/7EiHuZq4zwd406UoX2w5KtUrLRj9SI8mPOkUpHHqruSskYuJYahKWW7rNytPRaoCLfsigoFS0CDHBjlg== + version "4.0.12" + resolved "https://registry.yarnpkg.com/@formatjs/intl-pluralrules/-/intl-pluralrules-4.0.12.tgz#acd0e5e79b12a29b8189faa645a6abf50a4d17d2" + integrity sha512-jXXsWGQbBMvuhvxuG1AXBMMNMS1ZphSt/rWsGo6bE3KyWmddJnnVokeUD8E2sTtXoCJZoGUQkOxxjFa/gGLyxw== dependencies: - "@formatjs/ecma402-abstract" "1.5.2" - tslib "^2.0.1" + "@formatjs/ecma402-abstract" "1.6.3" + tslib "^2.1.0" -"@formatjs/intl-relativetimeformat@8.0.3": - version "8.0.3" - resolved "https://registry.yarnpkg.com/@formatjs/intl-relativetimeformat/-/intl-relativetimeformat-8.0.3.tgz#614c681b64f90d7000f1bfddc86c1a9c3447ecc3" - integrity sha512-OIobPtY5vtwe5IM0B0J3KmewYB/NTcbgiW9yRdWzMA1TeFSd8LfuficICYuzUZt25Kh/eIw4g37ArhS1WH/6Iw== +"@formatjs/intl@1.8.4": + version "1.8.4" + resolved "https://registry.yarnpkg.com/@formatjs/intl/-/intl-1.8.4.tgz#66418092611777f050ab99ba5fe66b89dbcbd846" + integrity sha512-m0/5ZRQZZfzXmeDieoG8kxu3QRvJazv2VbXhROs5khJKfUKu1rz6xfuUrh3gkmydWYtHuwJDIoC9oGR0ik4+/g== dependencies: - "@formatjs/ecma402-abstract" "1.5.1" - tslib "^2.0.1" - -"@formatjs/intl@1.6.1": - version "1.6.1" - resolved "https://registry.yarnpkg.com/@formatjs/intl/-/intl-1.6.1.tgz#6f6de1650136feb48c475409e7d77769ae851934" - integrity sha512-911RCkqyZuwbihUT98qBfVNsEmukdf5Y4DIVqAx+BGIyyPNUeS+JXpKx6M8awXx2L9UbeTHKKhjJbNzZlVFO+w== - dependencies: - "@formatjs/ecma402-abstract" "1.5.1" - "@formatjs/intl-datetimeformat" "3.2.4" - "@formatjs/intl-displaynames" "4.0.4" - "@formatjs/intl-listformat" "5.0.4" - "@formatjs/intl-relativetimeformat" "8.0.3" + "@formatjs/ecma402-abstract" "1.6.3" + "@formatjs/intl-displaynames" "4.0.11" + "@formatjs/intl-listformat" "5.0.12" fast-memoize "^2.5.2" - intl-messageformat "9.4.3" - intl-messageformat-parser "6.1.3" - tslib "^2.0.1" + intl-messageformat "9.5.3" + intl-messageformat-parser "6.4.3" + tslib "^2.1.0" "@formatjs/ts-transformer@^2.6.0": - version "2.12.0" - resolved "https://registry.yarnpkg.com/@formatjs/ts-transformer/-/ts-transformer-2.12.0.tgz#71bb3d1af3e818996211b36231d1b753859f46f9" - integrity sha512-bi7XVicXTPHMEK4vapp29wJQnBHt9IrNW5QREiBPR5NF52EGvuVQeLmfU17nnojnHf4+X0PoamW7Sr0WPTtmWA== + version "2.13.0" + resolved "https://registry.yarnpkg.com/@formatjs/ts-transformer/-/ts-transformer-2.13.0.tgz#df47b35cdd209269d282a411f1646e0498aa8fdc" + integrity sha512-mu7sHXZk1NWZrQ3eUqugpSYo8x5/tXkrI4uIbFqCEC0eNgQaIcoKgVeDFgDAcgG+cEme2atAUYSFF+DFWC4org== dependencies: - intl-messageformat-parser "^6.0.11" + intl-messageformat-parser "6.1.2" tslib "^2.0.1" typescript "^4.0" -"@fortawesome/fontawesome-common-types@^0.2.34": - version "0.2.34" - resolved "https://registry.yarnpkg.com/@fortawesome/fontawesome-common-types/-/fontawesome-common-types-0.2.34.tgz#0a8c348bb23b7b760030f5b1d912e582be4ec915" - integrity sha512-XcIn3iYbTEzGIxD0/dY5+4f019jIcEIWBiHc3KrmK/ROahwxmZ/s+tdj97p/5K0klz4zZUiMfUlYP0ajhSJjmA== +"@fortawesome/fontawesome-common-types@^0.2.35": + version "0.2.35" + resolved "https://registry.yarnpkg.com/@fortawesome/fontawesome-common-types/-/fontawesome-common-types-0.2.35.tgz#01dd3d054da07a00b764d78748df20daf2b317e9" + integrity sha512-IHUfxSEDS9dDGqYwIW7wTN6tn/O8E0n5PcAHz9cAaBoZw6UpG20IG/YM3NNLaGPwPqgjBAFjIURzqoQs3rrtuw== "@fortawesome/fontawesome-svg-core@^1.2.34": - version "1.2.34" - resolved "https://registry.yarnpkg.com/@fortawesome/fontawesome-svg-core/-/fontawesome-svg-core-1.2.34.tgz#1d1a7c92537cbc2b8a83eef6b6d824b4b5b46b26" - integrity sha512-0KNN0nc5eIzaJxlv43QcDmTkDY1CqeN6J7OCGSs+fwGPdtv0yOQqRjieopBCmw+yd7uD3N2HeNL3Zm5isDleLg== + version "1.2.35" + resolved "https://registry.yarnpkg.com/@fortawesome/fontawesome-svg-core/-/fontawesome-svg-core-1.2.35.tgz#85aea8c25645fcec88d35f2eb1045c38d3e65cff" + integrity sha512-uLEXifXIL7hnh2sNZQrIJWNol7cTVIzwI+4qcBIq9QWaZqUblm0IDrtSqbNg+3SQf8SMGHkiSigD++rHmCHjBg== dependencies: - "@fortawesome/fontawesome-common-types" "^0.2.34" + "@fortawesome/fontawesome-common-types" "^0.2.35" "@fortawesome/free-regular-svg-icons@^5.15.2": - version "5.15.2" - resolved "https://registry.yarnpkg.com/@fortawesome/free-regular-svg-icons/-/free-regular-svg-icons-5.15.2.tgz#61eeb8c206e792c530eaa58279cc32c55332fe8f" - integrity sha512-Uv5NQCYjyisNVTu/1Xjs+z8vwQjbfT6hiqYvQNfF0n8qdgfWLM581bAfVMQ3BCs1SPy+eEUKNcGkK4n0FihFHg== + version "5.15.3" + resolved "https://registry.yarnpkg.com/@fortawesome/free-regular-svg-icons/-/free-regular-svg-icons-5.15.3.tgz#1ec4f2410ff638db549c5c5484fc60b66407dbe6" + integrity sha512-q4/p8Xehy9qiVTdDWHL4Z+o5PCLRChePGZRTXkl+/Z7erDVL8VcZUuqzJjs6gUz6czss4VIPBRdCz6wP37/zMQ== dependencies: - "@fortawesome/fontawesome-common-types" "^0.2.34" + "@fortawesome/fontawesome-common-types" "^0.2.35" "@fortawesome/free-solid-svg-icons@^5.15.2": - version "5.15.2" - resolved "https://registry.yarnpkg.com/@fortawesome/free-solid-svg-icons/-/free-solid-svg-icons-5.15.2.tgz#25bb035de57cf85aee8072965732368ccc8e8943" - integrity sha512-ZfCU+QjaFsdNZmOGmfqEWhzI3JOe37x5dF4kz9GeXvKn/sTxhqMtZ7mh3lBf76SvcYY5/GKFuyG7p1r4iWMQqw== + version "5.15.3" + resolved "https://registry.yarnpkg.com/@fortawesome/free-solid-svg-icons/-/free-solid-svg-icons-5.15.3.tgz#52eebe354f60dc77e0bde934ffc5c75ffd04f9d8" + integrity sha512-XPeeu1IlGYqz4VWGRAT5ukNMd4VHUEEJ7ysZ7pSSgaEtNvSo+FLurybGJVmiqkQdK50OkSja2bfZXOeyMGRD8Q== dependencies: - "@fortawesome/fontawesome-common-types" "^0.2.34" + "@fortawesome/fontawesome-common-types" "^0.2.35" "@fortawesome/react-fontawesome@^0.1.14": version "0.1.14" @@ -2173,12 +1479,12 @@ tslib "~2.0.1" "@graphql-codegen/cli@^1.20.0": - version "1.20.0" - resolved "https://registry.yarnpkg.com/@graphql-codegen/cli/-/cli-1.20.0.tgz#e1bb62fce07caaf1395ca6e94ffc0f2ba1f57938" - integrity sha512-5pLtZoaqEmEui6PR7IArmD23VLD3++UQby6iNe4NFG4eMcRai2raIM0E4a/MSn7SjyfSRguekYMMC5JKS1VgQw== + version "1.21.3" + resolved "https://registry.yarnpkg.com/@graphql-codegen/cli/-/cli-1.21.3.tgz#3506c5d019c6995be1927bd4d9c67a739fafe5e6" + integrity sha512-jwg0mKhseg0QI4/T4IQcttTBCZgnahiTWqnYWIK+E8nrbXCE9o2hxvaYin/Kq9+5oFtxDePED56cjVs/ESRw6g== dependencies: "@graphql-codegen/core" "1.17.9" - "@graphql-codegen/plugin-helpers" "^1.18.2" + "@graphql-codegen/plugin-helpers" "^1.18.4" "@graphql-tools/apollo-engine-loader" "^6" "@graphql-tools/code-file-loader" "^6" "@graphql-tools/git-loader" "^6" @@ -2190,14 +1496,13 @@ "@graphql-tools/url-loader" "^6" "@graphql-tools/utils" "^7.0.0" ansi-escapes "^4.3.1" - camel-case "^4.1.2" chalk "^4.1.0" + change-case-all "1.0.12" chokidar "^3.4.3" common-tags "^1.8.0" - constant-case "^3.0.3" cosmiconfig "^7.0.0" debounce "^1.2.0" - dependency-graph "^0.9.0" + dependency-graph "^0.11.0" detect-indent "^6.0.0" glob "^7.1.6" graphql-config "^3.2.0" @@ -2209,15 +1514,11 @@ listr "^0.14.3" listr-update-renderer "^0.5.0" log-symbols "^4.0.0" - lower-case "^2.0.1" minimatch "^3.0.4" mkdirp "^1.0.4" - pascal-case "^3.1.1" - request "^2.88.2" string-env-interpolation "^1.0.1" ts-log "^2.2.3" - tslib "~2.0.1" - upper-case "^2.0.2" + tslib "~2.1.0" valid-url "^1.0.9" wrap-ansi "^7.0.0" yaml "^1.10.0" @@ -2233,22 +1534,16 @@ "@graphql-tools/utils" "^6" tslib "~2.0.1" -"@graphql-codegen/plugin-helpers@^1.18.2": - version "1.18.2" - resolved "https://registry.yarnpkg.com/@graphql-codegen/plugin-helpers/-/plugin-helpers-1.18.2.tgz#57011076cb8b8f5d04d37d226a5eda300c01be94" - integrity sha512-SvX+Ryq2naLcoD6jJNxtzc/moWTgHJ+X0KRfvhGWTa+xtFTS02i+PWOR89YYPcD8+LF6GmyFBjx2FmLCx4JwMg== +"@graphql-codegen/plugin-helpers@^1.18.2", "@graphql-codegen/plugin-helpers@^1.18.3", "@graphql-codegen/plugin-helpers@^1.18.4": + version "1.18.4" + resolved "https://registry.yarnpkg.com/@graphql-codegen/plugin-helpers/-/plugin-helpers-1.18.4.tgz#0adc4c0f88386a50b7a69d358080b6ee54fc3b16" + integrity sha512-dpfhUmn9GOS8ByoOPIN3V4Nn9HX7sl9NR7Hf26TgN6Clg7cQvkT6XjHdS2e56Q3kWrxZT1zJ1sEa67D3tj9ZtQ== dependencies: - "@graphql-tools/utils" "^6" - camel-case "4.1.1" + "@graphql-tools/utils" "^7.0.0" common-tags "1.8.0" - constant-case "3.0.3" import-from "3.0.0" lodash "~4.17.20" - lower-case "2.0.1" - param-case "3.0.3" - pascal-case "3.1.1" - tslib "~2.0.1" - upper-case "2.0.1" + tslib "~2.1.0" "@graphql-codegen/time@^2.0.2": version "2.0.2" @@ -2259,53 +1554,52 @@ moment "~2.29.1" "@graphql-codegen/typescript-operations@^1.17.13": - version "1.17.13" - resolved "https://registry.yarnpkg.com/@graphql-codegen/typescript-operations/-/typescript-operations-1.17.13.tgz#a5b08c1573b9507ca5a9e66e795aecc40ddc5305" - integrity sha512-Wm/S4pmPy+KPvFVpygNwC4pd9zKtGIwnS+2rlMUBZVSpv4fxjX2YDvYHP/gucck+SiS0RRxB7hW65bTwCns46g== + version "1.17.15" + resolved "https://registry.yarnpkg.com/@graphql-codegen/typescript-operations/-/typescript-operations-1.17.15.tgz#c992e29ead1cf5c3f65dbbe1f522b09be30c36d6" + integrity sha512-HStWj3mUe+0ir2J0jqgjegrvcO1DIe2gzsoBBo9RHIYwyaxedUivxXvWY9XBfKpHv6sLa/ST1iYGeedrJELPtw== dependencies: - "@graphql-codegen/plugin-helpers" "^1.18.2" - "@graphql-codegen/typescript" "^1.18.1" - "@graphql-codegen/visitor-plugin-common" "^1.17.22" + "@graphql-codegen/plugin-helpers" "^1.18.3" + "@graphql-codegen/typescript" "^1.21.1" + "@graphql-codegen/visitor-plugin-common" "^1.19.0" auto-bind "~4.0.0" - tslib "~2.0.1" + tslib "~2.1.0" "@graphql-codegen/typescript-react-apollo@^2.2.1": - version "2.2.1" - resolved "https://registry.yarnpkg.com/@graphql-codegen/typescript-react-apollo/-/typescript-react-apollo-2.2.1.tgz#4791e2b47478d8c1d5c9f8a4460156176cb185bc" - integrity sha512-xOOB0avE6w7UpuEZ56eNGkD5zek1+Z2x2/F9/f4AU2dUiRNbNmpYOzuiOg4nz9eA5DBXX7fS4TkLwyqJ3sF77A== + version "2.2.3" + resolved "https://registry.yarnpkg.com/@graphql-codegen/typescript-react-apollo/-/typescript-react-apollo-2.2.3.tgz#d5f54e81235d29bb57daeeda4dd58500c4f9d1e4" + integrity sha512-6OjDJQfVwMDEqQkgoQ3Uixq3KRb0H+lSwcWWFmdWErYhe5k2F8niBUJ32FEwu0KIqqx3PhzdwmLcXpBTC5gtyg== dependencies: - "@graphql-codegen/plugin-helpers" "^1.18.2" - "@graphql-codegen/visitor-plugin-common" "^1.17.20" + "@graphql-codegen/plugin-helpers" "^1.18.4" + "@graphql-codegen/visitor-plugin-common" "^1.19.1" auto-bind "~4.0.0" - camel-case "^4.1.1" - pascal-case "^3.1.1" - tslib "~2.0.1" + change-case-all "1.0.12" + tslib "~2.1.0" -"@graphql-codegen/typescript@^1.18.1", "@graphql-codegen/typescript@^1.20.00": - version "1.20.0" - resolved "https://registry.yarnpkg.com/@graphql-codegen/typescript/-/typescript-1.20.0.tgz#f9a17b869e5691276965a56c7a1efe4eb938b6e7" - integrity sha512-7xW+n0USNpr6iZ4Et17ZbPzBLNe/LrSgrQ6V/8Mlgp1reQWAZtoVw13Oq4GnxHCzAYio8nFindLl+emW9ZBeew== +"@graphql-codegen/typescript@^1.20.00", "@graphql-codegen/typescript@^1.21.1": + version "1.21.1" + resolved "https://registry.yarnpkg.com/@graphql-codegen/typescript/-/typescript-1.21.1.tgz#9bce3254b8ef30a6bf64e57ba3991f9be7a19b53" + integrity sha512-JF6Vsu5HSv3dAoS2ca3PFLUN0qVxotex/+BgWw/6SKhtd83MUPnzJ/RU3lACg4vuNTCWeQSeGvg8x5qrw9Go9w== dependencies: - "@graphql-codegen/plugin-helpers" "^1.18.2" - "@graphql-codegen/visitor-plugin-common" "^1.18.0" + "@graphql-codegen/plugin-helpers" "^1.18.3" + "@graphql-codegen/visitor-plugin-common" "^1.19.0" auto-bind "~4.0.0" - tslib "~2.0.1" + tslib "~2.1.0" -"@graphql-codegen/visitor-plugin-common@^1.17.20", "@graphql-codegen/visitor-plugin-common@^1.17.22", "@graphql-codegen/visitor-plugin-common@^1.18.0": - version "1.18.0" - resolved "https://registry.yarnpkg.com/@graphql-codegen/visitor-plugin-common/-/visitor-plugin-common-1.18.0.tgz#f4366ec1093c01e752e85f8bd30d09c58b1d6951" - integrity sha512-OR/Cm9nVaqMLe94Ski60UDhaAf/4+QeV/CQRYrCmxFzEsm03U41VplcNgJBXIAB3EgP/LEIZtgkRc1H4N9u9+Q== +"@graphql-codegen/visitor-plugin-common@^1.19.0", "@graphql-codegen/visitor-plugin-common@^1.19.1": + version "1.19.1" + resolved "https://registry.yarnpkg.com/@graphql-codegen/visitor-plugin-common/-/visitor-plugin-common-1.19.1.tgz#2584588351a343a2ad8cd76dde62eb2ec25abe18" + integrity sha512-MJZXe5vXxV6PLOgHhQoz93gnjzJtbnVQXQKqVEcbyB9W8ImoKuTHsEf/eJ6yCL79f7X/2dnOOM84d5Osh1eaKg== dependencies: - "@graphql-codegen/plugin-helpers" "^1.18.2" + "@graphql-codegen/plugin-helpers" "^1.18.4" "@graphql-tools/optimize" "^1.0.1" "@graphql-tools/relay-operation-optimizer" "^6" array.prototype.flatmap "^1.2.4" auto-bind "~4.0.0" - dependency-graph "^0.9.0" + change-case-all "1.0.12" + dependency-graph "^0.11.0" graphql-tag "^2.11.0" parse-filepath "^1.0.2" - pascal-case "^3.1.1" - tslib "~2.0.1" + tslib "~2.1.0" "@graphql-tools/apollo-engine-loader@^6": version "6.2.5" @@ -2327,36 +1621,35 @@ tslib "~2.0.1" "@graphql-tools/code-file-loader@^6": - version "6.2.5" - resolved "https://registry.yarnpkg.com/@graphql-tools/code-file-loader/-/code-file-loader-6.2.5.tgz#02832503e96c6c537083570208bd55ca1fbfaa68" - integrity sha512-KMy8c/I4NeQZUI9InydR14qP1pqPeJfgVJLri0RgJRWDiLAj/nIb2oDioN9AgBX3XYNijJT+pH0//B5EOO0BiA== + version "6.3.1" + resolved "https://registry.yarnpkg.com/@graphql-tools/code-file-loader/-/code-file-loader-6.3.1.tgz#42dfd4db5b968acdb453382f172ec684fa0c34ed" + integrity sha512-ZJimcm2ig+avgsEOWWVvAaxZrXXhiiSZyYYOJi0hk9wh5BxZcLUNKkTp6EFnZE/jmGUwuos3pIjUD3Hwi3Bwhg== dependencies: - "@graphql-tools/graphql-tag-pluck" "^6.2.6" + "@graphql-tools/graphql-tag-pluck" "^6.5.1" "@graphql-tools/utils" "^7.0.0" - fs-extra "9.0.1" - tslib "~2.0.1" + tslib "~2.1.0" -"@graphql-tools/delegate@^7.0.0", "@graphql-tools/delegate@^7.0.1": - version "7.0.2" - resolved "https://registry.yarnpkg.com/@graphql-tools/delegate/-/delegate-7.0.2.tgz#f933254727173bab4f6e0e13b2d8d4500eadf426" - integrity sha512-AkNkg8w966+cHEVNddYUPeuPybNvbnkL+iFA8rrW5V3F8e6LHRep5MnFwNKD4JTae9HeKEs9Jx0UQDwnuCsZng== +"@graphql-tools/delegate@^7.0.1", "@graphql-tools/delegate@^7.0.7": + version "7.0.10" + resolved "https://registry.yarnpkg.com/@graphql-tools/delegate/-/delegate-7.0.10.tgz#f87ac85a2dbd03b5b3aabf347f4479fabe8ceac3" + integrity sha512-6Di9ia5ohoDvrHuhj2cak1nJGhIefJmUsd3WKZcJ2nu2yZAFawWMxGvQImqv3N7iyaWKiVhrrK8Roi/JrYhdKg== dependencies: "@ardatan/aggregate-error" "0.0.6" "@graphql-tools/batch-execute" "^7.0.0" "@graphql-tools/schema" "^7.0.0" - "@graphql-tools/utils" "^7.0.1" + "@graphql-tools/utils" "^7.1.6" dataloader "2.0.0" is-promise "4.0.0" - tslib "~2.0.1" + tslib "~2.1.0" "@graphql-tools/git-loader@^6": - version "6.2.5" - resolved "https://registry.yarnpkg.com/@graphql-tools/git-loader/-/git-loader-6.2.5.tgz#a4b3e8826964e1752a3d3a5a33a44b70b9694353" - integrity sha512-WOQDSzazyPZMZUvymHBv5oZ80/mS7tc8XUNy2GmU5My8YRny5zu4fEgP4vQeFcD1trG3uoHUaJPGF7Mmvp6Yhg== + version "6.2.6" + resolved "https://registry.yarnpkg.com/@graphql-tools/git-loader/-/git-loader-6.2.6.tgz#c2226f4b8f51f1c05c9ab2649ba32d49c68cd077" + integrity sha512-ooQTt2CaG47vEYPP3CPD+nbA0F+FYQXfzrB1Y1ABN9K3d3O2RK3g8qwslzZaI8VJQthvKwt0A95ZeE4XxteYfw== dependencies: "@graphql-tools/graphql-tag-pluck" "^6.2.6" "@graphql-tools/utils" "^7.0.0" - tslib "~2.0.1" + tslib "~2.1.0" "@graphql-tools/github-loader@^6": version "6.2.5" @@ -2369,79 +1662,65 @@ tslib "~2.0.1" "@graphql-tools/graphql-file-loader@^6", "@graphql-tools/graphql-file-loader@^6.0.0": - version "6.2.5" - resolved "https://registry.yarnpkg.com/@graphql-tools/graphql-file-loader/-/graphql-file-loader-6.2.5.tgz#831289675e5f446baa19afbc0af8ea6bc94063bf" - integrity sha512-vYDn71FHqwCxWgw8swoVOsD5C0xGz/Lw4zUQnPcgZfAzhAAwl6e/rVWl/HF1UNNSf5CSZu+2oidjOWCI5Wl6Gg== - dependencies: - "@graphql-tools/import" "^6.2.4" - "@graphql-tools/utils" "^7.0.0" - fs-extra "9.0.1" - tslib "~2.0.1" - -"@graphql-tools/graphql-tag-pluck@^6.2.6": - version "6.3.0" - resolved "https://registry.yarnpkg.com/@graphql-tools/graphql-tag-pluck/-/graphql-tag-pluck-6.3.0.tgz#b1c178fe6e8c4823ca611cf1392f530ad0490dd9" - integrity sha512-wdXE6iKTD/ePvhPaukhXm6M8FcsiR9rrwFvkYN96sx2UjDjXzU6vS1QUniNuwjRPaQuSe635vqfaUSN9JuNHvA== - dependencies: - "@babel/parser" "7.11.5" - "@babel/traverse" "7.12.1" - "@babel/types" "7.12.1" - "@graphql-tools/utils" "^7.0.0" - tslib "~2.0.1" - optionalDependencies: - vue-template-compiler "^2.6.12" - -"@graphql-tools/import@^6.2.4": - version "6.2.4" - resolved "https://registry.yarnpkg.com/@graphql-tools/import/-/import-6.2.4.tgz#0547f6d4754a924e80439d6af013577cdb617194" - integrity sha512-Q6fk6hbtDevoEVcgwb3WRn7XOqGY4MnX3Mvc+x8/b8k4RZ4wT+0WSLRDXGAKiVKRxGhgouU2lZVnGE/LDrGSCg== - dependencies: - fs-extra "9.0.1" - resolve-from "5.0.0" - tslib "~2.0.1" - -"@graphql-tools/json-file-loader@^6", "@graphql-tools/json-file-loader@^6.0.0": - version "6.2.5" - resolved "https://registry.yarnpkg.com/@graphql-tools/json-file-loader/-/json-file-loader-6.2.5.tgz#1357d2efd2f416f44e0dd717da06463c29adbf60" - integrity sha512-9LS7WuQdSHlRUvXD7ixt5aDpr3hWsueURHOaWe7T0xZ+KWMTw+LIRtWIliCRzbjNmZ+4ZhwHB3Vc1SO2bfYLgg== - dependencies: - "@graphql-tools/utils" "^7.0.0" - fs-extra "9.0.1" - tslib "~2.0.1" - -"@graphql-tools/load@^6", "@graphql-tools/load@^6.0.0": - version "6.2.5" - resolved "https://registry.yarnpkg.com/@graphql-tools/load/-/load-6.2.5.tgz#7dd0d34c8ce2cfb24f61c6beba2817d9afdd7f2b" - integrity sha512-TpDgp+id0hhD1iMhdFSgWgWumdI/IpFWwouJeaEhEEAEBkdvH4W9gbBiJBSbPQwMPRNWx8/AZtry0cYKLW4lHg== - dependencies: - "@graphql-tools/merge" "^6.2.5" - "@graphql-tools/utils" "^7.0.0" - globby "11.0.1" - import-from "3.0.0" - is-glob "4.0.1" - p-limit "3.0.2" - tslib "~2.0.1" - unixify "1.0.0" - valid-url "1.0.9" - -"@graphql-tools/merge@^6": version "6.2.7" - resolved "https://registry.yarnpkg.com/@graphql-tools/merge/-/merge-6.2.7.tgz#c389bfa405d8d7562a05f794ede4254875e67f75" - integrity sha512-9acgDkkYeAHpuqhOa3E63NZPCX/iWo819Q320sCCMkydF1xgx0qCRYz/V03xPdpQETKRqBG2i2N2csneeEYYig== + resolved "https://registry.yarnpkg.com/@graphql-tools/graphql-file-loader/-/graphql-file-loader-6.2.7.tgz#d3720f2c4f4bb90eb2a03a7869a780c61945e143" + integrity sha512-5k2SNz0W87tDcymhEMZMkd6/vs6QawDyjQXWtqkuLTBF3vxjxPD1I4dwHoxgWPIjjANhXybvulD7E+St/7s9TQ== dependencies: - "@graphql-tools/schema" "^7.0.0" + "@graphql-tools/import" "^6.2.6" "@graphql-tools/utils" "^7.0.0" tslib "~2.1.0" -"@graphql-tools/merge@^6.0.0", "@graphql-tools/merge@^6.2.5": - version "6.2.5" - resolved "https://registry.yarnpkg.com/@graphql-tools/merge/-/merge-6.2.5.tgz#a03d6711f2a468b8de97c0fe9092469280ca66c9" - integrity sha512-T2UEm7L5MeS1ggbGKBkdV9kTqLqSHQM13RrjPzIAYzkFL/mK837sf+oq8h2+R8B+senuHX8akUhMTcU85kcMvw== +"@graphql-tools/graphql-tag-pluck@^6.2.6", "@graphql-tools/graphql-tag-pluck@^6.5.1": + version "6.5.1" + resolved "https://registry.yarnpkg.com/@graphql-tools/graphql-tag-pluck/-/graphql-tag-pluck-6.5.1.tgz#5fb227dbb1e19f4b037792b50f646f16a2d4c686" + integrity sha512-7qkm82iFmcpb8M6/yRgzjShtW6Qu2OlCSZp8uatA3J0eMl87TxyJoUmL3M3UMMOSundAK8GmoyNVFUrueueV5Q== + dependencies: + "@babel/parser" "7.12.16" + "@babel/traverse" "7.12.13" + "@babel/types" "7.12.13" + "@graphql-tools/utils" "^7.0.0" + tslib "~2.1.0" + +"@graphql-tools/import@^6.2.6": + version "6.3.0" + resolved "https://registry.yarnpkg.com/@graphql-tools/import/-/import-6.3.0.tgz#171472b425ea7cba4a612ad524b96bd206ae71b6" + integrity sha512-zmaVhJ3UPjzJSb005Pjn2iWvH+9AYRXI4IUiTi14uPupiXppJP3s7S25Si3+DbHpFwurDF2nWRxBLiFPWudCqw== + dependencies: + resolve-from "5.0.0" + tslib "~2.1.0" + +"@graphql-tools/json-file-loader@^6", "@graphql-tools/json-file-loader@^6.0.0": + version "6.2.6" + resolved "https://registry.yarnpkg.com/@graphql-tools/json-file-loader/-/json-file-loader-6.2.6.tgz#830482cfd3721a0799cbf2fe5b09959d9332739a" + integrity sha512-CnfwBSY5926zyb6fkDBHnlTblHnHI4hoBALFYXnrg0Ev4yWU8B04DZl/pBRUc459VNgO2x8/mxGIZj2hPJG1EA== dependencies: - "@graphql-tools/schema" "^7.0.0" "@graphql-tools/utils" "^7.0.0" tslib "~2.0.1" +"@graphql-tools/load@^6", "@graphql-tools/load@^6.0.0": + version "6.2.7" + resolved "https://registry.yarnpkg.com/@graphql-tools/load/-/load-6.2.7.tgz#61f7909d37fb1c095e3e8d4f7a6d3b8bb011e26a" + integrity sha512-b1qWjki1y/QvGtoqW3x8bcwget7xmMfLGsvGFWOB6m38tDbzVT3GlJViAC0nGPDks9OCoJzAdi5IYEkBaqH5GQ== + dependencies: + "@graphql-tools/merge" "^6.2.9" + "@graphql-tools/utils" "^7.5.0" + globby "11.0.2" + import-from "3.0.0" + is-glob "4.0.1" + p-limit "3.1.0" + tslib "~2.1.0" + unixify "1.0.0" + valid-url "1.0.9" + +"@graphql-tools/merge@^6", "@graphql-tools/merge@^6.0.0", "@graphql-tools/merge@^6.2.9": + version "6.2.10" + resolved "https://registry.yarnpkg.com/@graphql-tools/merge/-/merge-6.2.10.tgz#cadb37b1bed786cba1b3c6f728c5476a164e153d" + integrity sha512-dM3n37PcslvhOAkCz7Cwk0BfoiSVKXGmCX+VMZkATbXk/0vlxUfNEpVfA5yF4IkP27F04SzFQSaNrbD0W2Rszw== + dependencies: + "@graphql-tools/schema" "^7.0.0" + "@graphql-tools/utils" "^7.5.0" + tslib "~2.1.0" + "@graphql-tools/optimize@^1.0.1": version "1.0.1" resolved "https://registry.yarnpkg.com/@graphql-tools/optimize/-/optimize-1.0.1.tgz#9933fffc5a3c63f95102b1cb6076fb16ac7bb22d" @@ -2450,66 +1729,71 @@ tslib "~2.0.1" "@graphql-tools/prisma-loader@^6": - version "6.2.5" - resolved "https://registry.yarnpkg.com/@graphql-tools/prisma-loader/-/prisma-loader-6.2.5.tgz#1de03e548ef2c0c301b2386e0e8f4a715036adde" - integrity sha512-Xm/cQMV0oKm9tlmz3kMS0G+IRVsC8fJuOmYWvTxc4GorJpMnKCnYu0L7JDSMRBp0Q9yLEbh1ticGEMvjozD4OA== + version "6.3.0" + resolved "https://registry.yarnpkg.com/@graphql-tools/prisma-loader/-/prisma-loader-6.3.0.tgz#c907e17751ff2b26e7c2bc75d0913ebf03f970da" + integrity sha512-9V3W/kzsFBmUQqOsd96V4a4k7Didz66yh/IK89B1/rrvy9rYj+ULjEqR73x9BYZ+ww9FV8yP8LasWAJwWaqqJQ== dependencies: - "@graphql-tools/url-loader" "^6.3.1" + "@graphql-tools/url-loader" "^6.8.2" "@graphql-tools/utils" "^7.0.0" "@types/http-proxy-agent" "^2.0.2" - "@types/js-yaml" "^3.12.5" + "@types/js-yaml" "^4.0.0" "@types/json-stable-stringify" "^1.0.32" "@types/jsonwebtoken" "^8.5.0" - ajv "^6.12.5" - bluebird "^3.7.2" chalk "^4.1.0" - debug "^4.2.0" + debug "^4.3.1" dotenv "^8.2.0" - fs-extra "9.0.1" - graphql-request "^3.2.0" + graphql-request "^3.3.0" http-proxy-agent "^4.0.1" https-proxy-agent "^5.0.0" isomorphic-fetch "^3.0.0" - js-yaml "^3.14.0" + js-yaml "^4.0.0" json-stable-stringify "^1.0.1" jsonwebtoken "^8.5.1" lodash "^4.17.20" replaceall "^0.1.6" scuid "^1.1.0" - tslib "~2.0.1" + tslib "~2.1.0" yaml-ast-parser "^0.0.43" "@graphql-tools/relay-operation-optimizer@^6": - version "6.2.5" - resolved "https://registry.yarnpkg.com/@graphql-tools/relay-operation-optimizer/-/relay-operation-optimizer-6.2.5.tgz#49ca28ce488200b08de06d26313b4d987a45c6b6" - integrity sha512-i7iJl2/IbmgmoYVky0jhSMIfgaO8icYef2z/Y+0QUdkqBB6fwE2jfD3HrMlJzd45+eghtIj46Qjrp+Bns4o/TA== + version "6.3.0" + resolved "https://registry.yarnpkg.com/@graphql-tools/relay-operation-optimizer/-/relay-operation-optimizer-6.3.0.tgz#f8c7f6c8aa4a9cf50ab151fbc5db4f4282a79532" + integrity sha512-Or3UgRvkY9Fq1AAx7q38oPqFmTepLz7kp6wDHKyR0ceG7AvHv5En22R12mAeISInbhff4Rpwgf6cE8zHRu6bCw== dependencies: - "@graphql-tools/utils" "^7.0.0" - relay-compiler "10.0.1" + "@graphql-tools/utils" "^7.1.0" + relay-compiler "10.1.0" tslib "~2.0.1" -"@graphql-tools/schema@^7.0.0": - version "7.0.0" - resolved "https://registry.yarnpkg.com/@graphql-tools/schema/-/schema-7.0.0.tgz#f87e307d00a3d388f5c54d32f4697611396c0127" - integrity sha512-yDKgoT2+Uf3cdLYmiFB9lRIGsB6lZhILtCXHgZigYgURExrEPmfj3ZyszfEpPKYcPmKaO9FI4coDhIN0Toxl3w== +"@graphql-tools/schema@^7.0.0", "@graphql-tools/schema@^7.1.2": + version "7.1.3" + resolved "https://registry.yarnpkg.com/@graphql-tools/schema/-/schema-7.1.3.tgz#d816400da51fbac1f0086e35540ab63b5e30e858" + integrity sha512-ZY76hmcJlF1iyg3Im0sQ3ASRkiShjgv102vLTVcH22lEGJeCaCyyS/GF1eUHom418S60bS8Th6+autRUxfBiBg== dependencies: - "@graphql-tools/utils" "^7.0.0" - tslib "~2.0.1" + "@graphql-tools/utils" "^7.1.2" + tslib "~2.1.0" -"@graphql-tools/url-loader@^6", "@graphql-tools/url-loader@^6.0.0", "@graphql-tools/url-loader@^6.3.1": - version "6.3.2" - resolved "https://registry.yarnpkg.com/@graphql-tools/url-loader/-/url-loader-6.3.2.tgz#ed4e9dafcd83bda94ba7114629e712fc81a6a3ef" - integrity sha512-nrrZD33T7lFeOjIufCrwk2PAHYqFtdFcb1pe1ULWnvuFmFuhZnRCgIsfCsoy+WOMwmZHQ/eXBem//I/bewXlgw== +"@graphql-tools/url-loader@^6", "@graphql-tools/url-loader@^6.0.0", "@graphql-tools/url-loader@^6.8.2": + version "6.8.2" + resolved "https://registry.yarnpkg.com/@graphql-tools/url-loader/-/url-loader-6.8.2.tgz#a62b3e1988b4c49c6c488aaba66b1c7078886023" + integrity sha512-YzsXSCOwlSj8UqOMhQThPzgEChgS/MonyWV7f0WKmN9gAT/f3fPaUcYhVamsH0vGbvTkfNM4JdoZO/39amRs5Q== dependencies: "@graphql-tools/delegate" "^7.0.1" - "@graphql-tools/utils" "^7.0.1" - "@graphql-tools/wrap" "^7.0.0" - "@types/websocket" "1.0.1" - cross-fetch "3.0.6" - subscriptions-transport-ws "0.9.18" - tslib "~2.0.1" + "@graphql-tools/utils" "^7.1.5" + "@graphql-tools/wrap" "^7.0.4" + "@types/websocket" "1.0.2" + cross-fetch "3.1.1" + eventsource "1.1.0" + extract-files "9.0.0" + form-data "4.0.0" + graphql-upload "^11.0.0" + graphql-ws "4.2.2" + is-promise "4.0.0" + isomorphic-ws "4.0.1" + sse-z "0.3.0" + sync-fetch "0.3.0" + tslib "~2.1.0" valid-url "1.0.9" - websocket "1.0.32" + ws "7.4.4" "@graphql-tools/utils@^6", "@graphql-tools/utils@^6.0.0": version "6.2.4" @@ -2520,23 +1804,23 @@ camel-case "4.1.1" tslib "~2.0.1" -"@graphql-tools/utils@^7.0.0", "@graphql-tools/utils@^7.0.1": - version "7.0.1" - resolved "https://registry.yarnpkg.com/@graphql-tools/utils/-/utils-7.0.1.tgz#8bf54676de65878d2b1ba34fcd58ef4d8ae0b589" - integrity sha512-DsV7XfEJE6rPQ3Ysusf28MPun/YL+pc7L0hiBOph/F8+/H1pW1ndRqRrnmX3Owrq9xW1EHSw2WU8qvdjn8kOjw== +"@graphql-tools/utils@^7.0.0", "@graphql-tools/utils@^7.1.0", "@graphql-tools/utils@^7.1.2", "@graphql-tools/utils@^7.1.5", "@graphql-tools/utils@^7.1.6", "@graphql-tools/utils@^7.2.1", "@graphql-tools/utils@^7.5.0": + version "7.6.0" + resolved "https://registry.yarnpkg.com/@graphql-tools/utils/-/utils-7.6.0.tgz#ac570a2b5a9bcd5d6446995f58ba22609e01ca7d" + integrity sha512-YCZDDdhfb4Yhie0IH031eGdvQG8C73apDuNg6lqBNbauNw45OG/b8wi3+vuMiDnJTJN32GQUb1Gt9gxDKoRDKw== dependencies: "@ardatan/aggregate-error" "0.0.6" - camel-case "4.1.1" - tslib "~2.0.1" + camel-case "4.1.2" + tslib "~2.1.0" -"@graphql-tools/wrap@^7.0.0": - version "7.0.1" - resolved "https://registry.yarnpkg.com/@graphql-tools/wrap/-/wrap-7.0.1.tgz#a93e548439d19a1be6f7a032c7561059ea589b70" - integrity sha512-0feqjgEJSRLm2V0kEUaV2dw7ukVPjRujYMqNdcqHsIyXmf0VO8PGF5hcva/+5U/9Yfbf3Fck+P5JTJ5MlXPlsQ== +"@graphql-tools/wrap@^7.0.4": + version "7.0.5" + resolved "https://registry.yarnpkg.com/@graphql-tools/wrap/-/wrap-7.0.5.tgz#8659a119abef11754f712b0c202e41a484951e0b" + integrity sha512-KCWBXsDfvG46GNUawRltJL4j9BMGoOG7oo3WEyCQP+SByWXiTe5cBF45SLDVQgdjljGNZhZ4Lq/7avIkF7/zDQ== dependencies: - "@graphql-tools/delegate" "^7.0.0" - "@graphql-tools/schema" "^7.0.0" - "@graphql-tools/utils" "^7.0.0" + "@graphql-tools/delegate" "^7.0.7" + "@graphql-tools/schema" "^7.1.2" + "@graphql-tools/utils" "^7.2.1" is-promise "4.0.0" tslib "~2.0.1" @@ -2594,97 +1878,97 @@ resolve-from "^5.0.0" "@istanbuljs/schema@^0.1.2": - version "0.1.2" - resolved "https://registry.yarnpkg.com/@istanbuljs/schema/-/schema-0.1.2.tgz#26520bf09abe4a5644cd5414e37125a8954241dd" - integrity sha512-tsAQNx32a8CoFhjhijUIhI4kccIAgmGhy8LZMZgGfmXcpMbPRUqn5LWmgRttILi6yeGmBJd2xsPkFMs0PzgPCw== + version "0.1.3" + resolved "https://registry.yarnpkg.com/@istanbuljs/schema/-/schema-0.1.3.tgz#e45e384e4b8ec16bce2fd903af78450f6bf7ec98" + integrity sha512-ZXRY4jNvVgSVQ8DL3LTcakaAtXwTVUxE81hslsyD2AtoXW/wVob10HkOJ1X/pAlcI7D+2YoZKg5do8G/w6RYgA== -"@jest/console@^26.6.1": - version "26.6.1" - resolved "https://registry.yarnpkg.com/@jest/console/-/console-26.6.1.tgz#6a19eaac4aa8687b4db9130495817c65aec3d34e" - integrity sha512-cjqcXepwC5M+VeIhwT6Xpi/tT4AiNzlIx8SMJ9IihduHnsSrnWNvTBfKIpmqOOCNOPqtbBx6w2JqfoLOJguo8g== +"@jest/console@^26.6.2": + version "26.6.2" + resolved "https://registry.yarnpkg.com/@jest/console/-/console-26.6.2.tgz#4e04bc464014358b03ab4937805ee36a0aeb98f2" + integrity sha512-IY1R2i2aLsLr7Id3S6p2BA82GNWryt4oSvEXLAKc+L2zdi89dSkE8xC1C+0kpATG4JhBJREnQOH7/zmccM2B0g== dependencies: - "@jest/types" "^26.6.1" + "@jest/types" "^26.6.2" "@types/node" "*" chalk "^4.0.0" - jest-message-util "^26.6.1" - jest-util "^26.6.1" + jest-message-util "^26.6.2" + jest-util "^26.6.2" slash "^3.0.0" -"@jest/core@^26.6.0", "@jest/core@^26.6.1": - version "26.6.1" - resolved "https://registry.yarnpkg.com/@jest/core/-/core-26.6.1.tgz#77426822f667a2cda82bf917cee11cc8ba71f9ac" - integrity sha512-p4F0pgK3rKnoS9olXXXOkbus1Bsu6fd8pcvLMPsUy4CVXZ8WSeiwQ1lK5hwkCIqJ+amZOYPd778sbPha/S8Srw== +"@jest/core@^26.6.0", "@jest/core@^26.6.3": + version "26.6.3" + resolved "https://registry.yarnpkg.com/@jest/core/-/core-26.6.3.tgz#7639fcb3833d748a4656ada54bde193051e45fad" + integrity sha512-xvV1kKbhfUqFVuZ8Cyo+JPpipAHHAV3kcDBftiduK8EICXmTFddryy3P7NfZt8Pv37rA9nEJBKCCkglCPt/Xjw== dependencies: - "@jest/console" "^26.6.1" - "@jest/reporters" "^26.6.1" - "@jest/test-result" "^26.6.1" - "@jest/transform" "^26.6.1" - "@jest/types" "^26.6.1" + "@jest/console" "^26.6.2" + "@jest/reporters" "^26.6.2" + "@jest/test-result" "^26.6.2" + "@jest/transform" "^26.6.2" + "@jest/types" "^26.6.2" "@types/node" "*" ansi-escapes "^4.2.1" chalk "^4.0.0" exit "^0.1.2" graceful-fs "^4.2.4" - jest-changed-files "^26.6.1" - jest-config "^26.6.1" - jest-haste-map "^26.6.1" - jest-message-util "^26.6.1" + jest-changed-files "^26.6.2" + jest-config "^26.6.3" + jest-haste-map "^26.6.2" + jest-message-util "^26.6.2" jest-regex-util "^26.0.0" - jest-resolve "^26.6.1" - jest-resolve-dependencies "^26.6.1" - jest-runner "^26.6.1" - jest-runtime "^26.6.1" - jest-snapshot "^26.6.1" - jest-util "^26.6.1" - jest-validate "^26.6.1" - jest-watcher "^26.6.1" + jest-resolve "^26.6.2" + jest-resolve-dependencies "^26.6.3" + jest-runner "^26.6.3" + jest-runtime "^26.6.3" + jest-snapshot "^26.6.2" + jest-util "^26.6.2" + jest-validate "^26.6.2" + jest-watcher "^26.6.2" micromatch "^4.0.2" p-each-series "^2.1.0" rimraf "^3.0.0" slash "^3.0.0" strip-ansi "^6.0.0" -"@jest/environment@^26.6.0", "@jest/environment@^26.6.1": - version "26.6.1" - resolved "https://registry.yarnpkg.com/@jest/environment/-/environment-26.6.1.tgz#38a56f1cc66f96bf53befcc5ebeaf1c2dce90e9a" - integrity sha512-GNvHwkOFJtNgSwdzH9flUPzF9AYAZhUg124CBoQcwcZCM9s5TLz8Y3fMtiaWt4ffbigoetjGk5PU2Dd8nLrSEw== +"@jest/environment@^26.6.0", "@jest/environment@^26.6.2": + version "26.6.2" + resolved "https://registry.yarnpkg.com/@jest/environment/-/environment-26.6.2.tgz#ba364cc72e221e79cc8f0a99555bf5d7577cf92c" + integrity sha512-nFy+fHl28zUrRsCeMB61VDThV1pVTtlEokBRgqPrcT1JNq4yRNIyTHfyht6PqtUvY9IsuLGTrbG8kPXjSZIZwA== dependencies: - "@jest/fake-timers" "^26.6.1" - "@jest/types" "^26.6.1" + "@jest/fake-timers" "^26.6.2" + "@jest/types" "^26.6.2" "@types/node" "*" - jest-mock "^26.6.1" + jest-mock "^26.6.2" -"@jest/fake-timers@^26.6.1": - version "26.6.1" - resolved "https://registry.yarnpkg.com/@jest/fake-timers/-/fake-timers-26.6.1.tgz#5aafba1822075b7142e702b906094bea15f51acf" - integrity sha512-T/SkMLgOquenw/nIisBRD6XAYpFir0kNuclYLkse5BpzeDUukyBr+K31xgAo9M0hgjU9ORlekAYPSzc0DKfmKg== +"@jest/fake-timers@^26.6.2": + version "26.6.2" + resolved "https://registry.yarnpkg.com/@jest/fake-timers/-/fake-timers-26.6.2.tgz#459c329bcf70cee4af4d7e3f3e67848123535aad" + integrity sha512-14Uleatt7jdzefLPYM3KLcnUl1ZNikaKq34enpb5XG9i81JpppDb5muZvonvKyrl7ftEHkKS5L5/eB/kxJ+bvA== dependencies: - "@jest/types" "^26.6.1" + "@jest/types" "^26.6.2" "@sinonjs/fake-timers" "^6.0.1" "@types/node" "*" - jest-message-util "^26.6.1" - jest-mock "^26.6.1" - jest-util "^26.6.1" + jest-message-util "^26.6.2" + jest-mock "^26.6.2" + jest-util "^26.6.2" -"@jest/globals@^26.6.1": - version "26.6.1" - resolved "https://registry.yarnpkg.com/@jest/globals/-/globals-26.6.1.tgz#b232c7611d8a2de62b4bf9eb9a007138322916f4" - integrity sha512-acxXsSguuLV/CeMYmBseefw6apO7NuXqpE+v5r3yD9ye2PY7h1nS20vY7Obk2w6S7eJO4OIAJeDnoGcLC/McEQ== +"@jest/globals@^26.6.2": + version "26.6.2" + resolved "https://registry.yarnpkg.com/@jest/globals/-/globals-26.6.2.tgz#5b613b78a1aa2655ae908eba638cc96a20df720a" + integrity sha512-85Ltnm7HlB/KesBUuALwQ68YTU72w9H2xW9FjZ1eL1U3lhtefjjl5c2MiUbpXt/i6LaPRvoOFJ22yCBSfQ0JIA== dependencies: - "@jest/environment" "^26.6.1" - "@jest/types" "^26.6.1" - expect "^26.6.1" + "@jest/environment" "^26.6.2" + "@jest/types" "^26.6.2" + expect "^26.6.2" -"@jest/reporters@^26.6.1": - version "26.6.1" - resolved "https://registry.yarnpkg.com/@jest/reporters/-/reporters-26.6.1.tgz#582ede05278cf5eeffe58bc519f4a35f54fbcb0d" - integrity sha512-J6OlXVFY3q1SXWJhjme5i7qT/BAZSikdOK2t8Ht5OS32BDo6KfG5CzIzzIFnAVd82/WWbc9Hb7SJ/jwSvVH9YA== +"@jest/reporters@^26.6.2": + version "26.6.2" + resolved "https://registry.yarnpkg.com/@jest/reporters/-/reporters-26.6.2.tgz#1f518b99637a5f18307bd3ecf9275f6882a667f6" + integrity sha512-h2bW53APG4HvkOnVMo8q3QXa6pcaNt1HkwVsOPMBV6LD/q9oSpxNSYZQYkAnjdMjrJ86UuYeLo+aEZClV6opnw== dependencies: "@bcoe/v8-coverage" "^0.2.3" - "@jest/console" "^26.6.1" - "@jest/test-result" "^26.6.1" - "@jest/transform" "^26.6.1" - "@jest/types" "^26.6.1" + "@jest/console" "^26.6.2" + "@jest/test-result" "^26.6.2" + "@jest/transform" "^26.6.2" + "@jest/types" "^26.6.2" chalk "^4.0.0" collect-v8-coverage "^1.0.0" exit "^0.1.2" @@ -2695,73 +1979,73 @@ istanbul-lib-report "^3.0.0" istanbul-lib-source-maps "^4.0.0" istanbul-reports "^3.0.2" - jest-haste-map "^26.6.1" - jest-resolve "^26.6.1" - jest-util "^26.6.1" - jest-worker "^26.6.1" + jest-haste-map "^26.6.2" + jest-resolve "^26.6.2" + jest-util "^26.6.2" + jest-worker "^26.6.2" slash "^3.0.0" source-map "^0.6.0" string-length "^4.0.1" terminal-link "^2.0.0" - v8-to-istanbul "^6.0.1" + v8-to-istanbul "^7.0.0" optionalDependencies: node-notifier "^8.0.0" -"@jest/source-map@^26.5.0": - version "26.5.0" - resolved "https://registry.yarnpkg.com/@jest/source-map/-/source-map-26.5.0.tgz#98792457c85bdd902365cd2847b58fff05d96367" - integrity sha512-jWAw9ZwYHJMe9eZq/WrsHlwF8E3hM9gynlcDpOyCb9bR8wEd9ZNBZCi7/jZyzHxC7t3thZ10gO2IDhu0bPKS5g== +"@jest/source-map@^26.6.2": + version "26.6.2" + resolved "https://registry.yarnpkg.com/@jest/source-map/-/source-map-26.6.2.tgz#29af5e1e2e324cafccc936f218309f54ab69d535" + integrity sha512-YwYcCwAnNmOVsZ8mr3GfnzdXDAl4LaenZP5z+G0c8bzC9/dugL8zRmxZzdoTl4IaS3CryS1uWnROLPFmb6lVvA== dependencies: callsites "^3.0.0" graceful-fs "^4.2.4" source-map "^0.6.0" -"@jest/test-result@^26.6.0", "@jest/test-result@^26.6.1": - version "26.6.1" - resolved "https://registry.yarnpkg.com/@jest/test-result/-/test-result-26.6.1.tgz#d75698d8a06aa663e8936663778c831512330cc1" - integrity sha512-wqAgIerIN2gSdT2A8WeA5+AFh9XQBqYGf8etK143yng3qYd0mF0ie2W5PVmgnjw4VDU6ammI9NdXrKgNhreawg== +"@jest/test-result@^26.6.0", "@jest/test-result@^26.6.2": + version "26.6.2" + resolved "https://registry.yarnpkg.com/@jest/test-result/-/test-result-26.6.2.tgz#55da58b62df134576cc95476efa5f7949e3f5f18" + integrity sha512-5O7H5c/7YlojphYNrK02LlDIV2GNPYisKwHm2QTKjNZeEzezCbwYs9swJySv2UfPMyZ0VdsmMv7jIlD/IKYQpQ== dependencies: - "@jest/console" "^26.6.1" - "@jest/types" "^26.6.1" + "@jest/console" "^26.6.2" + "@jest/types" "^26.6.2" "@types/istanbul-lib-coverage" "^2.0.0" collect-v8-coverage "^1.0.0" -"@jest/test-sequencer@^26.6.1": - version "26.6.1" - resolved "https://registry.yarnpkg.com/@jest/test-sequencer/-/test-sequencer-26.6.1.tgz#34216ac2c194b0eeebde30d25424d1134703fd2e" - integrity sha512-0csqA/XApZiNeTIPYh6koIDCACSoR6hi29T61tKJMtCZdEC+tF3PoNt7MS0oK/zKC6daBgCbqXxia5ztr/NyCQ== +"@jest/test-sequencer@^26.6.3": + version "26.6.3" + resolved "https://registry.yarnpkg.com/@jest/test-sequencer/-/test-sequencer-26.6.3.tgz#98e8a45100863886d074205e8ffdc5a7eb582b17" + integrity sha512-YHlVIjP5nfEyjlrSr8t/YdNfU/1XEt7c5b4OxcXCjyRhjzLYu/rO69/WHPuYcbCWkz8kAeZVZp2N2+IOLLEPGw== dependencies: - "@jest/test-result" "^26.6.1" + "@jest/test-result" "^26.6.2" graceful-fs "^4.2.4" - jest-haste-map "^26.6.1" - jest-runner "^26.6.1" - jest-runtime "^26.6.1" + jest-haste-map "^26.6.2" + jest-runner "^26.6.3" + jest-runtime "^26.6.3" -"@jest/transform@^26.6.1": - version "26.6.1" - resolved "https://registry.yarnpkg.com/@jest/transform/-/transform-26.6.1.tgz#f70786f96e0f765947b4fb4f54ffcfb7bd783711" - integrity sha512-oNFAqVtqRxZRx6vXL3I4bPKUK0BIlEeaalkwxyQGGI8oXDQBtYQBpiMe5F7qPs4QdvvFYB42gPGIMMcxXaBBxQ== +"@jest/transform@^26.6.2": + version "26.6.2" + resolved "https://registry.yarnpkg.com/@jest/transform/-/transform-26.6.2.tgz#5ac57c5fa1ad17b2aae83e73e45813894dcf2e4b" + integrity sha512-E9JjhUgNzvuQ+vVAL21vlyfy12gP0GhazGgJC4h6qUt1jSdUXGWJ1wfu/X7Sd8etSgxV4ovT1pb9v5D6QW4XgA== dependencies: "@babel/core" "^7.1.0" - "@jest/types" "^26.6.1" + "@jest/types" "^26.6.2" babel-plugin-istanbul "^6.0.0" chalk "^4.0.0" convert-source-map "^1.4.0" fast-json-stable-stringify "^2.0.0" graceful-fs "^4.2.4" - jest-haste-map "^26.6.1" + jest-haste-map "^26.6.2" jest-regex-util "^26.0.0" - jest-util "^26.6.1" + jest-util "^26.6.2" micromatch "^4.0.2" pirates "^4.0.1" slash "^3.0.0" source-map "^0.6.1" write-file-atomic "^3.0.0" -"@jest/types@^26.6.0", "@jest/types@^26.6.1": - version "26.6.1" - resolved "https://registry.yarnpkg.com/@jest/types/-/types-26.6.1.tgz#2638890e8031c0bc8b4681e0357ed986e2f866c5" - integrity sha512-ywHavIKNpAVrStiRY5wiyehvcktpijpItvGiK72RAn5ctqmzvPk8OvKnvHeBqa1XdQr959CTWAJMqxI8BTibyg== +"@jest/types@^26.6.0", "@jest/types@^26.6.2": + version "26.6.2" + resolved "https://registry.yarnpkg.com/@jest/types/-/types-26.6.2.tgz#bef5a532030e1d88a2f5a6d933f84e97226ed48e" + integrity sha512-fC6QCp7Sc5sX6g8Tvbmj4XUTbyrik0akgRy03yjXbQaBWWNWGE7SGtJk98m0N8nzegD/7SggrUlivxo5ax4KWQ== dependencies: "@types/istanbul-lib-coverage" "^2.0.0" "@types/istanbul-reports" "^3.0.0" @@ -3059,33 +2343,34 @@ "@babel/runtime" "^7.7.2" regenerator-runtime "^0.13.3" -"@nodelib/fs.scandir@2.1.3": - version "2.1.3" - resolved "https://registry.yarnpkg.com/@nodelib/fs.scandir/-/fs.scandir-2.1.3.tgz#3a582bdb53804c6ba6d146579c46e52130cf4a3b" - integrity sha512-eGmwYQn3gxo4r7jdQnkrrN6bY478C3P+a/y72IJukF8LjB6ZHeB3c+Ehacj3sYeSmUXGlnA67/PmbM9CVwL7Dw== +"@nodelib/fs.scandir@2.1.4": + version "2.1.4" + resolved "https://registry.yarnpkg.com/@nodelib/fs.scandir/-/fs.scandir-2.1.4.tgz#d4b3549a5db5de2683e0c1071ab4f140904bbf69" + integrity sha512-33g3pMJk3bg5nXbL/+CY6I2eJDzZAni49PfJnL5fghPTggPvBd/pFNSgJsdAgWptuFu7qq/ERvOYFlhvsLTCKA== dependencies: - "@nodelib/fs.stat" "2.0.3" + "@nodelib/fs.stat" "2.0.4" run-parallel "^1.1.9" -"@nodelib/fs.stat@2.0.3", "@nodelib/fs.stat@^2.0.2": - version "2.0.3" - resolved "https://registry.yarnpkg.com/@nodelib/fs.stat/-/fs.stat-2.0.3.tgz#34dc5f4cabbc720f4e60f75a747e7ecd6c175bd3" - integrity sha512-bQBFruR2TAwoevBEd/NWMoAAtNGzTRgdrqnYCc7dhzfoNvqPzLyqlEQnzZ3kVnNrSp25iyxE00/3h2fqGAGArA== +"@nodelib/fs.stat@2.0.4", "@nodelib/fs.stat@^2.0.2": + version "2.0.4" + resolved "https://registry.yarnpkg.com/@nodelib/fs.stat/-/fs.stat-2.0.4.tgz#a3f2dd61bab43b8db8fa108a121cfffe4c676655" + integrity sha512-IYlHJA0clt2+Vg7bccq+TzRdJvv19c2INqBSsoOLp1je7xjtr7J26+WXR72MCdvU9q1qTzIWDfhMf+DRvQJK4Q== "@nodelib/fs.walk@^1.2.3": - version "1.2.4" - resolved "https://registry.yarnpkg.com/@nodelib/fs.walk/-/fs.walk-1.2.4.tgz#011b9202a70a6366e436ca5c065844528ab04976" - integrity sha512-1V9XOY4rDW0rehzbrcqAmHnz8e7SKvX27gh8Gt2WgB0+pdzdiLV83p72kZPU+jvMbS1qU5mauP2iOvO8rhmurQ== + version "1.2.6" + resolved "https://registry.yarnpkg.com/@nodelib/fs.walk/-/fs.walk-1.2.6.tgz#cce9396b30aa5afe9e3756608f5831adcb53d063" + integrity sha512-8Broas6vTtW4GIXTAHDoE32hnN2M5ykgCpWGbuXHQ15vEMqr23pB76e/GZcYsZCHALv50ktd24qhEyKr6wBtow== dependencies: - "@nodelib/fs.scandir" "2.1.3" + "@nodelib/fs.scandir" "2.1.4" fastq "^1.6.0" "@npmcli/move-file@^1.0.1": - version "1.0.1" - resolved "https://registry.yarnpkg.com/@npmcli/move-file/-/move-file-1.0.1.tgz#de103070dac0f48ce49cf6693c23af59c0f70464" - integrity sha512-Uv6h1sT+0DrblvIrolFtbvM1FgWm+/sy4B3pvLp67Zys+thcukzS5ekn7HsZFGpWP4Q3fYJCljbWQE/XivMRLw== + version "1.1.2" + resolved "https://registry.yarnpkg.com/@npmcli/move-file/-/move-file-1.1.2.tgz#1a82c3e372f7cae9253eb66d72543d6b8685c674" + integrity sha512-1SUf/Cg2GzGDyaf15aR9St9TWlb+XvbZXWpDx8YKs7MLzMH/BCeopv+y9vzrzgkfykCGuWOlSu3mZhj2+FQcrg== dependencies: mkdirp "^1.0.4" + rimraf "^3.0.2" "@pmmmwh/react-refresh-webpack-plugin@0.4.3": version "0.4.3" @@ -3100,9 +2385,9 @@ source-map "^0.7.3" "@popperjs/core@^2.5.3": - version "2.5.4" - resolved "https://registry.yarnpkg.com/@popperjs/core/-/core-2.5.4.tgz#de25b5da9f727985a3757fd59b5d028aba75841a" - integrity sha512-ZpKr+WTb8zsajqgDkvCEWgp6d5eJT6Q63Ng2neTbzBO76Lbe91vX/iVIW9dikq+Fs3yEo+ls4cxeXABD2LtcbQ== + version "2.9.1" + resolved "https://registry.yarnpkg.com/@popperjs/core/-/core-2.9.1.tgz#7f554e7368c9ab679a11f4a042ca17149d70cf12" + integrity sha512-DvJbbn3dUgMxDnJLH+RZQPnXak1h4ZVYQ7CWiFWjQwBFkVajT4rfw2PdpHLTSTwxrYfnoEXkuBiwkDm6tPMQeA== "@restart/context@^2.1.4": version "2.1.4" @@ -3110,12 +2395,12 @@ integrity sha512-INJYZQJP7g+IoDUh/475NlGiTeMfwTXUEr3tmRneckHIxNolGOW9CTq83S8cxq0CgJwwcMzMJFchxvlwe7Rk8Q== "@restart/hooks@^0.3.21", "@restart/hooks@^0.3.25": - version "0.3.25" - resolved "https://registry.yarnpkg.com/@restart/hooks/-/hooks-0.3.25.tgz#11004139ad1c70d2f5965a8939dcb5aeb96aa652" - integrity sha512-m2v3N5pxTsIiSH74/sb1yW8D9RxkJidGW+5Mfwn/lHb2QzhZNlaU1su7abSyT9EGf0xS/0waLjrf7/XxQHUk7w== + version "0.3.26" + resolved "https://registry.yarnpkg.com/@restart/hooks/-/hooks-0.3.26.tgz#ade155a7b0b014ef1073391dda46972c3a14a129" + integrity sha512-7Hwk2ZMYm+JLWcb7R9qIXk1OoUg1Z+saKWqZXlrvFwT3w6UArVNWgxYOzf+PJoK9zZejp8okPAKTctthhXLt5g== dependencies: - lodash "^4.17.15" - lodash-es "^4.17.15" + lodash "^4.17.20" + lodash-es "^4.17.20" "@rollup/plugin-node-resolve@^7.1.1": version "7.1.3" @@ -3129,9 +2414,9 @@ resolve "^1.14.2" "@rollup/plugin-replace@^2.3.1": - version "2.3.4" - resolved "https://registry.yarnpkg.com/@rollup/plugin-replace/-/plugin-replace-2.3.4.tgz#7dd84c17755d62b509577f2db37eb524d7ca88ca" - integrity sha512-waBhMzyAtjCL1GwZes2jaE9MjuQ/DQF2BatH3fRivUF3z0JBFrU0U6iBNC/4WR+2rLKhaAhPWDNPYp4mI6RqdQ== + version "2.4.1" + resolved "https://registry.yarnpkg.com/@rollup/plugin-replace/-/plugin-replace-2.4.1.tgz#c411b5ab72809fb1bfc8b487d8d02eef661460d3" + integrity sha512-XwC1oK5rrtRJ0tn1ioLHS6OV5JTluJF7QE1J/q1hN3bquwjnVxjtMyY9iCnoyH9DQbf92CxajB3o98wZbP3oAQ== dependencies: "@rollup/pluginutils" "^3.1.0" magic-string "^0.25.7" @@ -3158,9 +2443,9 @@ integrity sha512-9NET910DNaIPngYnLLPeg+Ogzqsi9uM4mSboU5y6p8S5DzMTVEsJZrawi+BoDNUVBa2DhJqQYUFvMDfgU062LQ== "@sinonjs/commons@^1.7.0": - version "1.8.1" - resolved "https://registry.yarnpkg.com/@sinonjs/commons/-/commons-1.8.1.tgz#e7df00f98a203324f6dc7cc606cad9d4a8ab2217" - integrity sha512-892K+kWUUi3cl+LlqEWIDrhvLgdL79tECi8JZUyq6IviKy/DNhuzCRlbHUjxK89f4ypPMMaFnFuR9Ie6DoIMsw== + version "1.8.2" + resolved "https://registry.yarnpkg.com/@sinonjs/commons/-/commons-1.8.2.tgz#858f5c4b48d80778fde4b9d541f27edc0d56488b" + integrity sha512-sruwd86RJHdsVf/AtBoijDmUqJp3B6hF/DGC23C+JaegnDHaZyewCjoVGTdg3J0uz3Zs7NnIT05OBOmML72lQw== dependencies: type-detect "4.0.8" @@ -3187,9 +2472,9 @@ unist-util-find-all-after "^3.0.2" "@surma/rollup-plugin-off-main-thread@^1.1.1": - version "1.4.1" - resolved "https://registry.yarnpkg.com/@surma/rollup-plugin-off-main-thread/-/rollup-plugin-off-main-thread-1.4.1.tgz#bf1343e5a926e5a1da55e3affd761dda4ce143ef" - integrity sha512-ZPBWYQDdO4JZiTmTP3DABsHhIPA7bEJk9Znk7tZsrbPGanoGo8YxMv//WLx5Cvb+lRgS42+6yiOIYYHCKDmkpQ== + version "1.4.2" + resolved "https://registry.yarnpkg.com/@surma/rollup-plugin-off-main-thread/-/rollup-plugin-off-main-thread-1.4.2.tgz#e6786b6af5799f82f7ab3a82e53f6182d2b91a58" + integrity sha512-yBMPqmd1yEJo/280PAMkychuaALyQ9Lkb5q1ck3mjJrFuEobIfhnQ4J3mbvBoISmR3SWMWV+cGB/I0lCQee79A== dependencies: ejs "^2.6.1" magic-string "^0.25.0" @@ -3324,9 +2609,9 @@ graphql "^15.3.0" "@types/babel__core@^7.0.0", "@types/babel__core@^7.1.7": - version "7.1.11" - resolved "https://registry.yarnpkg.com/@types/babel__core/-/babel__core-7.1.11.tgz#7fae4660a009a4031e293f25b213f142d823b3c4" - integrity sha512-E5nSOzrjnvhURYnbOR2dClTqcyhPbPvtEwLHf7JJADKedPbcZsoJVfP+I2vBNfBjz4bnZIuhL/tNmRi5nJ7Jlw== + version "7.1.14" + resolved "https://registry.yarnpkg.com/@types/babel__core/-/babel__core-7.1.14.tgz#faaeefc4185ec71c389f4501ee5ec84b170cc402" + integrity sha512-zGZJzzBUVDo/eV6KgbE0f0ZI7dInEYvo12Rb70uNQDshC3SkRMb67ja0GgRHZgAX3Za6rhaWlvbDO8rrGyAb1g== dependencies: "@babel/parser" "^7.1.0" "@babel/types" "^7.0.0" @@ -3342,17 +2627,17 @@ "@babel/types" "^7.0.0" "@types/babel__template@*": - version "7.0.3" - resolved "https://registry.yarnpkg.com/@types/babel__template/-/babel__template-7.0.3.tgz#b8aaeba0a45caca7b56a5de9459872dde3727214" - integrity sha512-uCoznIPDmnickEi6D0v11SBpW0OuVqHJCa7syXqQHy5uktSCreIlt0iglsCnmvz8yCb38hGcWeseA8cWJSwv5Q== + version "7.4.0" + resolved "https://registry.yarnpkg.com/@types/babel__template/-/babel__template-7.4.0.tgz#0c888dd70b3ee9eebb6e4f200e809da0076262be" + integrity sha512-NTPErx4/FiPCGScH7foPyr+/1Dkzkni+rHiYHHoTjvwou7AQzJkNeD60A9CXRy+ZEN2B1bggmkTMCDb+Mv5k+A== dependencies: "@babel/parser" "^7.1.0" "@babel/types" "^7.0.0" "@types/babel__traverse@*", "@types/babel__traverse@^7.0.4", "@types/babel__traverse@^7.0.6": - version "7.0.15" - resolved "https://registry.yarnpkg.com/@types/babel__traverse/-/babel__traverse-7.0.15.tgz#db9e4238931eb69ef8aab0ad6523d4d4caa39d03" - integrity sha512-Pzh9O3sTK8V6I1olsXpCfj2k/ygO2q1X0vhhnDrEQyYLHZesWz+zMZMVcwXLCYf0U36EtmyYaFGPfXlTtDHe3A== + version "7.11.1" + resolved "https://registry.yarnpkg.com/@types/babel__traverse/-/babel__traverse-7.11.1.tgz#654f6c4f67568e24c23b367e947098c6206fa639" + integrity sha512-Vs0hm0vPahPMYi9tDjtP66llufgO3ST16WXaSTtDGEl9cewAl3AibmxWw6TINOqHPT9z0uABKAYjT9jNSg4npw== dependencies: "@babel/types" "^7.3.0" @@ -3367,17 +2652,17 @@ integrity sha512-LKVP3cgXBT9RYj+t+9FDKwS5tdI+rPBXaNSkma7hvqy35lc7mAokC2zsqWJH0LaqIt3B962nuYI77hsJoT1gow== "@types/eslint@^7.2.6": - version "7.2.6" - resolved "https://registry.yarnpkg.com/@types/eslint/-/eslint-7.2.6.tgz#5e9aff555a975596c03a98b59ecd103decc70c3c" - integrity sha512-I+1sYH+NPQ3/tVqCeUSBwTE/0heyvtXqpIopUUArlBm0Kpocb8FbMa3AZ/ASKIFpN3rnEx932TTXDbt9OXsNDw== + version "7.2.7" + resolved "https://registry.yarnpkg.com/@types/eslint/-/eslint-7.2.7.tgz#f7ef1cf0dceab0ae6f9a976a0a9af14ab1baca26" + integrity sha512-EHXbc1z2GoQRqHaAT7+grxlTJ3WE2YNeD6jlpPoRc83cCoThRY+NUWjCUZaYmk51OICkPXn2hhphcWcWXgNW0Q== dependencies: "@types/estree" "*" "@types/json-schema" "*" "@types/estree@*": - version "0.0.45" - resolved "https://registry.yarnpkg.com/@types/estree/-/estree-0.0.45.tgz#e9387572998e5ecdac221950dab3e8c3b16af884" - integrity sha512-jnqIUKDUqJbDIUxm0Uj7bnlMnRm1T/eZ9N+AVMqhPgzrba2GhGG5o/jCTwmdPK709nEZsGoMzXEDUjcXHa3W0g== + version "0.0.46" + resolved "https://registry.yarnpkg.com/@types/estree/-/estree-0.0.46.tgz#0fb6bfbbeabd7a30880504993369c4bf1deab1fe" + integrity sha512-laIjwTQaD+5DukBZaygQ79K1Z0jb1bPEMRrkXSLjtCcZm+abyp5YbrqpSLzD42FwWW6gK/aS4NYpJ804nG2brg== "@types/estree@0.0.39": version "0.0.39" @@ -3390,9 +2675,9 @@ integrity sha512-ulxvlFU71yLVV3JxdBgryASAIp+aZQuQOpkhU1SznJlcWz0qsJCWHqdJqP6Lprs3blqGS5FH5GbBkU0977+Wew== "@types/fs-extra@^9.0.1": - version "9.0.2" - resolved "https://registry.yarnpkg.com/@types/fs-extra/-/fs-extra-9.0.2.tgz#e1e1b578c48e8d08ae7fc36e552b94c6f4621609" - integrity sha512-jp0RI6xfZpi5JL8v7WQwpBEQTq63RqW2kxwTZt+m27LcJqQdPVU1yGnT1ZI4EtCDynQQJtIGyQahkiCGCS7e+A== + version "9.0.8" + resolved "https://registry.yarnpkg.com/@types/fs-extra/-/fs-extra-9.0.8.tgz#32c3c07ddf8caa5020f84b5f65a48470519f78ba" + integrity sha512-bnlTVTwq03Na7DpWxFJ1dvnORob+Otb8xHyUqUWhqvz/Ksg8+JXPlR52oeMSZ37YEOa5PyccbgUNutiQdi13TA== dependencies: "@types/node" "*" @@ -3412,9 +2697,9 @@ "@types/node" "*" "@types/graceful-fs@^4.1.2": - version "4.1.4" - resolved "https://registry.yarnpkg.com/@types/graceful-fs/-/graceful-fs-4.1.4.tgz#4ff9f641a7c6d1a3508ff88bc3141b152772e753" - integrity sha512-mWA/4zFQhfvOA8zWkXobwJvBD7vzcxgrOQ0J5CH1votGqdq9m7+FwtGaqyCZqC3NyyBkc9z4m+iry4LlqcMWJg== + version "4.1.5" + resolved "https://registry.yarnpkg.com/@types/graceful-fs/-/graceful-fs-4.1.5.tgz#21ffba0d98da4350db64891f92a9e5db3cdb4e15" + integrity sha512-anKkLmZZ+xm4p8JWBf4hElkM4XR+EZeA2M9BAkkTldmcyDY4mbdIJnRghDJH3Ov5ooY7/UAoENtmdMSkaAd7Cw== dependencies: "@types/node" "*" @@ -3467,15 +2752,15 @@ dependencies: "@types/istanbul-lib-report" "*" -"@types/js-yaml@^3.12.5": - version "3.12.5" - resolved "https://registry.yarnpkg.com/@types/js-yaml/-/js-yaml-3.12.5.tgz#136d5e6a57a931e1cce6f9d8126aa98a9c92a6bb" - integrity sha512-JCcp6J0GV66Y4ZMDAQCXot4xprYB+Zfd3meK9+INSJeVZwJmHAW30BBEEkPzXswMXuiyReUGOP3GxrADc9wPww== +"@types/js-yaml@^4.0.0": + version "4.0.0" + resolved "https://registry.yarnpkg.com/@types/js-yaml/-/js-yaml-4.0.0.tgz#d1a11688112091f2c711674df3a65ea2f47b5dfb" + integrity sha512-4vlpCM5KPCL5CfGmTbpjwVKbISRYhduEJvvUWsH5EB7QInhEj94XPZ3ts/9FPiLZFqYO0xoW4ZL8z2AabTGgJA== "@types/json-schema@*", "@types/json-schema@^7.0.3", "@types/json-schema@^7.0.5", "@types/json-schema@^7.0.6": - version "7.0.6" - resolved "https://registry.yarnpkg.com/@types/json-schema/-/json-schema-7.0.6.tgz#f4c7ec43e81b319a9815115031709f26987891f0" - integrity sha512-3c+yGKvVP5Y9TYBEibGNR+kLtijnj7mYrXRg+WpFb2X9xm04g/DXYkfg4hmzJQosc9snFNUPkbYIhu+KAm6jJw== + version "7.0.7" + resolved "https://registry.yarnpkg.com/@types/json-schema/-/json-schema-7.0.7.tgz#98a993516c859eb0d5c4c8f098317a9ea68db9ad" + integrity sha512-cxWFQVseBm6O9Gbw1IWb8r6OS4OhSt3hPZLkFApLjM8TEXROBuQGLAH2i2gZpcXdLBIrpXuTDhH7Vbm1iXmNGA== "@types/json-stable-stringify@^1.0.32": version "1.0.32" @@ -3488,9 +2773,9 @@ integrity sha1-7ihweulOEdK4J7y+UnC86n8+ce4= "@types/jsonwebtoken@^8.5.0": - version "8.5.0" - resolved "https://registry.yarnpkg.com/@types/jsonwebtoken/-/jsonwebtoken-8.5.0.tgz#2531d5e300803aa63279b232c014acf780c981c5" - integrity sha512-9bVao7LvyorRGZCw0VmH/dr7Og+NdjYSsKAxB43OQoComFbBgsEpoR9JW6+qSq/ogwVBg8GI2MfAlk4SYI4OLg== + version "8.5.1" + resolved "https://registry.yarnpkg.com/@types/jsonwebtoken/-/jsonwebtoken-8.5.1.tgz#56958cb2d80f6d74352bd2e501a018e2506a8a84" + integrity sha512-rNAPdomlIUX0i0cg2+I+Q1wOUr531zHBQ+cV/28PJ39bSPKjahatZZ2LMuhiguETkCgLVzfruw/ZvNMNkKoSzw== dependencies: "@types/node" "*" @@ -3512,9 +2797,9 @@ integrity sha512-tHq6qdbT9U1IRSGf14CL0pUlULksvY9OZ+5eEgl1N7t+OA3tGvNpxJCzuKQlsNgCVwbAs670L1vcVQi8j9HjnA== "@types/minimist@^1.2.0": - version "1.2.0" - resolved "https://registry.yarnpkg.com/@types/minimist/-/minimist-1.2.0.tgz#69a23a3ad29caf0097f06eda59b361ee2f0639f6" - integrity sha1-aaI6OtKcrwCX8G7aWbNh7i8GOfY= + version "1.2.1" + resolved "https://registry.yarnpkg.com/@types/minimist/-/minimist-1.2.1.tgz#283f669ff76d7b8260df8ab7a4262cc83d988256" + integrity sha512-fZQQafSREFyuZcdWFAExYjBiCL7AUCdgsk80iO0q4yihYYdcIiH28CcuPTGFgLOCC8RlW49GSQxdHwZP+I7CNg== "@types/mousetrap@^1.6.5": version "1.6.5" @@ -3522,9 +2807,9 @@ integrity sha512-OwVhKFim9Y/MprzCe4I6a59p31pMy8+LrtP6qS7J0kaOxYmW6VVJPBw5NYm+g7nSbgPUz22FvqU1F1hC5YGTfg== "@types/node@*": - version "14.14.6" - resolved "https://registry.yarnpkg.com/@types/node/-/node-14.14.6.tgz#146d3da57b3c636cc0d1769396ce1cfa8991147f" - integrity sha512-6QlRuqsQ/Ox/aJEQWBEJG7A9+u7oSYl3mem/K8IzxXG/kAGbV1YPD9Bg9Zw3vyxC/YP+zONKwy8hGkSt1jxFMw== + version "14.14.35" + resolved "https://registry.yarnpkg.com/@types/node/-/node-14.14.35.tgz#42c953a4e2b18ab931f72477e7012172f4ffa313" + integrity sha512-Lt+wj8NVPx0zUmUwumiVXapmaLUcAk3yPuHCFVXras9k5VT9TdhJqKqGVUQCD60OTMCl0qxJ57OiTL0Mic3Iag== "@types/node@14.14.22": version "14.14.22" @@ -3542,9 +2827,9 @@ integrity sha512-//oorEZjL6sbPcKUaCdIGlIUeH26mgzimjBB77G6XRgnDl/L5wOnpyBGRe/Mmf5CVW3PwEBE1NjiMZ/ssFh4wA== "@types/prettier@^2.0.0": - version "2.1.5" - resolved "https://registry.yarnpkg.com/@types/prettier/-/prettier-2.1.5.tgz#b6ab3bba29e16b821d84e09ecfaded462b816b00" - integrity sha512-UEyp8LwZ4Dg30kVU2Q3amHHyTn1jEdhCIE59ANed76GaT1Vp76DD3ZWSAxgCrw6wJ0TqeoBpqmfUHiUDPs//HQ== + version "2.2.3" + resolved "https://registry.yarnpkg.com/@types/prettier/-/prettier-2.2.3.tgz#ef65165aea2924c9359205bf748865b8881753c0" + integrity sha512-PijRCG/K3s3w1We6ynUKdxEc5AcuuH3NBmMDP8uvKVp6X43UY7NQlTzczakXP3DJR0F4dfNQIGjU2cUeRYs2AA== "@types/prop-types@*", "@types/prop-types@^15.7.3": version "15.7.3" @@ -3557,9 +2842,9 @@ integrity sha512-1HcDas8SEj4z1Wc696tH56G8OlRaH/sqZOynNNB+HF0WOeXPaxTtbYzJY2oEfiUxjSKjhCKr+MvR7dCHcEelug== "@types/react-dom@*", "@types/react-dom@^17.0.0": - version "17.0.0" - resolved "https://registry.yarnpkg.com/@types/react-dom/-/react-dom-17.0.0.tgz#b3b691eb956c4b3401777ee67b900cb28415d95a" - integrity sha512-lUqY7OlkF/RbNtD5nIq7ot8NquXrdFrjSOR6+w9a9RFQevGi1oZO1dcJbXMeONAPKtZ2UrZOEJ5UOCVsxbLk/g== + version "17.0.3" + resolved "https://registry.yarnpkg.com/@types/react-dom/-/react-dom-17.0.3.tgz#7fdf37b8af9d6d40127137865bb3fff8871d7ee1" + integrity sha512-4NnJbCeWE+8YBzupn/YrJxZ8VnjcJq5iR1laqQ1vkpQgBiA7bwk0Rp24fxsdNinzJY2U+HHS4dJJDPdoMjdJ7w== dependencies: "@types/react" "*" @@ -3571,16 +2856,7 @@ "@types/react" "*" "@types/react-router-dom" "*" -"@types/react-router-dom@*": - version "5.1.6" - resolved "https://registry.yarnpkg.com/@types/react-router-dom/-/react-router-dom-5.1.6.tgz#07b14e7ab1893a837c8565634960dc398564b1fb" - integrity sha512-gjrxYqxz37zWEdMVvQtWPFMFj1dRDb4TGOcgyOfSXTrEXdF92L00WE3C471O3TV/RF1oskcStkXsOU0Ete4s/g== - dependencies: - "@types/history" "*" - "@types/react" "*" - "@types/react-router" "*" - -"@types/react-router-dom@5.1.7": +"@types/react-router-dom@*", "@types/react-router-dom@5.1.7": version "5.1.7" resolved "https://registry.yarnpkg.com/@types/react-router-dom/-/react-router-dom-5.1.7.tgz#a126d9ea76079ffbbdb0d9225073eb5797ab7271" integrity sha512-D5mHD6TbdV/DNHYsnwBTv+y73ei+mMjrkGrla86HthE4/PVvL1J94Bu3qABU+COXzpL23T1EZapVVpwHuBXiUg== @@ -3598,9 +2874,9 @@ "@types/react-router-dom" "*" "@types/react-router@*": - version "5.1.8" - resolved "https://registry.yarnpkg.com/@types/react-router/-/react-router-5.1.8.tgz#4614e5ba7559657438e17766bb95ef6ed6acc3fa" - integrity sha512-HzOyJb+wFmyEhyfp4D4NYrumi+LQgQL/68HvJO+q6XtuHSDvw6Aqov7sCAhjbNq3bUPgPqbdvjXC5HeB2oEAPg== + version "5.1.12" + resolved "https://registry.yarnpkg.com/@types/react-router/-/react-router-5.1.12.tgz#0f300e09468e7aed86e18241c90238c18c377e51" + integrity sha512-0bhXQwHYfMeJlCh7mGhc0VJTRm0Gk+Z8T00aiP4702mDUuLs9SMhnd2DitpjWFjdOecx2UXtICK14H9iMnziGA== dependencies: "@types/history" "*" "@types/react" "*" @@ -3615,21 +2891,22 @@ "@types/react-transition-group" "*" "@types/react-transition-group@*", "@types/react-transition-group@^4.4.0": - version "4.4.0" - resolved "https://registry.yarnpkg.com/@types/react-transition-group/-/react-transition-group-4.4.0.tgz#882839db465df1320e4753e6e9f70ca7e9b4d46d" - integrity sha512-/QfLHGpu+2fQOqQaXh8MG9q03bFENooTb/it4jr5kKaZlDQfWvjqWZg48AwzPVMBHlRuTRAY7hRHCEOXz5kV6w== + version "4.4.1" + resolved "https://registry.yarnpkg.com/@types/react-transition-group/-/react-transition-group-4.4.1.tgz#e1a3cb278df7f47f17b5082b1b3da17170bd44b1" + integrity sha512-vIo69qKKcYoJ8wKCJjwSgCTM+z3chw3g18dkrDfVX665tMH7tmbDxEAnPdey4gTlwZz5QuHGzd+hul0OVZDqqQ== dependencies: "@types/react" "*" -"@types/react@*", "@types/react@^16.9.11": - version "16.9.55" - resolved "https://registry.yarnpkg.com/@types/react/-/react-16.9.55.tgz#47078587f5bfe028a23b6b46c7b94ac0d436acff" - integrity sha512-6KLe6lkILeRwyyy7yG9rULKJ0sXplUsl98MGoCfpteXf9sPWFWWMknDcsvubcpaTdBuxtsLF6HDUwdApZL/xIg== +"@types/react@*", "@types/react@>=16.9.11", "@types/react@>=16.9.35": + version "17.0.3" + resolved "https://registry.yarnpkg.com/@types/react/-/react-17.0.3.tgz#ba6e215368501ac3826951eef2904574c262cc79" + integrity sha512-wYOUxIgs2HZZ0ACNiIayItyluADNbONl7kt8lkLjVK8IitMH5QMyAh75Fwhmo37r1m7L2JaFj03sIfxBVDvRAg== dependencies: "@types/prop-types" "*" + "@types/scheduler" "*" csstype "^3.0.2" -"@types/react@17.0.0", "@types/react@>=16.9.35": +"@types/react@17.0.0": version "17.0.0" resolved "https://registry.yarnpkg.com/@types/react/-/react-17.0.0.tgz#5af3eb7fad2807092f0046a1302b7823e27919b8" integrity sha512-aj/L7RIMsRlWML3YB6KZiXB3fV2t41+5RBGYF8z+tAKU43Px8C3cYUZsDvf1/+Bm4FK21QWBrDutu8ZJ/70qOw== @@ -3644,6 +2921,11 @@ dependencies: "@types/node" "*" +"@types/scheduler@*": + version "0.16.1" + resolved "https://registry.yarnpkg.com/@types/scheduler/-/scheduler-0.16.1.tgz#18845205e86ff0038517aab7a18a62a6b9f71275" + integrity sha512-EaCxbanVeyxDRTQBkdLb3Bvl/HK7PBK6UJjsSixB0iHKoWxE5uu2Q/DgtpOhPIojN0Zl1whvOd7PoHs2P0s5eA== + "@types/schema-utils@^2.4.0": version "2.4.0" resolved "https://registry.yarnpkg.com/@types/schema-utils/-/schema-utils-2.4.0.tgz#9983012045d541dcee053e685a27c9c87c840fcd" @@ -3667,9 +2949,9 @@ integrity sha512-W+bw9ds02rAQaMvaLYxAbJ6cvguW/iJXNT6lTssS1ps6QdrMKttqEAMEG/b5CR8TZl3/L7/lH0ZV5nNR1LXikA== "@types/uglify-js@*": - version "3.11.1" - resolved "https://registry.yarnpkg.com/@types/uglify-js/-/uglify-js-3.11.1.tgz#97ff30e61a0aa6876c270b5f538737e2d6ab8ceb" - integrity sha512-7npvPKV+jINLu1SpSYVWG8KvyJBhBa8tmzMMdDoVc2pWUYHN8KIXlPJhjJ4LT97c4dXJA2SHL/q6ADbDriZN+Q== + version "3.13.0" + resolved "https://registry.yarnpkg.com/@types/uglify-js/-/uglify-js-3.13.0.tgz#1cad8df1fb0b143c5aba08de5712ea9d1ff71124" + integrity sha512-EGkrJD5Uy+Pg0NUR8uA4bJ5WMfljyad0G+784vLCNUkD+QwOJXUbBYExXfVGf7YtyzdQp3L/XMYcliB987kL5Q== dependencies: source-map "^0.6.1" @@ -3689,18 +2971,18 @@ integrity sha1-DSUBJorY+ZYrdA04fEZU9fjiPlI= "@types/webpack-sources@*": - version "2.0.0" - resolved "https://registry.yarnpkg.com/@types/webpack-sources/-/webpack-sources-2.0.0.tgz#08216ab9be2be2e1499beaebc4d469cec81e82a7" - integrity sha512-a5kPx98CNFRKQ+wqawroFunvFqv7GHm/3KOI52NY9xWADgc8smu4R6prt4EU/M4QfVjvgBkMqU4fBhw3QfMVkg== + version "2.1.0" + resolved "https://registry.yarnpkg.com/@types/webpack-sources/-/webpack-sources-2.1.0.tgz#8882b0bd62d1e0ce62f183d0d01b72e6e82e8c10" + integrity sha512-LXn/oYIpBeucgP1EIJbKQ2/4ZmpvRl+dlrFdX7+94SKRUV3Evy3FsfMZY318vGhkWUS5MPhtOM3w1/hCOAOXcg== dependencies: "@types/node" "*" "@types/source-list-map" "*" source-map "^0.7.3" "@types/webpack@^4.41.8": - version "4.41.24" - resolved "https://registry.yarnpkg.com/@types/webpack/-/webpack-4.41.24.tgz#75b664abe3d5bcfe54e64313ca3b43e498550422" - integrity sha512-1A0MXPwZiMOD3DPMuOKUKcpkdPo8Lq33UGggZ7xio6wJ/jV1dAu5cXDrOfGDnldUroPIRLsr/DT43/GqOA4RFQ== + version "4.41.26" + resolved "https://registry.yarnpkg.com/@types/webpack/-/webpack-4.41.26.tgz#27a30d7d531e16489f9c7607c747be6bc1a459ef" + integrity sha512-7ZyTfxjCRwexh+EJFwRUM+CDB2XvgHl4vfuqf1ZKrgGvcS5BrNvPQqJh3tsZ0P6h6Aa1qClVHaJZszLPzpqHeA== dependencies: "@types/anymatch" "*" "@types/node" "*" @@ -3709,22 +2991,22 @@ "@types/webpack-sources" "*" source-map "^0.6.0" -"@types/websocket@1.0.1": - version "1.0.1" - resolved "https://registry.yarnpkg.com/@types/websocket/-/websocket-1.0.1.tgz#039272c196c2c0e4868a0d8a1a27bbb86e9e9138" - integrity sha512-f5WLMpezwVxCLm1xQe/kdPpQIOmL0TXYx2O15VYfYzc7hTIdxiOoOvez+McSIw3b7z/1zGovew9YSL7+h4h7/Q== +"@types/websocket@1.0.2": + version "1.0.2" + resolved "https://registry.yarnpkg.com/@types/websocket/-/websocket-1.0.2.tgz#d2855c6a312b7da73ed16ba6781815bf30c6187a" + integrity sha512-B5m9aq7cbbD/5/jThEr33nUY8WEfVi6A2YKCTOvw5Ldy7mtsOkqRvGjnzy6g7iMMDsgu7xREuCzqATLDLQVKcQ== dependencies: "@types/node" "*" "@types/yargs-parser@*": - version "15.0.0" - resolved "https://registry.yarnpkg.com/@types/yargs-parser/-/yargs-parser-15.0.0.tgz#cb3f9f741869e20cce330ffbeb9271590483882d" - integrity sha512-FA/BWv8t8ZWJ+gEOnLLd8ygxH/2UFbAvgEonyfN6yWGLKc7zVjbpl2Y4CTjid9h2RfgPP6SEt6uHwEOply00yw== + version "20.2.0" + resolved "https://registry.yarnpkg.com/@types/yargs-parser/-/yargs-parser-20.2.0.tgz#dd3e6699ba3237f0348cd085e4698780204842f9" + integrity sha512-37RSHht+gzzgYeobbG+KWryeAW8J33Nhr69cjTqSYymXVZEN9NbRYWoYlRtDhHKPVT1FyNKwaTPC1NynKZpzRA== "@types/yargs@^15.0.0": - version "15.0.9" - resolved "https://registry.yarnpkg.com/@types/yargs/-/yargs-15.0.9.tgz#524cd7998fe810cdb02f26101b699cccd156ff19" - integrity sha512-HmU8SeIRhZCWcnRskCs36Q1Q00KBV6Cqh/ora8WN1+22dY07AZdn6Gel8QZ3t26XYPImtcL8WV/eqjhVmMEw4g== + version "15.0.13" + resolved "https://registry.yarnpkg.com/@types/yargs/-/yargs-15.0.13.tgz#34f7fec8b389d7f3c1fd08026a5763e072d3c6dc" + integrity sha512-kQ5JNTrbDv3Rp5X2n/iUu37IJBDU2gsZ5R/g1/KHOOEc5IKfUFjXT6DENPGduh08I/pamwtEq4oul7gUqKTQDQ== dependencies: "@types/yargs-parser" "*" @@ -3734,17 +3016,17 @@ integrity sha512-9cwk3c87qQKZrT251EDoibiYRILjCmxBvvcb4meofCmx1vdnNcR9gyildy5vOHASpOKMsn42CugxUvcwK5eu1g== "@types/zen-observable@^0.8.0": - version "0.8.1" - resolved "https://registry.yarnpkg.com/@types/zen-observable/-/zen-observable-0.8.1.tgz#5668c0bce55a91f2b9566b1d8a4c0a8dbbc79764" - integrity sha512-wmk0xQI6Yy7Fs/il4EpOcflG4uonUpYGqvZARESLc2oy4u69fkatFLbJOeW4Q6awO15P4rduAe6xkwHevpXcUQ== + version "0.8.2" + resolved "https://registry.yarnpkg.com/@types/zen-observable/-/zen-observable-0.8.2.tgz#808c9fa7e4517274ed555fa158f2de4b4f468e71" + integrity sha512-HrCIVMLjE1MOozVoD86622S7aunluLb2PJdPfb3nYiEtohm8mIB/vyv0Fd37AdeMFrTUQXEunw78YloMA3Qilg== -"@typescript-eslint/eslint-plugin@^4.14.0": - version "4.14.0" - resolved "https://registry.yarnpkg.com/@typescript-eslint/eslint-plugin/-/eslint-plugin-4.14.0.tgz#92db8e7c357ed7d69632d6843ca70b71be3a721d" - integrity sha512-IJ5e2W7uFNfg4qh9eHkHRUCbgZ8VKtGwD07kannJvM5t/GU8P8+24NX8gi3Hf5jST5oWPY8kyV1s/WtfiZ4+Ww== +"@typescript-eslint/eslint-plugin@^4.14.0", "@typescript-eslint/eslint-plugin@^4.5.0": + version "4.19.0" + resolved "https://registry.yarnpkg.com/@typescript-eslint/eslint-plugin/-/eslint-plugin-4.19.0.tgz#56f8da9ee118fe9763af34d6a526967234f6a7f0" + integrity sha512-CRQNQ0mC2Pa7VLwKFbrGVTArfdVDdefS+gTw0oC98vSI98IX5A8EVH4BzJ2FOB0YlCmm8Im36Elad/Jgtvveaw== dependencies: - "@typescript-eslint/experimental-utils" "4.14.0" - "@typescript-eslint/scope-manager" "4.14.0" + "@typescript-eslint/experimental-utils" "4.19.0" + "@typescript-eslint/scope-manager" "4.19.0" debug "^4.1.1" functional-red-black-tree "^1.0.1" lodash "^4.17.15" @@ -3752,40 +3034,15 @@ semver "^7.3.2" tsutils "^3.17.1" -"@typescript-eslint/eslint-plugin@^4.5.0": - version "4.6.0" - resolved "https://registry.yarnpkg.com/@typescript-eslint/eslint-plugin/-/eslint-plugin-4.6.0.tgz#210cd538bb703f883aff81d3996961f5dba31fdb" - integrity sha512-1+419X+Ynijytr1iWI+/IcX/kJryc78YNpdaXR1aRO1sU3bC0vZrIAF1tIX7rudVI84W7o7M4zo5p1aVt70fAg== - dependencies: - "@typescript-eslint/experimental-utils" "4.6.0" - "@typescript-eslint/scope-manager" "4.6.0" - debug "^4.1.1" - functional-red-black-tree "^1.0.1" - regexpp "^3.0.0" - semver "^7.3.2" - tsutils "^3.17.1" - -"@typescript-eslint/experimental-utils@4.14.0": - version "4.14.0" - resolved "https://registry.yarnpkg.com/@typescript-eslint/experimental-utils/-/experimental-utils-4.14.0.tgz#5aa7b006736634f588a69ee343ca959cd09988df" - integrity sha512-6i6eAoiPlXMKRbXzvoQD5Yn9L7k9ezzGRvzC/x1V3650rUk3c3AOjQyGYyF9BDxQQDK2ElmKOZRD0CbtdkMzQQ== +"@typescript-eslint/experimental-utils@4.19.0", "@typescript-eslint/experimental-utils@^4.0.1": + version "4.19.0" + resolved "https://registry.yarnpkg.com/@typescript-eslint/experimental-utils/-/experimental-utils-4.19.0.tgz#9ca379919906dc72cb0fcd817d6cb5aa2d2054c6" + integrity sha512-9/23F1nnyzbHKuoTqFN1iXwN3bvOm/PRIXSBR3qFAYotK/0LveEOHr5JT1WZSzcD6BESl8kPOG3OoDRKO84bHA== dependencies: "@types/json-schema" "^7.0.3" - "@typescript-eslint/scope-manager" "4.14.0" - "@typescript-eslint/types" "4.14.0" - "@typescript-eslint/typescript-estree" "4.14.0" - eslint-scope "^5.0.0" - eslint-utils "^2.0.0" - -"@typescript-eslint/experimental-utils@4.6.0", "@typescript-eslint/experimental-utils@^4.0.1": - version "4.6.0" - resolved "https://registry.yarnpkg.com/@typescript-eslint/experimental-utils/-/experimental-utils-4.6.0.tgz#f750aef4dd8e5970b5c36084f0a5ca2f0db309a4" - integrity sha512-pnh6Beh2/4xjJVNL+keP49DFHk3orDHHFylSp3WEjtgW3y1U+6l+jNnJrGlbs6qhAz5z96aFmmbUyKhunXKvKw== - dependencies: - "@types/json-schema" "^7.0.3" - "@typescript-eslint/scope-manager" "4.6.0" - "@typescript-eslint/types" "4.6.0" - "@typescript-eslint/typescript-estree" "4.6.0" + "@typescript-eslint/scope-manager" "4.19.0" + "@typescript-eslint/types" "4.19.0" + "@typescript-eslint/typescript-estree" "4.19.0" eslint-scope "^5.0.0" eslint-utils "^2.0.0" @@ -3800,79 +3057,33 @@ eslint-scope "^5.0.0" eslint-utils "^2.0.0" -"@typescript-eslint/parser@4.4.1": - version "4.4.1" - resolved "https://registry.yarnpkg.com/@typescript-eslint/parser/-/parser-4.4.1.tgz#25fde9c080611f303f2f33cedb145d2c59915b80" - integrity sha512-S0fuX5lDku28Au9REYUsV+hdJpW/rNW0gWlc4SXzF/kdrRaAVX9YCxKpziH7djeWT/HFAjLZcnY7NJD8xTeUEg== +"@typescript-eslint/parser@^4.14.0", "@typescript-eslint/parser@^4.4.1", "@typescript-eslint/parser@^4.5.0": + version "4.19.0" + resolved "https://registry.yarnpkg.com/@typescript-eslint/parser/-/parser-4.19.0.tgz#4ae77513b39f164f1751f21f348d2e6cb2d11128" + integrity sha512-/uabZjo2ZZhm66rdAu21HA8nQebl3lAIDcybUoOxoI7VbZBYavLIwtOOmykKCJy+Xq6Vw6ugkiwn8Js7D6wieA== dependencies: - "@typescript-eslint/scope-manager" "4.4.1" - "@typescript-eslint/types" "4.4.1" - "@typescript-eslint/typescript-estree" "4.4.1" + "@typescript-eslint/scope-manager" "4.19.0" + "@typescript-eslint/types" "4.19.0" + "@typescript-eslint/typescript-estree" "4.19.0" debug "^4.1.1" -"@typescript-eslint/parser@^4.14.0": - version "4.14.0" - resolved "https://registry.yarnpkg.com/@typescript-eslint/parser/-/parser-4.14.0.tgz#62d4cd2079d5c06683e9bfb200c758f292c4dee7" - integrity sha512-sUDeuCjBU+ZF3Lzw0hphTyScmDDJ5QVkyE21pRoBo8iDl7WBtVFS+WDN3blY1CH3SBt7EmYCw6wfmJjF0l/uYg== +"@typescript-eslint/scope-manager@4.19.0": + version "4.19.0" + resolved "https://registry.yarnpkg.com/@typescript-eslint/scope-manager/-/scope-manager-4.19.0.tgz#5e0b49eca4df7684205d957c9856f4e720717a4f" + integrity sha512-GGy4Ba/hLXwJXygkXqMzduqOMc+Na6LrJTZXJWVhRrSuZeXmu8TAnniQVKgj8uTRKe4igO2ysYzH+Np879G75g== dependencies: - "@typescript-eslint/scope-manager" "4.14.0" - "@typescript-eslint/types" "4.14.0" - "@typescript-eslint/typescript-estree" "4.14.0" - debug "^4.1.1" - -"@typescript-eslint/parser@^4.5.0": - version "4.6.0" - resolved "https://registry.yarnpkg.com/@typescript-eslint/parser/-/parser-4.6.0.tgz#7e9ff7df2f21d5c8f65f17add3b99eeeec33199d" - integrity sha512-Dj6NJxBhbdbPSZ5DYsQqpR32MwujF772F2H3VojWU6iT4AqL4BKuoNWOPFCoSZvCcADDvQjDpa6OLDAaiZPz2Q== - dependencies: - "@typescript-eslint/scope-manager" "4.6.0" - "@typescript-eslint/types" "4.6.0" - "@typescript-eslint/typescript-estree" "4.6.0" - debug "^4.1.1" - -"@typescript-eslint/scope-manager@4.14.0": - version "4.14.0" - resolved "https://registry.yarnpkg.com/@typescript-eslint/scope-manager/-/scope-manager-4.14.0.tgz#55a4743095d684e1f7b7180c4bac2a0a3727f517" - integrity sha512-/J+LlRMdbPh4RdL4hfP1eCwHN5bAhFAGOTsvE6SxsrM/47XQiPSgF5MDgLyp/i9kbZV9Lx80DW0OpPkzL+uf8Q== - dependencies: - "@typescript-eslint/types" "4.14.0" - "@typescript-eslint/visitor-keys" "4.14.0" - -"@typescript-eslint/scope-manager@4.4.1": - version "4.4.1" - resolved "https://registry.yarnpkg.com/@typescript-eslint/scope-manager/-/scope-manager-4.4.1.tgz#d19447e60db2ce9c425898d62fa03b2cce8ea3f9" - integrity sha512-2oD/ZqD4Gj41UdFeWZxegH3cVEEH/Z6Bhr/XvwTtGv66737XkR4C9IqEkebCuqArqBJQSj4AgNHHiN1okzD/wQ== - dependencies: - "@typescript-eslint/types" "4.4.1" - "@typescript-eslint/visitor-keys" "4.4.1" - -"@typescript-eslint/scope-manager@4.6.0": - version "4.6.0" - resolved "https://registry.yarnpkg.com/@typescript-eslint/scope-manager/-/scope-manager-4.6.0.tgz#b7d8b57fe354047a72dfb31881d9643092838662" - integrity sha512-uZx5KvStXP/lwrMrfQQwDNvh2ppiXzz5TmyTVHb+5TfZ3sUP7U1onlz3pjoWrK9konRyFe1czyxObWTly27Ang== - dependencies: - "@typescript-eslint/types" "4.6.0" - "@typescript-eslint/visitor-keys" "4.6.0" + "@typescript-eslint/types" "4.19.0" + "@typescript-eslint/visitor-keys" "4.19.0" "@typescript-eslint/types@3.10.1": version "3.10.1" resolved "https://registry.yarnpkg.com/@typescript-eslint/types/-/types-3.10.1.tgz#1d7463fa7c32d8a23ab508a803ca2fe26e758727" integrity sha512-+3+FCUJIahE9q0lDi1WleYzjCwJs5hIsbugIgnbB+dSCYUxl8L6PwmsyOPFZde2hc1DlTo/xnkOgiTLSyAbHiQ== -"@typescript-eslint/types@4.14.0": - version "4.14.0" - resolved "https://registry.yarnpkg.com/@typescript-eslint/types/-/types-4.14.0.tgz#d8a8202d9b58831d6fd9cee2ba12f8a5a5dd44b6" - integrity sha512-VsQE4VvpldHrTFuVPY1ZnHn/Txw6cZGjL48e+iBxTi2ksa9DmebKjAeFmTVAYoSkTk7gjA7UqJ7pIsyifTsI4A== - -"@typescript-eslint/types@4.4.1": - version "4.4.1" - resolved "https://registry.yarnpkg.com/@typescript-eslint/types/-/types-4.4.1.tgz#c507b35cf523bc7ba00aae5f75ee9b810cdabbc1" - integrity sha512-KNDfH2bCyax5db+KKIZT4rfA8rEk5N0EJ8P0T5AJjo5xrV26UAzaiqoJCxeaibqc0c/IvZxp7v2g3difn2Pn3w== - -"@typescript-eslint/types@4.6.0": - version "4.6.0" - resolved "https://registry.yarnpkg.com/@typescript-eslint/types/-/types-4.6.0.tgz#157ca925637fd53c193c6bf226a6c02b752dde2f" - integrity sha512-5FAgjqH68SfFG4UTtIFv+rqYJg0nLjfkjD0iv+5O27a0xEeNZ5rZNDvFGZDizlCD1Ifj7MAbSW2DPMrf0E9zjA== +"@typescript-eslint/types@4.19.0": + version "4.19.0" + resolved "https://registry.yarnpkg.com/@typescript-eslint/types/-/types-4.19.0.tgz#5181d5d2afd02e5b8f149ebb37ffc8bd7b07a568" + integrity sha512-A4iAlexVvd4IBsSTNxdvdepW0D4uR/fwxDrKUa+iEY9UWvGREu2ZyB8ylTENM1SH8F7bVC9ac9+si3LWNxcBuA== "@typescript-eslint/typescript-estree@3.10.1": version "3.10.1" @@ -3888,45 +3099,16 @@ semver "^7.3.2" tsutils "^3.17.1" -"@typescript-eslint/typescript-estree@4.14.0": - version "4.14.0" - resolved "https://registry.yarnpkg.com/@typescript-eslint/typescript-estree/-/typescript-estree-4.14.0.tgz#4bcd67486e9acafc3d0c982b23a9ab8ac8911ed7" - integrity sha512-wRjZ5qLao+bvS2F7pX4qi2oLcOONIB+ru8RGBieDptq/SudYwshveORwCVU4/yMAd4GK7Fsf8Uq1tjV838erag== +"@typescript-eslint/typescript-estree@4.19.0": + version "4.19.0" + resolved "https://registry.yarnpkg.com/@typescript-eslint/typescript-estree/-/typescript-estree-4.19.0.tgz#8a709ffa400284ab72df33376df085e2e2f61147" + integrity sha512-3xqArJ/A62smaQYRv2ZFyTA+XxGGWmlDYrsfZG68zJeNbeqRScnhf81rUVa6QG4UgzHnXw5VnMT5cg75dQGDkA== dependencies: - "@typescript-eslint/types" "4.14.0" - "@typescript-eslint/visitor-keys" "4.14.0" + "@typescript-eslint/types" "4.19.0" + "@typescript-eslint/visitor-keys" "4.19.0" debug "^4.1.1" globby "^11.0.1" is-glob "^4.0.1" - lodash "^4.17.15" - semver "^7.3.2" - tsutils "^3.17.1" - -"@typescript-eslint/typescript-estree@4.4.1": - version "4.4.1" - resolved "https://registry.yarnpkg.com/@typescript-eslint/typescript-estree/-/typescript-estree-4.4.1.tgz#598f6de488106c2587d47ca2462c60f6e2797cb8" - integrity sha512-wP/V7ScKzgSdtcY1a0pZYBoCxrCstLrgRQ2O9MmCUZDtmgxCO/TCqOTGRVwpP4/2hVfqMz/Vw1ZYrG8cVxvN3g== - dependencies: - "@typescript-eslint/types" "4.4.1" - "@typescript-eslint/visitor-keys" "4.4.1" - debug "^4.1.1" - globby "^11.0.1" - is-glob "^4.0.1" - lodash "^4.17.15" - semver "^7.3.2" - tsutils "^3.17.1" - -"@typescript-eslint/typescript-estree@4.6.0": - version "4.6.0" - resolved "https://registry.yarnpkg.com/@typescript-eslint/typescript-estree/-/typescript-estree-4.6.0.tgz#85bd98dcc8280511cfc5b2ce7b03a9ffa1732b08" - integrity sha512-s4Z9qubMrAo/tw0CbN0IN4AtfwuehGXVZM0CHNMdfYMGBDhPdwTEpBrecwhP7dRJu6d9tT9ECYNaWDHvlFSngA== - dependencies: - "@typescript-eslint/types" "4.6.0" - "@typescript-eslint/visitor-keys" "4.6.0" - debug "^4.1.1" - globby "^11.0.1" - is-glob "^4.0.1" - lodash "^4.17.15" semver "^7.3.2" tsutils "^3.17.1" @@ -3937,28 +3119,12 @@ dependencies: eslint-visitor-keys "^1.1.0" -"@typescript-eslint/visitor-keys@4.14.0": - version "4.14.0" - resolved "https://registry.yarnpkg.com/@typescript-eslint/visitor-keys/-/visitor-keys-4.14.0.tgz#b1090d9d2955b044b2ea2904a22496849acbdf54" - integrity sha512-MeHHzUyRI50DuiPgV9+LxcM52FCJFYjJiWHtXlbyC27b80mfOwKeiKI+MHOTEpcpfmoPFm/vvQS88bYIx6PZTA== +"@typescript-eslint/visitor-keys@4.19.0": + version "4.19.0" + resolved "https://registry.yarnpkg.com/@typescript-eslint/visitor-keys/-/visitor-keys-4.19.0.tgz#cbea35109cbd9b26e597644556be4546465d8f7f" + integrity sha512-aGPS6kz//j7XLSlgpzU2SeTqHPsmRYxFztj2vPuMMFJXZudpRSehE3WCV+BaxwZFvfAqMoSd86TEuM0PQ59E/A== dependencies: - "@typescript-eslint/types" "4.14.0" - eslint-visitor-keys "^2.0.0" - -"@typescript-eslint/visitor-keys@4.4.1": - version "4.4.1" - resolved "https://registry.yarnpkg.com/@typescript-eslint/visitor-keys/-/visitor-keys-4.4.1.tgz#1769dc7a9e2d7d2cfd3318b77ed8249187aed5c3" - integrity sha512-H2JMWhLaJNeaylSnMSQFEhT/S/FsJbebQALmoJxMPMxLtlVAMy2uJP/Z543n9IizhjRayLSqoInehCeNW9rWcw== - dependencies: - "@typescript-eslint/types" "4.4.1" - eslint-visitor-keys "^2.0.0" - -"@typescript-eslint/visitor-keys@4.6.0": - version "4.6.0" - resolved "https://registry.yarnpkg.com/@typescript-eslint/visitor-keys/-/visitor-keys-4.6.0.tgz#fb05d6393891b0a089b243fc8f9fb8039383d5da" - integrity sha512-38Aa9Ztl0XyFPVzmutHXqDMCu15Xx8yKvUo38Gu3GhsuckCh3StPI5t2WIO9LHEsOH7MLmlGfKUisU8eW1Sjhg== - dependencies: - "@typescript-eslint/types" "4.6.0" + "@typescript-eslint/types" "4.19.0" eslint-visitor-keys "^2.0.0" "@ungap/global-this@^0.4.2": @@ -4112,30 +3278,23 @@ "@xtuc/long" "4.2.2" "@wry/context@^0.5.2": - version "0.5.2" - resolved "https://registry.yarnpkg.com/@wry/context/-/context-0.5.2.tgz#f2a5d5ab9227343aa74c81e06533c1ef84598ec7" - integrity sha512-B/JLuRZ/vbEKHRUiGj6xiMojST1kHhu4WcreLfNN7q9DqQFrb97cWgf/kiYsPSUCAMVN0HzfFc8XjJdzgZzfjw== + version "0.5.4" + resolved "https://registry.yarnpkg.com/@wry/context/-/context-0.5.4.tgz#b6c28038872e0a0e1ff14eb40b5bf4cab2ab4e06" + integrity sha512-/pktJKHUXDr4D6TJqWgudOPJW2Z+Nb+bqk40jufA3uTkLbnCRKdJPiYDIa/c7mfcPH8Hr6O8zjCERpg5Sq04Zg== dependencies: - tslib "^1.9.3" - -"@wry/equality@^0.2.0": - version "0.2.0" - resolved "https://registry.yarnpkg.com/@wry/equality/-/equality-0.2.0.tgz#a312d1b6a682d0909904c2bcd355b02303104fb7" - integrity sha512-Y4d+WH6hs+KZJUC8YKLYGarjGekBrhslDbf/R20oV+AakHPINSitHfDRQz3EGcEWc1luXYNUvMhawWtZVWNGvQ== - dependencies: - tslib "^1.9.3" + tslib "^1.14.1" "@wry/equality@^0.3.0": - version "0.3.1" - resolved "https://registry.yarnpkg.com/@wry/equality/-/equality-0.3.1.tgz#81080cdc2e0d8265cd303faa0c64b38a77884e06" - integrity sha512-8/Ftr3jUZ4EXhACfSwPIfNsE8V6WKesdjp+Dxi78Bej6qlasAxiz0/F8j0miACRj9CL4vC5Y5FsfwwEYAuhWbg== + version "0.3.4" + resolved "https://registry.yarnpkg.com/@wry/equality/-/equality-0.3.4.tgz#37f101552b18a046d5c0c06da7b2021b15f72c03" + integrity sha512-1gQQhCPenzxw/1HzLlvSIs/59eBHJf9ZDIussjjZhqNSqQuPKQIzN6SWt4kemvlBPDi7RqMuUa03pId7MAE93g== dependencies: tslib "^1.14.1" "@wry/trie@^0.2.1": - version "0.2.1" - resolved "https://registry.yarnpkg.com/@wry/trie/-/trie-0.2.1.tgz#4191e1d4a85dd77dfede383d65563138ed82fc47" - integrity sha512-sYkuXZqArky2MLQCv4tLW6hX3N8AfTZ5ZMBc8jC6Yy35WYr82UYLLtjS7k/uRGHOA0yTSjuNadG6QQ6a5CS5hQ== + version "0.2.2" + resolved "https://registry.yarnpkg.com/@wry/trie/-/trie-0.2.2.tgz#99f20f0fcbbcda17006069b155c826cbabfc402f" + integrity sha512-OxqBB39x6MfHaa2HpMiRMfhuUnQTddD32Ko020eBeJXq87ivX6xnSSnzKHVbA21p7iqBASz8n/07b6W5wW1BVQ== dependencies: tslib "^1.14.1" @@ -4149,7 +3308,7 @@ resolved "https://registry.yarnpkg.com/@xtuc/long/-/long-4.2.2.tgz#d291c6a4e97989b5c61d9acf396ae4fe133a718d" integrity sha512-NuHqBY1PB/D8xU6s/thBgOAiAP7HOYDQ32+BFZILJ8ivkUkAHQnWfn6WhL79Owj1qmUnoN/YPhktdIoucipkAQ== -abab@^2.0.3: +abab@^2.0.3, abab@^2.0.5: version "2.0.5" resolved "https://registry.yarnpkg.com/abab/-/abab-2.0.5.tgz#c0b678fb32d60fc1219c784d6a826fe385aeb79a" integrity sha512-9IK9EadsbHo6jLWIpxpR6pL0sazTXV6+SQv25ZB+F7Bj9mJNaOc4nCRabwd5M/JwmUa8idz6Eci6eKfJryPs6Q== @@ -4170,7 +3329,7 @@ acorn-globals@^6.0.0: acorn "^7.1.1" acorn-walk "^7.1.1" -acorn-jsx@^5.2.0, acorn-jsx@^5.3.1: +acorn-jsx@^5.3.1: version "5.3.1" resolved "https://registry.yarnpkg.com/acorn-jsx/-/acorn-jsx-5.3.1.tgz#fc8661e11b7ac1539c47dbfea2e72b3af34d267b" integrity sha512-K0Ptm/47OKfQRpNQ2J/oIN/3QYiK6FwW+eJbILhsdxh2WTLdl+30o8aGdTbm5JbffpFFAg/g+zi1E+jvJha5ng== @@ -4190,6 +3349,11 @@ acorn@^7.1.0, acorn@^7.1.1, acorn@^7.4.0: resolved "https://registry.yarnpkg.com/acorn/-/acorn-7.4.1.tgz#feaed255973d2e77555b83dbc08851a6c63520fa" integrity sha512-nQyp0o1/mNdbTO1PO6kHkwSrmgZ0MT/jCCpNiwbUjGoRN4dlBhqJtoQuCnEOKzgTVwg0ZWiCoQy6SxMebQVh8A== +acorn@^8.0.5: + version "8.1.0" + resolved "https://registry.yarnpkg.com/acorn/-/acorn-8.1.0.tgz#52311fd7037ae119cbb134309e901aa46295b3fe" + integrity sha512-LWCF/Wn0nfHOmJ9rzQApGnxnvgfROzGilS8936rqN/lfcYkY9MYZzdMqN+2NJ4SlTc+m5HiSa+kNfDtI64dwUA== + address@1.1.2, address@^1.0.1: version "1.1.2" resolved "https://registry.yarnpkg.com/address/-/address-1.1.2.tgz#bf1116c9c758c51b7a933d296b72c221ed9428b6" @@ -4239,9 +3403,9 @@ ajv@^6.1.0, ajv@^6.10.0, ajv@^6.10.2, ajv@^6.12.3, ajv@^6.12.4, ajv@^6.12.5: uri-js "^4.2.2" ajv@^7.0.2: - version "7.0.3" - resolved "https://registry.yarnpkg.com/ajv/-/ajv-7.0.3.tgz#13ae747eff125cafb230ac504b2406cf371eece2" - integrity sha512-R50QRlXSxqXcQP5SvKUrw8VZeypvo12i2IX0EeR5PiZ7bEKeHWgzgo264LDadUsCU42lTJVhFikTqJwNeH34gQ== + version "7.2.3" + resolved "https://registry.yarnpkg.com/ajv/-/ajv-7.2.3.tgz#ca78d1cf458d7d36d1c3fa0794dd143406db5772" + integrity sha512-idv5WZvKVXDqKralOImQgPM9v6WOdLNa0IY3B3doOjw/YxRGT8I+allIJ6kd7Uaj+SF1xZUSU+nPM5aDNBVtnw== dependencies: fast-deep-equal "^3.1.1" json-schema-traverse "^1.0.0" @@ -4371,6 +3535,11 @@ argparse@^1.0.7: dependencies: sprintf-js "~1.0.2" +argparse@^2.0.1: + version "2.0.1" + resolved "https://registry.yarnpkg.com/argparse/-/argparse-2.0.1.tgz#246f50f3ca78a3240f6c997e8a9bd1eac49e4b38" + integrity sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q== + aria-query@^4.2.2: version "4.2.2" resolved "https://registry.yarnpkg.com/aria-query/-/aria-query-4.2.2.tgz#0d2ca6c9aceb56b8977e9fed6aed7e15bbd2f83b" @@ -4409,13 +3578,15 @@ array-flatten@^2.1.0: resolved "https://registry.yarnpkg.com/array-flatten/-/array-flatten-2.1.2.tgz#24ef80a28c1a893617e2149b0c6d0d788293b099" integrity sha512-hNfzcOV8W4NdualtqBFPyVO+54DSJuZGY9qT4pRroB6S9e3iiido2ISIC5h9R2sPJ8H3FHCIiEnsv1lPXO3KtQ== -array-includes@^3.1.1: - version "3.1.1" - resolved "https://registry.yarnpkg.com/array-includes/-/array-includes-3.1.1.tgz#cdd67e6852bdf9c1215460786732255ed2459348" - integrity sha512-c2VXaCHl7zPsvpkFsw4nxvFie4fh1ur9bpcgsVkIjqn0H/Xwdg+7fv3n2r/isyS8EBj5b06M9kHyZuIr4El6WQ== +array-includes@^3.1.1, array-includes@^3.1.2, array-includes@^3.1.3: + version "3.1.3" + resolved "https://registry.yarnpkg.com/array-includes/-/array-includes-3.1.3.tgz#c7f619b382ad2afaf5326cddfdc0afc61af7690a" + integrity sha512-gcem1KlBU7c9rB+Rq8/3PPKsK2kjqeEBa3bD5kkQo4nYlOHQCJqIJFqBXDEfwaRuYTT4E+FxA9xez7Gf/e3Q7A== dependencies: + call-bind "^1.0.2" define-properties "^1.1.3" - es-abstract "^1.17.0" + es-abstract "^1.18.0-next.2" + get-intrinsic "^1.1.1" is-string "^1.0.5" array-union@^1.0.1: @@ -4441,21 +3612,13 @@ array-unique@^0.3.2: integrity sha1-qJS3XUvE9s1nnvMkSp/Y9Gri1Cg= array.prototype.flat@^1.2.3: - version "1.2.3" - resolved "https://registry.yarnpkg.com/array.prototype.flat/-/array.prototype.flat-1.2.3.tgz#0de82b426b0318dbfdb940089e38b043d37f6c7b" - integrity sha512-gBlRZV0VSmfPIeWfuuy56XZMvbVfbEUnOXUvt3F/eUUUSyzlgLxhEX4YAEpxNAogRGehPSnfXyPtYyKAhkzQhQ== + version "1.2.4" + resolved "https://registry.yarnpkg.com/array.prototype.flat/-/array.prototype.flat-1.2.4.tgz#6ef638b43312bd401b4c6199fdec7e2dc9e9a123" + integrity sha512-4470Xi3GAPAjZqFcljX2xzckv1qeKPizoNkiS0+O4IoPR2ZNpcjE0pkhdihlDouK+x6QOast26B4Q/O9DJnwSg== dependencies: + call-bind "^1.0.0" define-properties "^1.1.3" - es-abstract "^1.17.0-next.1" - -array.prototype.flatmap@^1.2.3: - version "1.2.3" - resolved "https://registry.yarnpkg.com/array.prototype.flatmap/-/array.prototype.flatmap-1.2.3.tgz#1c13f84a178566042dd63de4414440db9222e443" - integrity sha512-OOEk+lkePcg+ODXIpvuU9PAryCikCJyo7GlDG1upleEpQRx6mzL9puEBkozQ5iAx20KV0l3DbyQwqciJtqe5Pg== - dependencies: - define-properties "^1.1.3" - es-abstract "^1.17.0-next.1" - function-bind "^1.1.1" + es-abstract "^1.18.0-next.1" array.prototype.flatmap@^1.2.4: version "1.2.4" @@ -4522,11 +3685,6 @@ ast-types-flow@^0.0.7: resolved "https://registry.yarnpkg.com/ast-types-flow/-/ast-types-flow-0.0.7.tgz#f70b735c6bca1a5c9c22d982c3e39e7feba3bdad" integrity sha1-9wtzXGvKGlycItmCw+Oef+ujva0= -astral-regex@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/astral-regex/-/astral-regex-1.0.0.tgz#6c8c3fb827dd43ee3918f27b82782ab7658a6fd9" - integrity sha512-+Ryf6g3BKoRc7jfp7ad8tM4TtMiaWvbF/1/sQcZPkkS7ag3D5nMBCe2UfOTONtAkaG0tO0ij3C5Lwmf1EiyjHg== - astral-regex@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/astral-regex/-/astral-regex-2.0.0.tgz#483143c567aeed4785759c0865786dc77d7d2e31" @@ -4593,9 +3751,9 @@ aws4@^1.8.0: integrity sha512-xh1Rl34h6Fi1DC2WWKfxUTVqRsNnr6LsKz2+hfwDxQJWmrx8+c7ylaqBMcHfl1U1r2dsifOvKX3LQuLNZ+XSvA== axe-core@^4.0.2: - version "4.0.2" - resolved "https://registry.yarnpkg.com/axe-core/-/axe-core-4.0.2.tgz#c7cf7378378a51fcd272d3c09668002a4990b1cb" - integrity sha512-arU1h31OGFu+LPrOLGZ7nB45v940NMDMEJeNmbutu57P+UFDVnkZg3e+J1I2HJRZ9hT7gO8J91dn/PMrAiKakA== + version "4.1.3" + resolved "https://registry.yarnpkg.com/axe-core/-/axe-core-4.1.3.tgz#64a4c85509e0991f5168340edc4bedd1ceea6966" + integrity sha512-vwPpH4Aj4122EW38mxO/fxhGKtwWTMLDIJfZ1He0Edbtjcfna/R3YB67yVhezUMzqc3Jr3+Ii50KRntlENL4xQ== axios@0.21.1: version "0.21.1" @@ -4633,16 +3791,16 @@ babel-extract-comments@^1.0.0: dependencies: babylon "^6.18.0" -babel-jest@^26.6.0, babel-jest@^26.6.1: - version "26.6.1" - resolved "https://registry.yarnpkg.com/babel-jest/-/babel-jest-26.6.1.tgz#07bd7bec14de47fe0f2c9a139741329f1f41788b" - integrity sha512-duMWEOKrSBYRVTTNpL2SipNIWnZOjP77auOBMPQ3zXAdnDbyZQWU8r/RxNWpUf9N6cgPFecQYelYLytTVXVDtA== +babel-jest@^26.6.0, babel-jest@^26.6.3: + version "26.6.3" + resolved "https://registry.yarnpkg.com/babel-jest/-/babel-jest-26.6.3.tgz#d87d25cb0037577a0c89f82e5755c5d293c01056" + integrity sha512-pl4Q+GAVOHwvjrck6jKjvmGhnO3jHX/xuB9d27f+EJZ/6k+6nMuPjorrYp7s++bKKdANwzElBWnLWaObvTnaZA== dependencies: - "@jest/transform" "^26.6.1" - "@jest/types" "^26.6.1" + "@jest/transform" "^26.6.2" + "@jest/types" "^26.6.2" "@types/babel__core" "^7.1.7" babel-plugin-istanbul "^6.0.0" - babel-preset-jest "^26.5.0" + babel-preset-jest "^26.6.2" chalk "^4.0.0" graceful-fs "^4.2.4" slash "^3.0.0" @@ -4676,17 +3834,17 @@ babel-plugin-istanbul@^6.0.0: istanbul-lib-instrument "^4.0.0" test-exclude "^6.0.0" -babel-plugin-jest-hoist@^26.5.0: - version "26.5.0" - resolved "https://registry.yarnpkg.com/babel-plugin-jest-hoist/-/babel-plugin-jest-hoist-26.5.0.tgz#3916b3a28129c29528de91e5784a44680db46385" - integrity sha512-ck17uZFD3CDfuwCLATWZxkkuGGFhMij8quP8CNhwj8ek1mqFgbFzRJ30xwC04LLscj/aKsVFfRST+b5PT7rSuw== +babel-plugin-jest-hoist@^26.6.2: + version "26.6.2" + resolved "https://registry.yarnpkg.com/babel-plugin-jest-hoist/-/babel-plugin-jest-hoist-26.6.2.tgz#8185bd030348d254c6d7dd974355e6a28b21e62d" + integrity sha512-PO9t0697lNTmcEHH69mdtYiOIkkOlj9fySqfO3K1eCcdISevLAE0xY59VLLUj0SoiPiTX/JU2CYFpILydUa5Lw== dependencies: "@babel/template" "^7.3.3" "@babel/types" "^7.3.3" "@types/babel__core" "^7.0.0" "@types/babel__traverse" "^7.0.6" -babel-plugin-macros@2.8.0, babel-plugin-macros@^2.6.1: +babel-plugin-macros@2.8.0: version "2.8.0" resolved "https://registry.yarnpkg.com/babel-plugin-macros/-/babel-plugin-macros-2.8.0.tgz#0f958a7cc6556b1e65344465d99111a1e5e10138" integrity sha512-SEP5kJpfGYqYKpBrj5XU3ahw5p5GOHJ0U5ssOSQ/WBVdwkD2Dzlce95exQTs3jOVWPPKLBN2rlEWkCK7dSmLvg== @@ -4701,28 +3859,28 @@ babel-plugin-named-asset-import@^0.3.7: integrity sha512-squySRkf+6JGnvjoUtDEjSREJEBirnXi9NqP6rjSYsylxQxqBTz+pkmf395i9E2zsvmYUaI40BHo6SqZUdydlw== babel-plugin-polyfill-corejs2@^0.1.4: - version "0.1.8" - resolved "https://registry.yarnpkg.com/babel-plugin-polyfill-corejs2/-/babel-plugin-polyfill-corejs2-0.1.8.tgz#54ef37b1c4b2311e515029e8f1f07bbd4d7a5321" - integrity sha512-kB5/xNR9GYDuRmVlL9EGfdKBSUVI/9xAU7PCahA/1hbC2Jbmks9dlBBYjHF9IHMNY2jV/G2lIG7z0tJIW27Rog== + version "0.1.10" + resolved "https://registry.yarnpkg.com/babel-plugin-polyfill-corejs2/-/babel-plugin-polyfill-corejs2-0.1.10.tgz#a2c5c245f56c0cac3dbddbf0726a46b24f0f81d1" + integrity sha512-DO95wD4g0A8KRaHKi0D51NdGXzvpqVLnLu5BTvDlpqUEpTmeEtypgC1xqesORaWmiUOQI14UHKlzNd9iZ2G3ZA== dependencies: "@babel/compat-data" "^7.13.0" - "@babel/helper-define-polyfill-provider" "^0.1.4" + "@babel/helper-define-polyfill-provider" "^0.1.5" semver "^6.1.1" babel-plugin-polyfill-corejs3@^0.1.3: - version "0.1.6" - resolved "https://registry.yarnpkg.com/babel-plugin-polyfill-corejs3/-/babel-plugin-polyfill-corejs3-0.1.6.tgz#ed1b02fba4885e0892e06094e27865f499758d27" - integrity sha512-IkYhCxPrjrUWigEmkMDXYzM5iblzKCdCD8cZrSAkQOyhhJm26DcG+Mxbx13QT//Olkpkg/AlRdT2L+Ww4Ciphw== + version "0.1.7" + resolved "https://registry.yarnpkg.com/babel-plugin-polyfill-corejs3/-/babel-plugin-polyfill-corejs3-0.1.7.tgz#80449d9d6f2274912e05d9e182b54816904befd0" + integrity sha512-u+gbS9bbPhZWEeyy1oR/YaaSpod/KDT07arZHb80aTpl8H5ZBq+uN1nN9/xtX7jQyfLdPfoqI4Rue/MQSWJquw== dependencies: - "@babel/helper-define-polyfill-provider" "^0.1.4" + "@babel/helper-define-polyfill-provider" "^0.1.5" core-js-compat "^3.8.1" babel-plugin-polyfill-regenerator@^0.1.2: - version "0.1.5" - resolved "https://registry.yarnpkg.com/babel-plugin-polyfill-regenerator/-/babel-plugin-polyfill-regenerator-0.1.5.tgz#f42a58fd86a1c97fbe3a2752d80a4a3e017203e1" - integrity sha512-EyhBA6uN94W97lR7ecQVTvH9F5tIIdEw3ZqHuU4zekMlW82k5cXNXniiB7PRxQm06BqAjVr4sDT1mOy4RcphIA== + version "0.1.6" + resolved "https://registry.yarnpkg.com/babel-plugin-polyfill-regenerator/-/babel-plugin-polyfill-regenerator-0.1.6.tgz#0fe06a026fe0faa628ccc8ba3302da0a6ce02f3f" + integrity sha512-OUrYG9iKPKz8NxswXbRAdSwF0GhRdIEMTloQATJi4bDuFqrXaXcCUT/VGNrr8pBcjMh1RxZ7Xt9cytVJTJfvMg== dependencies: - "@babel/helper-define-polyfill-provider" "^0.1.4" + "@babel/helper-define-polyfill-provider" "^0.1.5" babel-plugin-react-intl@^7.0.0: version "7.9.4" @@ -4763,10 +3921,10 @@ babel-plugin-transform-react-remove-prop-types@0.4.24: resolved "https://registry.yarnpkg.com/babel-plugin-transform-react-remove-prop-types/-/babel-plugin-transform-react-remove-prop-types-0.4.24.tgz#f2edaf9b4c6a5fbe5c1d678bfb531078c1555f3a" integrity sha512-eqj0hVcJUR57/Ug2zE1Yswsw4LhuqqHhD+8v120T1cl3kjg76QwtyBrdIk4WVwK+lAhBJVYCd/v+4nc4y+8JsA== -babel-preset-current-node-syntax@^0.1.3: - version "0.1.4" - resolved "https://registry.yarnpkg.com/babel-preset-current-node-syntax/-/babel-preset-current-node-syntax-0.1.4.tgz#826f1f8e7245ad534714ba001f84f7e906c3b615" - integrity sha512-5/INNCYhUGqw7VbVjT/hb3ucjgkVHKXY7lX3ZjlN4gm565VyFmJUrJ/h+h16ECVB38R/9SF6aACydpKMLZ/c9w== +babel-preset-current-node-syntax@^1.0.0: + version "1.0.1" + resolved "https://registry.yarnpkg.com/babel-preset-current-node-syntax/-/babel-preset-current-node-syntax-1.0.1.tgz#b4399239b89b2a011f9ddbe3e4f401fc40cff73b" + integrity sha512-M7LQ0bxarkxQoN+vz5aJPsLBn77n8QgTFmo8WK0/44auK2xlCXrYcUxHFxgU7qW5Yzw/CjmLRK2uJzaCd7LvqQ== dependencies: "@babel/plugin-syntax-async-generators" "^7.8.4" "@babel/plugin-syntax-bigint" "^7.8.3" @@ -4779,6 +3937,7 @@ babel-preset-current-node-syntax@^0.1.3: "@babel/plugin-syntax-object-rest-spread" "^7.8.3" "@babel/plugin-syntax-optional-catch-binding" "^7.8.3" "@babel/plugin-syntax-optional-chaining" "^7.8.3" + "@babel/plugin-syntax-top-level-await" "^7.8.3" babel-preset-fbjs@^3.3.0: version "3.3.0" @@ -4813,13 +3972,13 @@ babel-preset-fbjs@^3.3.0: "@babel/plugin-transform-template-literals" "^7.0.0" babel-plugin-syntax-trailing-function-commas "^7.0.0-beta.0" -babel-preset-jest@^26.5.0: - version "26.5.0" - resolved "https://registry.yarnpkg.com/babel-preset-jest/-/babel-preset-jest-26.5.0.tgz#f1b166045cd21437d1188d29f7fba470d5bdb0e7" - integrity sha512-F2vTluljhqkiGSJGBg/jOruA8vIIIL11YrxRcO7nviNTMbbofPSHwnm8mgP7d/wS7wRSexRoI6X1A6T74d4LQA== +babel-preset-jest@^26.6.2: + version "26.6.2" + resolved "https://registry.yarnpkg.com/babel-preset-jest/-/babel-preset-jest-26.6.2.tgz#747872b1171df032252426586881d62d31798fee" + integrity sha512-YvdtlVm9t3k777c5NPQIv6cxFFFapys25HiUmuSgHwIZhfifweR5c5Sf5nwE3MAbfu327CYSvps8Yx6ANLyleQ== dependencies: - babel-plugin-jest-hoist "^26.5.0" - babel-preset-current-node-syntax "^0.1.3" + babel-plugin-jest-hoist "^26.6.2" + babel-preset-current-node-syntax "^1.0.0" babel-preset-react-app@^10.0.0: version "10.0.0" @@ -4878,9 +4037,9 @@ base64-blob@^1.4.1: b64-to-blob "^1.2.19" base64-js@^1.0.2, base64-js@^1.3.1: - version "1.3.1" - resolved "https://registry.yarnpkg.com/base64-js/-/base64-js-1.3.1.tgz#58ece8cb75dd07e71ed08c736abc5fac4dbf8df1" - integrity sha512-mLQ4i2QO1ytvGWFWmcngKO//JXAQueZvwEKtjgQFM4jIK0kU+ytMfplL8j+n5mspOfjHwoAg+9yhb7BwAHm36g== + version "1.5.1" + resolved "https://registry.yarnpkg.com/base64-js/-/base64-js-1.5.1.tgz#1b1b440160a5bf7ad40b650f095963481903930a" + integrity sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA== base@^0.11.1: version "0.11.2" @@ -4928,9 +4087,9 @@ binary-extensions@^1.0.0: integrity sha512-Un7MIEDdUC5gNpcGDV97op1Ywk748MpHcFTHoYs6qnj1Z3j7I53VG3nwZhKzoBZmbdRNnb6WRdFlwl7tSDuZGw== binary-extensions@^2.0.0: - version "2.1.0" - resolved "https://registry.yarnpkg.com/binary-extensions/-/binary-extensions-2.1.0.tgz#30fa40c9e7fe07dbc895678cd287024dea241dd9" - integrity sha512-1Yj8h9Q+QDF5FzhMs/c9+6UntbD5MkRfRwac8DoEm9ZfUBZ7tZ55YcGVAzEe4bXsdQHEk+s9S5wsOKVdZrw0tQ== + version "2.2.0" + resolved "https://registry.yarnpkg.com/binary-extensions/-/binary-extensions-2.2.0.tgz#75f502eeaf9ffde42fc98829645be4ea76bd9e2d" + integrity sha512-jDctJ/IVQbZoJykoeHbhXpOlNBqGNcwXJKJog42E5HDPUwQTSdjCHdihjj0DlnheQ7blbT6dHOafNAiS8ooQKA== bindings@^1.5.0: version "1.5.0" @@ -4939,7 +4098,7 @@ bindings@^1.5.0: dependencies: file-uri-to-path "1.0.0" -bluebird@^3.5.5, bluebird@^3.7.2: +bluebird@^3.5.5: version "3.7.2" resolved "https://registry.yarnpkg.com/bluebird/-/bluebird-3.7.2.tgz#9f229c15be272454ffa973ace0dbee79a1b0c36f" integrity sha512-XpNj6GDQzdfW+r2Wnn7xiSAd7TM3jzkxGXBGTtWKuSXv1xUV+azxAm8jdWZN06QTQk+2N2XB9jRDkvbmQmcRtg== @@ -4949,15 +4108,15 @@ bmp-js@^0.1.0: resolved "https://registry.yarnpkg.com/bmp-js/-/bmp-js-0.1.0.tgz#e05a63f796a6c1ff25f4771ec7adadc148c07233" integrity sha1-4Fpj95amwf8l9Hcex62twUjAcjM= -bn.js@^4.0.0, bn.js@^4.1.0, bn.js@^4.4.0: - version "4.11.9" - resolved "https://registry.yarnpkg.com/bn.js/-/bn.js-4.11.9.tgz#26d556829458f9d1e81fc48952493d0ba3507828" - integrity sha512-E6QoYqCKZfgatHTdHzs1RRKP7ip4vvm+EyRUeE2RF0NblwVvb0p6jSVeNTOFxPn26QXN2o6SMfNxKp6kU8zQaw== +bn.js@^4.0.0, bn.js@^4.1.0, bn.js@^4.11.9: + version "4.12.0" + resolved "https://registry.yarnpkg.com/bn.js/-/bn.js-4.12.0.tgz#775b3f278efbb9718eec7361f483fb36fbbfea88" + integrity sha512-c98Bf3tPniI+scsdk237ku1Dc3ujXQTSgyiPUDEOe7tRkhrqridvh8klBv0HCEso1OLOYcHuCv/cS6DNxKH+ZA== -bn.js@^5.1.1: - version "5.1.3" - resolved "https://registry.yarnpkg.com/bn.js/-/bn.js-5.1.3.tgz#beca005408f642ebebea80b042b4d18d2ac0ee6b" - integrity sha512-GkTiFpjFtUzU9CbMeJ5iazkCzGL3jrhzerzZIuqLABjbwRaFt33I9tUdSNryIptM+RxDet6OKm2WnLXzW51KsQ== +bn.js@^5.0.0, bn.js@^5.1.1: + version "5.2.0" + resolved "https://registry.yarnpkg.com/bn.js/-/bn.js-5.2.0.tgz#358860674396c6997771a9d051fcc1b57d4ae002" + integrity sha512-D7iWRBvnZE8ecXiLj/9wbxH7Tk79fAh8IHaTNq1RWRixsS02W+5qS+iE9yq6RYl0asXx5tw0bLhmT5pIfbSquw== body-parser@1.19.0: version "1.19.0" @@ -5028,7 +4187,7 @@ braces@^3.0.1, braces@~3.0.2: dependencies: fill-range "^7.0.1" -brorand@^1.0.1: +brorand@^1.0.1, brorand@^1.1.0: version "1.1.0" resolved "https://registry.yarnpkg.com/brorand/-/brorand-1.1.0.tgz#12c25efe40a45e3c323eb8675a0a0ce57b22371f" integrity sha1-EsJe/kCkXjwyPrhnWgoM5XsiNx8= @@ -5070,11 +4229,11 @@ browserify-des@^1.0.0: safe-buffer "^5.1.2" browserify-rsa@^4.0.0, browserify-rsa@^4.0.1: - version "4.0.1" - resolved "https://registry.yarnpkg.com/browserify-rsa/-/browserify-rsa-4.0.1.tgz#21e0abfaf6f2029cf2fafb133567a701d4135524" - integrity sha1-IeCr+vbyApzy+vsTNWenAdQTVSQ= + version "4.1.0" + resolved "https://registry.yarnpkg.com/browserify-rsa/-/browserify-rsa-4.1.0.tgz#b2fd06b5b75ae297f7ce2dc651f918f5be158c8d" + integrity sha512-AdEER0Hkspgno2aR97SAf6vi0y0k8NuOpGnVH3O99rcA5Q6sh8QxcngtHuJ6uXwnfAXNM4Gn1Gb7/MV1+Ymbog== dependencies: - bn.js "^4.1.0" + bn.js "^5.0.0" randombytes "^2.0.1" browserify-sign@^4.0.0: @@ -5109,17 +4268,7 @@ browserslist@4.14.2: escalade "^3.0.2" node-releases "^1.1.61" -browserslist@^4.0.0, browserslist@^4.12.0, browserslist@^4.6.2, browserslist@^4.6.4, browserslist@^4.8.5: - version "4.14.5" - resolved "https://registry.yarnpkg.com/browserslist/-/browserslist-4.14.5.tgz#1c751461a102ddc60e40993639b709be7f2c4015" - integrity sha512-Z+vsCZIvCBvqLoYkBFTwEYH3v5MCQbsAjp50ERycpOjnPmolg1Gjy4+KaWWpm8QOJt9GHkhdqAl14NpCX73CWA== - dependencies: - caniuse-lite "^1.0.30001135" - electron-to-chromium "^1.3.571" - escalade "^3.1.0" - node-releases "^1.1.61" - -browserslist@^4.14.5, browserslist@^4.16.3: +browserslist@^4.0.0, browserslist@^4.12.0, browserslist@^4.14.5, browserslist@^4.16.3, browserslist@^4.6.2, browserslist@^4.6.4: version "4.16.3" resolved "https://registry.yarnpkg.com/browserslist/-/browserslist-4.16.3.tgz#340aa46940d7db878748567c5dea24a48ddf3717" integrity sha512-vIyhWmIkULaq04Gt93txdh+j02yX/JzlyhLYbV3YQCn/zvES3JnY7TifHHvvr1w5hTDluNKMkV05cs4vy8Q7sw== @@ -5171,31 +4320,31 @@ buffer@^4.3.0: ieee754 "^1.1.4" isarray "^1.0.0" -buffer@^5.2.0: - version "5.7.0" - resolved "https://registry.yarnpkg.com/buffer/-/buffer-5.7.0.tgz#88afbd29fc89fa7b58e82b39206f31f2cf34feed" - integrity sha512-cd+5r1VLBwUqTrmnzW+D7ABkJUM6mr7uv1dv+6jRw4Rcl7tFIFHDqHPL98LhpGFn3dbAt3gtLxtrWp4m1kFrqg== +buffer@^5.2.0, buffer@^5.7.0: + version "5.7.1" + resolved "https://registry.yarnpkg.com/buffer/-/buffer-5.7.1.tgz#ba62e7c13133053582197160851a8f648e99eed0" + integrity sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ== dependencies: base64-js "^1.3.1" ieee754 "^1.1.13" -bufferutil@^4.0.1: - version "4.0.2" - resolved "https://registry.yarnpkg.com/bufferutil/-/bufferutil-4.0.2.tgz#79f68631910f6b993d870fc77dc0a2894eb96cd5" - integrity sha512-AtnG3W6M8B2n4xDQ5R+70EXvOpnXsFYg/AK2yTZd+HQ/oxAdz+GI+DvjmhBw3L0ole+LJ0ngqY4JMbDzkfNzhA== - dependencies: - node-gyp-build "^4.2.0" - builtin-modules@^3.1.0: - version "3.1.0" - resolved "https://registry.yarnpkg.com/builtin-modules/-/builtin-modules-3.1.0.tgz#aad97c15131eb76b65b50ef208e7584cd76a7484" - integrity sha512-k0KL0aWZuBt2lrxrcASWDfwOLMnodeQjodT/1SxEQAXsHANgo6ZC/VEaSEHCXt7aSTZ4/4H5LKa+tBXmW7Vtvw== + version "3.2.0" + resolved "https://registry.yarnpkg.com/builtin-modules/-/builtin-modules-3.2.0.tgz#45d5db99e7ee5e6bc4f362e008bf917ab5049887" + integrity sha512-lGzLKcioL90C7wMczpkY0n/oART3MbBa8R9OFGE1rJxoVI86u4WAGfEk8Wjv10eKSyTHVGkSo3bvBylCEtk7LA== builtin-status-codes@^3.0.0: version "3.0.0" resolved "https://registry.yarnpkg.com/builtin-status-codes/-/builtin-status-codes-3.0.0.tgz#85982878e21b98e1c66425e03d0174788f569ee8" integrity sha1-hZgoeOIbmOHGZCXgPQF0eI9Wnug= +busboy@^0.3.1: + version "0.3.1" + resolved "https://registry.yarnpkg.com/busboy/-/busboy-0.3.1.tgz#170899274c5bf38aae27d5c62b71268cd585fd1b" + integrity sha512-y7tTxhGKXcyBxRKAni+awqx8uqaJKrSFSNFSeRG5CsWNdmy2BIK+6VGWEW7TZnIO/533mtMEA4rOevQV815YJw== + dependencies: + dicer "0.3.0" + bytes@3.0.0: version "3.0.0" resolved "https://registry.yarnpkg.com/bytes/-/bytes-3.0.0.tgz#d32815404d689699f85a4ea4fa8755dd13a96048" @@ -5228,9 +4377,9 @@ cacache@^12.0.2: y18n "^4.0.0" cacache@^15.0.5: - version "15.0.5" - resolved "https://registry.yarnpkg.com/cacache/-/cacache-15.0.5.tgz#69162833da29170d6732334643c60e005f5f17d0" - integrity sha512-lloiL22n7sOjEEXdL8NAjTgv9a1u43xICE9/203qonkZUCj5X1UEWIdf2/Y0d6QcCtMzbKQyhrcDbdvlZTs/+A== + version "15.0.6" + resolved "https://registry.yarnpkg.com/cacache/-/cacache-15.0.6.tgz#65a8c580fda15b59150fb76bf3f3a8e45d583099" + integrity sha512-g1WYDMct/jzW+JdWEyjaX2zoBkZ6ZT9VpOyp2I/VMtDsNLffNat3kqPFfi1eDRSK9/SuKGyORDHcQMcPF8sQ/w== dependencies: "@npmcli/move-file" "^1.0.1" chownr "^2.0.0" @@ -5246,7 +4395,7 @@ cacache@^15.0.5: p-map "^4.0.0" promise-inflight "^1.0.1" rimraf "^3.0.2" - ssri "^8.0.0" + ssri "^8.0.1" tar "^6.0.2" unique-filename "^1.1.1" @@ -5278,13 +4427,13 @@ cacheable-request@^6.0.0: normalize-url "^4.1.0" responselike "^1.0.2" -call-bind@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/call-bind/-/call-bind-1.0.0.tgz#24127054bb3f9bdcb4b1fb82418186072f77b8ce" - integrity sha512-AEXsYIyyDY3MCzbwdhzG3Jx1R0J2wetQyUynn6dYHAO+bg8l1k7jwZtRv4ryryFs7EP+NDlikJlVe59jr0cM2w== +call-bind@^1.0.0, call-bind@^1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/call-bind/-/call-bind-1.0.2.tgz#b1d4e89e688119c3c9a903ad30abb2f6a919be3c" + integrity sha512-7O+FbCihrB5WGbFYesctwmTKae6rOiIzmz1icreWJ+0aA7LJfuqhEso2T9ncpcFtzMQtzXf2QGGueWJGTYsqrA== dependencies: function-bind "^1.1.1" - get-intrinsic "^1.0.0" + get-intrinsic "^1.0.2" caller-callsite@^2.0.0: version "2.0.0" @@ -5310,7 +4459,7 @@ callsites@^3.0.0: resolved "https://registry.yarnpkg.com/callsites/-/callsites-3.1.0.tgz#b3630abd8943432f54b3f0519238e33cd7df2f73" integrity sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ== -camel-case@4.1.1, camel-case@^4.1.1: +camel-case@4.1.1: version "4.1.1" resolved "https://registry.yarnpkg.com/camel-case/-/camel-case-4.1.1.tgz#1fc41c854f00e2f7d0139dfeba1542d6896fe547" integrity sha512-7fa2WcG4fYFkclIvEmxBbTvmibwF2/agfEBc6q3lOpVu0A13ltLsA+Hr/8Hp6kp5f+G7hKi6t8lys6XxP+1K6Q== @@ -5318,7 +4467,7 @@ camel-case@4.1.1, camel-case@^4.1.1: pascal-case "^3.1.1" tslib "^1.10.0" -camel-case@^4.1.2: +camel-case@4.1.2, camel-case@^4.1.1, camel-case@^4.1.2: version "4.1.2" resolved "https://registry.yarnpkg.com/camel-case/-/camel-case-4.1.2.tgz#9728072a954f805228225a6deea6b38461e1bd5a" integrity sha512-gxGWBrTT1JuMx6R+o5PTXMmUnhnVzLQ9SNutD4YqKtI6ap897t3tKECYla6gCWEkplXnlNybEkZg9GEGxKFCgw== @@ -5355,15 +4504,19 @@ caniuse-api@^3.0.0: lodash.memoize "^4.1.2" lodash.uniq "^4.5.0" -caniuse-lite@^1.0.0, caniuse-lite@^1.0.30000981, caniuse-lite@^1.0.30001109, caniuse-lite@^1.0.30001125, caniuse-lite@^1.0.30001135: - version "1.0.30001154" - resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001154.tgz#f3bbc245ce55e4c1cd20fa731b097880181a7f17" - integrity sha512-y9DvdSti8NnYB9Be92ddMZQrcOe04kcQtcxtBx4NkB04+qZ+JUWotnXBJTmxlKudhxNTQ3RRknMwNU2YQl/Org== +caniuse-lite@^1.0.0, caniuse-lite@^1.0.30000981, caniuse-lite@^1.0.30001109, caniuse-lite@^1.0.30001125, caniuse-lite@^1.0.30001181: + version "1.0.30001204" + resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001204.tgz#256c85709a348ec4d175e847a3b515c66e79f2aa" + integrity sha512-JUdjWpcxfJ9IPamy2f5JaRDCaqJOxDzOSKtbdx4rH9VivMd1vIzoPumsJa9LoMIi4Fx2BV2KZOxWhNkBjaYivQ== -caniuse-lite@^1.0.30001181: - version "1.0.30001192" - resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001192.tgz#b848ebc0ab230cf313d194a4775a30155d50ae40" - integrity sha512-63OrUnwJj5T1rUmoyqYTdRWBqFFxZFlyZnRRjDR8NSUQFB6A+j/uBORU/SyJ5WzDLg4SPiZH40hQCBNdZ/jmAw== +capital-case@^1.0.4: + version "1.0.4" + resolved "https://registry.yarnpkg.com/capital-case/-/capital-case-1.0.4.tgz#9d130292353c9249f6b00fa5852bee38a717e669" + integrity sha512-ds37W8CytHgwnhGGTi88pcPyR15qoNkOpYwmMMfnWqqWgESapLqvDx6huFjQ5vqWSn2Z06173XNA7LtMOeUh1A== + dependencies: + no-case "^3.0.4" + tslib "^2.0.3" + upper-case-first "^2.0.2" capture-exit@^2.0.0: version "2.0.0" @@ -5382,6 +4535,11 @@ caseless@~0.12.0: resolved "https://registry.yarnpkg.com/caseless/-/caseless-0.12.0.tgz#1b681c21ff84033c826543090689420d187151dc" integrity sha1-G2gcIf+EAzyCZUMJBolCDRhxUdw= +ccount@^1.0.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/ccount/-/ccount-1.1.0.tgz#246687debb6014735131be8abab2d93898f8d043" + integrity sha512-vlNK021QdI7PNeiUh/lKkC/mNHHfV0m/Ad5JoI0TYtlBnJAslM/JIkm/tGC88bkLIwO6OQ5uV6ztS6kVAtCDlg== + chalk@2.4.2, chalk@^2.0.0, chalk@^2.4.1, chalk@^2.4.2: version "2.4.2" resolved "https://registry.yarnpkg.com/chalk/-/chalk-2.4.2.tgz#cd42541677a54333cf541a49108c1432b44c9424" @@ -5410,6 +4568,40 @@ chalk@^4.0.0, chalk@^4.1.0: ansi-styles "^4.1.0" supports-color "^7.1.0" +change-case-all@1.0.12: + version "1.0.12" + resolved "https://registry.yarnpkg.com/change-case-all/-/change-case-all-1.0.12.tgz#ae3e0faf5e610e8e25c5d5eaa4a6d5c2f1d68797" + integrity sha512-zdQus7R0lkprF99lrWUC5bFj6Nog4Xt4YCEjQ/vM4vbc6b5JHFBQMxRPAjfx+HJH8WxMzH0E+lQ8yQJLgmPCBg== + dependencies: + change-case "^4.1.1" + is-lower-case "^2.0.1" + is-upper-case "^2.0.1" + lower-case "^2.0.1" + lower-case-first "^2.0.1" + sponge-case "^1.0.0" + swap-case "^2.0.1" + title-case "^3.0.2" + upper-case "^2.0.1" + upper-case-first "^2.0.1" + +change-case@^4.1.1: + version "4.1.2" + resolved "https://registry.yarnpkg.com/change-case/-/change-case-4.1.2.tgz#fedfc5f136045e2398c0410ee441f95704641e12" + integrity sha512-bSxY2ws9OtviILG1EiY5K7NNxkqg/JnRnFxLtKQ96JaviiIxi7djMrSd0ECT9AC+lttClmYwKw53BWpOMblo7A== + dependencies: + camel-case "^4.1.2" + capital-case "^1.0.4" + constant-case "^3.0.4" + dot-case "^3.0.4" + header-case "^2.0.4" + no-case "^3.0.4" + param-case "^3.0.4" + pascal-case "^3.1.2" + path-case "^3.0.4" + sentence-case "^3.0.4" + snake-case "^3.0.4" + tslib "^2.0.3" + char-regex@^1.0.2: version "1.0.2" resolved "https://registry.yarnpkg.com/char-regex/-/char-regex-1.0.2.tgz#d744358226217f981ed58f479b1d6bcc29545dcf" @@ -5440,7 +4632,7 @@ check-types@^11.1.1: resolved "https://registry.yarnpkg.com/check-types/-/check-types-11.1.2.tgz#86a7c12bf5539f6324eb0e70ca8896c0e38f3e2f" integrity sha512-tzWzvgePgLORb9/3a0YenggReLKAIb2owL03H2Xdoe5pKcUyWRSEQ8xfCar8t2SIAuEDwtmx2da1YB52YuHQMQ== -"chokidar@>=2.0.0 <4.0.0": +"chokidar@>=2.0.0 <4.0.0", chokidar@^3.4.1, chokidar@^3.4.3: version "3.5.1" resolved "https://registry.yarnpkg.com/chokidar/-/chokidar-3.5.1.tgz#ee9ce7bbebd2b79f49f304799d5468e31e14e68a" integrity sha512-9+s+Od+W0VJJzawDma/gvBNQqkTiqYTWLuZoyAsivsI4AaWTCzHG06/TMjsf1cYe9Cb97UCEhjz7HvnPk2p/tw== @@ -5474,21 +4666,6 @@ chokidar@^2.1.8: optionalDependencies: fsevents "^1.2.7" -chokidar@^3.4.1, chokidar@^3.4.3: - version "3.4.3" - resolved "https://registry.yarnpkg.com/chokidar/-/chokidar-3.4.3.tgz#c1df38231448e45ca4ac588e6c79573ba6a57d5b" - integrity sha512-DtM3g7juCXQxFVSNPNByEC2+NImtBuxQQvWlHunpJIS5Ocr0lG306cC7FCi7cEA0fzmybPUIl4txBIobk1gGOQ== - dependencies: - anymatch "~3.1.1" - braces "~3.0.2" - glob-parent "~5.1.0" - is-binary-path "~2.1.0" - is-glob "~4.0.1" - normalize-path "~3.0.0" - readdirp "~3.5.0" - optionalDependencies: - fsevents "~2.1.2" - chownr@^1.1.1: version "1.1.4" resolved "https://registry.yarnpkg.com/chownr/-/chownr-1.1.4.tgz#6fc9d7b42d32a583596337666e7d08084da2cc6b" @@ -5519,10 +4696,10 @@ cipher-base@^1.0.0, cipher-base@^1.0.1, cipher-base@^1.0.3: inherits "^2.0.1" safe-buffer "^5.0.1" -cjs-module-lexer@^0.4.2: - version "0.4.3" - resolved "https://registry.yarnpkg.com/cjs-module-lexer/-/cjs-module-lexer-0.4.3.tgz#9e31f7fe701f5fcee5793f77ab4e58fa8dcde8bc" - integrity sha512-5RLK0Qfs0PNDpEyBXIr3bIT1Muw3ojSlvpw6dAmkUcO0+uTrsBn7GuEIgx40u+OzbCBLDta7nvmud85P4EmTsQ== +cjs-module-lexer@^0.6.0: + version "0.6.0" + resolved "https://registry.yarnpkg.com/cjs-module-lexer/-/cjs-module-lexer-0.6.0.tgz#4186fcca0eae175970aee870b9fe2d6cf8d5655f" + integrity sha512-uc2Vix1frTfnuzxxu1Hp4ktSvM3QaI4oXl4ZUqL1wjTu/BGki9TrCWoqLTg/drR1KwAEarXuRFCG2Svr1GxPFw== class-utils@^0.3.5: version "0.3.6" @@ -5602,9 +4779,9 @@ cliui@^6.0.0: wrap-ansi "^6.2.0" cliui@^7.0.2: - version "7.0.3" - resolved "https://registry.yarnpkg.com/cliui/-/cliui-7.0.3.tgz#ef180f26c8d9bff3927ee52428bfec2090427981" - integrity sha512-Gj3QHTkVMPKqwP3f7B4KPkBZRMR9r4rfi5bXFpg1a+Svvj8l7q5CnkBkVQzfxT5DFSsGk2+PascOgL0JYkL2kw== + version "7.0.4" + resolved "https://registry.yarnpkg.com/cliui/-/cliui-7.0.4.tgz#a0265ee655476fc807aea9df3df8df7783808b4f" + integrity sha512-OcRE68cOsVMXp1Yvonl/fzkQOyjLSu/8bhPDfQt0e0/Eb283TKP20Fs2MqoPsr9SwA595rRCA+QMzYc9nBP+JQ== dependencies: string-width "^4.2.0" strip-ansi "^6.0.0" @@ -5681,9 +4858,9 @@ color-name@^1.0.0, color-name@~1.1.4: integrity sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA== color-string@^1.5.4: - version "1.5.4" - resolved "https://registry.yarnpkg.com/color-string/-/color-string-1.5.4.tgz#dd51cd25cfee953d138fe4002372cc3d0e504cb6" - integrity sha512-57yF5yt8Xa3czSEW1jfQDE79Idk0+AkN/4KWad6tbdxUmAs3MvjxlWSWD4deYytcRfoZ9nhKyFl1kj5tBvidbw== + version "1.5.5" + resolved "https://registry.yarnpkg.com/color-string/-/color-string-1.5.5.tgz#65474a8f0e7439625f3d27a6a19d89fc45223014" + integrity sha512-jgIoum0OfQfq9Whcfc2z/VhCNcmQjWbey6qBX0vqt7YICflUmBCh9E9CiQD5GSJ+Uehixm3NUwHVhqUAWRivZg== dependencies: color-name "^1.0.0" simple-swizzle "^0.2.2" @@ -5696,10 +4873,10 @@ color@^3.0.0: color-convert "^1.9.1" color-string "^1.5.4" -colorette@^1.2.1: - version "1.2.1" - resolved "https://registry.yarnpkg.com/colorette/-/colorette-1.2.1.tgz#4d0b921325c14faf92633086a536db6e89564b1b" - integrity sha512-puCDz0CzydiSYOrnXpz/PKd69zRrribezjtE9yd4zvytoRc8+RY/KJPvtPFKZS3E3wP6neGyMe0vOTlHO5L3Pw== +colorette@^1.2.1, colorette@^1.2.2: + version "1.2.2" + resolved "https://registry.yarnpkg.com/colorette/-/colorette-1.2.2.tgz#cbcc79d5e99caea2dbf10eb3a26fd8b3e6acfa94" + integrity sha512-MKGMzyfeuutC/ZJ1cba9NqcNpfeqMUcYmyF1ZFY6/Cn7CNSAKx6a+s48sqLqyAiZuaP2TcqMhoo+dlwFnVxT9w== combined-stream@^1.0.6, combined-stream@^1.0.8, combined-stream@~1.0.6: version "1.0.8" @@ -5775,7 +4952,7 @@ concat-stream@^1.5.0: readable-stream "^2.2.2" typedarray "^0.0.6" -confusing-browser-globals@^1.0.10, confusing-browser-globals@^1.0.9: +confusing-browser-globals@^1.0.10: version "1.0.10" resolved "https://registry.yarnpkg.com/confusing-browser-globals/-/confusing-browser-globals-1.0.10.tgz#30d1e7f3d1b882b25ec4933d1d1adac353d20a59" integrity sha512-gNld/3lySHwuhaVluJUKLePYirM3QNCKzVxqAdhJII9/WXKVX5PURzMVJspS1jTslSqjeuG4KMVTSouit5YPHA== @@ -5790,14 +4967,14 @@ console-browserify@^1.1.0: resolved "https://registry.yarnpkg.com/console-browserify/-/console-browserify-1.2.0.tgz#67063cef57ceb6cf4993a2ab3a55840ae8c49336" integrity sha512-ZMkYO/LkF17QvCPqM0gxw8yUzigAOZOSWSHg91FH6orS7vcEj5dVZTidN2fQ14yBSdg97RqhSNwLUXInd52OTA== -constant-case@3.0.3, constant-case@^3.0.3: - version "3.0.3" - resolved "https://registry.yarnpkg.com/constant-case/-/constant-case-3.0.3.tgz#ac910a99caf3926ac5112f352e3af599d8c5fc0a" - integrity sha512-FXtsSnnrFYpzDmvwDGQW+l8XK3GV1coLyBN0eBz16ZUzGaZcT2ANVCJmLeuw2GQgxKHQIe9e0w2dzkSfaRlUmA== +constant-case@^3.0.4: + version "3.0.4" + resolved "https://registry.yarnpkg.com/constant-case/-/constant-case-3.0.4.tgz#3b84a9aeaf4cf31ec45e6bf5de91bdfb0589faf1" + integrity sha512-I2hSBi7Vvs7BEuJDr5dDHfzb/Ruj3FyvFyh7KLilAjNQw3Be+xgqUBA2W6scVEcL0hL1dwPRtIqEPVUCKkSsyQ== dependencies: - no-case "^3.0.3" - tslib "^1.10.0" - upper-case "^2.0.1" + no-case "^3.0.4" + tslib "^2.0.3" + upper-case "^2.0.2" constants-browserify@^1.0.0: version "1.0.0" @@ -5821,7 +4998,7 @@ content-type@~1.0.4: resolved "https://registry.yarnpkg.com/content-type/-/content-type-1.0.4.tgz#e138cc75e040c727b1966fe5e5f8c9aee256fe3b" integrity sha512-hIP3EEPs8tB9AT1L+NUqtwOAps4mk2Zob89MWXMHjHWg9milF/j4osnnQLXBCBFBk/tvIG/tUc9mOUJiPBhPXA== -convert-source-map@1.7.0, convert-source-map@^1.4.0, convert-source-map@^1.5.0, convert-source-map@^1.6.0, convert-source-map@^1.7.0: +convert-source-map@1.7.0, convert-source-map@^1.4.0, convert-source-map@^1.6.0, convert-source-map@^1.7.0: version "1.7.0" resolved "https://registry.yarnpkg.com/convert-source-map/-/convert-source-map-1.7.0.tgz#17a2cb882d7f77d3490585e2ce6c524424a3a442" integrity sha512-4FJkXzKXEDB1snCFZlLP4gpC3JILicCpGbzG9f9G7tGqGCzETQ2hWPrcinA9oU4wtf2biUaEH5065UnMeR33oA== @@ -5865,15 +5042,7 @@ copy-descriptor@^0.1.0: resolved "https://registry.yarnpkg.com/copy-descriptor/-/copy-descriptor-0.1.1.tgz#676f6eb3c39997c2ee1ac3a924fd6124748f578d" integrity sha1-Z29us8OZl8LuGsOpJP1hJHSPV40= -core-js-compat@^3.6.2: - version "3.6.5" - resolved "https://registry.yarnpkg.com/core-js-compat/-/core-js-compat-3.6.5.tgz#2a51d9a4e25dfd6e690251aa81f99e3c05481f1c" - integrity sha512-7ItTKOhOZbznhXAQ2g/slGg1PJV5zDO/WdkTwi7UEOJmkvsE32PWvx6mKtDjiMpjnR2CNf6BAD6sSxIlv7ptng== - dependencies: - browserslist "^4.8.5" - semver "7.0.0" - -core-js-compat@^3.8.1, core-js-compat@^3.9.0: +core-js-compat@^3.6.2, core-js-compat@^3.8.1, core-js-compat@^3.9.0: version "3.9.1" resolved "https://registry.yarnpkg.com/core-js-compat/-/core-js-compat-3.9.1.tgz#4e572acfe90aff69d76d8c37759d21a5c59bb455" integrity sha512-jXAirMQxrkbiiLsCx9bQPJFA6llDadKMpYrBJQJ3/c4/vsPP/fAf29h24tviRlvwUL6AmY5CHLu2GvjuYviQqA== @@ -5882,19 +5051,19 @@ core-js-compat@^3.8.1, core-js-compat@^3.9.0: semver "7.0.0" core-js-pure@^3.0.0: - version "3.6.5" - resolved "https://registry.yarnpkg.com/core-js-pure/-/core-js-pure-3.6.5.tgz#c79e75f5e38dbc85a662d91eea52b8256d53b813" - integrity sha512-lacdXOimsiD0QyNf9BC/mxivNJ/ybBGJXQFKzRekp1WTHoVUWsUHEn+2T8GJAzzIhyOuXA+gOxCVN3l+5PLPUA== + version "3.9.1" + resolved "https://registry.yarnpkg.com/core-js-pure/-/core-js-pure-3.9.1.tgz#677b322267172bd490e4464696f790cbc355bec5" + integrity sha512-laz3Zx0avrw9a4QEIdmIblnVuJz8W51leY9iLThatCsFawWxC3sE4guASC78JbCin+DkwMpCdp1AVAuzL/GN7A== -core-js@^2.4.0, core-js@^2.4.1: - version "2.6.11" - resolved "https://registry.yarnpkg.com/core-js/-/core-js-2.6.11.tgz#38831469f9922bded8ee21c9dc46985e0399308c" - integrity sha512-5wjnpaT/3dV+XB4borEsnAYQchn00XSgTAWKDkEqv+K8KevjbzmofK6hfJ9TZIlpj2N0xQpazy7PiRQiWHqzWg== +core-js@^2.4.0: + version "2.6.12" + resolved "https://registry.yarnpkg.com/core-js/-/core-js-2.6.12.tgz#d9333dfa7b065e347cc5682219d6f690859cc2ec" + integrity sha512-Kb2wC0fvsWfQrgk8HU5lW6U/Lcs8+9aaYcy4ZFc6DDlo4nZ7n70dEgE5rtR0oG6ufKDUnrwfWL1mXR5ljDatrQ== core-js@^3.6.5: - version "3.6.5" - resolved "https://registry.yarnpkg.com/core-js/-/core-js-3.6.5.tgz#7395dc273af37fb2e50e9bd3d9fe841285231d1a" - integrity sha512-vZVEEwZoIsI+vPEuoF9Iqf5H7/M3eeQqWlQnYa8FSKKePuYTf5MWnxb5SDAzCa60b3JBRS5g9b+Dq7b1y/RCrA== + version "3.9.1" + resolved "https://registry.yarnpkg.com/core-js/-/core-js-3.9.1.tgz#cec8de593db8eb2a85ffb0dbdeb312cb6e5460ae" + integrity sha512-gSjRvzkxQc1zjM/5paAmL4idJBFzuJoo+jDjF1tStYFMV2ERfD02HhahhCGXUyHxQRG4yFKVSdO6g62eoRMcDg== core-util-is@1.0.2, core-util-is@~1.0.0: version "1.0.2" @@ -5976,13 +5145,27 @@ create-require@^1.1.0: resolved "https://registry.yarnpkg.com/create-require/-/create-require-1.1.1.tgz#c1d7e8f1e5f6cfc9ff65f9cd352d37348756c333" integrity sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ== -cross-fetch@3.0.6, cross-fetch@^3.0.6: +cross-fetch@3.0.6: version "3.0.6" resolved "https://registry.yarnpkg.com/cross-fetch/-/cross-fetch-3.0.6.tgz#3a4040bc8941e653e0e9cf17f29ebcd177d3365c" integrity sha512-KBPUbqgFjzWlVcURG+Svp9TlhA5uliYtiNx/0r8nv0pdypeQCRJ9IaSIc3q/x3q8t3F75cHuwxVql1HFGHCNJQ== dependencies: node-fetch "2.6.1" +cross-fetch@3.1.1: + version "3.1.1" + resolved "https://registry.yarnpkg.com/cross-fetch/-/cross-fetch-3.1.1.tgz#a7ed5a9201d46223d805c5e9ecdc23ea600219eb" + integrity sha512-eIF+IHQpRzoGd/0zPrwQmHwDC90mdvjk+hcbYhKoaRrEk4GEIDqdjs/MljmdPPoHTQudbmWS+f0hZsEpFaEvWw== + dependencies: + node-fetch "2.6.1" + +cross-fetch@^3.0.4, cross-fetch@^3.0.6: + version "3.1.2" + resolved "https://registry.yarnpkg.com/cross-fetch/-/cross-fetch-3.1.2.tgz#ee0c2f18844c4fde36150c2a4ddc068d20c1bc41" + integrity sha512-+JhD65rDNqLbGmB3Gzs3HrEKC0aQnD+XA3SY6RjgkF88jV2q5cTc5+CwxlS3sdmLk98gpPt5CF9XRnPdlxZe6w== + dependencies: + node-fetch "2.6.1" + cross-spawn@7.0.3, cross-spawn@^7.0.0, cross-spawn@^7.0.2: version "7.0.3" resolved "https://registry.yarnpkg.com/cross-spawn/-/cross-spawn-7.0.3.tgz#f73a85b9d5d41d045551c177e2882d4ac85728a6" @@ -6083,17 +5266,7 @@ css-select-base-adapter@^0.1.1: resolved "https://registry.yarnpkg.com/css-select-base-adapter/-/css-select-base-adapter-0.1.1.tgz#3b2ff4972cc362ab88561507a95408a1432135d7" integrity sha512-jQVeeRG70QI08vSTwf1jHxp74JoZsr2XSgETae8/xC8ovSnL2WF87GTLO86Sbwdt2lK4Umg4HnnwMO4YF3Ce7w== -css-select@^1.1.0: - version "1.2.0" - resolved "https://registry.yarnpkg.com/css-select/-/css-select-1.2.0.tgz#2b3a110539c5355f1cd8d314623e870b121ec858" - integrity sha1-KzoRBTnFNV8c2NMUYj6HCxIeyFg= - dependencies: - boolbase "~1.0.0" - css-what "2.1" - domutils "1.5.1" - nth-check "~1.0.1" - -css-select@^2.0.0: +css-select@^2.0.0, css-select@^2.0.2: version "2.1.0" resolved "https://registry.yarnpkg.com/css-select/-/css-select-2.1.0.tgz#6a34653356635934a81baca68d0255432105dbef" integrity sha512-Dqk7LQKpwLoH3VovzZnkzegqNSuAziQyNZUcrdDM401iY+R5NkGBXGmtO05/yaXQziALuPogeG0b7UAgjnTJTQ== @@ -6111,19 +5284,14 @@ css-tree@1.0.0-alpha.37: mdn-data "2.0.4" source-map "^0.6.1" -css-tree@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/css-tree/-/css-tree-1.0.0.tgz#21993fa270d742642a90409a2c0cb3ac0298adf6" - integrity sha512-CdVYz/Yuqw0VdKhXPBIgi8DO3NicJVYZNWeX9XcIuSp9ZoFT5IcleVRW07O5rMjdcx1mb+MEJPknTTEW7DdsYw== +css-tree@^1.1.2: + version "1.1.2" + resolved "https://registry.yarnpkg.com/css-tree/-/css-tree-1.1.2.tgz#9ae393b5dafd7dae8a622475caec78d3d8fbd7b5" + integrity sha512-wCoWush5Aeo48GLhfHPbmvZs59Z+M7k5+B1xDnXbdWNcEF423DoFdqSWE0PM5aNk5nI5cp1q7ms36zGApY/sKQ== dependencies: - mdn-data "2.0.12" + mdn-data "2.0.14" source-map "^0.6.1" -css-what@2.1: - version "2.1.3" - resolved "https://registry.yarnpkg.com/css-what/-/css-what-2.1.3.tgz#a6d7604573365fe74686c3f311c56513d88285f2" - integrity sha512-a+EPoD+uZiNfh+5fxw2nO9QwFa6nJe2Or35fGY6Ipw1R3R4AGz1d1TEZrCegvw2YTmZ0jXirGYlzxxpYSHwpEg== - css-what@^3.2.1: version "3.4.2" resolved "https://registry.yarnpkg.com/css-what/-/css-what-3.4.2.tgz#ea7026fcb01777edbde52124e21f327e7ae950e4" @@ -6223,11 +5391,11 @@ cssnano@^4.1.10: postcss "^7.0.0" csso@^4.0.2: - version "4.1.0" - resolved "https://registry.yarnpkg.com/csso/-/csso-4.1.0.tgz#1d31193efa99b87aa6bad6c0cef155e543d09e8b" - integrity sha512-h+6w/W1WqXaJA4tb1dk7r5tVbOm97MsKxzwnvOR04UQ6GILroryjMWu3pmCCtL2mLaEStQ0fZgeGiy99mo7iyg== + version "4.2.0" + resolved "https://registry.yarnpkg.com/csso/-/csso-4.2.0.tgz#ea3a561346e8dc9f546d6febedd50187cf389529" + integrity sha512-wvlcdIbf6pwKEk7vHj8/Bkc0B4ylXZruLvOgs9doS5eOsOpuodOV2zJChSpkp+pRpYQLQMeF04nr3Z68Sta9jA== dependencies: - css-tree "^1.0.0" + css-tree "^1.1.2" cssom@^0.4.4: version "0.4.4" @@ -6239,7 +5407,7 @@ cssom@~0.3.6: resolved "https://registry.yarnpkg.com/cssom/-/cssom-0.3.8.tgz#9f1276f5b2b463f2114d3f2c75250af8c1a36f4a" integrity sha512-b0tGHbfegbhPJpxpiBPU2sCkigAqtM9O121le6bbOlgyV+NyGyCmVfJ6QW9eRjz8CpNfWEOYBIMIGRYkLwsIYg== -cssstyle@^2.2.0: +cssstyle@^2.3.0: version "2.3.0" resolved "https://registry.yarnpkg.com/cssstyle/-/cssstyle-2.3.0.tgz#ff665a0ddbdc31864b09647f34163443d90b0852" integrity sha512-AZL67abkUzIuvcHqk7c09cezpGNcxUxU4Ioi/05xHk4DQeTkWmGYftIE6ctU6AEt+Gn4n1lDStOtj7FKycP71A== @@ -6247,9 +5415,9 @@ cssstyle@^2.2.0: cssom "~0.3.6" csstype@^3.0.2: - version "3.0.4" - resolved "https://registry.yarnpkg.com/csstype/-/csstype-3.0.4.tgz#b156d7be03b84ff425c9a0a4b1e5f4da9c5ca888" - integrity sha512-xc8DUsCLmjvCfoD7LTGE0ou2MIWLx0K9RCZwSHMOdynqRsP4MtUcLeqh1HcQ2dInwDTqn+3CE0/FZh1et+p4jA== + version "3.0.7" + resolved "https://registry.yarnpkg.com/csstype/-/csstype-3.0.7.tgz#2a5fb75e1015e84dd15692f71e89a1450290950b" + integrity sha512-KxnUB0ZMlnUWCsx2Z8MUsr6qV6ja1w9ArPErJaJaF8a5SOWoHLIszeCTKGRGRgtLgYrs1E8CHkNSP1VZTTPc9g== cyclist@^1.0.1: version "1.0.1" @@ -6295,15 +5463,10 @@ date-fns@^1.27.2: resolved "https://registry.yarnpkg.com/date-fns/-/date-fns-1.30.1.tgz#2e71bf0b119153dbb4cc4e88d9ea5acfb50dc05c" integrity sha512-hBSVCvSmWC+QypYObzwGOd9wqdDpOt+0wl0KbU+R+uuZBS1jN8VsD1ss3irQDknRj5NvxiTF6oj/nDRnN/UQNw== -de-indent@^1.0.2: - version "1.0.2" - resolved "https://registry.yarnpkg.com/de-indent/-/de-indent-1.0.2.tgz#b2038e846dc33baa5796128d0804b455b8c1e21d" - integrity sha1-sgOOhG3DO6pXlhKNCAS0VbjB4h0= - debounce@^1.2.0: - version "1.2.0" - resolved "https://registry.yarnpkg.com/debounce/-/debounce-1.2.0.tgz#44a540abc0ea9943018dc0eaa95cce87f65cd131" - integrity sha512-mYtLl1xfZLi1m4RtQYlZgJUNQjl4ZxVnHzIR8nLLgi4q1YT8o/WM+MK/f8yfcc9s5Ir5zRaPZyZU6xs1Syoocg== + version "1.2.1" + resolved "https://registry.yarnpkg.com/debounce/-/debounce-1.2.1.tgz#38881d8f4166a5c5848020c11827b834bcb3e0a5" + integrity sha512-XRRe6Glud4rd/ZGQfiV1ruXSfbvfJedlV9Y6zOlP+2K04vBYiJEte6stfFkCP03aMnY5tsipamumUjL14fofug== debug@2.6.9, debug@^2.2.0, debug@^2.3.3, debug@^2.6.0, debug@^2.6.9: version "2.6.9" @@ -6312,34 +5475,20 @@ debug@2.6.9, debug@^2.2.0, debug@^2.3.3, debug@^2.6.0, debug@^2.6.9: dependencies: ms "2.0.0" -debug@4, debug@^4.0.0, debug@^4.0.1, debug@^4.1.0, debug@^4.1.1, debug@^4.2.0: - version "4.2.0" - resolved "https://registry.yarnpkg.com/debug/-/debug-4.2.0.tgz#7f150f93920e94c58f5574c2fd01a3110effe7f1" - integrity sha512-IX2ncY78vDTjZMFUdmsvIRFY2Cf4FnD0wRs+nQwJU8Lu99/tPFdb0VybiiMTPe3I6rQmwsqQqRBvxU+bZ/I8sg== - dependencies: - ms "2.1.2" - -debug@^3.1.1: - version "3.2.6" - resolved "https://registry.yarnpkg.com/debug/-/debug-3.2.6.tgz#e83d17de16d8a7efb7717edbe5fb10135eee629b" - integrity sha512-mel+jf7nrtEl5Pn1Qx46zARXKDpBbvzezse7p7LqINmdoIk8PYP5SySaxEmYv6TZ0JyEKA1hsCId6DIhgITtWQ== - dependencies: - ms "^2.1.1" - -debug@^3.2.6: - version "3.2.7" - resolved "https://registry.yarnpkg.com/debug/-/debug-3.2.7.tgz#72580b7e9145fb39b6676f9c5e5fb100b934179a" - integrity sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ== - dependencies: - ms "^2.1.1" - -debug@^4.3.1: +debug@4, debug@^4.0.0, debug@^4.0.1, debug@^4.1.0, debug@^4.1.1, debug@^4.3.1: version "4.3.1" resolved "https://registry.yarnpkg.com/debug/-/debug-4.3.1.tgz#f0d229c505e0c6d8c49ac553d1b13dc183f6b2ee" integrity sha512-doEwdvm4PCeK4K3RQN2ZC2BYUBaxwLARCqZmMjtF8a51J2Rb0xpVloFRnCODwqjpwnAoao4pelN8l3RJdv3gRQ== dependencies: ms "2.1.2" +debug@^3.1.1, debug@^3.2.6: + version "3.2.7" + resolved "https://registry.yarnpkg.com/debug/-/debug-3.2.7.tgz#72580b7e9145fb39b6676f9c5e5fb100b934179a" + integrity sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ== + dependencies: + ms "^2.1.1" + decamelize-keys@^1.1.0: version "1.1.0" resolved "https://registry.yarnpkg.com/decamelize-keys/-/decamelize-keys-1.1.0.tgz#d171a87933252807eb3cb61dc1c1445d078df2d9" @@ -6353,7 +5502,7 @@ decamelize@^1.1.0, decamelize@^1.2.0: resolved "https://registry.yarnpkg.com/decamelize/-/decamelize-1.2.0.tgz#f6534d15148269b20352e7bee26f501f9a191290" integrity sha1-9lNNFRSCabIDUue+4m9QH5oZEpA= -decimal.js@^10.2.0: +decimal.js@^10.2.1: version "10.2.1" resolved "https://registry.yarnpkg.com/decimal.js/-/decimal.js-10.2.1.tgz#238ae7b0f0c793d3e3cea410108b35a2c01426a3" integrity sha512-KaL7+6Fw6i5A2XSnsbhm/6B+NuEA7TZ4vqxnd5tXz9sbKtrN9Srj8ab4vKVdK8YAqZO9P1kg45Y6YLoduPf+kw== @@ -6472,10 +5621,10 @@ depd@~1.1.2: resolved "https://registry.yarnpkg.com/depd/-/depd-1.1.2.tgz#9bcd52e14c097763e749b274c4346ed2e560b5a9" integrity sha1-m81S4UwJd2PnSbJ0xDRu0uVgtak= -dependency-graph@^0.9.0: - version "0.9.0" - resolved "https://registry.yarnpkg.com/dependency-graph/-/dependency-graph-0.9.0.tgz#11aed7e203bc8b00f48356d92db27b265c445318" - integrity sha512-9YLIBURXj4DJMFALxXw9K3Y3rwb5Fk0X5/8ipCzaN84+gKxoHK43tVKRNakCQbiEx07E8Uwhuq21BpUagFhZ8w== +dependency-graph@^0.11.0: + version "0.11.0" + resolved "https://registry.yarnpkg.com/dependency-graph/-/dependency-graph-0.11.0.tgz#ac0ce7ed68a54da22165a85e97a01d53f5eb2e27" + integrity sha512-JeMq7fEshyepOWDfcfHK06N3MhyPhz++vtqWhMT5O9A3K42rdsEDpfdVqjaqaAhsw6a+ZqeDvQVtD0hFHQWrzg== des.js@^1.0.0: version "1.0.1" @@ -6501,9 +5650,9 @@ detect-newline@^3.0.0: integrity sha512-TLz+x/vEXm/Y7P7wn1EJFNLxYpUD4TgMosxY6fAVJUnJMbupHBOncxyWUG9OpTaH9EBD7uFI5LfEgmMOc54DsA== detect-node@^2.0.4: - version "2.0.4" - resolved "https://registry.yarnpkg.com/detect-node/-/detect-node-2.0.4.tgz#014ee8f8f669c5c58023da64b8179c083a28c46c" - integrity sha512-ZIzRpLJrOj7jjP2miAtgqIfmzbxa4ZOr5jJc601zklsfEx9oTzmmj2nVpIPRpNlRTIh8lc1kyViIY7BWSGNmKw== + version "2.0.5" + resolved "https://registry.yarnpkg.com/detect-node/-/detect-node-2.0.5.tgz#9d270aa7eaa5af0b72c4c9d9b814e7f4ce738b79" + integrity sha512-qi86tE6hRcFHy8jI1m2VG+LaPUR1LhqDa5G8tVjuUXmOrpuAgqsA1pN0+ldgr3aKUH+QLI9hCY/OcRYisERejw== detect-port-alt@1.1.6: version "1.1.6" @@ -6518,10 +5667,17 @@ diacritics@1.3.0: resolved "https://registry.yarnpkg.com/diacritics/-/diacritics-1.3.0.tgz#3efa87323ebb863e6696cebb0082d48ff3d6f7a1" integrity sha1-PvqHMj67hj5mls67AILUj/PW96E= -diff-sequences@^26.5.0: - version "26.5.0" - resolved "https://registry.yarnpkg.com/diff-sequences/-/diff-sequences-26.5.0.tgz#ef766cf09d43ed40406611f11c6d8d9dd8b2fefd" - integrity sha512-ZXx86srb/iYy6jG71k++wBN9P9J05UNQ5hQHQd9MtMPvcqXPx/vKU69jfHV637D00Q2gSgPk2D+jSx3l1lDW/Q== +dicer@0.3.0: + version "0.3.0" + resolved "https://registry.yarnpkg.com/dicer/-/dicer-0.3.0.tgz#eacd98b3bfbf92e8ab5c2fdb71aaac44bb06b872" + integrity sha512-MdceRRWqltEG2dZqO769g27N/3PXfcKl04VhYnBlo2YhH7zPi88VebsjTKclaOyiuMaGU72hTfw3VkUitGcVCA== + dependencies: + streamsearch "0.1.2" + +diff-sequences@^26.6.2: + version "26.6.2" + resolved "https://registry.yarnpkg.com/diff-sequences/-/diff-sequences-26.6.2.tgz#48ba99157de1923412eed41db6b6d4aa9ca7c0b1" + integrity sha512-Mv/TDa3nZ9sbc5soK+OoA74BsS3mL37yixCvUAQkiuA4Wz6YtwP/K47n2rv2ovzHZvoiQeA5FTQOschKkEwB0Q== diff@^4.0.1: version "4.0.2" @@ -6610,12 +5766,12 @@ dom-serializer@0: entities "^2.0.0" dom-serializer@^1.0.1: - version "1.1.0" - resolved "https://registry.yarnpkg.com/dom-serializer/-/dom-serializer-1.1.0.tgz#5f7c828f1bfc44887dc2a315ab5c45691d544b58" - integrity sha512-ox7bvGXt2n+uLWtCRLybYx60IrOlWL/aCebWJk1T0d4m3y2tzf4U3ij9wBMUb6YJZpz06HCCYuyCDveE2xXmzQ== + version "1.2.0" + resolved "https://registry.yarnpkg.com/dom-serializer/-/dom-serializer-1.2.0.tgz#3433d9136aeb3c627981daa385fc7f32d27c48f1" + integrity sha512-n6kZFH/KlCrqs/1GHMOd5i2fd/beQHuehKdWvNNffbGHTr/almdhuVvTVFb3V7fglz+nC50fFusu3lY33h12pA== dependencies: domelementtype "^2.0.1" - domhandler "^3.0.0" + domhandler "^4.0.0" entities "^2.0.0" dom-walk@^0.1.0: @@ -6633,10 +5789,10 @@ domelementtype@1, domelementtype@^1.3.1: resolved "https://registry.yarnpkg.com/domelementtype/-/domelementtype-1.3.1.tgz#d048c44b37b0d10a7f2a3d5fee3f4333d790481f" integrity sha512-BSKB+TSpMpFI/HOxCNr1O8aMOTZ8hT3pM3GQ0w/mWRmkhEDSFJkkyzz4XQsBV44BChwGkrDfMyjVD0eA2aFV3w== -domelementtype@^2.0.1: - version "2.0.2" - resolved "https://registry.yarnpkg.com/domelementtype/-/domelementtype-2.0.2.tgz#f3b6e549201e46f588b59463dd77187131fe6971" - integrity sha512-wFwTwCVebUrMgGeAwRL/NhZtHAUyT9n9yg4IMDwf10+6iCMxSkVq9MGCVEH+QZWo1nNidy8kNvwmv4zWHDTqvA== +domelementtype@^2.0.1, domelementtype@^2.1.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/domelementtype/-/domelementtype-2.1.0.tgz#a851c080a6d1c3d94344aed151d99f669edf585e" + integrity sha512-LsTgx/L5VpD+Q8lmsXSHW2WpA+eBlZ9HPf3erD1IoPF00/3JKHZ3BknUVA2QGDNu69ZNmyFmCWBSO45XjYKC5w== domexception@^2.0.1: version "2.0.1" @@ -6652,20 +5808,19 @@ domhandler@^2.3.0: dependencies: domelementtype "1" -domhandler@^3.0.0, domhandler@^3.3.0: +domhandler@^3.3.0: version "3.3.0" resolved "https://registry.yarnpkg.com/domhandler/-/domhandler-3.3.0.tgz#6db7ea46e4617eb15cf875df68b2b8524ce0037a" integrity sha512-J1C5rIANUbuYK+FuFL98650rihynUOEzRLxW+90bKZRWB6A1X1Tf82GxR1qAWLyfNPRvjqfip3Q5tdYlmAa9lA== dependencies: domelementtype "^2.0.1" -domutils@1.5.1: - version "1.5.1" - resolved "https://registry.yarnpkg.com/domutils/-/domutils-1.5.1.tgz#dcd8488a26f563d61079e48c9f7b7e32373682cf" - integrity sha1-3NhIiib1Y9YQeeSMn3t+Mjc2gs8= +domhandler@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/domhandler/-/domhandler-4.0.0.tgz#01ea7821de996d85f69029e81fa873c21833098e" + integrity sha512-KPTbnGQ1JeEMQyO1iYXoagsI6so/C96HZiFyByU3T6iAzpXn8EGEvct6unm1ZGoed8ByO2oirxgwxBmqKF9haA== dependencies: - dom-serializer "0" - domelementtype "1" + domelementtype "^2.1.0" domutils@^1.5.1, domutils@^1.7.0: version "1.7.0" @@ -6676,21 +5831,21 @@ domutils@^1.5.1, domutils@^1.7.0: domelementtype "1" domutils@^2.4.2: - version "2.4.2" - resolved "https://registry.yarnpkg.com/domutils/-/domutils-2.4.2.tgz#7ee5be261944e1ad487d9aa0616720010123922b" - integrity sha512-NKbgaM8ZJOecTZsIzW5gSuplsX2IWW2mIK7xVr8hTQF2v1CJWTmLZ1HOCh5sH+IzVPAGE5IucooOkvwBRAdowA== + version "2.5.0" + resolved "https://registry.yarnpkg.com/domutils/-/domutils-2.5.0.tgz#42f49cffdabb92ad243278b331fd761c1c2d3039" + integrity sha512-Ho16rzNMOFk2fPwChGh3D2D9OEHAfG19HgmRR2l+WLSsIstNsAYBzePH412bL0y5T44ejABIVfTHQ8nqi/tBCg== dependencies: dom-serializer "^1.0.1" domelementtype "^2.0.1" - domhandler "^3.3.0" + domhandler "^4.0.0" -dot-case@^3.0.3: - version "3.0.3" - resolved "https://registry.yarnpkg.com/dot-case/-/dot-case-3.0.3.tgz#21d3b52efaaba2ea5fda875bb1aa8124521cf4aa" - integrity sha512-7hwEmg6RiSQfm/GwPL4AAWXKy3YNNZA3oFv2Pdiey0mwkRCPZ9x6SZbkLcn8Ma5PYeVokzoD4Twv2n7LKp5WeA== +dot-case@^3.0.4: + version "3.0.4" + resolved "https://registry.yarnpkg.com/dot-case/-/dot-case-3.0.4.tgz#9b2b670d00a431667a8a75ba29cd1b98809ce751" + integrity sha512-Kv5nKlh6yRrdrGvxeJ2e5y2eRUpkUosIW4A2AS38zwSz27zu7ufDwQPi5Jhs3XAlGNetl3bmnGhQsMtkKJnj3w== dependencies: - no-case "^3.0.3" - tslib "^1.10.0" + no-case "^3.0.4" + tslib "^2.0.3" dot-prop@^5.2.0: version "5.3.0" @@ -6754,15 +5909,10 @@ ejs@^2.6.1: resolved "https://registry.yarnpkg.com/ejs/-/ejs-2.7.4.tgz#48661287573dcc53e366c7a1ae52c3a120eec9ba" integrity sha512-7vmuyh5+kuUyJKePhQfRQBhXV5Ce+RnaeeQArKu1EAMpL3WbgMt5WG6uQZpEVvYSSsxMXRKOewtDk9RaTKXRlA== -electron-to-chromium@^1.3.564, electron-to-chromium@^1.3.571: - version "1.3.585" - resolved "https://registry.yarnpkg.com/electron-to-chromium/-/electron-to-chromium-1.3.585.tgz#71cdb722c73488b9475ad1c572cf43a763ef9081" - integrity sha512-xoeqjMQhgHDZM7FiglJAb2aeOxHZWFruUc3MbAGTgE7GB8rr5fTn1Sdh5THGuQtndU3GuXlu91ZKqRivxoCZ/A== - -electron-to-chromium@^1.3.649: - version "1.3.676" - resolved "https://registry.yarnpkg.com/electron-to-chromium/-/electron-to-chromium-1.3.676.tgz#18b2336239b6cdc68c57ce96bf73daf80bab9eba" - integrity sha512-t0eEgYCP+XEbH/KwxWYZDY0XKwzmokDAsjFJ2rBstp2XuwuBCUZ+ni5qXI6XDRNkvDpVJcAOp2aJxkSkshKkmw== +electron-to-chromium@^1.3.564, electron-to-chromium@^1.3.649: + version "1.3.697" + resolved "https://registry.yarnpkg.com/electron-to-chromium/-/electron-to-chromium-1.3.697.tgz#4ba8af135d72d06534bd78f68d1be0dca3d2c590" + integrity sha512-VTAS+IWwGlfaL7VtfUMzFeV55PT/HglNFqQ6eW9E3PfjvPqhZfqJj+8dd9zrqrJYcouUfCgQw0OIse85Dz9V9Q== elegant-spinner@^1.0.1: version "1.0.1" @@ -6770,17 +5920,17 @@ elegant-spinner@^1.0.1: integrity sha1-2wQ1IcldfjA/2PNFvtwzSc+wcp4= elliptic@^6.5.3: - version "6.5.3" - resolved "https://registry.yarnpkg.com/elliptic/-/elliptic-6.5.3.tgz#cb59eb2efdaf73a0bd78ccd7015a62ad6e0f93d6" - integrity sha512-IMqzv5wNQf+E6aHeIqATs0tOLeOTwj1QKbRcS3jBbYkl5oLAserA8yJTT7/VyHUYG91PRmPyeQDObKLPpeS4dw== + version "6.5.4" + resolved "https://registry.yarnpkg.com/elliptic/-/elliptic-6.5.4.tgz#da37cebd31e79a1367e941b592ed1fbebd58abbb" + integrity sha512-iLhC6ULemrljPZb+QutR5TQGB+pdW6KGD5RSegS+8sorOZT+rdQFbsQFJgvN3eRqNALqJer4oQ16YvJHlU8hzQ== dependencies: - bn.js "^4.4.0" - brorand "^1.0.1" + bn.js "^4.11.9" + brorand "^1.1.0" hash.js "^1.0.0" - hmac-drbg "^1.0.0" - inherits "^2.0.1" - minimalistic-assert "^1.0.0" - minimalistic-crypto-utils "^1.0.0" + hmac-drbg "^1.0.1" + inherits "^2.0.4" + minimalistic-assert "^1.0.1" + minimalistic-crypto-utils "^1.0.1" emittery@^0.7.1: version "0.7.2" @@ -6798,9 +5948,9 @@ emoji-regex@^8.0.0: integrity sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A== emoji-regex@^9.0.0: - version "9.2.0" - resolved "https://registry.yarnpkg.com/emoji-regex/-/emoji-regex-9.2.0.tgz#a26da8e832b16a9753309f25e35e3c0efb9a066a" - integrity sha512-DNc3KFPK18bPdElMJnf/Pkv5TXhxFU3YFDEuGLDRtPmV4rkmCjBkCSEp22u6rBHdSN9Vlp/GK7k98prmE1Jgug== + version "9.2.2" + resolved "https://registry.yarnpkg.com/emoji-regex/-/emoji-regex-9.2.2.tgz#840c8803b0d8047f4ff0cf963176b32d4ef3ed72" + integrity sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg== emojis-list@^2.0.0: version "2.1.0" @@ -6817,13 +5967,6 @@ encodeurl@~1.0.2: resolved "https://registry.yarnpkg.com/encodeurl/-/encodeurl-1.0.2.tgz#ad3ff4c86ec2d029322f5a02c3a9a606c95b3f59" integrity sha1-rT/0yG7C0CkyL1oCw6mmBslbP1k= -encoding@^0.1.11: - version "0.1.13" - resolved "https://registry.yarnpkg.com/encoding/-/encoding-0.1.13.tgz#56574afdd791f54a8e9b2785c0582a2d26210fa9" - integrity sha512-ETBauow1T35Y/WZMkio9jiM0Z5xjHHmJ4XmjZOq1l/dXz3lr2sRn87nJy20RupqSh1F2m3HHPSp8ShIPQJrJ3A== - dependencies: - iconv-lite "^0.6.2" - end-of-stream@^1.0.0, end-of-stream@^1.1.0: version "1.4.4" resolved "https://registry.yarnpkg.com/end-of-stream/-/end-of-stream-1.4.4.tgz#5ae64a5f45057baf3626ec14da0ca5e4b2431eb0" @@ -6832,9 +5975,9 @@ end-of-stream@^1.0.0, end-of-stream@^1.1.0: once "^1.4.0" enhanced-resolve@^4.3.0: - version "4.3.0" - resolved "https://registry.yarnpkg.com/enhanced-resolve/-/enhanced-resolve-4.3.0.tgz#3b806f3bfafc1ec7de69551ef93cca46c1704126" - integrity sha512-3e87LvavsdxyoCfGusJnrZ5G8SLPOFeHSNpZI/ATL9a5leXo2k0w6MKnbqhdBad9qTobSfB20Ld7UmgoNbAZkQ== + version "4.5.0" + resolved "https://registry.yarnpkg.com/enhanced-resolve/-/enhanced-resolve-4.5.0.tgz#2f3cfd84dbe3b487f18f2db2ef1e064a571ca5ec" + integrity sha512-Nv9m36S/vxpsI+Hc4/ZGRs0n9mXqSWGGq49zxb/cJfPAQMbUtttJAlNPS4AQzaBdw/pKskw5bMbekT/Y7W/Wlg== dependencies: graceful-fs "^4.1.2" memory-fs "^0.5.0" @@ -6853,14 +5996,14 @@ entities@^1.1.1: integrity sha512-f2LZMYl1Fzu7YSBKg+RoROelpOaNrcGmE9AZubeDfrCEia483oW4MI4VyFd5VNHIgQ/7qm1I0wUHK1eJnn2y2w== entities@^2.0.0: - version "2.1.0" - resolved "https://registry.yarnpkg.com/entities/-/entities-2.1.0.tgz#992d3129cf7df6870b96c57858c249a120f8b8b5" - integrity sha512-hCx1oky9PFrJ611mf0ifBLBRW8lUUVRlFolb5gWRfIELabBlbp9xZvrqZLZAs+NxFnbfQoeGd8wDkygjg7U85w== + version "2.2.0" + resolved "https://registry.yarnpkg.com/entities/-/entities-2.2.0.tgz#098dc90ebb83d8dffa089d55256b351d34c4da55" + integrity sha512-p92if5Nz619I0w+akJrLZH0MX0Pb5DX39XOwQTtXSdQQOaYH03S1uIQp4mhOZtAXrxq4ViO67YTiLBo2638o9A== errno@^0.1.3, errno@~0.1.7: - version "0.1.7" - resolved "https://registry.yarnpkg.com/errno/-/errno-0.1.7.tgz#4684d71779ad39af177e3f007996f7c67c852618" - integrity sha512-MfrRBDWzIWifgq6tJj60gkAwtLNb6sQPlcFrSOflcP1aFmmruKQ2wRnze/8V6kgyz7H3FF8Npzv78mZ7XLLflg== + version "0.1.8" + resolved "https://registry.yarnpkg.com/errno/-/errno-0.1.8.tgz#8bb3e9c7d463be4976ff888f76b4809ebc2e811f" + integrity sha512-dJ6oBr5SQ1VSd9qkk7ByRgb/1SH4JZjCHSW/mr63/QcXO9zLVxvJ6Oy13nio03rxpSnVDDjFor75SjVeZWPW/A== dependencies: prr "~1.0.1" @@ -6878,40 +6021,27 @@ error-stack-parser@^2.0.6: dependencies: stackframe "^1.1.1" -es-abstract@^1.17.0, es-abstract@^1.17.0-next.1, es-abstract@^1.17.2, es-abstract@^1.17.5: - version "1.17.7" - resolved "https://registry.yarnpkg.com/es-abstract/-/es-abstract-1.17.7.tgz#a4de61b2f66989fc7421676c1cb9787573ace54c" - integrity sha512-VBl/gnfcJ7OercKA9MVaegWsBHFjV492syMudcnQZvt/Dw8ezpcOHYZXa/J96O8vx+g4x65YKhxOwDUh63aS5g== +es-abstract@^1.17.2, es-abstract@^1.18.0-next.1, es-abstract@^1.18.0-next.2: + version "1.18.0" + resolved "https://registry.yarnpkg.com/es-abstract/-/es-abstract-1.18.0.tgz#ab80b359eecb7ede4c298000390bc5ac3ec7b5a4" + integrity sha512-LJzK7MrQa8TS0ja2w3YNLzUgJCGPdPOV1yVvezjNnS89D+VR08+Szt2mz3YB2Dck/+w5tfIq/RoUAFqJJGM2yw== dependencies: + call-bind "^1.0.2" es-to-primitive "^1.2.1" function-bind "^1.1.1" + get-intrinsic "^1.1.1" has "^1.0.3" - has-symbols "^1.0.1" - is-callable "^1.2.2" - is-regex "^1.1.1" - object-inspect "^1.8.0" + has-symbols "^1.0.2" + is-callable "^1.2.3" + is-negative-zero "^2.0.1" + is-regex "^1.1.2" + is-string "^1.0.5" + object-inspect "^1.9.0" object-keys "^1.1.1" - object.assign "^4.1.1" - string.prototype.trimend "^1.0.1" - string.prototype.trimstart "^1.0.1" - -es-abstract@^1.18.0-next.0, es-abstract@^1.18.0-next.1: - version "1.18.0-next.1" - resolved "https://registry.yarnpkg.com/es-abstract/-/es-abstract-1.18.0-next.1.tgz#6e3a0a4bda717e5023ab3b8e90bec36108d22c68" - integrity sha512-I4UGspA0wpZXWENrdA0uHbnhte683t3qT/1VFH9aX2dA5PPSf6QW5HHXf5HImaqPmjXaVeVk4RGWnaylmV7uAA== - dependencies: - es-to-primitive "^1.2.1" - function-bind "^1.1.1" - has "^1.0.3" - has-symbols "^1.0.1" - is-callable "^1.2.2" - is-negative-zero "^2.0.0" - is-regex "^1.1.1" - object-inspect "^1.8.0" - object-keys "^1.1.1" - object.assign "^4.1.1" - string.prototype.trimend "^1.0.1" - string.prototype.trimstart "^1.0.1" + object.assign "^4.1.2" + string.prototype.trimend "^1.0.4" + string.prototype.trimstart "^1.0.4" + unbox-primitive "^1.0.0" es-to-primitive@^1.2.1: version "1.2.1" @@ -6948,7 +6078,7 @@ es6-symbol@^3.1.1, es6-symbol@~3.1.3: d "^1.0.1" ext "^1.1.2" -escalade@^3.0.2, escalade@^3.1.0, escalade@^3.1.1: +escalade@^3.0.2, escalade@^3.1.1: version "3.1.1" resolved "https://registry.yarnpkg.com/escalade/-/escalade-3.1.1.tgz#d8cfdc7000965c5a0174b4a82eaa5c0552742e40" integrity sha512-k0er2gUkLf8O0zKJiAhmkTnJlTvINGv7ygDNPbeIsX/TJjGJZHuh9B2UxbsaEkmlEo9MfhrSzmhIlhRlI2GXnw== @@ -6973,43 +6103,43 @@ escape-string-regexp@^4.0.0: resolved "https://registry.yarnpkg.com/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz#14ba83a5d373e3d311e5afca29cf5bfad965bf34" integrity sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA== -escodegen@^1.14.1: - version "1.14.3" - resolved "https://registry.yarnpkg.com/escodegen/-/escodegen-1.14.3.tgz#4e7b81fba61581dc97582ed78cab7f0e8d63f503" - integrity sha512-qFcX0XJkdg+PB3xjZZG/wKSuT1PnQWx57+TVSjIMmILd2yC/6ByYElPwJnslDsuWuSAp4AwJGumarAAmJch5Kw== +escodegen@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/escodegen/-/escodegen-2.0.0.tgz#5e32b12833e8aa8fa35e1bf0befa89380484c7dd" + integrity sha512-mmHKys/C8BFUGI+MAWNcSYoORYLMdPzjrknd2Vc+bUsjN5bXcr8EhrNB+UTqfL1y3I9c4fw2ihgtMPQLBRiQxw== dependencies: esprima "^4.0.1" - estraverse "^4.2.0" + estraverse "^5.2.0" esutils "^2.0.2" optionator "^0.8.1" optionalDependencies: source-map "~0.6.1" -eslint-config-airbnb-base@14.2.0, eslint-config-airbnb-base@^14.2.0: - version "14.2.0" - resolved "https://registry.yarnpkg.com/eslint-config-airbnb-base/-/eslint-config-airbnb-base-14.2.0.tgz#fe89c24b3f9dc8008c9c0d0d88c28f95ed65e9c4" - integrity sha512-Snswd5oC6nJaevs3nZoLSTvGJBvzTfnBqOIArkf3cbyTyq9UD79wOk8s+RiL6bhca0p/eRO6veczhf6A/7Jy8Q== +eslint-config-airbnb-base@^14.2.0, eslint-config-airbnb-base@^14.2.1: + version "14.2.1" + resolved "https://registry.yarnpkg.com/eslint-config-airbnb-base/-/eslint-config-airbnb-base-14.2.1.tgz#8a2eb38455dc5a312550193b319cdaeef042cd1e" + integrity sha512-GOrQyDtVEc1Xy20U7vsB2yAoB4nBlfH5HZJeatRXHleO+OS5Ot+MWij4Dpltw4/DyIkqUfqz1epfhVR5XWWQPA== dependencies: - confusing-browser-globals "^1.0.9" - object.assign "^4.1.0" + confusing-browser-globals "^1.0.10" + object.assign "^4.1.2" object.entries "^1.1.2" eslint-config-airbnb-typescript@^12.0.0: - version "12.0.0" - resolved "https://registry.yarnpkg.com/eslint-config-airbnb-typescript/-/eslint-config-airbnb-typescript-12.0.0.tgz#4bb6b4b72b1cfc45ef1fa0607735679ceb9a3814" - integrity sha512-TUCVru1Z09eKnVAX5i3XoNzjcCOU3nDQz2/jQGkg1jVYm+25fKClveziSl16celfCq+npU0MBPW/ZnXdGFZ9lw== - dependencies: - "@typescript-eslint/parser" "4.4.1" - eslint-config-airbnb "18.2.0" - eslint-config-airbnb-base "14.2.0" - -eslint-config-airbnb@18.2.0: - version "18.2.0" - resolved "https://registry.yarnpkg.com/eslint-config-airbnb/-/eslint-config-airbnb-18.2.0.tgz#8a82168713effce8fc08e10896a63f1235499dcd" - integrity sha512-Fz4JIUKkrhO0du2cg5opdyPKQXOI2MvF8KUvN2710nJMT6jaRUpRE2swrJftAjVGL7T1otLM5ieo5RqS1v9Udg== + version "12.3.1" + resolved "https://registry.yarnpkg.com/eslint-config-airbnb-typescript/-/eslint-config-airbnb-typescript-12.3.1.tgz#83ab40d76402c208eb08516260d1d6fac8f8acbc" + integrity sha512-ql/Pe6/hppYuRp4m3iPaHJqkBB7dgeEmGPQ6X0UNmrQOfTF+dXw29/ZjU2kQ6RDoLxaxOA+Xqv07Vbef6oVTWw== dependencies: + "@typescript-eslint/parser" "^4.4.1" + eslint-config-airbnb "^18.2.0" eslint-config-airbnb-base "^14.2.0" - object.assign "^4.1.0" + +eslint-config-airbnb@^18.2.0: + version "18.2.1" + resolved "https://registry.yarnpkg.com/eslint-config-airbnb/-/eslint-config-airbnb-18.2.1.tgz#b7fe2b42f9f8173e825b73c8014b592e449c98d9" + integrity sha512-glZNDEZ36VdlZWoxn/bUR1r/sdFKPd1mHPbqUtkctgNG4yT2DLLtJ3D+yCV+jzZCc2V1nBVkmdknOJBZ5Hc0fg== + dependencies: + eslint-config-airbnb-base "^14.2.1" + object.assign "^4.1.2" object.entries "^1.1.2" eslint-config-prettier@^7.2.0: @@ -7041,9 +6171,9 @@ eslint-module-utils@^2.6.0: pkg-dir "^2.0.0" eslint-plugin-flowtype@^5.2.0: - version "5.2.0" - resolved "https://registry.yarnpkg.com/eslint-plugin-flowtype/-/eslint-plugin-flowtype-5.2.0.tgz#a4bef5dc18f9b2bdb41569a4ab05d73805a3d261" - integrity sha512-z7ULdTxuhlRJcEe1MVljePXricuPOrsWfScRXFhNzVD5dmTHWjIF57AxD0e7AbEoLSbjSsaA5S+hCg43WvpXJQ== + version "5.4.0" + resolved "https://registry.yarnpkg.com/eslint-plugin-flowtype/-/eslint-plugin-flowtype-5.4.0.tgz#a559526e56403cb97b470b524957fc526e2485fe" + integrity sha512-O0s0iTT5UxYuoOpHMLSIO2qZMyvrb9shhk1EM5INNGtJ2CffrfUmsnh6TVsnoT41fkXIEndP630WNovhoO87xQ== dependencies: lodash "^4.17.15" string-natural-compare "^3.0.1" @@ -7068,9 +6198,9 @@ eslint-plugin-import@^2.22.1: tsconfig-paths "^3.9.0" eslint-plugin-jest@^24.1.0: - version "24.1.0" - resolved "https://registry.yarnpkg.com/eslint-plugin-jest/-/eslint-plugin-jest-24.1.0.tgz#6708037d7602e5288ce877fd0103f329dc978361" - integrity sha512-827YJ+E8B9PvXu/0eiVSNFfxxndbKv+qE/3GSMhdorCaeaOehtqHGX2YDW9B85TEOre9n/zscledkFW/KbnyGg== + version "24.3.2" + resolved "https://registry.yarnpkg.com/eslint-plugin-jest/-/eslint-plugin-jest-24.3.2.tgz#30a8b2dea6278d0da1d6fb9d6cd530aaf58050a1" + integrity sha512-cicWDr+RvTAOKS3Q/k03+Z3odt3VCiWamNUHWd6QWbVQWcYJyYgUTu8x0mx9GfeDEimawU5kQC+nQ3MFxIM6bw== dependencies: "@typescript-eslint/experimental-utils" "^4.0.1" @@ -7096,44 +6226,28 @@ eslint-plugin-react-hooks@^4.2.0: resolved "https://registry.yarnpkg.com/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-4.2.0.tgz#8c229c268d468956334c943bb45fc860280f5556" integrity sha512-623WEiZJqxR7VdxFCKLI6d6LLpwJkGPYKODnkH3D7WpOG5KM8yWueBd8TLsNAetEJNF5iJmolaAKO3F8yzyVBQ== -eslint-plugin-react@^7.21.5: - version "7.21.5" - resolved "https://registry.yarnpkg.com/eslint-plugin-react/-/eslint-plugin-react-7.21.5.tgz#50b21a412b9574bfe05b21db176e8b7b3b15bff3" - integrity sha512-8MaEggC2et0wSF6bUeywF7qQ46ER81irOdWS4QWxnnlAEsnzeBevk1sWh7fhpCghPpXb+8Ks7hvaft6L/xsR6g== +eslint-plugin-react@^7.21.5, eslint-plugin-react@^7.22.0: + version "7.23.1" + resolved "https://registry.yarnpkg.com/eslint-plugin-react/-/eslint-plugin-react-7.23.1.tgz#f1a2e844c0d1967c822388204a8bc4dee8415b11" + integrity sha512-MvFGhZjI8Z4HusajmSw0ougGrq3Gs4vT/0WgwksZgf5RrLrRa2oYAw56okU4tZJl8+j7IYNuTM+2RnFEuTSdRQ== dependencies: - array-includes "^3.1.1" - array.prototype.flatmap "^1.2.3" + array-includes "^3.1.3" + array.prototype.flatmap "^1.2.4" doctrine "^2.1.0" has "^1.0.3" jsx-ast-utils "^2.4.1 || ^3.0.0" - object.entries "^1.1.2" - object.fromentries "^2.0.2" - object.values "^1.1.1" + minimatch "^3.0.4" + object.entries "^1.1.3" + object.fromentries "^2.0.4" + object.values "^1.1.3" prop-types "^15.7.2" - resolve "^1.18.1" - string.prototype.matchall "^4.0.2" - -eslint-plugin-react@^7.22.0: - version "7.22.0" - resolved "https://registry.yarnpkg.com/eslint-plugin-react/-/eslint-plugin-react-7.22.0.tgz#3d1c542d1d3169c45421c1215d9470e341707269" - integrity sha512-p30tuX3VS+NWv9nQot9xIGAHBXR0+xJVaZriEsHoJrASGCJZDJ8JLNM0YqKqI0AKm6Uxaa1VUHoNEibxRCMQHA== - dependencies: - array-includes "^3.1.1" - array.prototype.flatmap "^1.2.3" - doctrine "^2.1.0" - has "^1.0.3" - jsx-ast-utils "^2.4.1 || ^3.0.0" - object.entries "^1.1.2" - object.fromentries "^2.0.2" - object.values "^1.1.1" - prop-types "^15.7.2" - resolve "^1.18.1" - string.prototype.matchall "^4.0.2" + resolve "^2.0.0-next.3" + string.prototype.matchall "^4.0.4" eslint-plugin-testing-library@^3.9.2: - version "3.10.0" - resolved "https://registry.yarnpkg.com/eslint-plugin-testing-library/-/eslint-plugin-testing-library-3.10.0.tgz#8c3c9c475bb4e5794446920d363403ae5bcf7f1c" - integrity sha512-zqITQ9qS9tdTG5hY+JnY4k3osolg4sGMD9gTnJr0L1xKB8CvPXXts7tp331ZjQ6qL37kRgH0288/XtsG+bcsxQ== + version "3.10.2" + resolved "https://registry.yarnpkg.com/eslint-plugin-testing-library/-/eslint-plugin-testing-library-3.10.2.tgz#609ec2b0369da7cf2e6d9edff5da153cc31d87bd" + integrity sha512-WAmOCt7EbF1XM8XfbCKAEzAPnShkNSwcIsAD2jHdsMUT9mZJPjLCG7pMzbcC8kK366NOuGip8HKLDC+Xk4yIdA== dependencies: "@typescript-eslint/experimental-utils" "^3.10.1" @@ -7181,56 +6295,13 @@ eslint-webpack-plugin@^2.5.2: micromatch "^4.0.2" schema-utils "^3.0.0" -eslint@^7.11.0: - version "7.12.1" - resolved "https://registry.yarnpkg.com/eslint/-/eslint-7.12.1.tgz#bd9a81fa67a6cfd51656cdb88812ce49ccec5801" - integrity sha512-HlMTEdr/LicJfN08LB3nM1rRYliDXOmfoO4vj39xN6BLpFzF00hbwBoqHk8UcJ2M/3nlARZWy/mslvGEuZFvsg== +eslint@^7.11.0, eslint@^7.18.0: + version "7.22.0" + resolved "https://registry.yarnpkg.com/eslint/-/eslint-7.22.0.tgz#07ecc61052fec63661a2cab6bd507127c07adc6f" + integrity sha512-3VawOtjSJUQiiqac8MQc+w457iGLfuNGLFn8JmF051tTKbh5/x/0vlcEj8OgDCaw7Ysa2Jn8paGshV7x2abKXg== dependencies: - "@babel/code-frame" "^7.0.0" - "@eslint/eslintrc" "^0.2.1" - ajv "^6.10.0" - chalk "^4.0.0" - cross-spawn "^7.0.2" - debug "^4.0.1" - doctrine "^3.0.0" - enquirer "^2.3.5" - eslint-scope "^5.1.1" - eslint-utils "^2.1.0" - eslint-visitor-keys "^2.0.0" - espree "^7.3.0" - esquery "^1.2.0" - esutils "^2.0.2" - file-entry-cache "^5.0.1" - functional-red-black-tree "^1.0.1" - glob-parent "^5.0.0" - globals "^12.1.0" - ignore "^4.0.6" - import-fresh "^3.0.0" - imurmurhash "^0.1.4" - is-glob "^4.0.0" - js-yaml "^3.13.1" - json-stable-stringify-without-jsonify "^1.0.1" - levn "^0.4.1" - lodash "^4.17.19" - minimatch "^3.0.4" - natural-compare "^1.4.0" - optionator "^0.9.1" - progress "^2.0.0" - regexpp "^3.1.0" - semver "^7.2.1" - strip-ansi "^6.0.0" - strip-json-comments "^3.1.0" - table "^5.2.3" - text-table "^0.2.0" - v8-compile-cache "^2.0.3" - -eslint@^7.18.0: - version "7.18.0" - resolved "https://registry.yarnpkg.com/eslint/-/eslint-7.18.0.tgz#7fdcd2f3715a41fe6295a16234bd69aed2c75e67" - integrity sha512-fbgTiE8BfUJZuBeq2Yi7J3RB3WGUQ9PNuNbmgi6jt9Iv8qrkxfy19Ds3OpL1Pm7zg3BtTVhvcUZbIRQ0wmSjAQ== - dependencies: - "@babel/code-frame" "^7.0.0" - "@eslint/eslintrc" "^0.3.0" + "@babel/code-frame" "7.12.11" + "@eslint/eslintrc" "^0.4.0" ajv "^6.10.0" chalk "^4.0.0" cross-spawn "^7.0.2" @@ -7241,12 +6312,12 @@ eslint@^7.18.0: eslint-utils "^2.1.0" eslint-visitor-keys "^2.0.0" espree "^7.3.1" - esquery "^1.2.0" + esquery "^1.4.0" esutils "^2.0.2" - file-entry-cache "^6.0.0" + file-entry-cache "^6.0.1" functional-red-black-tree "^1.0.1" glob-parent "^5.0.0" - globals "^12.1.0" + globals "^13.6.0" ignore "^4.0.6" import-fresh "^3.0.0" imurmurhash "^0.1.4" @@ -7254,7 +6325,7 @@ eslint@^7.18.0: js-yaml "^3.13.1" json-stable-stringify-without-jsonify "^1.0.1" levn "^0.4.1" - lodash "^4.17.20" + lodash "^4.17.21" minimatch "^3.0.4" natural-compare "^1.4.0" optionator "^0.9.1" @@ -7267,16 +6338,7 @@ eslint@^7.18.0: text-table "^0.2.0" v8-compile-cache "^2.0.3" -espree@^7.3.0: - version "7.3.0" - resolved "https://registry.yarnpkg.com/espree/-/espree-7.3.0.tgz#dc30437cf67947cf576121ebd780f15eeac72348" - integrity sha512-dksIWsvKCixn1yrEXO8UosNSxaDoSYpq9reEjZSbHLpT5hpaCAKTLBwq0RHtLrIr+c0ByiYzWT8KTMRzoRCNlw== - dependencies: - acorn "^7.4.0" - acorn-jsx "^5.2.0" - eslint-visitor-keys "^1.3.0" - -espree@^7.3.1: +espree@^7.3.0, espree@^7.3.1: version "7.3.1" resolved "https://registry.yarnpkg.com/espree/-/espree-7.3.1.tgz#f2df330b752c6f55019f8bd89b7660039c1bbbb6" integrity sha512-v3JCNCE64umkFpmkFGqzVKsOT0tN1Zr+ueqLZfpV1Ob8e+CEgPWa+OxCoGH3tnhimMKIaBm4m/vaRpJ/krRz2g== @@ -7290,10 +6352,10 @@ esprima@^4.0.0, esprima@^4.0.1: resolved "https://registry.yarnpkg.com/esprima/-/esprima-4.0.1.tgz#13b04cdb3e6c5d19df91ab6987a8695619b0aa71" integrity sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A== -esquery@^1.2.0: - version "1.3.1" - resolved "https://registry.yarnpkg.com/esquery/-/esquery-1.3.1.tgz#b78b5828aa8e214e29fb74c4d5b752e1c033da57" - integrity sha512-olpvt9QG0vniUBZspVRN6lwB7hOZoTRtT+jzR+tS4ffYx2mzbw+z0XCOk44aaLYKApNX5nMm+E+P6o25ip/DHQ== +esquery@^1.4.0: + version "1.4.0" + resolved "https://registry.yarnpkg.com/esquery/-/esquery-1.4.0.tgz#2148ffc38b82e8c7057dfed48425b3e61f0f24a5" + integrity sha512-cCDispWt5vHHtwMY2YrAQ4ibFkAL8RbH5YGBnZBc90MolvvfkkQcJro/aZiAQUlQ3qgrYS6D6v8Gc5G5CQsc9w== dependencies: estraverse "^5.1.0" @@ -7304,7 +6366,7 @@ esrecurse@^4.1.0, esrecurse@^4.3.0: dependencies: estraverse "^5.2.0" -estraverse@^4.1.1, estraverse@^4.2.0: +estraverse@^4.1.1: version "4.3.0" resolved "https://registry.yarnpkg.com/estraverse/-/estraverse-4.3.0.tgz#398ad3f3c5a24948be7725e83d11a7de28cdbd1d" integrity sha512-39nnKffWz8xN1BU/2c79n9nB9HDzo0niYUqx6xyqUnyoAnQyyWpOTdZEeiCch8BBu515t4wp9ZmgVfVhn9EBpw== @@ -7345,14 +6407,14 @@ eventemitter3@^4.0.0: integrity sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw== events@^3.0.0: - version "3.2.0" - resolved "https://registry.yarnpkg.com/events/-/events-3.2.0.tgz#93b87c18f8efcd4202a461aec4dfc0556b639379" - integrity sha512-/46HWwbfCX2xTawVfkKLGxMifJYQBWMwY1mjywRtb4c9x8l5NP3KoJtnIOiL1hfdRkIuYhETxQlo62IF8tcnlg== + version "3.3.0" + resolved "https://registry.yarnpkg.com/events/-/events-3.3.0.tgz#31a95ad0a924e2d2c419a813aeb2c4e878ea7400" + integrity sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q== -eventsource@^1.0.7: - version "1.0.7" - resolved "https://registry.yarnpkg.com/eventsource/-/eventsource-1.0.7.tgz#8fbc72c93fcd34088090bc0a4e64f4b5cee6d8d0" - integrity sha512-4Ln17+vVT0k8aWq+t/bF5arcS3EpT9gYtW66EPacdj/mAFevznsnyoHLPy2BA8gbIQeIHoPsvwmfBftfcG//BQ== +eventsource@1.1.0, eventsource@^1.0.7: + version "1.1.0" + resolved "https://registry.yarnpkg.com/eventsource/-/eventsource-1.1.0.tgz#00e8ca7c92109e94b0ddf32dac677d841028cfaf" + integrity sha512-VSJjT5oCNrFvCS6igjzPAt5hBzQ2qPBFIbJ03zLI9SE0mxwZpMw6BfJrbFHm1a141AavMEB8JHmBhWAd66PfCg== dependencies: original "^1.0.0" @@ -7365,9 +6427,9 @@ evp_bytestokey@^1.0.0, evp_bytestokey@^1.0.3: safe-buffer "^5.1.1" exec-sh@^0.3.2: - version "0.3.4" - resolved "https://registry.yarnpkg.com/exec-sh/-/exec-sh-0.3.4.tgz#3a018ceb526cc6f6df2bb504b2bfe8e3a4934ec5" - integrity sha512-sEFIkc61v75sWeOe72qyrqg2Qg0OuLESziUDk/O/z2qgS15y2gWVFrI6f2Qn/qw/0/NCfCEsmNA4zOjkwEZT1A== + version "0.3.5" + resolved "https://registry.yarnpkg.com/exec-sh/-/exec-sh-0.3.5.tgz#1b46bd6bcbf54fdc1e926a4f3d90126d42cec3df" + integrity sha512-0hzpaUazv4mEccxdn3TXC+HWNeVXNKMCJRK6E7Xyg+LwGAYI3yFag6jTkd4injV+kChYDQS1ftqDhnDVWNhU8A== execa@^1.0.0: version "1.0.0" @@ -7427,16 +6489,16 @@ expand-brackets@^2.1.4: snapdragon "^0.8.1" to-regex "^3.0.1" -expect@^26.6.0, expect@^26.6.1: - version "26.6.1" - resolved "https://registry.yarnpkg.com/expect/-/expect-26.6.1.tgz#e1e053cdc43b21a452b36fc7cc9401e4603949c1" - integrity sha512-BRfxIBHagghMmr1D2MRY0Qv5d3Nc8HCqgbDwNXw/9izmM5eBb42a2YjLKSbsqle76ozGkAEPELQX4IdNHAKRNA== +expect@^26.6.0, expect@^26.6.2: + version "26.6.2" + resolved "https://registry.yarnpkg.com/expect/-/expect-26.6.2.tgz#c6b996bf26bf3fe18b67b2d0f51fc981ba934417" + integrity sha512-9/hlOBkQl2l/PLHJx6JjoDF6xPKcJEsUlWKb23rKE7KzeDqUZKXKNMW27KIue5JMdBV9HgmoJPcc8HtO85t9IA== dependencies: - "@jest/types" "^26.6.1" + "@jest/types" "^26.6.2" ansi-styles "^4.0.0" jest-get-type "^26.3.0" - jest-matcher-utils "^26.6.1" - jest-message-util "^26.6.1" + jest-matcher-utils "^26.6.2" + jest-message-util "^26.6.2" jest-regex-util "^26.0.0" express@^4.17.1: @@ -7525,7 +6587,7 @@ extglob@^2.0.4: snapdragon "^0.8.1" to-regex "^3.0.1" -extract-files@^9.0.0: +extract-files@9.0.0, extract-files@^9.0.0: version "9.0.0" resolved "https://registry.yarnpkg.com/extract-files/-/extract-files-9.0.0.tgz#8a7744f2437f81f5ed3250ed9f1550de902fe54a" integrity sha512-CvdFfHkC95B4bBBk36hcEmvdR2awOdhhVUYH6S/zrVj3477zven/fJMYg7121h4T1xHZC+tetUpubpAhxwI7hQ== @@ -7566,19 +6628,7 @@ fast-deep-equal@^3.1.1: resolved "https://registry.yarnpkg.com/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz#3a7d56b559d6cbc3eb512325244e619a65c6c525" integrity sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q== -fast-glob@^3.1.1: - version "3.2.4" - resolved "https://registry.yarnpkg.com/fast-glob/-/fast-glob-3.2.4.tgz#d20aefbf99579383e7f3cc66529158c9b98554d3" - integrity sha512-kr/Oo6PX51265qeuCYsyGypiO5uJFgBS0jksyG7FUeCyQzNwYnzrNIMR1NXfkZXsMYXYLRAHgISHBz8gQcxKHQ== - dependencies: - "@nodelib/fs.stat" "^2.0.2" - "@nodelib/fs.walk" "^1.2.3" - glob-parent "^5.1.0" - merge2 "^1.3.0" - micromatch "^4.0.2" - picomatch "^2.2.1" - -fast-glob@^3.2.5: +fast-glob@^3.1.1, fast-glob@^3.2.5: version "3.2.5" resolved "https://registry.yarnpkg.com/fast-glob/-/fast-glob-3.2.5.tgz#7939af2a656de79a4f1901903ee8adcaa7cb9661" integrity sha512-2DtFcgT68wiTTiwZ2hNdJfcHNke9XOfnwmBRWXhmeKM8rF0TGwmC/Qto3S7RoZKp5cilZbxzO5iTNTQsJ+EeDg== @@ -7611,9 +6661,9 @@ fastest-levenshtein@^1.0.12: integrity sha512-On2N+BpYJ15xIC974QNVuYGMOlEVt4s0EOI3wwMqOmK1fdDY+FN/zltPV8vosq4ad4c/gJ1KHScUn/6AWIgiow== fastq@^1.6.0: - version "1.9.0" - resolved "https://registry.yarnpkg.com/fastq/-/fastq-1.9.0.tgz#e16a72f338eaca48e91b5c23593bcc2ef66b7947" - integrity sha512-i7FVWL8HhVY+CTkwFxkN2mk3h+787ixS5S63eb78diVRc1MCssarHq3W5cj0av7YDSwmaV928RNag+U1etRQ7w== + version "1.11.0" + resolved "https://registry.yarnpkg.com/fastq/-/fastq-1.11.0.tgz#bb9fb955a07130a918eb63c1f5161cc32a5d0858" + integrity sha512-7Eczs8gIPDrVzT+EksYBcupqMyxSHXXrHOLRRxU2/DicV8789MRBRR8+Hc2uWzUupOs4YS4JzBmBxjjCVBxD/g== dependencies: reusify "^1.0.4" @@ -7636,14 +6686,13 @@ fbjs-css-vars@^1.0.0: resolved "https://registry.yarnpkg.com/fbjs-css-vars/-/fbjs-css-vars-1.0.2.tgz#216551136ae02fe255932c3ec8775f18e2c078b8" integrity sha512-b2XGFAFdWZWg0phtAWLHCk836A1Xann+I+Dgd3Gk64MHKZO44FfoD1KxyvbSh0qZsIoXQGGlVztIY+oitJPpRQ== -fbjs@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/fbjs/-/fbjs-1.0.0.tgz#52c215e0883a3c86af2a7a776ed51525ae8e0a5a" - integrity sha512-MUgcMEJaFhCaF1QtWGnmq9ZDRAzECTCRAF7O6UZIlAlkTs1SasiX9aP0Iw7wfD2mJ7wDTNfg2w7u5fSCwJk1OA== +fbjs@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/fbjs/-/fbjs-3.0.0.tgz#0907067fb3f57a78f45d95f1eacffcacd623c165" + integrity sha512-dJd4PiDOFuhe7vk4F80Mba83Vr2QuK86FoxtgPmzBqEJahncp+13YCmfoa53KHCo6OnlXLG7eeMWPfB5CrpVKg== dependencies: - core-js "^2.4.1" + cross-fetch "^3.0.4" fbjs-css-vars "^1.0.0" - isomorphic-fetch "^2.1.1" loose-envify "^1.0.0" object-assign "^4.1.0" promise "^7.1.1" @@ -7677,17 +6726,10 @@ figures@^3.0.0: dependencies: escape-string-regexp "^1.0.5" -file-entry-cache@^5.0.1: - version "5.0.1" - resolved "https://registry.yarnpkg.com/file-entry-cache/-/file-entry-cache-5.0.1.tgz#ca0f6efa6dd3d561333fb14515065c2fafdf439c" - integrity sha512-bCg29ictuBaKUwwArK4ouCaqDgLZcysCFLmM/Yn/FDoqndh/9vNuQfXRDvTuXKLxfD/JtZQGKFT8MGcJBK644g== - dependencies: - flat-cache "^2.0.1" - -file-entry-cache@^6.0.0: - version "6.0.0" - resolved "https://registry.yarnpkg.com/file-entry-cache/-/file-entry-cache-6.0.0.tgz#7921a89c391c6d93efec2169ac6bf300c527ea0a" - integrity sha512-fqoO76jZ3ZnYrXLDRxBR1YvOvc0k844kcOg40bgsPrE25LAb/PDqTY+ho64Xh2c8ZXgIKldchCFHczG2UVRcWA== +file-entry-cache@^6.0.1: + version "6.0.1" + resolved "https://registry.yarnpkg.com/file-entry-cache/-/file-entry-cache-6.0.1.tgz#211b2dd9659cb0394b073e7323ac3c933d522027" + integrity sha512-7Gps/XWymbLk2QLYK4NzpMOrYjMhdIxXuIvy2QBsLE6ljuodKvdkWs/cpyJJ3CVIVpH0Oi1Hvg1ovbMzLdFBBg== dependencies: flat-cache "^3.0.4" @@ -7762,11 +6804,6 @@ find-cache-dir@^3.3.1: make-dir "^3.0.2" pkg-dir "^4.1.0" -find-root@^1.1.0: - version "1.1.0" - resolved "https://registry.yarnpkg.com/find-root/-/find-root-1.1.0.tgz#abcfc8ba76f708c42a97b3d685b7e9450bfb9ce4" - integrity sha512-NKfW6bec6GfKc0SGx1e07QZY9PE99u0Bft/0rzSD5k3sO/vwkVUpDUKVm5Gpp5Ue3YfShPFTX2070tDs5kB9Ng== - find-up@4.1.0, find-up@^4.0.0, find-up@^4.1.0: version "4.1.0" resolved "https://registry.yarnpkg.com/find-up/-/find-up-4.1.0.tgz#97afe7d6cdc0bc5928584b7c8d7b16e8a9aa5d19" @@ -7794,15 +6831,6 @@ flag-icon-css@^3.5.0: resolved "https://registry.yarnpkg.com/flag-icon-css/-/flag-icon-css-3.5.0.tgz#430747d5cb91e60babf85494de99173c16dc7cf2" integrity sha512-pgJnJLrtb0tcDgU1fzGaQXmR8h++nXvILJ+r5SmOXaaL/2pocunQo2a8TAXhjQnBpRLPtZ1KCz/TYpqeNuE2ew== -flat-cache@^2.0.1: - version "2.0.1" - resolved "https://registry.yarnpkg.com/flat-cache/-/flat-cache-2.0.1.tgz#5d296d6f04bda44a4630a301413bdbc2ec085ec0" - integrity sha512-LoQe6yDuUMDzQAEH8sgmh4Md6oZnc/7PjtwjNFSzveXqSHt6ka9fPBuso7IGf9Rz4uqnSnWiFH2B/zj24a5ReA== - dependencies: - flatted "^2.0.0" - rimraf "2.6.3" - write "1.0.3" - flat-cache@^3.0.4: version "3.0.4" resolved "https://registry.yarnpkg.com/flat-cache/-/flat-cache-3.0.4.tgz#61b0338302b2fe9f957dcc32fc2a87f1c3048b11" @@ -7816,11 +6844,6 @@ flat@^5.0.0: resolved "https://registry.yarnpkg.com/flat/-/flat-5.0.2.tgz#8ca6fe332069ffa9d324c327198c598259ceb241" integrity sha512-b6suED+5/3rTpUBdG1gupIl8MPFCAMA0QXwmljLhvCUKcUvdE4gWky9zpuGCcXHOsz4J9wPGNWq6OKpmIzz3hQ== -flatted@^2.0.0: - version "2.0.2" - resolved "https://registry.yarnpkg.com/flatted/-/flatted-2.0.2.tgz#4575b21e2bcee7434aa9be662f4b7b5f9c2b5138" - integrity sha512-r5wGx7YeOwNWNlCA0wQ86zKyDLMQr+/RB8xy74M4hTphfmjlijTSSXGuH8rnvKZnfT9i+75zmd8jcKdMR4O6jA== - flatted@^3.1.0: version "3.1.1" resolved "https://registry.yarnpkg.com/flatted/-/flatted-3.1.1.tgz#c4b489e80096d9df1dfc97c79871aea7c617c469" @@ -7845,9 +6868,9 @@ flush-write-stream@^1.0.0: readable-stream "^2.3.6" follow-redirects@^1.0.0, follow-redirects@^1.10.0: - version "1.13.0" - resolved "https://registry.yarnpkg.com/follow-redirects/-/follow-redirects-1.13.0.tgz#b42e8d93a2a7eea5ed88633676d6597bc8e384db" - integrity sha512-aq6gF1BEKje4a9i9+5jimNFIpq4Q1WiwBToeRK5NvZBd/TRsmW8BsJfOEGkr76TbOyPVD3OVDN910EcUNtRYEA== + version "1.13.3" + resolved "https://registry.yarnpkg.com/follow-redirects/-/follow-redirects-1.13.3.tgz#e5598ad50174c1bc4e872301e82ac2cd97f90267" + integrity sha512-DUgl6+HDzB0iEptNQEXLx/KhTmDb8tZUHSeLqpnjpknR70H0nC2t9N73BK6fN4hOvJ84pKlIQVQ4k5FFlBedKA== for-in@^1.0.2: version "1.0.2" @@ -7872,10 +6895,19 @@ fork-ts-checker-webpack-plugin@4.1.6: tapable "^1.0.0" worker-rpc "^0.1.0" +form-data@4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/form-data/-/form-data-4.0.0.tgz#93919daeaf361ee529584b9b31664dc12c9fa452" + integrity sha512-ETEklSGi5t0QMZuiXoA/Q6vcnxcLQP5vdugSpuAyi6SVGi2clPPp+xgEhuMaHC+zGgn31Kd235W35f7Hykkaww== + dependencies: + asynckit "^0.4.0" + combined-stream "^1.0.8" + mime-types "^2.1.12" + form-data@^3.0.0: - version "3.0.0" - resolved "https://registry.yarnpkg.com/form-data/-/form-data-3.0.0.tgz#31b7e39c85f1355b7139ee0c647cf0de7f83c682" - integrity sha512-CKMFDglpbMi6PyN+brwB9Q/GOw0eAnsrEZDgcsH5Krhz5Od/haKHAX0NmQfha2zPPz0JpWzA7GJHGSnvCRLWsg== + version "3.0.1" + resolved "https://registry.yarnpkg.com/form-data/-/form-data-3.0.1.tgz#ebd53791b78356a99af9a300d4282c4d5eb9755f" + integrity sha512-RHkBKtLWUVwd7SqRIvCZMEvAMoGUp0XU+seQiZejj0COz3RI3hWP4sCv3gZWWLjJTd7rGwcsF5eKZGii0r/hbg== dependencies: asynckit "^0.4.0" combined-stream "^1.0.8" @@ -7928,15 +6960,10 @@ from2@^2.1.0: inherits "^2.0.1" readable-stream "^2.0.0" -fs-extra@9.0.1, fs-extra@^9.0.0, fs-extra@^9.0.1: - version "9.0.1" - resolved "https://registry.yarnpkg.com/fs-extra/-/fs-extra-9.0.1.tgz#910da0062437ba4c39fedd863f1675ccfefcb9fc" - integrity sha512-h2iAoN838FqAFJY2/qVpzFXy+EBxfVE220PalAqQLDVsFOHLJrZvut5puAbCdNv6WJk+B8ihI+k0c7JK5erwqQ== - dependencies: - at-least-node "^1.0.0" - graceful-fs "^4.2.0" - jsonfile "^6.0.1" - universalify "^1.0.0" +fs-capacitor@^6.1.0: + version "6.2.0" + resolved "https://registry.yarnpkg.com/fs-capacitor/-/fs-capacitor-6.2.0.tgz#fa79ac6576629163cb84561995602d8999afb7f5" + integrity sha512-nKcE1UduoSKX27NSZlg879LdQc94OtbOsEmKMN2MBNudXREvijRKx2GEBsTMTfws+BrbkJoEuynbGSVRSpauvw== fs-extra@^7.0.0: version "7.0.1" @@ -7956,6 +6983,16 @@ fs-extra@^8.1.0: jsonfile "^4.0.0" universalify "^0.1.0" +fs-extra@^9.0.0, fs-extra@^9.0.1: + version "9.1.0" + resolved "https://registry.yarnpkg.com/fs-extra/-/fs-extra-9.1.0.tgz#5954460c764a8da2094ba3554bf839e6b9a7c86d" + integrity sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ== + dependencies: + at-least-node "^1.0.0" + graceful-fs "^4.2.0" + jsonfile "^6.0.1" + universalify "^2.0.0" + fs-minipass@^2.0.0: version "2.1.0" resolved "https://registry.yarnpkg.com/fs-minipass/-/fs-minipass-2.1.0.tgz#7f5036fdbf12c63c169190cbe4199c852271f9fb" @@ -7986,15 +7023,10 @@ fsevents@^1.2.7: bindings "^1.5.0" nan "^2.12.1" -fsevents@^2.1.2, fsevents@^2.1.3, fsevents@~2.1.2: - version "2.1.3" - resolved "https://registry.yarnpkg.com/fsevents/-/fsevents-2.1.3.tgz#fb738703ae8d2f9fe900c33836ddebee8b97f23e" - integrity sha512-Auw9a4AxqWpa9GUfj370BMPzzyncfBABW8Mab7BGWBYDj4Isgq+cDKtx0i6u9jcX9pQDnswsaaOTgTmA5pEjuQ== - -fsevents@~2.3.1: - version "2.3.1" - resolved "https://registry.yarnpkg.com/fsevents/-/fsevents-2.3.1.tgz#b209ab14c61012636c8863507edf7fb68cc54e9f" - integrity sha512-YR47Eg4hChJGAB1O3yEAOkGO+rlzutoICGqGo9EZ4lKWokzZRSyIW1QmTzqjtw8MJdj9srP869CuWw/hyzSiBw== +fsevents@^2.1.2, fsevents@^2.1.3, fsevents@~2.3.1: + version "2.3.2" + resolved "https://registry.yarnpkg.com/fsevents/-/fsevents-2.3.2.tgz#8a526f78b8fdf4623b709e0b975c52c24c02fd1a" + integrity sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA== function-bind@^1.1.1: version "1.1.1" @@ -8016,19 +7048,10 @@ get-caller-file@^2.0.1, get-caller-file@^2.0.5: resolved "https://registry.yarnpkg.com/get-caller-file/-/get-caller-file-2.0.5.tgz#4f94412a82db32f36e3b0b9741f8a97feb031f7e" integrity sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg== -get-intrinsic@^1.0.0: - version "1.0.1" - resolved "https://registry.yarnpkg.com/get-intrinsic/-/get-intrinsic-1.0.1.tgz#94a9768fcbdd0595a1c9273aacf4c89d075631be" - integrity sha512-ZnWP+AmS1VUaLgTRy47+zKtjTxz+0xMpx3I52i+aalBK1QP19ggLF3Db89KJX7kjfOfP2eoa01qc++GwPgufPg== - dependencies: - function-bind "^1.1.1" - has "^1.0.3" - has-symbols "^1.0.1" - -get-intrinsic@^1.0.1: - version "1.0.2" - resolved "https://registry.yarnpkg.com/get-intrinsic/-/get-intrinsic-1.0.2.tgz#6820da226e50b24894e08859469dc68361545d49" - integrity sha512-aeX0vrFm21ILl3+JpFFRNe9aUvp6VFZb2/CTbgLb8j75kOhvoNYjt9d8KA/tJG4gSo8nzEDedRl0h7vDmBYRVg== +get-intrinsic@^1.0.2, get-intrinsic@^1.1.0, get-intrinsic@^1.1.1: + version "1.1.1" + resolved "https://registry.yarnpkg.com/get-intrinsic/-/get-intrinsic-1.1.1.tgz#15f59f376f855c446963948f0d24cd3637b4abc6" + integrity sha512-kWZrnVM42QCiEA2Ig1bG8zjoIMOgxWwYCEeNdwY6Tv/cOSeGpcoX4pXHfKUxNKVoArnrEr2e9srnAxxGIraS9Q== dependencies: function-bind "^1.1.1" has "^1.0.3" @@ -8092,9 +7115,9 @@ glob-parent@^3.1.0: path-dirname "^1.0.0" glob-parent@^5.0.0, glob-parent@^5.1.0, glob-parent@~5.1.0: - version "5.1.1" - resolved "https://registry.yarnpkg.com/glob-parent/-/glob-parent-5.1.1.tgz#b6c1ef417c4e5663ea498f1c45afac6916bbc229" - integrity sha512-FnI+VGOpnlGHWZxthPGR+QhR78fuiK0sNLkHQv+bL9fQi57lNNdquIbna/WrfROrolq8GK5Ek6BiMwqL/voRYQ== + version "5.1.2" + resolved "https://registry.yarnpkg.com/glob-parent/-/glob-parent-5.1.2.tgz#869832c58034fe68a4093c17dc15e8340d8401c4" + integrity sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow== dependencies: is-glob "^4.0.1" @@ -8126,13 +7149,13 @@ global-prefix@^3.0.0: kind-of "^6.0.2" which "^1.3.1" -global@~4.3.0: - version "4.3.2" - resolved "https://registry.yarnpkg.com/global/-/global-4.3.2.tgz#e76989268a6c74c38908b1305b10fc0e394e9d0f" - integrity sha1-52mJJopsdMOJCLEwWxD8DjlOnQ8= +global@~4.4.0: + version "4.4.0" + resolved "https://registry.yarnpkg.com/global/-/global-4.4.0.tgz#3e7b105179006a323ed71aafca3e9c57a5cc6406" + integrity sha512-wv/LAoHdRE3BeTGz53FAamhGlPLhlssK45usmGFThIi4XqnBmjKQ16u+RNbP7WvigRZDxUsM0J3gcQ5yicaL0w== dependencies: min-document "^2.19.0" - process "~0.5.1" + process "^0.11.10" globals@^11.1.0: version "11.12.0" @@ -8146,7 +7169,14 @@ globals@^12.1.0: dependencies: type-fest "^0.8.1" -globby@11.0.1, globby@^11.0.1: +globals@^13.6.0: + version "13.7.0" + resolved "https://registry.yarnpkg.com/globals/-/globals-13.7.0.tgz#aed3bcefd80ad3ec0f0be2cf0c895110c0591795" + integrity sha512-Aipsz6ZKRxa/xQkZhNg0qIWXT6x6rD46f6x/PCnBomlttdIyAPak4YD9jTmKpZ72uROSMU87qJtcgpgHaVchiA== + dependencies: + type-fest "^0.20.2" + +globby@11.0.1: version "11.0.1" resolved "https://registry.yarnpkg.com/globby/-/globby-11.0.1.tgz#9a2bf107a068f3ffeabc49ad702c79ede8cfd357" integrity sha512-iH9RmgwCmUJHi2z5o2l3eTtGBtXek1OYlHrbcxOYugyHLmAsZrPj43OtHThd62Buh/Vv6VyCBD2bdyWcGNQqoQ== @@ -8158,7 +7188,7 @@ globby@11.0.1, globby@^11.0.1: merge2 "^1.3.0" slash "^3.0.0" -globby@^11.0.2: +globby@11.0.2: version "11.0.2" resolved "https://registry.yarnpkg.com/globby/-/globby-11.0.2.tgz#1af538b766a3b540ebfb58a32b2e2d5897321d83" integrity sha512-2ZThXDvvV8fYFRVIxnrMQBipZQDr7MxKAmQK1vujaj9/7eF0efG7BPUKJ7jP7G5SLF37xKDXvO4S/KKLj/Z0og== @@ -8170,6 +7200,18 @@ globby@^11.0.2: merge2 "^1.3.0" slash "^3.0.0" +globby@^11.0.1, globby@^11.0.2: + version "11.0.3" + resolved "https://registry.yarnpkg.com/globby/-/globby-11.0.3.tgz#9b1f0cb523e171dd1ad8c7b2a9fb4b644b9593cb" + integrity sha512-ffdmosjA807y7+lA1NM0jELARVmYul/715xiILEjo3hBLPTcirgQNnXECn5g3mtR8TOLCVbkfua1Hpen25/Xcg== + dependencies: + array-union "^2.1.0" + dir-glob "^3.0.1" + fast-glob "^3.1.1" + ignore "^5.1.4" + merge2 "^1.3.0" + slash "^3.0.0" + globby@^6.1.0: version "6.1.0" resolved "https://registry.yarnpkg.com/globby/-/globby-6.1.0.tgz#f5a6d70e8395e21c858fb0489d64df02424d506c" @@ -8211,9 +7253,9 @@ got@^9.6.0: url-parse-lax "^3.0.0" graceful-fs@^4.1.11, graceful-fs@^4.1.15, graceful-fs@^4.1.2, graceful-fs@^4.1.6, graceful-fs@^4.2.0, graceful-fs@^4.2.4: - version "4.2.4" - resolved "https://registry.yarnpkg.com/graceful-fs/-/graceful-fs-4.2.4.tgz#2256bde14d3632958c465ebc96dc467ca07a29fb" - integrity sha512-WjKPNJF79dtJAVniUlGGWHYGz2jWxT6VhN/4m1NdkbZ2nOsEF+cI1Edgql5zCRhs/VsQYRvrXctxktVXZUkixw== + version "4.2.6" + resolved "https://registry.yarnpkg.com/graceful-fs/-/graceful-fs-4.2.6.tgz#ff040b2b0853b23c3d31027523706f1885d76bee" + integrity sha512-nTnJ528pbqxYanhpDYsi4Rd8MAeaBA67+RZ10CM1m3bTAVFEDcd5AuA4a6W5YkGZ1iNXHzZz8T6TBKLeBuNriQ== graphql-config@^3.2.0: version "3.2.0" @@ -8233,10 +7275,10 @@ graphql-config@^3.2.0: string-env-interpolation "1.0.1" tslib "^2.0.0" -graphql-request@^3.2.0: - version "3.3.0" - resolved "https://registry.yarnpkg.com/graphql-request/-/graphql-request-3.3.0.tgz#1b9003f34b73cd40d691803d2d422fde5c713af3" - integrity sha512-NHj65WSIUh8j7TBYgzWU0fqvLfxrqFDrLG8nZUh+IREZw50ljR6JXlXRkr52/fL/46wpItiQNLDrG+UZI+KmzA== +graphql-request@^3.3.0: + version "3.4.0" + resolved "https://registry.yarnpkg.com/graphql-request/-/graphql-request-3.4.0.tgz#3a400cd5511eb3c064b1873afb059196bbea9c2b" + integrity sha512-acrTzidSlwAj8wBNO7Q/UQHS8T+z5qRGquCQRv9J1InwR01BBWV9ObnoE+JS5nCCEj8wSGS0yrDXVDoRiKZuOg== dependencies: cross-fetch "^3.0.6" extract-files "^9.0.0" @@ -8247,10 +7289,33 @@ graphql-tag@^2.11.0: resolved "https://registry.yarnpkg.com/graphql-tag/-/graphql-tag-2.11.0.tgz#1deb53a01c46a7eb401d6cb59dec86fa1cccbffd" integrity sha512-VmsD5pJqWJnQZMUeRwrDhfgoyqcfwEkvtpANqcoUG8/tOLkwNgU9mzub/Mc78OJMhHjx7gfAMTxzdG43VGg3bA== +graphql-tag@^2.12.0: + version "2.12.3" + resolved "https://registry.yarnpkg.com/graphql-tag/-/graphql-tag-2.12.3.tgz#ac47bf9d51c67c68ada8a33fd527143ed15bb647" + integrity sha512-5wJMjSvj30yzdciEuk9dPuUBUR56AqDi3xncoYQl1i42pGdSqOJrJsdb/rz5BDoy+qoGvQwABcBeF0xXY3TrKw== + dependencies: + tslib "^2.1.0" + +graphql-upload@^11.0.0: + version "11.0.0" + resolved "https://registry.yarnpkg.com/graphql-upload/-/graphql-upload-11.0.0.tgz#24b245ff18f353bab6715e8a055db9fd73035e10" + integrity sha512-zsrDtu5gCbQFDWsNa5bMB4nf1LpKX9KDgh+f8oL1288ijV4RxeckhVozAjqjXAfRpxOHD1xOESsh6zq8SjdgjA== + dependencies: + busboy "^0.3.1" + fs-capacitor "^6.1.0" + http-errors "^1.7.3" + isobject "^4.0.0" + object-path "^0.11.4" + +graphql-ws@4.2.2: + version "4.2.2" + resolved "https://registry.yarnpkg.com/graphql-ws/-/graphql-ws-4.2.2.tgz#73ede40c064fe76c48c6869df7fc0bfbef80cc20" + integrity sha512-b6TLtWLAmKunD72muL9EeItRGpio9+V3Cx4zJsBkRA+3wxzTWXDvQr9/3qSwJ3D/2abz0ys2KHTM6lB1uH7KIQ== + graphql@^15.3.0, graphql@^15.4.0: - version "15.4.0" - resolved "https://registry.yarnpkg.com/graphql/-/graphql-15.4.0.tgz#e459dea1150da5a106486ba7276518b5295a4347" - integrity sha512-EB3zgGchcabbsU9cFe1j+yxdzKQKAbGUWRb13DsrsMN1yyfmmIq+2+L5MqVWcDCE4V89R5AyUOi7sMOGxdsYtA== + version "15.5.0" + resolved "https://registry.yarnpkg.com/graphql/-/graphql-15.5.0.tgz#39d19494dbe69d1ea719915b578bf920344a69d5" + integrity sha512-OmaM7y0kaK31NKG31q4YbD2beNYa6jBBKtMFT6gLYJljHLJr42IqJ8KX08u3Li/0ifzTU5HjmoOOrwa5BRLeDA== growly@^1.3.0: version "1.3.0" @@ -8300,6 +7365,11 @@ has-ansi@^2.0.0: dependencies: ansi-regex "^2.0.0" +has-bigints@^1.0.0: + version "1.0.1" + resolved "https://registry.yarnpkg.com/has-bigints/-/has-bigints-1.0.1.tgz#64fe6acb020673e3b78db035a5af69aa9d07b113" + integrity sha512-LSBS2LjbNBTf6287JEbEzvJgftkF5qFkmCo9hDRpAzKhUOlJ+hx8dd4USs00SgsUNwc4617J9ki5YtEClM2ffA== + has-flag@^3.0.0: version "3.0.0" resolved "https://registry.yarnpkg.com/has-flag/-/has-flag-3.0.0.tgz#b5d454dc2199ae225699f3467e5a07f3b955bafd" @@ -8310,10 +7380,10 @@ has-flag@^4.0.0: resolved "https://registry.yarnpkg.com/has-flag/-/has-flag-4.0.0.tgz#944771fd9c81c81265c4d6941860da06bb59479b" integrity sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ== -has-symbols@^1.0.1: - version "1.0.1" - resolved "https://registry.yarnpkg.com/has-symbols/-/has-symbols-1.0.1.tgz#9f5214758a44196c406d9bd76cebf81ec2dd31e8" - integrity sha512-PLcsoqu++dmEIZB+6totNFKq/7Do+Z0u4oT0zKOJNl3lYK6vGwwu2hjHs+68OEZbTjiUE9bgOABXbP/GvrS0Kg== +has-symbols@^1.0.0, has-symbols@^1.0.1, has-symbols@^1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/has-symbols/-/has-symbols-1.0.2.tgz#165d3070c00309752a1236a479331e3ac56f1423" + integrity sha512-chXa79rL/UC2KlX17jo3vRGz0azaWEx5tGqZg5pO3NUyEJVB17dMruQlzCCOfUvElghKcm5194+BCRvi2Rv/Gw== has-value@^0.3.1: version "0.3.1" @@ -8370,11 +7440,19 @@ hash.js@^1.0.0, hash.js@^1.0.3: inherits "^2.0.3" minimalistic-assert "^1.0.1" -he@^1.1.0, he@^1.2.0: +he@^1.2.0: version "1.2.0" resolved "https://registry.yarnpkg.com/he/-/he-1.2.0.tgz#84ae65fa7eafb165fddb61566ae14baf05664f0f" integrity sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw== +header-case@^2.0.4: + version "2.0.4" + resolved "https://registry.yarnpkg.com/header-case/-/header-case-2.0.4.tgz#5a42e63b55177349cf405beb8d775acabb92c063" + integrity sha512-H/vuk5TEEVZwrR0lp2zed9OCo1uAILMlx0JEMgC26rzyJJ3N1v6XkwHHXJQdR2doSjcGPM6OKPYoJgf0plJ11Q== + dependencies: + capital-case "^1.0.4" + tslib "^2.0.3" + hex-color-regex@^1.1.0: version "1.1.0" resolved "https://registry.yarnpkg.com/hex-color-regex/-/hex-color-regex-1.1.0.tgz#4c06fccb4602fe2602b3c93df82d7e7dbf1a8a8e" @@ -8392,7 +7470,7 @@ history@^4.9.0: tiny-warning "^1.0.0" value-equal "^1.0.1" -hmac-drbg@^1.0.0: +hmac-drbg@^1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/hmac-drbg/-/hmac-drbg-1.0.1.tgz#d2745701025a6c775a6c545793ed502fc0c649a1" integrity sha1-0nRXAQJabHdabFRXk+1QL8DGSaE= @@ -8418,10 +7496,10 @@ hosted-git-info@^2.1.4: resolved "https://registry.yarnpkg.com/hosted-git-info/-/hosted-git-info-2.8.8.tgz#7539bd4bc1e0e0a895815a2e0262420b12858488" integrity sha512-f/wzC2QaWBs7t9IYqB4T3sR1xviIViXJRJTWBlx2Gf3g0Xi5vI7Yy4koXQ1c9OYDGHN9sBy1DQ2AB8fqZBWhUg== -hosted-git-info@^3.0.6: - version "3.0.7" - resolved "https://registry.yarnpkg.com/hosted-git-info/-/hosted-git-info-3.0.7.tgz#a30727385ea85acfcee94e0aad9e368c792e036c" - integrity sha512-fWqc0IcuXs+BmE9orLDyVykAG9GJtGLGuZAAqgcckPgv5xad4AcXGIv8galtQvlwutxSlaMcdw7BUtq2EIvqCQ== +hosted-git-info@^4.0.1: + version "4.0.1" + resolved "https://registry.yarnpkg.com/hosted-git-info/-/hosted-git-info-4.0.1.tgz#710ef5452ea429a844abc33c981056e7371edab7" + integrity sha512-eT7NrxAsppPRQEBSwKSosReE+v8OzABwEScQYk5d4uxaEPlzxTIku7LINXtBGalthkLhJnq5lBI89PfK43zAKg== dependencies: lru-cache "^6.0.0" @@ -8458,9 +7536,9 @@ html-encoding-sniffer@^2.0.1: whatwg-encoding "^1.0.5" html-entities@^1.2.1, html-entities@^1.3.1: - version "1.3.1" - resolved "https://registry.yarnpkg.com/html-entities/-/html-entities-1.3.1.tgz#fb9a1a4b5b14c5daba82d3e34c6ae4fe701a0e44" - integrity sha512-rhE/4Z3hIhzHAUKbW8jVcCyuT5oJCXXqhN/6mXXVCpzTmvJnoH2HL/bt3EZ6p55jbFJBeAe1ZNpL5BugLujxNA== + version "1.4.0" + resolved "https://registry.yarnpkg.com/html-entities/-/html-entities-1.4.0.tgz#cfbd1b01d2afaf9adca1b10ae7dffab98c71d2dc" + integrity sha512-8nxjcBcd8wovbeKx7h3wTji4e6+rhaVuPNpMqwWgnHh+N9ToqsCs6XztWRBPQ+UtzsoMAdKZtUENoVzU/EMtZA== html-escaper@^2.0.0: version "2.0.2" @@ -8510,7 +7588,7 @@ html-webpack-plugin@4.5.0: tapable "^1.1.3" util.promisify "1.0.0" -htmlparser2@^3.10.0, htmlparser2@^3.3.0: +htmlparser2@^3.10.0, htmlparser2@^3.10.1: version "3.10.1" resolved "https://registry.yarnpkg.com/htmlparser2/-/htmlparser2-3.10.1.tgz#bd679dc3f59897b6a34bb10749c855bb53a9392f" integrity sha512-IgieNijUMbkDovyoKObU1DUhm1iwNYE/fuifEoEHfd1oZKZDaONBSkal7Y01shxsM49R4XaMdGez3WnF9UfiCQ== @@ -8553,6 +7631,17 @@ http-errors@1.7.2: statuses ">= 1.5.0 < 2" toidentifier "1.0.0" +http-errors@^1.7.3: + version "1.8.0" + resolved "https://registry.yarnpkg.com/http-errors/-/http-errors-1.8.0.tgz#75d1bbe497e1044f51e4ee9e704a62f28d336507" + integrity sha512-4I8r0C5JDhT5VkvI47QktDW75rNlGVsUf/8hzjCC/wkWI/jdTRmBb9aI7erSG82r1bjKY3F6k28WnsVxB1C73A== + dependencies: + depd "~1.1.2" + inherits "2.0.4" + setprototypeof "1.2.0" + statuses ">= 1.5.0 < 2" + toidentifier "1.0.0" + http-errors@~1.6.2: version "1.6.3" resolved "https://registry.yarnpkg.com/http-errors/-/http-errors-1.6.3.tgz#8b55680bb4be283a0b5bf4ea2e38580be1d9320d" @@ -8575,9 +7664,9 @@ http-errors@~1.7.2: toidentifier "1.0.0" http-parser-js@>=0.5.1: - version "0.5.2" - resolved "https://registry.yarnpkg.com/http-parser-js/-/http-parser-js-0.5.2.tgz#da2e31d237b393aae72ace43882dd7e270a8ff77" - integrity sha512-opCO9ASqg5Wy2FNo7A0sxy71yGbbkJJXLdgMK04Tcypw9jr2MgWbyubb0+WdmDmGnFflO7fRbqbaihh/ENDlRQ== + version "0.5.3" + resolved "https://registry.yarnpkg.com/http-parser-js/-/http-parser-js-0.5.3.tgz#01d2709c79d41698bb01d4decc5e9da4e4a033d9" + integrity sha512-t7hjvef/5HEK7RWTdUzVUhl8zkEu+LlaE0IYzdMuvbSDipxBRpOn4Uhw8ZyECEa808iVT8XCjzo6xmYt4CiLZg== http-proxy-agent@^4.0.1: version "4.0.1" @@ -8635,9 +7724,9 @@ human-signals@^1.1.1: integrity sha512-SEQu7vl8KjNL2eoGBLF3+wAjpsNfA9XMlXAYj/3EdaNfAlxKthD1xjEQfGOUhllCGGJVNY34bRr6lPINhNjyZw== i18n-iso-countries@^6.4.0: - version "6.4.0" - resolved "https://registry.yarnpkg.com/i18n-iso-countries/-/i18n-iso-countries-6.4.0.tgz#777582a5b7abb7505196b2e90c0b10ddf3789ac4" - integrity sha512-OTDUJq0OqCP+rXnWksTbMDsTE3ZGj92h8GCqIxxZM0eNtdkLVgd5c27q6C6uaZQHy0H/Vskyyx9ieMBBJCMdPw== + version "6.6.0" + resolved "https://registry.yarnpkg.com/i18n-iso-countries/-/i18n-iso-countries-6.6.0.tgz#7723b5a8099d4609780f918e0f7df15795ec80ef" + integrity sha512-3r8nV9GR0mY4BiJbrOBYBhepqiiarLeWI7WKxYy2eWNBvU0Ozk0Qc4KTSfqsFKpapdnV3h9sukZdfOwhiqOU9w== dependencies: diacritics "1.3.0" @@ -8648,13 +7737,6 @@ iconv-lite@0.4.24, iconv-lite@^0.4.24: dependencies: safer-buffer ">= 2.1.2 < 3" -iconv-lite@^0.6.2: - version "0.6.2" - resolved "https://registry.yarnpkg.com/iconv-lite/-/iconv-lite-0.6.2.tgz#ce13d1875b0c3a674bd6a04b7f76b01b1b6ded01" - integrity sha512-2y91h5OpQlolefMPmUlivelittSWy0rP+oYVpn6A7GwVHNE8AWzoYOBNmlwks3LobaJxgHCYZAnyNo2GgpNRNQ== - dependencies: - safer-buffer ">= 2.1.2 < 3.0.0" - icss-utils@^4.0.0, icss-utils@^4.1.1: version "4.1.1" resolved "https://registry.yarnpkg.com/icss-utils/-/icss-utils-4.1.1.tgz#21170b53789ee27447c2f47dd683081403f9a467" @@ -8725,9 +7807,9 @@ import-fresh@^2.0.0: resolve-from "^3.0.0" import-fresh@^3.0.0, import-fresh@^3.1.0, import-fresh@^3.2.1: - version "3.2.1" - resolved "https://registry.yarnpkg.com/import-fresh/-/import-fresh-3.2.1.tgz#633ff618506e793af5ac91bf48b72677e15cbe66" - integrity sha512-6e1q1cnWP2RXD9/keSkxHScg508CdXqXWgWBaETNhyuBFz+kUZlKboh+ISK+bU++DmbHimVBrOz/zzPe0sZ3sQ== + version "3.3.0" + resolved "https://registry.yarnpkg.com/import-fresh/-/import-fresh-3.3.0.tgz#37162c25fcb9ebaa2e6e53d5b4d88ce17d9e0c2b" + integrity sha512-veYYhQa+D1QBKznvhUHxb8faxlrwUnxseDAbAp457E0wLNio2bOSKnjYDhMj+YiAq61xrMGhQk9iXVk5FzgQMw== dependencies: parent-module "^1.0.0" resolve-from "^4.0.0" @@ -8816,9 +7898,9 @@ inherits@2.0.3: integrity sha1-Yzwsg+PaQqUC9SRmAiSA9CCCYd4= ini@^1.3.5, ini@~1.3.0: - version "1.3.5" - resolved "https://registry.yarnpkg.com/ini/-/ini-1.3.5.tgz#eee25f56db1c9ec6085e0c22778083f596abf927" - integrity sha512-RZY5huIKCMRWDUqZlEi72f/lmXKMvuszcMBduliQ3nnWbx9X/ZBQO7DijMEYS9EhHBb2qacRUMtC7svLwe0lcw== + version "1.3.8" + resolved "https://registry.yarnpkg.com/ini/-/ini-1.3.8.tgz#a29da425b48806f34767a4efce397269af28432c" + integrity sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew== inquirer@^7.3.3: version "7.3.3" @@ -8847,28 +7929,36 @@ internal-ip@^4.3.0: default-gateway "^4.2.0" ipaddr.js "^1.9.0" -internal-slot@^1.0.2: - version "1.0.2" - resolved "https://registry.yarnpkg.com/internal-slot/-/internal-slot-1.0.2.tgz#9c2e9fb3cd8e5e4256c6f45fe310067fcfa378a3" - integrity sha512-2cQNfwhAfJIkU4KZPkDI+Gj5yNNnbqi40W9Gge6dfnk4TocEVm00B3bdiL+JINrbGJil2TeHvM4rETGzk/f/0g== +internal-slot@^1.0.3: + version "1.0.3" + resolved "https://registry.yarnpkg.com/internal-slot/-/internal-slot-1.0.3.tgz#7347e307deeea2faac2ac6205d4bc7d34967f59c" + integrity sha512-O0DB1JC/sPyZl7cIo78n5dR7eUSwwpYPiXRhTzNxZVAMUuB8vlnRFyLxdrVToks6XPLVnFfbzaVd5WLjhgg+vA== dependencies: - es-abstract "^1.17.0-next.1" + get-intrinsic "^1.1.0" has "^1.0.3" - side-channel "^1.0.2" + side-channel "^1.0.4" intersection-observer@^0.12.0: version "0.12.0" resolved "https://registry.yarnpkg.com/intersection-observer/-/intersection-observer-0.12.0.tgz#6c84628f67ce8698e5f9ccf857d97718745837aa" integrity sha512-2Vkz8z46Dv401zTWudDGwO7KiGHNDkMv417T5ItcNYfmvHR/1qCTVBO9vwH8zZmQ0WkA/1ARwpysR9bsnop4NQ== -intl-messageformat-parser@6.1.3: - version "6.1.3" - resolved "https://registry.yarnpkg.com/intl-messageformat-parser/-/intl-messageformat-parser-6.1.3.tgz#c333850f66d686eca5c9d87eff1ad46f8721b64d" - integrity sha512-rQTtrVTFy/Z6Lg0ieHkkhdFfi/47BKv1P9+wMWlKWaAxpdDP0FIsp2LRyLPpIVKTwUfL3xf26QT25d69cSkZgQ== +intl-messageformat-parser@6.1.2: + version "6.1.2" + resolved "https://registry.yarnpkg.com/intl-messageformat-parser/-/intl-messageformat-parser-6.1.2.tgz#28c65f3689f538e66c7cf628881548d6a82ff3c2" + integrity sha512-4GQDEPhl/ZMNDKwMsLqyw1LG2IAWjmLJXdmnRcHKeLQzpgtNYZI6lVw1279pqIkRk2MfKb9aDsVFzm565azK5A== dependencies: - "@formatjs/ecma402-abstract" "1.5.1" + "@formatjs/ecma402-abstract" "1.5.0" tslib "^2.0.1" +intl-messageformat-parser@6.4.3: + version "6.4.3" + resolved "https://registry.yarnpkg.com/intl-messageformat-parser/-/intl-messageformat-parser-6.4.3.tgz#4326201256c52907f342c7bb058208113c3c7f95" + integrity sha512-gpB7OeKDSd9wqjIQ7wVQM9byrpMlokGoUfJND7DS9SjoBbOsZIHAHw+lrmAWYmq+MI3WQUeLouSFdYAZ6zSX9A== + dependencies: + "@formatjs/ecma402-abstract" "1.6.3" + tslib "^2.1.0" + intl-messageformat-parser@^5.3.7: version "5.5.1" resolved "https://registry.yarnpkg.com/intl-messageformat-parser/-/intl-messageformat-parser-5.5.1.tgz#f09a692755813e6220081e3374df3fb1698bd0c6" @@ -8876,22 +7966,14 @@ intl-messageformat-parser@^5.3.7: dependencies: "@formatjs/intl-numberformat" "^5.5.2" -intl-messageformat-parser@^6.0.11: - version "6.0.11" - resolved "https://registry.yarnpkg.com/intl-messageformat-parser/-/intl-messageformat-parser-6.0.11.tgz#b5769990f9eb8c015ef726cf4d782a9445544993" - integrity sha512-0avgk5LN8KGpH6CC8OsDAttzkTtR/6Oy50dYaUpbulnNUDrSxPFMc79IJRktN8PT+HwOzhk26vvfJHXyPdf6ZA== - dependencies: - "@formatjs/ecma402-abstract" "^1.2.6" - tslib "^2.0.1" - -intl-messageformat@9.4.3: - version "9.4.3" - resolved "https://registry.yarnpkg.com/intl-messageformat/-/intl-messageformat-9.4.3.tgz#c769923deced44b4c13ad35f84333a20c4f2bf38" - integrity sha512-vTn8gCY5EvHYCYha22QcX4zMSYxkIT8r+3vXeUtvb2udicb7Z+0Ev9p/8hHzcvyrMJk0HPADnkNLVsx8EUfRkg== +intl-messageformat@9.5.3: + version "9.5.3" + resolved "https://registry.yarnpkg.com/intl-messageformat/-/intl-messageformat-9.5.3.tgz#cb89a91cc2da875c5c824d374ba8209fac63a3ca" + integrity sha512-Ei8vH41/icJsc16ZfWk1FzZ2SpaVn0gElXsQCKKPerxK/28m1gVdH0G26GuCqAyz5ETEJiSRn8sPMaSWJDuTjg== dependencies: fast-memoize "^2.5.2" - intl-messageformat-parser "6.1.3" - tslib "^2.0.1" + intl-messageformat-parser "6.4.3" + tslib "^2.1.0" invariant@^2.2.4: version "2.2.4" @@ -8961,9 +8043,11 @@ is-alphanumerical@^1.0.0: is-decimal "^1.0.0" is-arguments@^1.0.4: - version "1.0.4" - resolved "https://registry.yarnpkg.com/is-arguments/-/is-arguments-1.0.4.tgz#3faf966c7cba0ff437fb31f6250082fcf0448cf3" - integrity sha512-xPh0Rmt8NE65sNzvyUmWgI1tz3mKq74lGA0mL8LYZcoIzKOzDh6HmrYm3d18k60nHerC8A9Km8kYu87zfSFnLA== + version "1.1.0" + resolved "https://registry.yarnpkg.com/is-arguments/-/is-arguments-1.1.0.tgz#62353031dfbee07ceb34656a6bde59efecae8dd9" + integrity sha512-1Ij4lOMPl/xB5kBDn7I+b2ttPMKa8szhEIrXDuXQD/oe3HJLTLhqhgGspwgyGd6MOywBUqVvYicF72lkgDnIHg== + dependencies: + call-bind "^1.0.0" is-arrayish@^0.2.1: version "0.2.1" @@ -8975,6 +8059,11 @@ is-arrayish@^0.3.1: resolved "https://registry.yarnpkg.com/is-arrayish/-/is-arrayish-0.3.2.tgz#4574a2ae56f7ab206896fb431eaeed066fdf8f03" integrity sha512-eVRqCvVlZbuw3GrM63ovNSNAeA1K16kaR/LRY/92w0zxQ5/1YzwblUX652i4Xs9RwAGjW9d9y6X88t8OaAJfWQ== +is-bigint@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/is-bigint/-/is-bigint-1.0.1.tgz#6923051dfcbc764278540b9ce0e6b3213aa5ebc2" + integrity sha512-J0ELF4yHFxHy0cmSxZuheDOz2luOdVvqjwmEcj8H/L1JHeuEDSDbeRP+Dk9kFVk5RTFzbucJ2Kb9F7ixY2QaCg== + is-binary-path@^1.0.0: version "1.0.1" resolved "https://registry.yarnpkg.com/is-binary-path/-/is-binary-path-1.0.1.tgz#75f16642b480f187a711c814161fd3a4a7655898" @@ -8989,20 +8078,27 @@ is-binary-path@~2.1.0: dependencies: binary-extensions "^2.0.0" +is-boolean-object@^1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/is-boolean-object/-/is-boolean-object-1.1.0.tgz#e2aaad3a3a8fca34c28f6eee135b156ed2587ff0" + integrity sha512-a7Uprx8UtD+HWdyYwnD1+ExtTgqQtD2k/1yJgtXP6wnMm8byhkoTZRl+95LLThpzNZJ5aEvi46cdH+ayMFRwmA== + dependencies: + call-bind "^1.0.0" + is-buffer@^1.1.5: version "1.1.6" resolved "https://registry.yarnpkg.com/is-buffer/-/is-buffer-1.1.6.tgz#efaa2ea9daa0d7ab2ea13a97b2b8ad51fefbe8be" integrity sha512-NcdALwpXkTm5Zvvbk7owOUSvVvBKDgKP5/ewfXEznmQFfs4ZRmanOeKBTjRVjka3QFoN6XJ+9F3USqfHqTaU5w== is-buffer@^2.0.0: - version "2.0.4" - resolved "https://registry.yarnpkg.com/is-buffer/-/is-buffer-2.0.4.tgz#3e572f23c8411a5cfd9557c849e3665e0b290623" - integrity sha512-Kq1rokWXOPXWuaMAqZiJW4XxsmD9zGx9q4aePabbn3qCRGedtH7Cm+zV8WETitMfu1wdh+Rvd6w5egwSngUX2A== + version "2.0.5" + resolved "https://registry.yarnpkg.com/is-buffer/-/is-buffer-2.0.5.tgz#ebc252e400d22ff8d77fa09888821a24a658c191" + integrity sha512-i2R6zNFDwgEHJyQUtJEk0XFi1i0dPFn/oqjK3/vPCcDeJvW5NQ83V8QbicfF1SupOaB0h8ntgBC2YiE7dfyctQ== -is-callable@^1.1.4, is-callable@^1.2.2: - version "1.2.2" - resolved "https://registry.yarnpkg.com/is-callable/-/is-callable-1.2.2.tgz#c7c6715cd22d4ddb48d3e19970223aceabb080d9" - integrity sha512-dnMqspv5nU3LoewK2N/y7KLtxtakvTuaCsU9FU50/QDmdbHNy/4/JuRtMHqRU22o3q+W89YQndQEeCVwK+3qrA== +is-callable@^1.1.4, is-callable@^1.2.3: + version "1.2.3" + resolved "https://registry.yarnpkg.com/is-callable/-/is-callable-1.2.3.tgz#8b1e0500b73a1d76c70487636f368e519de8db8e" + integrity sha512-J1DcMe8UYTBSrKezuIUTUwjXsho29693unXM2YhJUTR2txK/eG47bvNa/wipPFmZFgr/N6f1GA66dv0mEyTIyQ== is-ci@^2.0.0: version "2.0.0" @@ -9023,10 +8119,10 @@ is-color-stop@^1.0.0: rgb-regex "^1.0.1" rgba-regex "^1.0.0" -is-core-module@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/is-core-module/-/is-core-module-2.0.0.tgz#58531b70aed1db7c0e8d4eb1a0a2d1ddd64bd12d" - integrity sha512-jq1AH6C8MuteOoBPwkxHafmByhL9j5q4OaPGdbuD+ZtQJVzH+i6E3BJDQcBA09k57i2Hh2yQbEG8yObZ0jdlWw== +is-core-module@^2.0.0, is-core-module@^2.2.0: + version "2.2.0" + resolved "https://registry.yarnpkg.com/is-core-module/-/is-core-module-2.2.0.tgz#97037ef3d52224d85163f5597b2b63d9afed981a" + integrity sha512-XRAfAdyyY5F5cOXn7hYQDqh2Xmii+DEfIcQGxK/uNwMHhIkPWO0g8msXcbzLe+MpGoR951MlqM/2iIlU4vKDdQ== dependencies: has "^1.0.3" @@ -9145,15 +8241,27 @@ is-hexadecimal@^1.0.0: resolved "https://registry.yarnpkg.com/is-hexadecimal/-/is-hexadecimal-1.0.4.tgz#cc35c97588da4bd49a8eedd6bc4082d44dcb23a7" integrity sha512-gyPJuv83bHMpocVYoqof5VDiZveEoGoFL8m3BXNb2VW8Xs+rz9kqO8LOQ5DH6EsuvilT1ApazU0pyl+ytbPtlw== +is-lower-case@^2.0.1: + version "2.0.2" + resolved "https://registry.yarnpkg.com/is-lower-case/-/is-lower-case-2.0.2.tgz#1c0884d3012c841556243483aa5d522f47396d2a" + integrity sha512-bVcMJy4X5Og6VZfdOZstSexlEy20Sr0k/p/b2IlQJlfdKAQuMpiv5w2Ccxb8sKdRUNAG1PnHVHjFSdRDVS6NlQ== + dependencies: + tslib "^2.0.3" + is-module@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/is-module/-/is-module-1.0.0.tgz#3258fb69f78c14d5b815d664336b4cffb6441591" integrity sha1-Mlj7afeMFNW4FdZkM2tM/7ZEFZE= -is-negative-zero@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/is-negative-zero/-/is-negative-zero-2.0.0.tgz#9553b121b0fac28869da9ed459e20c7543788461" - integrity sha1-lVOxIbD6wohp2p7UWeIMdUN4hGE= +is-negative-zero@^2.0.1: + version "2.0.1" + resolved "https://registry.yarnpkg.com/is-negative-zero/-/is-negative-zero-2.0.1.tgz#3de746c18dda2319241a53675908d8f766f11c24" + integrity sha512-2z6JzQvZRa9A2Y7xC6dQQm4FSTSTNWjKIYYTt4246eMTJmIo0Q+ZyOsU66X8lxK1AbB92dFeglPLrhwpeRKO6w== + +is-number-object@^1.0.4: + version "1.0.4" + resolved "https://registry.yarnpkg.com/is-number-object/-/is-number-object-1.0.4.tgz#36ac95e741cf18b283fc1ddf5e83da798e3ec197" + integrity sha512-zohwelOAur+5uXtk8O3GPQ1eAcu4ZX3UwxQhUlfFFMNpUd83gXgjbhJh6HmB6LUNV/ieOLQuDwJO3dWJosUeMw== is-number@^3.0.0: version "3.0.0" @@ -9235,11 +8343,12 @@ is-promise@^2.1.0: resolved "https://registry.yarnpkg.com/is-promise/-/is-promise-2.2.2.tgz#39ab959ccbf9a774cf079f7b40c7a26f763135f1" integrity sha512-+lP4/6lKUBfQjZ2pdxThZvLUAafmZb8OAxFb8XXtiQmS35INgr85hdOGoEs124ez1FCnZJt6jau/T+alh58QFQ== -is-regex@^1.0.4, is-regex@^1.1.1: - version "1.1.1" - resolved "https://registry.yarnpkg.com/is-regex/-/is-regex-1.1.1.tgz#c6f98aacc546f6cec5468a07b7b153ab564a57b9" - integrity sha512-1+QkEcxiLlB7VEyFtyBg94e08OAsvq7FUBgApTq/w2ymCLyKJgDPsybBENVtA7XCQEgEXxKPonG+mvYRxh/LIg== +is-regex@^1.0.4, is-regex@^1.1.2: + version "1.1.2" + resolved "https://registry.yarnpkg.com/is-regex/-/is-regex-1.1.2.tgz#81c8ebde4db142f2cf1c53fc86d6a45788266251" + integrity sha512-axvdhb5pdhEVThqJzYXwMlVuZwC+FF2DpcOhTS+y/8jVq4trxyPgfcwIxIKiyeuLlSQYKkmUaPQJ8ZE4yNKXDg== dependencies: + call-bind "^1.0.2" has-symbols "^1.0.1" is-regexp@^1.0.0: @@ -9269,7 +8378,7 @@ is-root@2.1.0: resolved "https://registry.yarnpkg.com/is-root/-/is-root-2.1.0.tgz#809e18129cf1129644302a4f8544035d51984a9c" integrity sha512-AGOriNp96vNBd3HtU+RzFEc75FfR5ymiYv8E553I71SCeXBiMsVDUtdio1OEFvrPyLIQ9tVR5RxXIFe5PUFjMg== -is-stream@^1.0.1, is-stream@^1.1.0: +is-stream@^1.1.0: version "1.1.0" resolved "https://registry.yarnpkg.com/is-stream/-/is-stream-1.1.0.tgz#12d4a3dd4e68e0b79ceb8dbc84173ae80d91ca44" integrity sha1-EtSj3U5o4Lec6428hBc66A2RykQ= @@ -9291,7 +8400,7 @@ is-svg@^3.0.0: dependencies: html-comment-regex "^1.1.0" -is-symbol@^1.0.2: +is-symbol@^1.0.2, is-symbol@^1.0.3: version "1.0.3" resolved "https://registry.yarnpkg.com/is-symbol/-/is-symbol-1.0.3.tgz#38e1014b9e6329be0de9d24a414fd7441ec61937" integrity sha512-OwijhaRSgqvhm/0ZdAcXNZt9lYdKFpcRDT5ULUuYXPoT794UNOdU+gpT6Rzo7b4V2HUl/op6GqY894AZwv9faQ== @@ -9310,6 +8419,18 @@ is-unc-path@^1.0.0: dependencies: unc-path-regex "^0.1.2" +is-unicode-supported@^0.1.0: + version "0.1.0" + resolved "https://registry.yarnpkg.com/is-unicode-supported/-/is-unicode-supported-0.1.0.tgz#3f26c76a809593b52bfa2ecb5710ed2779b522a7" + integrity sha512-knxG2q4UC3u8stRGyAVJCOdxFmv5DZiRcdlIaAQXAbSfJya+OhopNotLQrstBhququ4ZpuKbDc/8S6mgXgPFPw== + +is-upper-case@^2.0.1: + version "2.0.2" + resolved "https://registry.yarnpkg.com/is-upper-case/-/is-upper-case-2.0.2.tgz#f1105ced1fe4de906a5f39553e7d3803fd804649" + integrity sha512-44pxmxAvnnAOwBg4tHPnkfvgjPwbc5QIsSstNU+YcJ1ovxVzCWpSGosPJOZh/a1tdl81fbgnLc9LLv+x2ywbPQ== + dependencies: + tslib "^2.0.3" + is-windows@^1.0.1, is-windows@^1.0.2: version "1.0.2" resolved "https://registry.yarnpkg.com/is-windows/-/is-windows-1.0.2.tgz#d1850eb9791ecd18e6182ce12a30f396634bb19d" @@ -9354,13 +8475,10 @@ isobject@^3.0.0, isobject@^3.0.1: resolved "https://registry.yarnpkg.com/isobject/-/isobject-3.0.1.tgz#4e431e92b11a9731636aa1f9c8d1ccbcfdab78df" integrity sha1-TkMekrEalzFjaqH5yNHMvP2reN8= -isomorphic-fetch@^2.1.1: - version "2.2.1" - resolved "https://registry.yarnpkg.com/isomorphic-fetch/-/isomorphic-fetch-2.2.1.tgz#611ae1acf14f5e81f729507472819fe9733558a9" - integrity sha1-YRrhrPFPXoH3KVB0coGf6XM1WKk= - dependencies: - node-fetch "^1.0.1" - whatwg-fetch ">=0.10.0" +isobject@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/isobject/-/isobject-4.0.0.tgz#3f1c9155e73b192022a80819bacd0343711697b0" + integrity sha512-S/2fF5wH8SJA/kmwr6HYhK/RI/OkhD84k8ntalo0iJjZikgq1XFvR5M8NPT1x5F7fBwCG3qHfnzeP/Vh/ZxCUA== isomorphic-fetch@^3.0.0: version "3.0.0" @@ -9370,6 +8488,11 @@ isomorphic-fetch@^3.0.0: node-fetch "^2.6.1" whatwg-fetch "^3.4.1" +isomorphic-ws@4.0.1: + version "4.0.1" + resolved "https://registry.yarnpkg.com/isomorphic-ws/-/isomorphic-ws-4.0.1.tgz#55fd4cd6c5e6491e76dc125938dd863f5cd4f2dc" + integrity sha512-BhBvN2MBpWTaSHdWRb/bwdZJ1WaehQ2L1KngkCkfLUGF0mAWAT1sQUQacEmQ0jXkFw/czDXPNQSL5u2/Krsz1w== + isstream@~0.1.2: version "0.1.2" resolved "https://registry.yarnpkg.com/isstream/-/isstream-0.1.2.tgz#47e63f7af55afa6f92e1500e690eb8b8529c099a" @@ -9421,12 +8544,12 @@ iterall@^1.2.1: resolved "https://registry.yarnpkg.com/iterall/-/iterall-1.3.0.tgz#afcb08492e2915cbd8a0884eb93a8c94d0d72fea" integrity sha512-QZ9qOMdF+QLHxy1QIpUHUU1D5pS2CG2P69LF6L6CPjPYA/XMOmKV3PZpawHoAjHNyB0swdVTRxdYT4tbBbxqwg== -jest-changed-files@^26.6.1: - version "26.6.1" - resolved "https://registry.yarnpkg.com/jest-changed-files/-/jest-changed-files-26.6.1.tgz#2fac3dc51297977ee883347948d8e3d37c417fba" - integrity sha512-NhSdZ5F6b/rIN5V46x1l31vrmukD/bJUXgYAY8VtP1SknYdJwjYDRxuLt7Z8QryIdqCjMIn2C0Cd98EZ4umo8Q== +jest-changed-files@^26.6.2: + version "26.6.2" + resolved "https://registry.yarnpkg.com/jest-changed-files/-/jest-changed-files-26.6.2.tgz#f6198479e1cc66f22f9ae1e22acaa0b429c042d0" + integrity sha512-fDS7szLcY9sCtIip8Fjry9oGf3I2ht/QT21bAHm5Dmf0mD4X3ReNUf17y+bO6fR8WgbIZTlbyG1ak/53cbRzKQ== dependencies: - "@jest/types" "^26.6.1" + "@jest/types" "^26.6.2" execa "^4.0.0" throat "^5.0.0" @@ -9458,57 +8581,57 @@ jest-circus@26.6.0: throat "^5.0.0" jest-cli@^26.6.0: - version "26.6.1" - resolved "https://registry.yarnpkg.com/jest-cli/-/jest-cli-26.6.1.tgz#8952242fa812c05bd129abf7c022424045b7fd67" - integrity sha512-aPLoEjlwFrCWhiPpW5NUxQA1X1kWsAnQcQ0SO/fHsCvczL3W75iVAcH9kP6NN+BNqZcHNEvkhxT5cDmBfEAh+w== + version "26.6.3" + resolved "https://registry.yarnpkg.com/jest-cli/-/jest-cli-26.6.3.tgz#43117cfef24bc4cd691a174a8796a532e135e92a" + integrity sha512-GF9noBSa9t08pSyl3CY4frMrqp+aQXFGFkf5hEPbh/pIUFYWMK6ZLTfbmadxJVcJrdRoChlWQsA2VkJcDFK8hg== dependencies: - "@jest/core" "^26.6.1" - "@jest/test-result" "^26.6.1" - "@jest/types" "^26.6.1" + "@jest/core" "^26.6.3" + "@jest/test-result" "^26.6.2" + "@jest/types" "^26.6.2" chalk "^4.0.0" exit "^0.1.2" graceful-fs "^4.2.4" import-local "^3.0.2" is-ci "^2.0.0" - jest-config "^26.6.1" - jest-util "^26.6.1" - jest-validate "^26.6.1" + jest-config "^26.6.3" + jest-util "^26.6.2" + jest-validate "^26.6.2" prompts "^2.0.1" yargs "^15.4.1" -jest-config@^26.6.1: - version "26.6.1" - resolved "https://registry.yarnpkg.com/jest-config/-/jest-config-26.6.1.tgz#8c343fbdd9c24ad003e261f73583c3c020f32b42" - integrity sha512-mtJzIynIwW1d1nMlKCNCQiSgWaqFn8cH/fOSNY97xG7Y9tBCZbCSuW2GTX0RPmceSJGO7l27JgwC18LEg0Vg+g== +jest-config@^26.6.3: + version "26.6.3" + resolved "https://registry.yarnpkg.com/jest-config/-/jest-config-26.6.3.tgz#64f41444eef9eb03dc51d5c53b75c8c71f645349" + integrity sha512-t5qdIj/bCj2j7NFVHb2nFB4aUdfucDn3JRKgrZnplb8nieAirAzRSHP8uDEd+qV6ygzg9Pz4YG7UTJf94LPSyg== dependencies: "@babel/core" "^7.1.0" - "@jest/test-sequencer" "^26.6.1" - "@jest/types" "^26.6.1" - babel-jest "^26.6.1" + "@jest/test-sequencer" "^26.6.3" + "@jest/types" "^26.6.2" + babel-jest "^26.6.3" chalk "^4.0.0" deepmerge "^4.2.2" glob "^7.1.1" graceful-fs "^4.2.4" - jest-environment-jsdom "^26.6.1" - jest-environment-node "^26.6.1" + jest-environment-jsdom "^26.6.2" + jest-environment-node "^26.6.2" jest-get-type "^26.3.0" - jest-jasmine2 "^26.6.1" + jest-jasmine2 "^26.6.3" jest-regex-util "^26.0.0" - jest-resolve "^26.6.1" - jest-util "^26.6.1" - jest-validate "^26.6.1" + jest-resolve "^26.6.2" + jest-util "^26.6.2" + jest-validate "^26.6.2" micromatch "^4.0.2" - pretty-format "^26.6.1" + pretty-format "^26.6.2" -jest-diff@^26.6.1: - version "26.6.1" - resolved "https://registry.yarnpkg.com/jest-diff/-/jest-diff-26.6.1.tgz#38aa194979f454619bb39bdee299fb64ede5300c" - integrity sha512-BBNy/zin2m4kG5In126O8chOBxLLS/XMTuuM2+YhgyHk87ewPzKTuTJcqj3lOWOi03NNgrl+DkMeV/exdvG9gg== +jest-diff@^26.6.2: + version "26.6.2" + resolved "https://registry.yarnpkg.com/jest-diff/-/jest-diff-26.6.2.tgz#1aa7468b52c3a68d7d5c5fdcdfcd5e49bd164394" + integrity sha512-6m+9Z3Gv9wN0WFVasqjCL/06+EFCMTqDEUl/b87HYK2rAPTyfz4ZIuSlPhY51PIQRWx5TaxeF1qmXKe9gfN3sA== dependencies: chalk "^4.0.0" - diff-sequences "^26.5.0" + diff-sequences "^26.6.2" jest-get-type "^26.3.0" - pretty-format "^26.6.1" + pretty-format "^26.6.2" jest-docblock@^26.0.0: version "26.0.0" @@ -9517,130 +8640,131 @@ jest-docblock@^26.0.0: dependencies: detect-newline "^3.0.0" -jest-each@^26.6.0, jest-each@^26.6.1: - version "26.6.1" - resolved "https://registry.yarnpkg.com/jest-each/-/jest-each-26.6.1.tgz#e968e88309a3e2ae9648634af8f89d8ee5acfddd" - integrity sha512-gSn8eB3buchuq45SU7pLB7qmCGax1ZSxfaWuEFblCyNMtyokYaKFh9dRhYPujK6xYL57dLIPhLKatjmB5XWzGA== +jest-each@^26.6.0, jest-each@^26.6.2: + version "26.6.2" + resolved "https://registry.yarnpkg.com/jest-each/-/jest-each-26.6.2.tgz#02526438a77a67401c8a6382dfe5999952c167cb" + integrity sha512-Mer/f0KaATbjl8MCJ+0GEpNdqmnVmDYqCTJYTvoo7rqmRiDllmp2AYN+06F93nXcY3ur9ShIjS+CO/uD+BbH4A== dependencies: - "@jest/types" "^26.6.1" + "@jest/types" "^26.6.2" chalk "^4.0.0" jest-get-type "^26.3.0" - jest-util "^26.6.1" - pretty-format "^26.6.1" + jest-util "^26.6.2" + pretty-format "^26.6.2" -jest-environment-jsdom@^26.6.1: - version "26.6.1" - resolved "https://registry.yarnpkg.com/jest-environment-jsdom/-/jest-environment-jsdom-26.6.1.tgz#63093bf89daee6139616568a43633b84cf7aac21" - integrity sha512-A17RiXuHYNVlkM+3QNcQ6n5EZyAc6eld8ra9TW26luounGWpku4tj03uqRgHJCI1d4uHr5rJiuCH5JFRtdmrcA== +jest-environment-jsdom@^26.6.2: + version "26.6.2" + resolved "https://registry.yarnpkg.com/jest-environment-jsdom/-/jest-environment-jsdom-26.6.2.tgz#78d09fe9cf019a357009b9b7e1f101d23bd1da3e" + integrity sha512-jgPqCruTlt3Kwqg5/WVFyHIOJHsiAvhcp2qiR2QQstuG9yWox5+iHpU3ZrcBxW14T4fe5Z68jAfLRh7joCSP2Q== dependencies: - "@jest/environment" "^26.6.1" - "@jest/fake-timers" "^26.6.1" - "@jest/types" "^26.6.1" + "@jest/environment" "^26.6.2" + "@jest/fake-timers" "^26.6.2" + "@jest/types" "^26.6.2" "@types/node" "*" - jest-mock "^26.6.1" - jest-util "^26.6.1" + jest-mock "^26.6.2" + jest-util "^26.6.2" jsdom "^16.4.0" -jest-environment-node@^26.6.1: - version "26.6.1" - resolved "https://registry.yarnpkg.com/jest-environment-node/-/jest-environment-node-26.6.1.tgz#4d73d8b33c26989a92a0ed3ad0bfd6f7a196d9bd" - integrity sha512-YffaCp6h0j1kbcf1NVZ7umC6CPgD67YS+G1BeornfuSkx5s3xdhuwG0DCxSiHPXyT81FfJzA1L7nXvhq50OWIg== +jest-environment-node@^26.6.2: + version "26.6.2" + resolved "https://registry.yarnpkg.com/jest-environment-node/-/jest-environment-node-26.6.2.tgz#824e4c7fb4944646356f11ac75b229b0035f2b0c" + integrity sha512-zhtMio3Exty18dy8ee8eJ9kjnRyZC1N4C1Nt/VShN1apyXc8rWGtJ9lI7vqiWcyyXS4BVSEn9lxAM2D+07/Tag== dependencies: - "@jest/environment" "^26.6.1" - "@jest/fake-timers" "^26.6.1" - "@jest/types" "^26.6.1" + "@jest/environment" "^26.6.2" + "@jest/fake-timers" "^26.6.2" + "@jest/types" "^26.6.2" "@types/node" "*" - jest-mock "^26.6.1" - jest-util "^26.6.1" + jest-mock "^26.6.2" + jest-util "^26.6.2" jest-get-type@^26.3.0: version "26.3.0" resolved "https://registry.yarnpkg.com/jest-get-type/-/jest-get-type-26.3.0.tgz#e97dc3c3f53c2b406ca7afaed4493b1d099199e0" integrity sha512-TpfaviN1R2pQWkIihlfEanwOXK0zcxrKEE4MlU6Tn7keoXdN6/3gK/xl0yEh8DOunn5pOVGKf8hB4R9gVh04ig== -jest-haste-map@^26.6.1: - version "26.6.1" - resolved "https://registry.yarnpkg.com/jest-haste-map/-/jest-haste-map-26.6.1.tgz#97e96f5fd7576d980307fbe6160b10c016b543d4" - integrity sha512-9kPafkv0nX6ta1PrshnkiyhhoQoFWncrU/uUBt3/AP1r78WSCU5iLceYRTwDvJl67H3RrXqSlSVDDa/AsUB7OQ== +jest-haste-map@^26.6.2: + version "26.6.2" + resolved "https://registry.yarnpkg.com/jest-haste-map/-/jest-haste-map-26.6.2.tgz#dd7e60fe7dc0e9f911a23d79c5ff7fb5c2cafeaa" + integrity sha512-easWIJXIw71B2RdR8kgqpjQrbMRWQBgiBwXYEhtGUTaX+doCjBheluShdDMeR8IMfJiTqH4+zfhtg29apJf/8w== dependencies: - "@jest/types" "^26.6.1" + "@jest/types" "^26.6.2" "@types/graceful-fs" "^4.1.2" "@types/node" "*" anymatch "^3.0.3" fb-watchman "^2.0.0" graceful-fs "^4.2.4" jest-regex-util "^26.0.0" - jest-serializer "^26.5.0" - jest-util "^26.6.1" - jest-worker "^26.6.1" + jest-serializer "^26.6.2" + jest-util "^26.6.2" + jest-worker "^26.6.2" micromatch "^4.0.2" sane "^4.0.3" walker "^1.0.7" optionalDependencies: fsevents "^2.1.2" -jest-jasmine2@^26.6.1: - version "26.6.1" - resolved "https://registry.yarnpkg.com/jest-jasmine2/-/jest-jasmine2-26.6.1.tgz#11c92603d1fa97e3c33404359e69d6cec7e57017" - integrity sha512-2uYdT32o/ZzSxYAPduAgokO8OlAL1YdG/9oxcEY138EDNpIK5XRRJDaGzTZdIBWSxk0aR8XxN44FvfXtHB+Fiw== +jest-jasmine2@^26.6.3: + version "26.6.3" + resolved "https://registry.yarnpkg.com/jest-jasmine2/-/jest-jasmine2-26.6.3.tgz#adc3cf915deacb5212c93b9f3547cd12958f2edd" + integrity sha512-kPKUrQtc8aYwBV7CqBg5pu+tmYXlvFlSFYn18ev4gPFtrRzB15N2gW/Roew3187q2w2eHuu0MU9TJz6w0/nPEg== dependencies: "@babel/traverse" "^7.1.0" - "@jest/environment" "^26.6.1" - "@jest/source-map" "^26.5.0" - "@jest/test-result" "^26.6.1" - "@jest/types" "^26.6.1" + "@jest/environment" "^26.6.2" + "@jest/source-map" "^26.6.2" + "@jest/test-result" "^26.6.2" + "@jest/types" "^26.6.2" "@types/node" "*" chalk "^4.0.0" co "^4.6.0" - expect "^26.6.1" + expect "^26.6.2" is-generator-fn "^2.0.0" - jest-each "^26.6.1" - jest-matcher-utils "^26.6.1" - jest-message-util "^26.6.1" - jest-runtime "^26.6.1" - jest-snapshot "^26.6.1" - jest-util "^26.6.1" - pretty-format "^26.6.1" + jest-each "^26.6.2" + jest-matcher-utils "^26.6.2" + jest-message-util "^26.6.2" + jest-runtime "^26.6.3" + jest-snapshot "^26.6.2" + jest-util "^26.6.2" + pretty-format "^26.6.2" throat "^5.0.0" -jest-leak-detector@^26.6.1: - version "26.6.1" - resolved "https://registry.yarnpkg.com/jest-leak-detector/-/jest-leak-detector-26.6.1.tgz#f63e46dc4e3aa30d29b40ae49966a15730d25bbe" - integrity sha512-j9ZOtJSJKlHjrs4aIxWjiQUjyrffPdiAQn2Iw0916w7qZE5Lk0T2KhIH6E9vfhzP6sw0Q0jtnLLb4vQ71o1HlA== +jest-leak-detector@^26.6.2: + version "26.6.2" + resolved "https://registry.yarnpkg.com/jest-leak-detector/-/jest-leak-detector-26.6.2.tgz#7717cf118b92238f2eba65054c8a0c9c653a91af" + integrity sha512-i4xlXpsVSMeKvg2cEKdfhh0H39qlJlP5Ex1yQxwF9ubahboQYMgTtz5oML35AVA3B4Eu+YsmwaiKVev9KCvLxg== dependencies: jest-get-type "^26.3.0" - pretty-format "^26.6.1" + pretty-format "^26.6.2" -jest-matcher-utils@^26.6.0, jest-matcher-utils@^26.6.1: - version "26.6.1" - resolved "https://registry.yarnpkg.com/jest-matcher-utils/-/jest-matcher-utils-26.6.1.tgz#bc90822d352c91c2ec1814731327691d06598400" - integrity sha512-9iu3zrsYlUnl8pByhREF9rr5eYoiEb1F7ymNKg6lJr/0qD37LWS5FSW/JcoDl8UdMX2+zAzabDs7sTO+QFKjCg== +jest-matcher-utils@^26.6.0, jest-matcher-utils@^26.6.2: + version "26.6.2" + resolved "https://registry.yarnpkg.com/jest-matcher-utils/-/jest-matcher-utils-26.6.2.tgz#8e6fd6e863c8b2d31ac6472eeb237bc595e53e7a" + integrity sha512-llnc8vQgYcNqDrqRDXWwMr9i7rS5XFiCwvh6DTP7Jqa2mqpcCBBlpCbn+trkG0KNhPu/h8rzyBkriOtBstvWhw== dependencies: chalk "^4.0.0" - jest-diff "^26.6.1" + jest-diff "^26.6.2" jest-get-type "^26.3.0" - pretty-format "^26.6.1" + pretty-format "^26.6.2" -jest-message-util@^26.6.0, jest-message-util@^26.6.1: - version "26.6.1" - resolved "https://registry.yarnpkg.com/jest-message-util/-/jest-message-util-26.6.1.tgz#d62c20c0fe7be10bfd6020b675abb9b5fa933ff3" - integrity sha512-cqM4HnqncIebBNdTKrBoWR/4ufHTll0pK/FWwX0YasK+TlBQEMqw3IEdynuuOTjDPFO3ONlFn37280X48beByw== +jest-message-util@^26.6.0, jest-message-util@^26.6.2: + version "26.6.2" + resolved "https://registry.yarnpkg.com/jest-message-util/-/jest-message-util-26.6.2.tgz#58173744ad6fc0506b5d21150b9be56ef001ca07" + integrity sha512-rGiLePzQ3AzwUshu2+Rn+UMFk0pHN58sOG+IaJbk5Jxuqo3NYO1U2/MIR4S1sKgsoYSXSzdtSa0TgrmtUwEbmA== dependencies: "@babel/code-frame" "^7.0.0" - "@jest/types" "^26.6.1" + "@jest/types" "^26.6.2" "@types/stack-utils" "^2.0.0" chalk "^4.0.0" graceful-fs "^4.2.4" micromatch "^4.0.2" + pretty-format "^26.6.2" slash "^3.0.0" stack-utils "^2.0.2" -jest-mock@^26.6.1: - version "26.6.1" - resolved "https://registry.yarnpkg.com/jest-mock/-/jest-mock-26.6.1.tgz#6c12a92a82fc833f81a5b6de6b67d78386e276a3" - integrity sha512-my0lPTBu1awY8iVG62sB2sx9qf8zxNDVX+5aFgoB8Vbqjb6LqIOsfyFA8P1z6H2IsqMbvOX9oCJnK67Y3yUIMA== +jest-mock@^26.6.2: + version "26.6.2" + resolved "https://registry.yarnpkg.com/jest-mock/-/jest-mock-26.6.2.tgz#d6cb712b041ed47fe0d9b6fc3474bc6543feb302" + integrity sha512-YyFjePHHp1LzpzYcmgqkJ0nm0gg/lJx2aZFzFy1S6eUqNjXsOqTK10zNRff2dNfssgokjkG65OlWNcIlgd3zew== dependencies: - "@jest/types" "^26.6.1" + "@jest/types" "^26.6.2" "@types/node" "*" jest-pnp-resolver@^1.2.2: @@ -9653,14 +8777,14 @@ jest-regex-util@^26.0.0: resolved "https://registry.yarnpkg.com/jest-regex-util/-/jest-regex-util-26.0.0.tgz#d25e7184b36e39fd466c3bc41be0971e821fee28" integrity sha512-Gv3ZIs/nA48/Zvjrl34bf+oD76JHiGDUxNOVgUjh3j890sblXryjY4rss71fPtD/njchl6PSE2hIhvyWa1eT0A== -jest-resolve-dependencies@^26.6.1: - version "26.6.1" - resolved "https://registry.yarnpkg.com/jest-resolve-dependencies/-/jest-resolve-dependencies-26.6.1.tgz#e9d091a159ad198c029279737a8b4c507791d75c" - integrity sha512-MN6lufbZJ3RBfTnJesZtHu3hUCBqPdHRe2+FhIt0yiqJ3fMgzWRqMRQyN/d/QwOE7KXwAG2ekZutbPhuD7s51A== +jest-resolve-dependencies@^26.6.3: + version "26.6.3" + resolved "https://registry.yarnpkg.com/jest-resolve-dependencies/-/jest-resolve-dependencies-26.6.3.tgz#6680859ee5d22ee5dcd961fe4871f59f4c784fb6" + integrity sha512-pVwUjJkxbhe4RY8QEWzN3vns2kqyuldKpxlxJlzEYfKSvY6/bMvxoFrYYzUO1Gx28yKWN37qyV7rIoIp2h8fTg== dependencies: - "@jest/types" "^26.6.1" + "@jest/types" "^26.6.2" jest-regex-util "^26.0.0" - jest-snapshot "^26.6.1" + jest-snapshot "^26.6.2" jest-resolve@26.6.0: version "26.6.0" @@ -9676,132 +8800,132 @@ jest-resolve@26.6.0: resolve "^1.17.0" slash "^3.0.0" -jest-resolve@^26.6.1: - version "26.6.1" - resolved "https://registry.yarnpkg.com/jest-resolve/-/jest-resolve-26.6.1.tgz#e9a9130cc069620d5aeeb87043dd9e130b68c6a1" - integrity sha512-hiHfQH6rrcpAmw9xCQ0vD66SDuU+7ZulOuKwc4jpbmFFsz0bQG/Ib92K+9/489u5rVw0btr/ZhiHqBpmkbCvuQ== +jest-resolve@^26.6.2: + version "26.6.2" + resolved "https://registry.yarnpkg.com/jest-resolve/-/jest-resolve-26.6.2.tgz#a3ab1517217f469b504f1b56603c5bb541fbb507" + integrity sha512-sOxsZOq25mT1wRsfHcbtkInS+Ek7Q8jCHUB0ZUTP0tc/c41QHriU/NunqMfCUWsL4H3MHpvQD4QR9kSYhS7UvQ== dependencies: - "@jest/types" "^26.6.1" + "@jest/types" "^26.6.2" chalk "^4.0.0" graceful-fs "^4.2.4" jest-pnp-resolver "^1.2.2" - jest-util "^26.6.1" + jest-util "^26.6.2" read-pkg-up "^7.0.1" resolve "^1.18.1" slash "^3.0.0" -jest-runner@^26.6.0, jest-runner@^26.6.1: - version "26.6.1" - resolved "https://registry.yarnpkg.com/jest-runner/-/jest-runner-26.6.1.tgz#a945971b5a23740c1fe20e372a38de668b7c76bf" - integrity sha512-DmpNGdgsbl5s0FGkmsInmqnmqCtliCSnjWA2TFAJS1m1mL5atwfPsf+uoZ8uYQ2X0uDj4NM+nPcDnUpbNTRMBA== +jest-runner@^26.6.0, jest-runner@^26.6.3: + version "26.6.3" + resolved "https://registry.yarnpkg.com/jest-runner/-/jest-runner-26.6.3.tgz#2d1fed3d46e10f233fd1dbd3bfaa3fe8924be159" + integrity sha512-atgKpRHnaA2OvByG/HpGA4g6CSPS/1LK0jK3gATJAoptC1ojltpmVlYC3TYgdmGp+GLuhzpH30Gvs36szSL2JQ== dependencies: - "@jest/console" "^26.6.1" - "@jest/environment" "^26.6.1" - "@jest/test-result" "^26.6.1" - "@jest/types" "^26.6.1" + "@jest/console" "^26.6.2" + "@jest/environment" "^26.6.2" + "@jest/test-result" "^26.6.2" + "@jest/types" "^26.6.2" "@types/node" "*" chalk "^4.0.0" emittery "^0.7.1" exit "^0.1.2" graceful-fs "^4.2.4" - jest-config "^26.6.1" + jest-config "^26.6.3" jest-docblock "^26.0.0" - jest-haste-map "^26.6.1" - jest-leak-detector "^26.6.1" - jest-message-util "^26.6.1" - jest-resolve "^26.6.1" - jest-runtime "^26.6.1" - jest-util "^26.6.1" - jest-worker "^26.6.1" + jest-haste-map "^26.6.2" + jest-leak-detector "^26.6.2" + jest-message-util "^26.6.2" + jest-resolve "^26.6.2" + jest-runtime "^26.6.3" + jest-util "^26.6.2" + jest-worker "^26.6.2" source-map-support "^0.5.6" throat "^5.0.0" -jest-runtime@^26.6.0, jest-runtime@^26.6.1: - version "26.6.1" - resolved "https://registry.yarnpkg.com/jest-runtime/-/jest-runtime-26.6.1.tgz#9a131e7b4f0bc6beefd62e7443f757c1d5fa9dec" - integrity sha512-7uOCNeezXDWgjEyzYbRN2ViY7xNZzusNVGAMmU0UHRUNXuY4j4GBHKGMqPo/cBPZA9bSYp+lwK2DRRBU5Dv6YQ== +jest-runtime@^26.6.0, jest-runtime@^26.6.3: + version "26.6.3" + resolved "https://registry.yarnpkg.com/jest-runtime/-/jest-runtime-26.6.3.tgz#4f64efbcfac398331b74b4b3c82d27d401b8fa2b" + integrity sha512-lrzyR3N8sacTAMeonbqpnSka1dHNux2uk0qqDXVkMv2c/A3wYnvQ4EXuI013Y6+gSKSCxdaczvf4HF0mVXHRdw== dependencies: - "@jest/console" "^26.6.1" - "@jest/environment" "^26.6.1" - "@jest/fake-timers" "^26.6.1" - "@jest/globals" "^26.6.1" - "@jest/source-map" "^26.5.0" - "@jest/test-result" "^26.6.1" - "@jest/transform" "^26.6.1" - "@jest/types" "^26.6.1" + "@jest/console" "^26.6.2" + "@jest/environment" "^26.6.2" + "@jest/fake-timers" "^26.6.2" + "@jest/globals" "^26.6.2" + "@jest/source-map" "^26.6.2" + "@jest/test-result" "^26.6.2" + "@jest/transform" "^26.6.2" + "@jest/types" "^26.6.2" "@types/yargs" "^15.0.0" chalk "^4.0.0" - cjs-module-lexer "^0.4.2" + cjs-module-lexer "^0.6.0" collect-v8-coverage "^1.0.0" exit "^0.1.2" glob "^7.1.3" graceful-fs "^4.2.4" - jest-config "^26.6.1" - jest-haste-map "^26.6.1" - jest-message-util "^26.6.1" - jest-mock "^26.6.1" + jest-config "^26.6.3" + jest-haste-map "^26.6.2" + jest-message-util "^26.6.2" + jest-mock "^26.6.2" jest-regex-util "^26.0.0" - jest-resolve "^26.6.1" - jest-snapshot "^26.6.1" - jest-util "^26.6.1" - jest-validate "^26.6.1" + jest-resolve "^26.6.2" + jest-snapshot "^26.6.2" + jest-util "^26.6.2" + jest-validate "^26.6.2" slash "^3.0.0" strip-bom "^4.0.0" yargs "^15.4.1" -jest-serializer@^26.5.0: - version "26.5.0" - resolved "https://registry.yarnpkg.com/jest-serializer/-/jest-serializer-26.5.0.tgz#f5425cc4c5f6b4b355f854b5f0f23ec6b962bc13" - integrity sha512-+h3Gf5CDRlSLdgTv7y0vPIAoLgX/SI7T4v6hy+TEXMgYbv+ztzbg5PSN6mUXAT/hXYHvZRWm+MaObVfqkhCGxA== +jest-serializer@^26.6.2: + version "26.6.2" + resolved "https://registry.yarnpkg.com/jest-serializer/-/jest-serializer-26.6.2.tgz#d139aafd46957d3a448f3a6cdabe2919ba0742d1" + integrity sha512-S5wqyz0DXnNJPd/xfIzZ5Xnp1HrJWBczg8mMfMpN78OJ5eDxXyf+Ygld9wX1DnUWbIbhM1YDY95NjR4CBXkb2g== dependencies: "@types/node" "*" graceful-fs "^4.2.4" -jest-snapshot@^26.6.0, jest-snapshot@^26.6.1: - version "26.6.1" - resolved "https://registry.yarnpkg.com/jest-snapshot/-/jest-snapshot-26.6.1.tgz#469e9d0b749496aea7dad0d7e5e5c88b91cdb4cc" - integrity sha512-JA7bZp7HRTIJYAi85pJ/OZ2eur2dqmwIToA5/6d7Mn90isGEfeF9FvuhDLLEczgKP1ihreBzrJ6Vr7zteP5JNA== +jest-snapshot@^26.6.0, jest-snapshot@^26.6.2: + version "26.6.2" + resolved "https://registry.yarnpkg.com/jest-snapshot/-/jest-snapshot-26.6.2.tgz#f3b0af1acb223316850bd14e1beea9837fb39c84" + integrity sha512-OLhxz05EzUtsAmOMzuupt1lHYXCNib0ECyuZ/PZOx9TrZcC8vL0x+DUG3TL+GLX3yHG45e6YGjIm0XwDc3q3og== dependencies: "@babel/types" "^7.0.0" - "@jest/types" "^26.6.1" + "@jest/types" "^26.6.2" "@types/babel__traverse" "^7.0.4" "@types/prettier" "^2.0.0" chalk "^4.0.0" - expect "^26.6.1" + expect "^26.6.2" graceful-fs "^4.2.4" - jest-diff "^26.6.1" + jest-diff "^26.6.2" jest-get-type "^26.3.0" - jest-haste-map "^26.6.1" - jest-matcher-utils "^26.6.1" - jest-message-util "^26.6.1" - jest-resolve "^26.6.1" + jest-haste-map "^26.6.2" + jest-matcher-utils "^26.6.2" + jest-message-util "^26.6.2" + jest-resolve "^26.6.2" natural-compare "^1.4.0" - pretty-format "^26.6.1" + pretty-format "^26.6.2" semver "^7.3.2" -jest-util@^26.6.0, jest-util@^26.6.1: - version "26.6.1" - resolved "https://registry.yarnpkg.com/jest-util/-/jest-util-26.6.1.tgz#4cc0d09ec57f28d12d053887eec5dc976a352e9b" - integrity sha512-xCLZUqVoqhquyPLuDXmH7ogceGctbW8SMyQVjD9o+1+NPWI7t0vO08udcFLVPLgKWcvc+zotaUv/RuaR6l8HIA== +jest-util@^26.6.0, jest-util@^26.6.2: + version "26.6.2" + resolved "https://registry.yarnpkg.com/jest-util/-/jest-util-26.6.2.tgz#907535dbe4d5a6cb4c47ac9b926f6af29576cbc1" + integrity sha512-MDW0fKfsn0OI7MS7Euz6h8HNDXVQ0gaM9uW6RjfDmd1DAFcaxX9OqIakHIqhbnmF08Cf2DLDG+ulq8YQQ0Lp0Q== dependencies: - "@jest/types" "^26.6.1" + "@jest/types" "^26.6.2" "@types/node" "*" chalk "^4.0.0" graceful-fs "^4.2.4" is-ci "^2.0.0" micromatch "^4.0.2" -jest-validate@^26.6.1: - version "26.6.1" - resolved "https://registry.yarnpkg.com/jest-validate/-/jest-validate-26.6.1.tgz#28730eb8570d60968d9d06f1a8c94d922167bd2a" - integrity sha512-BEFpGbylKocnNPZULcnk+TGaz1oFZQH/wcaXlaXABbu0zBwkOGczuWgdLucUouuQqn7VadHZZeTvo8VSFDLMOA== +jest-validate@^26.6.2: + version "26.6.2" + resolved "https://registry.yarnpkg.com/jest-validate/-/jest-validate-26.6.2.tgz#23d380971587150467342911c3d7b4ac57ab20ec" + integrity sha512-NEYZ9Aeyj0i5rQqbq+tpIOom0YS1u2MVu6+euBsvpgIme+FOfRmoC4R5p0JiAUpaFvFy24xgrpMknarR/93XjQ== dependencies: - "@jest/types" "^26.6.1" + "@jest/types" "^26.6.2" camelcase "^6.0.0" chalk "^4.0.0" jest-get-type "^26.3.0" leven "^3.1.0" - pretty-format "^26.6.1" + pretty-format "^26.6.2" jest-watch-typeahead@0.6.1: version "0.6.1" @@ -9816,17 +8940,17 @@ jest-watch-typeahead@0.6.1: string-length "^4.0.1" strip-ansi "^6.0.0" -jest-watcher@^26.3.0, jest-watcher@^26.6.1: - version "26.6.1" - resolved "https://registry.yarnpkg.com/jest-watcher/-/jest-watcher-26.6.1.tgz#debfa34e9c5c3e735593403794fe53d2955bfabc" - integrity sha512-0LBIPPncNi9CaLKK15bnxyd2E8OMl4kJg0PTiNOI+MXztXw1zVdtX/x9Pr6pXaQYps+eS/ts43O4+HByZ7yJSw== +jest-watcher@^26.3.0, jest-watcher@^26.6.2: + version "26.6.2" + resolved "https://registry.yarnpkg.com/jest-watcher/-/jest-watcher-26.6.2.tgz#a5b683b8f9d68dbcb1d7dae32172d2cca0592975" + integrity sha512-WKJob0P/Em2csiVthsI68p6aGKTIcsfjH9Gsx1f0A3Italz43e3ho0geSAVsmj09RWOELP1AZ/DXyJgOgDKxXQ== dependencies: - "@jest/test-result" "^26.6.1" - "@jest/types" "^26.6.1" + "@jest/test-result" "^26.6.2" + "@jest/types" "^26.6.2" "@types/node" "*" ansi-escapes "^4.2.1" chalk "^4.0.0" - jest-util "^26.6.1" + jest-util "^26.6.2" string-length "^4.0.1" jest-worker@^24.9.0: @@ -9837,16 +8961,7 @@ jest-worker@^24.9.0: merge-stream "^2.0.0" supports-color "^6.1.0" -jest-worker@^26.5.0, jest-worker@^26.6.1: - version "26.6.1" - resolved "https://registry.yarnpkg.com/jest-worker/-/jest-worker-26.6.1.tgz#c2ae8cde6802cc14056043f997469ec170d9c32a" - integrity sha512-R5IE3qSGz+QynJx8y+ICEkdI2OJ3RJjRQVEyCcFAd3yVhQSEtquziPO29Mlzgn07LOVE8u8jhJ1FqcwegiXWOw== - dependencies: - "@types/node" "*" - merge-stream "^2.0.0" - supports-color "^7.0.0" - -jest-worker@^26.6.2: +jest-worker@^26.5.0, jest-worker@^26.6.2: version "26.6.2" resolved "https://registry.yarnpkg.com/jest-worker/-/jest-worker-26.6.2.tgz#7f72cbc4d643c365e27b9fd775f9d0eaa9c7a8ed" integrity sha512-KWYVV1c4i+jbMpaBC+U++4Va0cp8OisU185o73T1vo99hqi7w8tSJfUXYswwqqrjzwxa6KpRK54WhPvwf5w6PQ== @@ -9885,49 +9000,56 @@ jpeg-js@0.4.2: resolved "https://registry.yarnpkg.com/js-tokens/-/js-tokens-4.0.0.tgz#19203fb59991df98e3a287050d4647cdeaf32499" integrity sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ== -js-yaml@^3.13.1, js-yaml@^3.14.0: - version "3.14.0" - resolved "https://registry.yarnpkg.com/js-yaml/-/js-yaml-3.14.0.tgz#a7a34170f26a21bb162424d8adacb4113a69e482" - integrity sha512-/4IbIeHcD9VMHFqDR/gQ7EdZdLimOvW2DdcxFjdyyZ9NsbS+ccrXqVWDtab/lRl5AlUqmpBx8EhPaWR+OtY17A== +js-yaml@^3.13.1: + version "3.14.1" + resolved "https://registry.yarnpkg.com/js-yaml/-/js-yaml-3.14.1.tgz#dae812fdb3825fa306609a8717383c50c36a0537" + integrity sha512-okMH7OXXJ7YrN9Ok3/SXrnu4iX9yOk+25nqX4imS2npuvTYDmo/QEZoqwZkYaIDk3jVvBOTOIEgEhaLOynBS9g== dependencies: argparse "^1.0.7" esprima "^4.0.0" +js-yaml@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/js-yaml/-/js-yaml-4.0.0.tgz#f426bc0ff4b4051926cd588c71113183409a121f" + integrity sha512-pqon0s+4ScYUvX30wxQi3PogGFAlUyH0awepWvwkj4jD4v+ova3RiYw8bmA6x2rDrEaj8i/oWKoRxpVNW+Re8Q== + dependencies: + argparse "^2.0.1" + jsbn@~0.1.0: version "0.1.1" resolved "https://registry.yarnpkg.com/jsbn/-/jsbn-0.1.1.tgz#a5e654c2e5a2deb5f201d96cefbca80c0ef2f513" integrity sha1-peZUwuWi3rXyAdls77yoDA7y9RM= jsdom@^16.4.0: - version "16.4.0" - resolved "https://registry.yarnpkg.com/jsdom/-/jsdom-16.4.0.tgz#36005bde2d136f73eee1a830c6d45e55408edddb" - integrity sha512-lYMm3wYdgPhrl7pDcRmvzPhhrGVBeVhPIqeHjzeiHN3DFmD1RBpbExbi8vU7BJdH8VAZYovR8DMt0PNNDM7k8w== + version "16.5.1" + resolved "https://registry.yarnpkg.com/jsdom/-/jsdom-16.5.1.tgz#4ced6bbd7b77d67fb980e64d9e3e6fb900f97dd6" + integrity sha512-pF73EOsJgwZekbDHEY5VO/yKXUkab/DuvrQB/ANVizbr6UAHJsDdHXuotZYwkJSGQl1JM+ivXaqY+XBDDL4TiA== dependencies: - abab "^2.0.3" - acorn "^7.1.1" + abab "^2.0.5" + acorn "^8.0.5" acorn-globals "^6.0.0" cssom "^0.4.4" - cssstyle "^2.2.0" + cssstyle "^2.3.0" data-urls "^2.0.0" - decimal.js "^10.2.0" + decimal.js "^10.2.1" domexception "^2.0.1" - escodegen "^1.14.1" + escodegen "^2.0.0" html-encoding-sniffer "^2.0.1" is-potential-custom-element-name "^1.0.0" nwsapi "^2.2.0" - parse5 "5.1.1" + parse5 "6.0.1" request "^2.88.2" - request-promise-native "^1.0.8" - saxes "^5.0.0" + request-promise-native "^1.0.9" + saxes "^5.0.1" symbol-tree "^3.2.4" - tough-cookie "^3.0.1" + tough-cookie "^4.0.0" w3c-hr-time "^1.0.2" w3c-xmlserializer "^2.0.0" webidl-conversions "^6.1.0" whatwg-encoding "^1.0.5" whatwg-mimetype "^2.3.0" whatwg-url "^8.0.0" - ws "^7.2.3" + ws "^7.4.4" xml-name-validator "^3.0.0" jsesc@^2.5.1: @@ -10008,9 +9130,9 @@ json5@^1.0.1: minimist "^1.2.0" json5@^2.1.2: - version "2.1.3" - resolved "https://registry.yarnpkg.com/json5/-/json5-2.1.3.tgz#c9b0f7fa9233bfe5807fe66fcf3a5617ed597d43" - integrity sha512-KXPvOm8K9IJKFM0bmdn8QXh7udDh1g/giieX0NLCaMnb4hEiVFqnop2ImTXCc5e0/oHz3LTqmHGtExn5hfMkOA== + version "2.2.0" + resolved "https://registry.yarnpkg.com/json5/-/json5-2.2.0.tgz#2dfefe720c6ba525d9ebd909950f0515316c89a3" + integrity sha512-f+8cldu7X/y7RAJurMEJmdoKXGB/X550w2Nr3tTbezL6RwEE/iMcm+tZnXeoZtKuOq6ft8+CqzEkrIgx1fPoQA== dependencies: minimist "^1.2.5" @@ -10062,12 +9184,12 @@ jsprim@^1.2.2: verror "1.10.0" "jsx-ast-utils@^2.4.1 || ^3.0.0", jsx-ast-utils@^3.1.0: - version "3.1.0" - resolved "https://registry.yarnpkg.com/jsx-ast-utils/-/jsx-ast-utils-3.1.0.tgz#642f1d7b88aa6d7eb9d8f2210e166478444fa891" - integrity sha512-d4/UOjg+mxAWxCiF0c5UTSwyqbchkbqCvK87aBovhnh8GtysTjWmgC63tY0cJx/HzGgm9qnA147jVBdpOiQ2RA== + version "3.2.0" + resolved "https://registry.yarnpkg.com/jsx-ast-utils/-/jsx-ast-utils-3.2.0.tgz#41108d2cec408c3453c1bbe8a4aae9e1e2bd8f82" + integrity sha512-EIsmt3O3ljsU6sot/J4E1zDRxfBNrhjyf/OKjlydwgEimQuznlM4Wv7U+ueONJMyEn1WRE0K8dhi3dVAXYT24Q== dependencies: - array-includes "^3.1.1" - object.assign "^4.1.1" + array-includes "^3.1.2" + object.assign "^4.1.2" jwa@^1.4.1: version "1.4.1" @@ -10132,10 +9254,10 @@ klona@^2.0.4: resolved "https://registry.yarnpkg.com/klona/-/klona-2.0.4.tgz#7bb1e3affb0cb8624547ef7e8f6708ea2e39dfc0" integrity sha512-ZRbnvdg/NxqzC7L9Uyqzf4psi1OM4Cuc+sJAkQPjO6XkQIJTNbfK2Rsmbw8fx1p2mkZdp2FZYo2+LwXYY/uwIA== -known-css-properties@^0.20.0: - version "0.20.0" - resolved "https://registry.yarnpkg.com/known-css-properties/-/known-css-properties-0.20.0.tgz#0570831661b47dd835293218381166090ff60e96" - integrity sha512-URvsjaA9ypfreqJ2/ylDr5MUERhJZ+DhguoWRr2xgS5C7aGCalXo+ewL+GixgKBfhT2vuL02nbIgNGqVWgTOYw== +known-css-properties@^0.21.0: + version "0.21.0" + resolved "https://registry.yarnpkg.com/known-css-properties/-/known-css-properties-0.21.0.tgz#15fbd0bbb83447f3ce09d8af247ed47c68ede80d" + integrity sha512-sZLUnTqimCkvkgRS+kbPlYW5o8q5w1cu+uIisKpEWkj31I8mx8kNG162DwRav8Zirkva6N5uoFsm9kzK4mUXjw== language-subtag-registry@~0.3.2: version "0.3.21" @@ -10192,14 +9314,6 @@ lie@3.1.1: dependencies: immediate "~3.0.5" -line-column@^1.0.2: - version "1.0.2" - resolved "https://registry.yarnpkg.com/line-column/-/line-column-1.0.2.tgz#d25af2936b6f4849172b312e4792d1d987bc34a2" - integrity sha1-0lryk2tvSEkXKzEuR5LR2Ye8NKI= - dependencies: - isarray "^1.0.0" - isobject "^2.0.0" - lines-and-columns@^1.1.6: version "1.1.6" resolved "https://registry.yarnpkg.com/lines-and-columns/-/lines-and-columns-1.1.6.tgz#1c00c743b433cd0a4e80758f7b64a57440d9ff00" @@ -10345,16 +9459,11 @@ locate-path@^5.0.0: dependencies: p-locate "^4.1.0" -lodash-es@^4.17.14: +lodash-es@^4.17.14, lodash-es@^4.17.15, lodash-es@^4.17.20: version "4.17.21" resolved "https://registry.yarnpkg.com/lodash-es/-/lodash-es-4.17.21.tgz#43e626c46e6591b7750beb2b50117390c609e3ee" integrity sha512-mKnC+QJ9pWVzv+C4/U3rRsHapFfHvQFoFB92e52xeyGMcX6/OlIl78je1u8vePzYZSkkogMPJ2yjxxsb89cxyw== -lodash-es@^4.17.15: - version "4.17.15" - resolved "https://registry.yarnpkg.com/lodash-es/-/lodash-es-4.17.15.tgz#21bd96839354412f23d7a10340e5eac6ee455d78" - integrity sha512-rlrc3yU3+JNOpZ9zj5pQtxnx2THmvRykwL4Xlxoa8I9lHBlVbbyPhgyPMioxVZ4NqyxaVVtaJnzsyOidQIhyyQ== - lodash._reinterpolate@^3.0.0: version "3.0.0" resolved "https://registry.yarnpkg.com/lodash._reinterpolate/-/lodash._reinterpolate-3.0.0.tgz#0ccf2d89166af03b3663c796538b75ac6e114d9d" @@ -10455,12 +9564,7 @@ lodash.uniq@^4.5.0: resolved "https://registry.yarnpkg.com/lodash.uniq/-/lodash.uniq-4.5.0.tgz#d0225373aeb652adc1bc82e4945339a842754773" integrity sha1-0CJTc662Uq3BvILklFM5qEJ1R3M= -"lodash@>=3.5 <5", lodash@^4.17.11, lodash@^4.17.15, lodash@^4.17.19, lodash@^4.17.20, lodash@^4.17.5, lodash@~4.17.20: - version "4.17.20" - resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.20.tgz#b44a9b6297bcb698f1c51a3545a2b3b368d59c52" - integrity sha512-PlhdFcillOINfeV7Ni6oF1TAEayyZBoZ8bcshTHqOYJYlrqzRK5hagpagky5o4HfCzzd1TRkXPMFq6cKk9rGmA== - -lodash@^4.17.14: +"lodash@>=3.5 <5", lodash@^4.17.11, lodash@^4.17.14, lodash@^4.17.15, lodash@^4.17.19, lodash@^4.17.20, lodash@^4.17.21, lodash@^4.17.5, lodash@~4.17.20: version "4.17.21" resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.21.tgz#679591c564c3bffaae8454cf0b3df370c3d6911c" integrity sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg== @@ -10473,11 +9577,12 @@ log-symbols@^1.0.2: chalk "^1.0.0" log-symbols@^4.0.0: - version "4.0.0" - resolved "https://registry.yarnpkg.com/log-symbols/-/log-symbols-4.0.0.tgz#69b3cc46d20f448eccdb75ea1fa733d9e821c920" - integrity sha512-FN8JBzLx6CzeMrB0tg6pqlGU1wCrXW+ZXGH481kfsBqer0hToTIiHdjH4Mq8xJUbvATujKCvaREGWpGUionraA== + version "4.1.0" + resolved "https://registry.yarnpkg.com/log-symbols/-/log-symbols-4.1.0.tgz#3fbdbb95b4683ac9fc785111e792e558d4abd503" + integrity sha512-8XPvpAA8uyhfteu8pIvQxpJZ7SYYdpUivZpGy6sFsBuKRY/7rQGavedeB8aK+Zkyq6upMFVL/9AW6vOYzfRyLg== dependencies: - chalk "^4.0.0" + chalk "^4.1.0" + is-unicode-supported "^0.1.0" log-update@^2.3.0: version "2.3.0" @@ -10489,9 +9594,9 @@ log-update@^2.3.0: wrap-ansi "^3.0.1" loglevel@^1.6.8: - version "1.7.0" - resolved "https://registry.yarnpkg.com/loglevel/-/loglevel-1.7.0.tgz#728166855a740d59d38db01cf46f042caa041bb0" - integrity sha512-i2sY04nal5jDcagM3FMfG++T69GEEM8CYuOfeOIvmXzOIcwE9a/CJPR0MFM97pYMj/u10lzz7/zd7+qwhrBTqQ== + version "1.7.1" + resolved "https://registry.yarnpkg.com/loglevel/-/loglevel-1.7.1.tgz#005fde2f5e6e47068f935ff28573e125ef72f197" + integrity sha512-Hesni4s5UkWkwCGJMQGAh71PaLUmKFM60dHvq0zi/vDhhrzuk+4GgNbTXJ12YYQJn6ZKBDNIjYcuQGKudvqrIw== longest-streak@^2.0.0: version "2.0.4" @@ -10505,14 +9610,14 @@ loose-envify@^1.0.0, loose-envify@^1.1.0, loose-envify@^1.2.0, loose-envify@^1.3 dependencies: js-tokens "^3.0.0 || ^4.0.0" -lower-case@2.0.1, lower-case@^2.0.1: - version "2.0.1" - resolved "https://registry.yarnpkg.com/lower-case/-/lower-case-2.0.1.tgz#39eeb36e396115cc05e29422eaea9e692c9408c7" - integrity sha512-LiWgfDLLb1dwbFQZsSglpRj+1ctGnayXz3Uv0/WO8n558JycT5fg6zkNcnW0G68Nn0aEldTFeEfmjCfmqry/rQ== +lower-case-first@^2.0.1: + version "2.0.2" + resolved "https://registry.yarnpkg.com/lower-case-first/-/lower-case-first-2.0.2.tgz#64c2324a2250bf7c37c5901e76a5b5309301160b" + integrity sha512-EVm/rR94FJTZi3zefZ82fLWab+GX14LJN4HrWBcuo6Evmsl9hEfnqxgcHCKb9q+mNf6EVdsjx/qucYFIIB84pg== dependencies: - tslib "^1.10.0" + tslib "^2.0.3" -lower-case@^2.0.2: +lower-case@^2.0.1, lower-case@^2.0.2: version "2.0.2" resolved "https://registry.yarnpkg.com/lower-case/-/lower-case-2.0.2.tgz#6fa237c63dbdc4a82ca0fd882e4722dc5e634e28" integrity sha512-7fm3l3NAF9WfN6W3JOmf5drwpVqX78JtoGJ3A6W0a6ZnldM41w2fV5D490psKFTpMds8TJse/eHLFFsNHHjHgg== @@ -10588,9 +9693,9 @@ map-obj@^1.0.0: integrity sha1-2TPOuSBdgr3PSIb2dCvcK03qFG0= map-obj@^4.0.0: - version "4.1.0" - resolved "https://registry.yarnpkg.com/map-obj/-/map-obj-4.1.0.tgz#b91221b542734b9f14256c0132c897c5d7256fd5" - integrity sha512-glc9y00wgtwcDmp7GaE/0b0OnxpNJsVf3ael/An6Fe2Q51LLwN1er6sdomLRzz5h0+yMpiYLhWYF5R7HeqVd4g== + version "4.2.0" + resolved "https://registry.yarnpkg.com/map-obj/-/map-obj-4.2.0.tgz#0e8bc823e2aaca8a0942567d12ed14f389eec153" + integrity sha512-NAq0fCmZYGz9UFEQyndp7sisrow4GroyGeKluyKC/chuITZsPyOyC1UJZPJlVFImhXdROIP5xqouRLThT3BbpQ== map-visit@^1.0.0: version "1.0.0" @@ -10627,57 +9732,72 @@ mdast-add-list-metadata@1.0.1: dependencies: unist-util-visit-parents "1.1.2" +mdast-util-find-and-replace@^1.1.0: + version "1.1.1" + resolved "https://registry.yarnpkg.com/mdast-util-find-and-replace/-/mdast-util-find-and-replace-1.1.1.tgz#b7db1e873f96f66588c321f1363069abf607d1b5" + integrity sha512-9cKl33Y21lyckGzpSmEQnIDjEfeeWelN5s1kUW1LwdB0Fkuq2u+4GdqcGEygYxJE8GVqCl0741bYXHgamfWAZA== + dependencies: + escape-string-regexp "^4.0.0" + unist-util-is "^4.0.0" + unist-util-visit-parents "^3.0.0" + mdast-util-from-markdown@^0.8.0: - version "0.8.1" - resolved "https://registry.yarnpkg.com/mdast-util-from-markdown/-/mdast-util-from-markdown-0.8.1.tgz#781371d493cac11212947226190270c15dc97116" - integrity sha512-qJXNcFcuCSPqUF0Tb0uYcFDIq67qwB3sxo9RPdf9vG8T90ViKnksFqdB/Coq2a7sTnxL/Ify2y7aIQXDkQFH0w== + version "0.8.5" + resolved "https://registry.yarnpkg.com/mdast-util-from-markdown/-/mdast-util-from-markdown-0.8.5.tgz#d1ef2ca42bc377ecb0463a987910dae89bd9a28c" + integrity sha512-2hkTXtYYnr+NubD/g6KGBS/0mFmBcifAsI0yIWRiRo0PjVs6SSOSOdtzbp6kSGnShDN6G5aWZpKQ2lWRy27mWQ== dependencies: "@types/mdast" "^3.0.0" - mdast-util-to-string "^1.0.0" - micromark "~2.10.0" + mdast-util-to-string "^2.0.0" + micromark "~2.11.0" parse-entities "^2.0.0" + unist-util-stringify-position "^2.0.0" mdast-util-gfm-autolink-literal@^0.1.0: - version "0.1.1" - resolved "https://registry.yarnpkg.com/mdast-util-gfm-autolink-literal/-/mdast-util-gfm-autolink-literal-0.1.1.tgz#94675074d725ed7254b3172fa7e7c3252960de39" - integrity sha512-gJ2xSpqKCetSr22GEWpZH3f5ffb4pPn/72m4piY0v7T/S+O7n7rw+sfoPLhb2b4O7WdnERoYdALRcmD68FMtlw== + version "0.1.3" + resolved "https://registry.yarnpkg.com/mdast-util-gfm-autolink-literal/-/mdast-util-gfm-autolink-literal-0.1.3.tgz#9c4ff399c5ddd2ece40bd3b13e5447d84e385fb7" + integrity sha512-GjmLjWrXg1wqMIO9+ZsRik/s7PLwTaeCHVB7vRxUwLntZc8mzmTsLVr6HW1yLokcnhfURsn5zmSVdi3/xWWu1A== + dependencies: + ccount "^1.0.0" + mdast-util-find-and-replace "^1.1.0" + micromark "^2.11.3" mdast-util-gfm-strikethrough@^0.2.0: - version "0.2.2" - resolved "https://registry.yarnpkg.com/mdast-util-gfm-strikethrough/-/mdast-util-gfm-strikethrough-0.2.2.tgz#6e9ddd33ce41b06a60463e817f6ef4cf7bfa0655" - integrity sha512-T37ZbaokJcRbHROXmoVAieWnesPD5N21tv2ifYzaGRLbkh1gknItUGhZzHefUn5Zc/eaO/iTDSAFOBrn/E8kWw== + version "0.2.3" + resolved "https://registry.yarnpkg.com/mdast-util-gfm-strikethrough/-/mdast-util-gfm-strikethrough-0.2.3.tgz#45eea337b7fff0755a291844fbea79996c322890" + integrity sha512-5OQLXpt6qdbttcDG/UxYY7Yjj3e8P7X16LzvpX8pIQPYJ/C2Z1qFGMmcw+1PZMUM3Z8wt8NRfYTvCni93mgsgA== dependencies: - mdast-util-to-markdown "^0.5.0" + mdast-util-to-markdown "^0.6.0" mdast-util-gfm-table@^0.1.0: - version "0.1.4" - resolved "https://registry.yarnpkg.com/mdast-util-gfm-table/-/mdast-util-gfm-table-0.1.4.tgz#5b3d71d16294c6fae1c2c424d3a081ffc7407b83" - integrity sha512-T4xFSON9kUb/IpYA5N+KGWcsdGczAvILvKiXQwUGind6V9fvjPCR9yhZnIeaLdBWXaz3m/Gq77ZtuLMjtFR4IQ== + version "0.1.6" + resolved "https://registry.yarnpkg.com/mdast-util-gfm-table/-/mdast-util-gfm-table-0.1.6.tgz#af05aeadc8e5ee004eeddfb324b2ad8c029b6ecf" + integrity sha512-j4yDxQ66AJSBwGkbpFEp9uG/LS1tZV3P33fN1gkyRB2LoRL+RR3f76m0HPHaby6F4Z5xr9Fv1URmATlRRUIpRQ== dependencies: markdown-table "^2.0.0" - mdast-util-to-markdown "^0.5.0" + mdast-util-to-markdown "~0.6.0" mdast-util-gfm-task-list-item@^0.1.0: - version "0.1.5" - resolved "https://registry.yarnpkg.com/mdast-util-gfm-task-list-item/-/mdast-util-gfm-task-list-item-0.1.5.tgz#3179e77f1c881370818302e7b93537d7c281401d" - integrity sha512-6O0bt34r+e7kYjeSwedhjDPYraspKIYKbhvhQEEioL7gSmXDxhN7WQW2KoxhVMpNzjNc03yC7K5KH6NHlz2jOA== + version "0.1.6" + resolved "https://registry.yarnpkg.com/mdast-util-gfm-task-list-item/-/mdast-util-gfm-task-list-item-0.1.6.tgz#70c885e6b9f543ddd7e6b41f9703ee55b084af10" + integrity sha512-/d51FFIfPsSmCIRNp7E6pozM9z1GYPIkSy1urQ8s/o4TC22BZ7DqfHFWiqBD23bc7J3vV1Fc9O4QIHBlfuit8A== dependencies: - mdast-util-to-markdown "^0.5.0" + mdast-util-to-markdown "~0.6.0" mdast-util-gfm@^0.1.0: - version "0.1.0" - resolved "https://registry.yarnpkg.com/mdast-util-gfm/-/mdast-util-gfm-0.1.0.tgz#bac0efe703670d1b40474e6be13dbdd887273a04" - integrity sha512-HLfygQL6HdhJhFbLta4Ki9hClrzyAxRjyRvpm5caN65QZL+NyHPmqFlnF9vm1Rn58JT2+AbLwNcEDY4MEvkk8Q== + version "0.1.2" + resolved "https://registry.yarnpkg.com/mdast-util-gfm/-/mdast-util-gfm-0.1.2.tgz#8ecddafe57d266540f6881f5c57ff19725bd351c" + integrity sha512-NNkhDx/qYcuOWB7xHUGWZYVXvjPFFd6afg6/e2g+SV4r9q5XUcCbV4Wfa3DLYIiD+xAEZc6K4MGaE/m0KDcPwQ== dependencies: mdast-util-gfm-autolink-literal "^0.1.0" mdast-util-gfm-strikethrough "^0.2.0" mdast-util-gfm-table "^0.1.0" mdast-util-gfm-task-list-item "^0.1.0" + mdast-util-to-markdown "^0.6.1" -mdast-util-to-markdown@^0.5.0: - version "0.5.4" - resolved "https://registry.yarnpkg.com/mdast-util-to-markdown/-/mdast-util-to-markdown-0.5.4.tgz#be680ed0c0e11a07d07c7adff9551eec09c1b0f9" - integrity sha512-0jQTkbWYx0HdEA/h++7faebJWr5JyBoBeiRf0u3F4F3QtnyyGaWIsOwo749kRb1ttKrLLr+wRtOkfou9yB0p6A== +mdast-util-to-markdown@^0.6.0, mdast-util-to-markdown@^0.6.1, mdast-util-to-markdown@~0.6.0: + version "0.6.5" + resolved "https://registry.yarnpkg.com/mdast-util-to-markdown/-/mdast-util-to-markdown-0.6.5.tgz#b33f67ca820d69e6cc527a93d4039249b504bebe" + integrity sha512-XeV9sDE7ZlOQvs45C9UKMtfTcctcaj/pGwH8YLbMHoMOXNNCn2LsqVQOqrF1+/NU8lKDAqozme9SCXWyo9oAcQ== dependencies: "@types/unist" "^2.0.0" longest-streak "^2.0.0" @@ -10686,32 +9806,15 @@ mdast-util-to-markdown@^0.5.0: repeat-string "^1.0.0" zwitch "^1.0.0" -mdast-util-to-markdown@^0.6.0: - version "0.6.2" - resolved "https://registry.yarnpkg.com/mdast-util-to-markdown/-/mdast-util-to-markdown-0.6.2.tgz#8fe6f42a2683c43c5609dfb40407c095409c85b4" - integrity sha512-iRczns6WMvu0hUw02LXsPDJshBIwtUPbvHBWo19IQeU0YqmzlA8Pd30U8V7uiI0VPkxzS7A/NXBXH6u+HS87Zg== - dependencies: - "@types/unist" "^2.0.0" - longest-streak "^2.0.0" - mdast-util-to-string "^2.0.0" - parse-entities "^2.0.0" - repeat-string "^1.0.0" - zwitch "^1.0.0" - -mdast-util-to-string@^1.0.0: - version "1.1.0" - resolved "https://registry.yarnpkg.com/mdast-util-to-string/-/mdast-util-to-string-1.1.0.tgz#27055500103f51637bd07d01da01eb1967a43527" - integrity sha512-jVU0Nr2B9X3MU4tSK7JP1CMkSvOj7X5l/GboG1tKRw52lLF1x2Ju92Ms9tNetCcbfX3hzlM73zYo2NKkWSfF/A== - mdast-util-to-string@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/mdast-util-to-string/-/mdast-util-to-string-2.0.0.tgz#b8cfe6a713e1091cb5b728fc48885a4767f8b97b" integrity sha512-AW4DRS3QbBayY/jJmD8437V1Gombjf8RSOUCMFBuo5iHi58AGEgVCKQ+ezHkZZDpAQS75hcBMpLqjpJTjtUL7w== -mdn-data@2.0.12: - version "2.0.12" - resolved "https://registry.yarnpkg.com/mdn-data/-/mdn-data-2.0.12.tgz#bbb658d08b38f574bbb88f7b83703defdcc46844" - integrity sha512-ULbAlgzVb8IqZ0Hsxm6hHSlQl3Jckst2YEQS7fODu9ilNWy2LvcoSY7TRFIktABP2mdppBioc66va90T+NUs8Q== +mdn-data@2.0.14: + version "2.0.14" + resolved "https://registry.yarnpkg.com/mdn-data/-/mdn-data-2.0.14.tgz#7113fc4281917d63ce29b43446f701e68c25ba50" + integrity sha512-dn6wd0uw5GsdswPFfsgMp5NSB0/aDe6fK94YJV/AJDYXL6HVLWBsxeq7js7Ad+mU2K9LAlwpk6kN2D5mwCPVow== mdn-data@2.0.4: version "2.0.4" @@ -10805,25 +9908,25 @@ microevent.ts@~0.1.1: integrity sha512-jo1OfR4TaEwd5HOrt5+tAZ9mqT4jmpNAusXtyfNzqVm9uiSYFZlKM1wYL4oU7azZW/PxQW53wM0S6OR1JHNa2g== micromark-extension-gfm-autolink-literal@~0.5.0: - version "0.5.1" - resolved "https://registry.yarnpkg.com/micromark-extension-gfm-autolink-literal/-/micromark-extension-gfm-autolink-literal-0.5.1.tgz#5326fc86f3ae0fbba57bb0bfc2f158c9456528ce" - integrity sha512-j30923tDp0faCNDjwqe4cMi+slegbGfc3VEAExEU8d54Q/F6pR6YxCVH+6xV0ItRoj3lCn1XkUWcy6FC3S9BOw== + version "0.5.6" + resolved "https://registry.yarnpkg.com/micromark-extension-gfm-autolink-literal/-/micromark-extension-gfm-autolink-literal-0.5.6.tgz#406a58914d7a9cc6fb4cfafccc61a8ca36d7a12a" + integrity sha512-nHbR1NUOVhmlZNsnhE5B7WJzL7Xd8lc888z4AF27IpHMtO3NstclZmbrMI+AcdTPpO1wuGVwlK1Cnq+n8Sxlrw== dependencies: - micromark "~2.10.0" + micromark "~2.11.3" -micromark-extension-gfm-strikethrough@~0.6.0: - version "0.6.2" - resolved "https://registry.yarnpkg.com/micromark-extension-gfm-strikethrough/-/micromark-extension-gfm-strikethrough-0.6.2.tgz#754788bdd13046e7f69edaa0d3f3d555d23128d6" - integrity sha512-aehEEqtTn3JekJNwZZxa7ZJVfzmuaWp4ew6x6sl3VAKIwdDZdqYeYSQIrNKwNgH7hX0g56fAwnSDLusJggjlCQ== +micromark-extension-gfm-strikethrough@~0.6.5: + version "0.6.5" + resolved "https://registry.yarnpkg.com/micromark-extension-gfm-strikethrough/-/micromark-extension-gfm-strikethrough-0.6.5.tgz#96cb83356ff87bf31670eefb7ad7bba73e6514d1" + integrity sha512-PpOKlgokpQRwUesRwWEp+fHjGGkZEejj83k9gU5iXCbDG+XBA92BqnRKYJdfqfkrRcZRgGuPuXb7DaK/DmxOhw== dependencies: - micromark "~2.10.0" + micromark "~2.11.0" micromark-extension-gfm-table@~0.4.0: - version "0.4.1" - resolved "https://registry.yarnpkg.com/micromark-extension-gfm-table/-/micromark-extension-gfm-table-0.4.1.tgz#79cc37da82d6ae0cc3901c1c6264b97a72372fbd" - integrity sha512-xVpqOnfFaa2OtC/Y7rlt4tdVFlUHdoLH3RXAZgb/KP3DDyKsAOx6BRS3UxiiyvmD/p2l6VUpD4bMIniuP4o4JA== + version "0.4.3" + resolved "https://registry.yarnpkg.com/micromark-extension-gfm-table/-/micromark-extension-gfm-table-0.4.3.tgz#4d49f1ce0ca84996c853880b9446698947f1802b" + integrity sha512-hVGvESPq0fk6ALWtomcwmgLvH8ZSVpcPjzi0AjPclB9FsVRgMtGZkUcpE0zgjOCFAznKepF4z3hX8z6e3HODdA== dependencies: - micromark "~2.10.0" + micromark "~2.11.0" micromark-extension-gfm-tagfilter@~0.3.0: version "0.3.0" @@ -10831,28 +9934,28 @@ micromark-extension-gfm-tagfilter@~0.3.0: integrity sha512-9GU0xBatryXifL//FJH+tAZ6i240xQuFrSL7mYi8f4oZSbc+NvXjkrHemeYP0+L4ZUT+Ptz3b95zhUZnMtoi/Q== micromark-extension-gfm-task-list-item@~0.3.0: - version "0.3.2" - resolved "https://registry.yarnpkg.com/micromark-extension-gfm-task-list-item/-/micromark-extension-gfm-task-list-item-0.3.2.tgz#74dbcf473276e762d2062baa0764b53c19205797" - integrity sha512-cm8lYS10YAqeXE9B27TK3u1Ihumo3H9p/3XumT+jp8vSuSbSpFIJe0bDi2kq4YAAIxtcTzUOxhEH4ko2/NYDkQ== + version "0.3.3" + resolved "https://registry.yarnpkg.com/micromark-extension-gfm-task-list-item/-/micromark-extension-gfm-task-list-item-0.3.3.tgz#d90c755f2533ed55a718129cee11257f136283b8" + integrity sha512-0zvM5iSLKrc/NQl84pZSjGo66aTGd57C1idmlWmE87lkMcXrTxg1uXa/nXomxJytoje9trP0NDLvw4bZ/Z/XCQ== dependencies: - micromark "~2.10.0" + micromark "~2.11.0" micromark-extension-gfm@^0.3.0: - version "0.3.1" - resolved "https://registry.yarnpkg.com/micromark-extension-gfm/-/micromark-extension-gfm-0.3.1.tgz#30b8706bd2a3f7fd31aa37873d743946a9e856c3" - integrity sha512-lJlhcOqzoJdjQg+LMumVHdUQ61LjtqGdmZtrAdfvatRUnJTqZlRwXXHdLQgNDYlFw4mycZ4NSTKlya5QcQXl1A== + version "0.3.3" + resolved "https://registry.yarnpkg.com/micromark-extension-gfm/-/micromark-extension-gfm-0.3.3.tgz#36d1a4c089ca8bdfd978c9bd2bf1a0cb24e2acfe" + integrity sha512-oVN4zv5/tAIA+l3GbMi7lWeYpJ14oQyJ3uEim20ktYFAcfX1x3LNlFGGlmrZHt7u9YlKExmyJdDGaTt6cMSR/A== dependencies: - micromark "~2.10.0" + micromark "~2.11.0" micromark-extension-gfm-autolink-literal "~0.5.0" - micromark-extension-gfm-strikethrough "~0.6.0" + micromark-extension-gfm-strikethrough "~0.6.5" micromark-extension-gfm-table "~0.4.0" micromark-extension-gfm-tagfilter "~0.3.0" micromark-extension-gfm-task-list-item "~0.3.0" -micromark@~2.10.0: - version "2.10.1" - resolved "https://registry.yarnpkg.com/micromark/-/micromark-2.10.1.tgz#cd73f54e0656f10e633073db26b663a221a442a7" - integrity sha512-fUuVF8sC1X7wsCS29SYQ2ZfIZYbTymp0EYr6sab3idFjigFFjGa5UwoniPlV9tAgntjuapW1t9U+S0yDYeGKHQ== +micromark@^2.11.3, micromark@~2.11.0, micromark@~2.11.3: + version "2.11.4" + resolved "https://registry.yarnpkg.com/micromark/-/micromark-2.11.4.tgz#d13436138eea826383e822449c9a5c50ee44665a" + integrity sha512-+WoovN/ppKolQOFIAajxi7Lu9kInbPxFuTBVEavFcL8eAfVstoc5MocPmqBeAdBOJV00uaVjegzH4+MA0DN/uA== dependencies: debug "^4.0.0" parse-entities "^2.0.0" @@ -10892,22 +9995,17 @@ miller-rabin@^4.0.0: bn.js "^4.0.0" brorand "^1.0.1" -mime-db@1.44.0: - version "1.44.0" - resolved "https://registry.yarnpkg.com/mime-db/-/mime-db-1.44.0.tgz#fa11c5eb0aca1334b4233cb4d52f10c5a6272f92" - integrity sha512-/NOTfLrsPBVeH7YtFPgsVWveuL+4SjjYxaQ1xtM1KMFj7HdxlBlxeyNLzhyJVx7r4rZGJAZ/6lkKCitSc/Nmpg== - -"mime-db@>= 1.43.0 < 2": - version "1.45.0" - resolved "https://registry.yarnpkg.com/mime-db/-/mime-db-1.45.0.tgz#cceeda21ccd7c3a745eba2decd55d4b73e7879ea" - integrity sha512-CkqLUxUk15hofLoLyljJSrukZi8mAtgd+yE5uO4tqRZsdsAJKv0O+rFMhVDRJgozy+yG6md5KwuXhD4ocIoP+w== +mime-db@1.46.0, "mime-db@>= 1.43.0 < 2": + version "1.46.0" + resolved "https://registry.yarnpkg.com/mime-db/-/mime-db-1.46.0.tgz#6267748a7f799594de3cbc8cde91def349661cee" + integrity sha512-svXaP8UQRZ5K7or+ZmfNhg2xX3yKDMUzqadsSqi4NCH/KomcH75MAMYAGVlvXn4+b/xOPhS3I2uHKRUzvjY7BQ== mime-types@^2.1.12, mime-types@^2.1.27, mime-types@~2.1.17, mime-types@~2.1.19, mime-types@~2.1.24: - version "2.1.27" - resolved "https://registry.yarnpkg.com/mime-types/-/mime-types-2.1.27.tgz#47949f98e279ea53119f5722e0f34e529bec009f" - integrity sha512-JIhqnCasI9yD+SsmkquHBxTSEuZdQX5BuQnS2Vc7puQQQ+8yiP5AY5uWhpdv4YL4VM5c6iliiYWPgJ/nJQLp7w== + version "2.1.29" + resolved "https://registry.yarnpkg.com/mime-types/-/mime-types-2.1.29.tgz#1d4ab77da64b91f5f72489df29236563754bb1b2" + integrity sha512-Y/jMt/S5sR9OaqteJtslsFZKWOIIqMACsJSiHghlCAyhf7jfVYjKBmLiX8OgpWeW+fjJ2b+Az69aPFPkUOY6xQ== dependencies: - mime-db "1.44.0" + mime-db "1.46.0" mime@1.6.0, mime@^1.3.4: version "1.6.0" @@ -10915,9 +10013,9 @@ mime@1.6.0, mime@^1.3.4: integrity sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg== mime@^2.4.4: - version "2.4.6" - resolved "https://registry.yarnpkg.com/mime/-/mime-2.4.6.tgz#e5b407c90db442f2beb5b162373d07b69affa4d1" - integrity sha512-RZKhC3EmpBchfTGBVb8fb+RL2cWyw/32lshnsETttkBAyAUXSGHxbEJWWRXc751DrIxG1q04b8QwMbAwkRPpUA== + version "2.5.2" + resolved "https://registry.yarnpkg.com/mime/-/mime-2.5.2.tgz#6e3dc6cc2b9510643830e5f19d5cb753da5eeabe" + integrity sha512-tqkh47FzKeCPD2PUiPB6pkbMzsCasjxAfC62/Wap5qrUWcb+sFasXUC5I3gYM5iBM8v/Qpn4UK0x+j0iHyFPDg== mimic-fn@^1.0.0: version "1.2.0" @@ -10969,7 +10067,7 @@ minimalistic-assert@^1.0.0, minimalistic-assert@^1.0.1: resolved "https://registry.yarnpkg.com/minimalistic-assert/-/minimalistic-assert-1.0.1.tgz#2e194de044626d4a10e7f7fbc00ce73e83e4d5c7" integrity sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A== -minimalistic-crypto-utils@^1.0.0, minimalistic-crypto-utils@^1.0.1: +minimalistic-crypto-utils@^1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/minimalistic-crypto-utils/-/minimalistic-crypto-utils-1.0.1.tgz#f6c00c1c0b082246e5c4d99dfb8c7c083b2b582a" integrity sha1-9sAMHAsIIkblxNmd+4x8CDsrWCo= @@ -11104,11 +10202,16 @@ ms@2.1.1: resolved "https://registry.yarnpkg.com/ms/-/ms-2.1.1.tgz#30a5864eb3ebb0a66f2ebe6d727af06a09d86e0a" integrity sha512-tgp+dl5cGk28utYktBsrFqA7HKgrhgPsg6Z/EfhWI4gl1Hwq8B/GmY/0oXZ6nF8hDVesS/FpnYaD/kOWhYQvyg== -ms@2.1.2, ms@^2.1.1: +ms@2.1.2: version "2.1.2" resolved "https://registry.yarnpkg.com/ms/-/ms-2.1.2.tgz#d09d1f357b443f493382a8eb3ccd183872ae6009" integrity sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w== +ms@^2.1.1: + version "2.1.3" + resolved "https://registry.yarnpkg.com/ms/-/ms-2.1.3.tgz#574c8138ce1d2b5861f0b44579dbadd60c6615b2" + integrity sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA== + multicast-dns-service-types@^1.1.0: version "1.1.0" resolved "https://registry.yarnpkg.com/multicast-dns-service-types/-/multicast-dns-service-types-1.1.0.tgz#899f11d9686e5e05cb91b35d5f0e63b773cfc901" @@ -11137,10 +10240,10 @@ nanoclone@^0.2.1: resolved "https://registry.yarnpkg.com/nanoclone/-/nanoclone-0.2.1.tgz#dd4090f8f1a110d26bb32c49ed2f5b9235209ed4" integrity sha512-wynEP02LmIbLpcYw8uBKpcfF6dmg2vcpKqxeH5UcoKEYdExslsdUA4ugFauuaeYdTB76ez6gJW8XAZ6CgkXYxA== -nanoid@^3.1.15: - version "3.1.16" - resolved "https://registry.yarnpkg.com/nanoid/-/nanoid-3.1.16.tgz#b21f0a7d031196faf75314d7c65d36352beeef64" - integrity sha512-+AK8MN0WHji40lj8AEuwLOvLSbWYApQpre/aFJZD71r43wVRLrOYS4FmJOPQYon1TqB462RzrrxlfA74XRES8w== +nanoid@^3.1.20: + version "3.1.22" + resolved "https://registry.yarnpkg.com/nanoid/-/nanoid-3.1.22.tgz#b35f8fb7d151990a8aebd5aa5015c03cf726f844" + integrity sha512-/2ZUaJX2ANuLtTvqTlgqBQNJoQO398KyJgZloL0PZkC0dpysjncRUPsFe3DUPzz/y3h+u7C46np8RMuvF3jsSQ== nanomatch@^1.2.9: version "1.2.13" @@ -11191,14 +10294,6 @@ nice-try@^1.0.4: resolved "https://registry.yarnpkg.com/nice-try/-/nice-try-1.0.5.tgz#a3378a7696ce7d223e88fc9b764bd7ef1089e366" integrity sha512-1nh45deeb5olNY7eX82BkPO7SSxR5SSYJiPTrTdFUVYwAl8CKMA5N9PjTYkHiRjisVcxcQ1HXdLhx2qxxJzLNQ== -no-case@^3.0.3: - version "3.0.3" - resolved "https://registry.yarnpkg.com/no-case/-/no-case-3.0.3.tgz#c21b434c1ffe48b39087e86cfb4d2582e9df18f8" - integrity sha512-ehY/mVQCf9BL0gKfsJBvFJen+1V//U+0HQMPrWct40ixE4jnv0bfvxDbWtAHL9EcaPEOJHVVYKoQn1TlZUB8Tw== - dependencies: - lower-case "^2.0.1" - tslib "^1.10.0" - no-case@^3.0.4: version "3.0.4" resolved "https://registry.yarnpkg.com/no-case/-/no-case-3.0.4.tgz#d361fd5c9800f558551a8369fc0dcd4662b6124d" @@ -11212,24 +10307,11 @@ node-fetch@2.6.1, node-fetch@^2.6.1: resolved "https://registry.yarnpkg.com/node-fetch/-/node-fetch-2.6.1.tgz#045bd323631f76ed2e2b55573394416b639a0052" integrity sha512-V4aYg89jEoVRxRb2fJdAg8FHvI7cEyYdVAh94HH0UIK8oJxUfkjlDQN9RbMx+bEjP7+ggMiFRprSti032Oipxw== -node-fetch@^1.0.1: - version "1.7.3" - resolved "https://registry.yarnpkg.com/node-fetch/-/node-fetch-1.7.3.tgz#980f6f72d85211a5347c6b2bc18c5b84c3eb47ef" - integrity sha512-NhZ4CsKx7cYm2vSrBAr2PvFOe6sWDf0UYLRqA6svUYg7+/TSfVAu49jYC4BvQ4Sms9SZgdqGBgroqfDhJdTyKQ== - dependencies: - encoding "^0.1.11" - is-stream "^1.0.1" - node-forge@^0.10.0: version "0.10.0" resolved "https://registry.yarnpkg.com/node-forge/-/node-forge-0.10.0.tgz#32dea2afb3e9926f02ee5ce8794902691a676bf3" integrity sha512-PPmu8eEeG9saEUvI97fm4OYxXVB6bFvyNTyiUOBichBpFG8A1Ljw3bY62+5oOjDEMHRnd0Y7HQ+x7uzxOzC6JA== -node-gyp-build@^4.2.0: - version "4.2.3" - resolved "https://registry.yarnpkg.com/node-gyp-build/-/node-gyp-build-4.2.3.tgz#ce6277f853835f718829efb47db20f3e4d9c4739" - integrity sha512-MN6ZpzmfNCRM+3t57PTJHgHyw/h4OWnZ6mR8P5j/uZtqQr46RRuDE/P+g3n0YR/AiYXeWixZZzaip77gdICfRg== - node-int64@^0.4.0: version "0.4.0" resolved "https://registry.yarnpkg.com/node-int64/-/node-int64-0.4.0.tgz#87a9065cdb355d3182d8f94ce11188b825c68a3b" @@ -11270,9 +10352,9 @@ node-modules-regexp@^1.0.0: integrity sha1-jZ2+KJZKSsVxLpExZCEHxx6Q7EA= node-notifier@^8.0.0: - version "8.0.0" - resolved "https://registry.yarnpkg.com/node-notifier/-/node-notifier-8.0.0.tgz#a7eee2d51da6d0f7ff5094bc7108c911240c1620" - integrity sha512-46z7DUmcjoYdaWyXouuFNNfUo6eFa94t23c53c+lG/9Cvauk4a98rAUp9672X5dxGdQmLpPzTxzu8f/OeEPaFA== + version "8.0.2" + resolved "https://registry.yarnpkg.com/node-notifier/-/node-notifier-8.0.2.tgz#f3167a38ef0d2c8a866a83e318c1ba0efeb702c5" + integrity sha512-oJP/9NAdd9+x2Q+rfphB2RJCHjod70RcRLjosiPMMu5gjIfwVnOUGq2nbTjTUbmy0DJ/tFIVT30+Qe3nzl4TJg== dependencies: growly "^1.3.0" is-wsl "^2.2.0" @@ -11281,12 +10363,7 @@ node-notifier@^8.0.0: uuid "^8.3.0" which "^2.0.2" -node-releases@^1.1.61: - version "1.1.65" - resolved "https://registry.yarnpkg.com/node-releases/-/node-releases-1.1.65.tgz#52d9579176bd60f23eba05c4438583f341944b81" - integrity sha512-YpzJOe2WFIW0V4ZkJQd/DGR/zdVwc/pI4Nl1CZrBO19FdRcSTmsuhdttw9rsTzzJLrNcSloLiBbEYx1C4f6gpA== - -node-releases@^1.1.70: +node-releases@^1.1.61, node-releases@^1.1.70: version "1.1.71" resolved "https://registry.yarnpkg.com/node-releases/-/node-releases-1.1.71.tgz#cb1334b179896b1c89ecfdd4b725fb7bbdfc7dbb" integrity sha512-zR6HoT6LrLCRBwukmrVbHv0EpEQjksO6GmFcZQQuCAy139BEsoVKPYnf3jongYW83fAa1torLGYwxxky/p28sg== @@ -11302,13 +10379,13 @@ normalize-package-data@^2.3.2, normalize-package-data@^2.5.0: validate-npm-package-license "^3.0.1" normalize-package-data@^3.0.0: - version "3.0.0" - resolved "https://registry.yarnpkg.com/normalize-package-data/-/normalize-package-data-3.0.0.tgz#1f8a7c423b3d2e85eb36985eaf81de381d01301a" - integrity sha512-6lUjEI0d3v6kFrtgA/lOx4zHCWULXsFNIjHolnZCKCTLA6m/G625cdn3O7eNmT0iD3jfo6HZ9cdImGZwf21prw== + version "3.0.2" + resolved "https://registry.yarnpkg.com/normalize-package-data/-/normalize-package-data-3.0.2.tgz#cae5c410ae2434f9a6c1baa65d5bc3b9366c8699" + integrity sha512-6CdZocmfGaKnIHPVFhJJZ3GuR8SsLKvDANFp47Jmy51aKIr8akjAWTSxtpI+MBgBFdSMRyo4hMpDlT6dTffgZg== dependencies: - hosted-git-info "^3.0.6" - resolve "^1.17.0" - semver "^7.3.2" + hosted-git-info "^4.0.1" + resolve "^1.20.0" + semver "^7.3.4" validate-npm-package-license "^3.0.1" normalize-path@^2.1.1: @@ -11367,7 +10444,7 @@ npm-run-path@^4.0.0: dependencies: path-key "^3.0.0" -nth-check@^1.0.2, nth-check@~1.0.1: +nth-check@^1.0.2: version "1.0.2" resolved "https://registry.yarnpkg.com/nth-check/-/nth-check-1.0.2.tgz#b2bd295c37e3dd58a3bf0700376663ba4d9cf05c" integrity sha512-WeBOdju8SnzPN5vTUJYxYUxLeXpCaVP5i5e0LF8fg7WORF2Wd7wFX/pk0tYZk7s8T+J7VLy0Da6J1+wCT0AtHg== @@ -11413,24 +10490,29 @@ object-copy@^0.1.0: define-property "^0.2.5" kind-of "^3.0.3" -object-inspect@^1.8.0: - version "1.8.0" - resolved "https://registry.yarnpkg.com/object-inspect/-/object-inspect-1.8.0.tgz#df807e5ecf53a609cc6bfe93eac3cc7be5b3a9d0" - integrity sha512-jLdtEOB112fORuypAyl/50VRVIBIdVQOSUUGQHzJ4xBSbit81zRarz7GThkEFZy1RceYrWYcPcBFPQwHyAc1gA== +object-inspect@^1.9.0: + version "1.9.0" + resolved "https://registry.yarnpkg.com/object-inspect/-/object-inspect-1.9.0.tgz#c90521d74e1127b67266ded3394ad6116986533a" + integrity sha512-i3Bp9iTqwhaLZBxGkRfo5ZbE07BQRT7MGu8+nNgwW9ItGp1TzCTw2DLEoWwjClxBjOFI/hWljTAmYGCEwmtnOw== object-is@^1.0.1: - version "1.1.3" - resolved "https://registry.yarnpkg.com/object-is/-/object-is-1.1.3.tgz#2e3b9e65560137455ee3bd62aec4d90a2ea1cc81" - integrity sha512-teyqLvFWzLkq5B9ki8FVWA902UER2qkxmdA4nLf+wjOLAWgxzCWZNCxpDq9MvE8MmhWNr+I8w3BN49Vx36Y6Xg== + version "1.1.5" + resolved "https://registry.yarnpkg.com/object-is/-/object-is-1.1.5.tgz#b9deeaa5fc7f1846a0faecdceec138e5778f53ac" + integrity sha512-3cyDsyHgtmi7I7DfSSI2LDp6SK2lwvtbg0p0R1e0RvTqF5ceGx+K2dfSjm1bKDMVCFEDAQvy+o8c6a7VujOddw== dependencies: + call-bind "^1.0.2" define-properties "^1.1.3" - es-abstract "^1.18.0-next.1" object-keys@^1.0.12, object-keys@^1.1.1: version "1.1.1" resolved "https://registry.yarnpkg.com/object-keys/-/object-keys-1.1.1.tgz#1c47f272df277f3b1daf061677d9c82e2322c60e" integrity sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA== +object-path@^0.11.4: + version "0.11.5" + resolved "https://registry.yarnpkg.com/object-path/-/object-path-0.11.5.tgz#d4e3cf19601a5140a55a16ad712019a9c50b577a" + integrity sha512-jgSbThcoR/s+XumvGMTMf81QVBmah+/Q7K7YduKeKVWL7N111unR2d6pZZarSk6kY/caeNxUDyxOvMWyzoU2eg== + object-visit@^1.0.0: version "1.0.1" resolved "https://registry.yarnpkg.com/object-visit/-/object-visit-1.0.1.tgz#f79c4493af0c5377b59fe39d395e41042dd045bb" @@ -11438,7 +10520,7 @@ object-visit@^1.0.0: dependencies: isobject "^3.0.0" -object.assign@^4.1.0, object.assign@^4.1.1: +object.assign@^4.1.0, object.assign@^4.1.2: version "4.1.2" resolved "https://registry.yarnpkg.com/object.assign/-/object.assign-4.1.2.tgz#0ed54a342eceb37b38ff76eb831a0e788cb63940" integrity sha512-ixT2L5THXsApyiUPYKmW+2EHpXXe5Ii3M+f4e+aJFAHao5amFRW6J0OO6c/LU8Be47utCx2GL89hxGB6XSmKuQ== @@ -11448,32 +10530,34 @@ object.assign@^4.1.0, object.assign@^4.1.1: has-symbols "^1.0.1" object-keys "^1.1.1" -object.entries@^1.1.0, object.entries@^1.1.2: - version "1.1.2" - resolved "https://registry.yarnpkg.com/object.entries/-/object.entries-1.1.2.tgz#bc73f00acb6b6bb16c203434b10f9a7e797d3add" - integrity sha512-BQdB9qKmb/HyNdMNWVr7O3+z5MUIx3aiegEIJqjMBbBf0YT9RRxTJSim4mzFqtyr7PDAHigq0N9dO0m0tRakQA== +object.entries@^1.1.0, object.entries@^1.1.2, object.entries@^1.1.3: + version "1.1.3" + resolved "https://registry.yarnpkg.com/object.entries/-/object.entries-1.1.3.tgz#c601c7f168b62374541a07ddbd3e2d5e4f7711a6" + integrity sha512-ym7h7OZebNS96hn5IJeyUmaWhaSM4SVtAPPfNLQEI2MYWCO2egsITb9nab2+i/Pwibx+R0mtn+ltKJXRSeTMGg== dependencies: + call-bind "^1.0.0" define-properties "^1.1.3" - es-abstract "^1.17.5" + es-abstract "^1.18.0-next.1" has "^1.0.3" -object.fromentries@^2.0.2: - version "2.0.2" - resolved "https://registry.yarnpkg.com/object.fromentries/-/object.fromentries-2.0.2.tgz#4a09c9b9bb3843dd0f89acdb517a794d4f355ac9" - integrity sha512-r3ZiBH7MQppDJVLx6fhD618GKNG40CZYH9wgwdhKxBDDbQgjeWGGd4AtkZad84d291YxvWe7bJGuE65Anh0dxQ== +object.fromentries@^2.0.4: + version "2.0.4" + resolved "https://registry.yarnpkg.com/object.fromentries/-/object.fromentries-2.0.4.tgz#26e1ba5c4571c5c6f0890cef4473066456a120b8" + integrity sha512-EsFBshs5RUUpQEY1D4q/m59kMfz4YJvxuNCJcv/jWwOJr34EaVnG11ZrZa0UHB3wnzV1wx8m58T4hQL8IuNXlQ== dependencies: + call-bind "^1.0.2" define-properties "^1.1.3" - es-abstract "^1.17.0-next.1" - function-bind "^1.1.1" + es-abstract "^1.18.0-next.2" has "^1.0.3" object.getownpropertydescriptors@^2.0.3, object.getownpropertydescriptors@^2.1.0: - version "2.1.0" - resolved "https://registry.yarnpkg.com/object.getownpropertydescriptors/-/object.getownpropertydescriptors-2.1.0.tgz#369bf1f9592d8ab89d712dced5cb81c7c5352649" - integrity sha512-Z53Oah9A3TdLoblT7VKJaTDdXdT+lQO+cNpKVnya5JDe9uLvzu1YyY1yFDFrcxrlRgWrEFH0jJtD/IbuwjcEVg== + version "2.1.2" + resolved "https://registry.yarnpkg.com/object.getownpropertydescriptors/-/object.getownpropertydescriptors-2.1.2.tgz#1bd63aeacf0d5d2d2f31b5e393b03a7c601a23f7" + integrity sha512-WtxeKSzfBjlzL+F9b7M7hewDzMwy+C8NRssHd1YrNlzHzIDrXcXiNOMrezdAEM4UXixgV+vvnyBeN7Rygl2ttQ== dependencies: + call-bind "^1.0.2" define-properties "^1.1.3" - es-abstract "^1.17.0-next.1" + es-abstract "^1.18.0-next.2" object.pick@^1.3.0: version "1.3.0" @@ -11482,14 +10566,14 @@ object.pick@^1.3.0: dependencies: isobject "^3.0.1" -object.values@^1.1.0, object.values@^1.1.1: - version "1.1.1" - resolved "https://registry.yarnpkg.com/object.values/-/object.values-1.1.1.tgz#68a99ecde356b7e9295a3c5e0ce31dc8c953de5e" - integrity sha512-WTa54g2K8iu0kmS/us18jEmdv1a4Wi//BZ/DTVYEcH0XhLM5NYdpDHja3gt57VrZLcNAO2WGA+KpWsDBaHt6eA== +object.values@^1.1.0, object.values@^1.1.1, object.values@^1.1.3: + version "1.1.3" + resolved "https://registry.yarnpkg.com/object.values/-/object.values-1.1.3.tgz#eaa8b1e17589f02f698db093f7c62ee1699742ee" + integrity sha512-nkF6PfDB9alkOUxpf1HNm/QlkeW3SReqL5WXeBLpEJJnlPSvRaDQpW3gQTksTN3fgJX4hL42RzKyOin6ff3tyw== dependencies: + call-bind "^1.0.2" define-properties "^1.1.3" - es-abstract "^1.17.0-next.1" - function-bind "^1.1.1" + es-abstract "^1.18.0-next.2" has "^1.0.3" obuf@^1.0.0, obuf@^1.1.2: @@ -11536,9 +10620,9 @@ onetime@^5.1.0: mimic-fn "^2.1.0" open@^7.0.2: - version "7.3.0" - resolved "https://registry.yarnpkg.com/open/-/open-7.3.0.tgz#45461fdee46444f3645b6e14eb3ca94b82e1be69" - integrity sha512-mgLwQIx2F/ye9SmbrUkurZCnkoXyXyu9EbHtJZrICjVAJfyMArdHp3KkixGdZx1ZHFPNIwl0DDM1dFFqXbTLZw== + version "7.4.2" + resolved "https://registry.yarnpkg.com/open/-/open-7.4.2.tgz#b8147e26dcf3e426316c730089fd71edd29c2321" + integrity sha512-MVHddDVweXZF3awtlAS+6pgKLlm/JgxZ90+/NBurBoQctVOOB/zDdVjcyPzQ+0laDGbsWgrRkflI65sQeOgT9Q== dependencies: is-docker "^2.0.0" is-wsl "^2.1.1" @@ -11550,17 +10634,10 @@ opn@^5.5.0: dependencies: is-wsl "^1.1.0" -optimism@^0.13.0: - version "0.13.0" - resolved "https://registry.yarnpkg.com/optimism/-/optimism-0.13.0.tgz#c08904e1439a0eb9e7f86183dafa06cc715ff351" - integrity sha512-6JAh3dH+YUE4QUdsgUw8nUQyrNeBKfAEKOHMlLkQ168KhIYFIxzPsHakWrRXDnTO+x61RJrS3/2uEt6W0xlocA== - dependencies: - "@wry/context" "^0.5.2" - optimism@^0.14.0: - version "0.14.0" - resolved "https://registry.yarnpkg.com/optimism/-/optimism-0.14.0.tgz#256fb079a3428585b40a3a8462f907e0abd2fc49" - integrity sha512-ygbNt8n4DOCVpkwiLF+IrKKeNHOjtr9aXLWGP9HNJGoblSGsnVbJLstcH6/nE9Xy5ZQtlkSioFQNnthmENW6FQ== + version "0.14.1" + resolved "https://registry.yarnpkg.com/optimism/-/optimism-0.14.1.tgz#db35a0c770e16863f6c288f7cf58341a2348db44" + integrity sha512-7+1lSN+LJEtaj3uBLLFk8uFCFKy3txLvcvln5Dh1szXjF9yghEMeWclmnk0qdtYZ+lcMNyu48RmQQRw+LRYKSQ== dependencies: "@wry/context" "^0.5.2" "@wry/trie" "^0.2.1" @@ -11620,21 +10697,21 @@ p-cancelable@^1.0.0: integrity sha512-s73XxOZ4zpt1edZYZzvhqFa6uvQc1vwUa0K0BdtIZgQMAJj9IbebH+JkgKZc9h+B05PKHLOTl4ajG1BmNrVZlw== p-each-series@^2.1.0: - version "2.1.0" - resolved "https://registry.yarnpkg.com/p-each-series/-/p-each-series-2.1.0.tgz#961c8dd3f195ea96c747e636b262b800a6b1af48" - integrity sha512-ZuRs1miPT4HrjFa+9fRfOFXxGJfORgelKV9f9nNOWw2gl6gVsRaVDOQP0+MI0G0wGKns1Yacsu0GjOFbTK0JFQ== + version "2.2.0" + resolved "https://registry.yarnpkg.com/p-each-series/-/p-each-series-2.2.0.tgz#105ab0357ce72b202a8a8b94933672657b5e2a9a" + integrity sha512-ycIL2+1V32th+8scbpTvyHNaHe02z0sjgh91XXjAk+ZeXoPN4Z46DVUnzdso0aX4KckKw0FNNFHdjZ2UsZvxiA== p-finally@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/p-finally/-/p-finally-1.0.0.tgz#3fbcfb15b899a44123b34b6dcc18b724336a2cae" integrity sha1-P7z7FbiZpEEjs0ttzBi3JDNqLK4= -p-limit@3.0.2, p-limit@^3.0.2: - version "3.0.2" - resolved "https://registry.yarnpkg.com/p-limit/-/p-limit-3.0.2.tgz#1664e010af3cadc681baafd3e2a437be7b0fb5fe" - integrity sha512-iwqZSOoWIW+Ew4kAGUlN16J4M7OB3ysMLSZtnhmqx7njIHFPlxWBX8xo3lVTyFVq6mI/lL9qt2IsN1sHwaxJkg== +p-limit@3.1.0, p-limit@^3.0.2: + version "3.1.0" + resolved "https://registry.yarnpkg.com/p-limit/-/p-limit-3.1.0.tgz#e1daccbe78d0d1388ca18c64fea38e3e57e3706b" + integrity sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ== dependencies: - p-try "^2.0.0" + yocto-queue "^0.1.0" p-limit@^1.1.0: version "1.3.0" @@ -11724,13 +10801,13 @@ parallel-transform@^1.1.0: inherits "^2.0.3" readable-stream "^2.1.5" -param-case@3.0.3, param-case@^3.0.3: - version "3.0.3" - resolved "https://registry.yarnpkg.com/param-case/-/param-case-3.0.3.tgz#4be41f8399eff621c56eebb829a5e451d9801238" - integrity sha512-VWBVyimc1+QrzappRs7waeN2YmoZFCGXWASRYX1/rGHtXqEcrGEIDm+jqIwFa2fRXNgQEwrxaYuIrX0WcAguTA== +param-case@^3.0.3, param-case@^3.0.4: + version "3.0.4" + resolved "https://registry.yarnpkg.com/param-case/-/param-case-3.0.4.tgz#7d17fe4aa12bde34d4a77d91acfb6219caad01c5" + integrity sha512-RXlj7zCYokReqWpOPH9oYivUzLYZ5vAPIfEmCTNViosC78F8F0H9y7T7gG2M39ymgutxF5gcFEsyZQSph9Bp3A== dependencies: - dot-case "^3.0.3" - tslib "^1.10.0" + dot-case "^3.0.4" + tslib "^2.0.3" parent-module@^1.0.0: version "1.0.1" @@ -11810,34 +10887,26 @@ parse-json@^4.0.0: json-parse-better-errors "^1.0.1" parse-json@^5.0.0: - version "5.1.0" - resolved "https://registry.yarnpkg.com/parse-json/-/parse-json-5.1.0.tgz#f96088cdf24a8faa9aea9a009f2d9d942c999646" - integrity sha512-+mi/lmVVNKFNVyLXV31ERiy2CY5E1/F6QtJFEzoChPRwwngMNXRDQ9GJ5WdE2Z2P4AujsOi0/+2qHID68KwfIQ== + version "5.2.0" + resolved "https://registry.yarnpkg.com/parse-json/-/parse-json-5.2.0.tgz#c76fc66dee54231c962b22bcc8a72cf2f99753cd" + integrity sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg== dependencies: "@babel/code-frame" "^7.0.0" error-ex "^1.3.1" json-parse-even-better-errors "^2.3.0" lines-and-columns "^1.1.6" -parse5@5.1.1: - version "5.1.1" - resolved "https://registry.yarnpkg.com/parse5/-/parse5-5.1.1.tgz#f68e4e5ba1852ac2cadc00f4555fff6c2abb6178" - integrity sha512-ugq4DFI0Ptb+WWjAdOK16+u/nHfiIrcE+sh8kZMaM0WllQKLI9rOUq6c2b7cwPkXdzfQESqvoqK6ug7U/Yyzug== +parse5@6.0.1: + version "6.0.1" + resolved "https://registry.yarnpkg.com/parse5/-/parse5-6.0.1.tgz#e1a1c085c569b3dc08321184f19a39cc27f7c30b" + integrity sha512-Ofn/CTFzRGTTxwpNEs9PP93gXShHcTq255nzRYSKe8AkVpZY7e1fpmTfOyoIvjP5HG7Z2ZM7VS9PPhQGW2pOpw== parseurl@~1.3.2, parseurl@~1.3.3: version "1.3.3" resolved "https://registry.yarnpkg.com/parseurl/-/parseurl-1.3.3.tgz#9da19e7bee8d12dff0513ed5b76957793bc2e8d4" integrity sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ== -pascal-case@3.1.1, pascal-case@^3.1.1: - version "3.1.1" - resolved "https://registry.yarnpkg.com/pascal-case/-/pascal-case-3.1.1.tgz#5ac1975133ed619281e88920973d2cd1f279de5f" - integrity sha512-XIeHKqIrsquVTQL2crjq3NfJUxmdLasn3TYOU0VBM+UX2a6ztAWBlJQBePLGY7VHW8+2dRadeIPK5+KImwTxQA== - dependencies: - no-case "^3.0.3" - tslib "^1.10.0" - -pascal-case@^3.1.2: +pascal-case@^3.1.1, pascal-case@^3.1.2: version "3.1.2" resolved "https://registry.yarnpkg.com/pascal-case/-/pascal-case-3.1.2.tgz#b48e0ef2b98e205e7c1dae747d0b1508237660eb" integrity sha512-uWlGT3YSnK9x3BQJaOdcZwrnV6hPpd8jFH1/ucpiLRPh/2zCVJKS19E4GvYHvaCcACn3foXZ0cLB9Wrx1KGe5g== @@ -11855,6 +10924,14 @@ path-browserify@0.0.1: resolved "https://registry.yarnpkg.com/path-browserify/-/path-browserify-0.0.1.tgz#e6c4ddd7ed3aa27c68a20cc4e50e1a4ee83bbc4a" integrity sha512-BapA40NHICOS+USX9SN4tyhq+A2RrN/Ws5F0Z5aMHDp98Fl86lX8Oti8B7uN93L4Ifv4fHOEA+pQw87gmMO/lQ== +path-case@^3.0.4: + version "3.0.4" + resolved "https://registry.yarnpkg.com/path-case/-/path-case-3.0.4.tgz#9168645334eb942658375c56f80b4c0cb5f82c6f" + integrity sha512-qO4qCFjXqVTrcbPt/hQfhTQ+VhFsqNKOPtytgNKkKxSoEp3XPUQ8ObFuePylOIok5gjn69ry8XiULxCwot3Wfg== + dependencies: + dot-case "^3.0.4" + tslib "^2.0.3" + path-dirname@^1.0.0: version "1.0.2" resolved "https://registry.yarnpkg.com/path-dirname/-/path-dirname-1.0.2.tgz#cc33d24d525e099a5388c0336c6e32b9160609e0" @@ -12669,9 +11746,9 @@ postcss-selector-matches@^4.0.0: postcss "^7.0.2" postcss-selector-not@^4.0.0: - version "4.0.0" - resolved "https://registry.yarnpkg.com/postcss-selector-not/-/postcss-selector-not-4.0.0.tgz#c68ff7ba96527499e832724a2674d65603b645c0" - integrity sha512-W+bkBZRhqJaYN8XAnbbZPLWMvZD1wKTu0UxtFKdhtGjWYmxhkUneoeOhRJKdAE5V7ZTlnbHfCR+6bNwK9e1dTQ== + version "4.0.1" + resolved "https://registry.yarnpkg.com/postcss-selector-not/-/postcss-selector-not-4.0.1.tgz#263016eef1cf219e0ade9a913780fc1f48204cbf" + integrity sha512-YolvBgInEK5/79C+bdFMyzqTg6pkYqDbzZIST/PDMqa/o3qtXenD05apBG2jLgT0/BQ77d4U2UK12jWpilqMAQ== dependencies: balanced-match "^1.0.0" postcss "^7.0.2" @@ -12774,13 +11851,12 @@ postcss@^7, postcss@^7.0.0, postcss@^7.0.1, postcss@^7.0.14, postcss@^7.0.17, po supports-color "^6.1.0" postcss@^8.1.0: - version "8.1.4" - resolved "https://registry.yarnpkg.com/postcss/-/postcss-8.1.4.tgz#356dfef367a70f3d04347f74560c85846e20e4c1" - integrity sha512-LfqcwgMq9LOd8pX7K2+r2HPitlIGC5p6PoZhVELlqhh2YGDVcXKpkCseqan73Hrdik6nBd2OvoDPUaP/oMj9hQ== + version "8.2.8" + resolved "https://registry.yarnpkg.com/postcss/-/postcss-8.2.8.tgz#0b90f9382efda424c4f0f69a2ead6f6830d08ece" + integrity sha512-1F0Xb2T21xET7oQV9eKuctbM9S7BC0fetoHCc4H13z0PT6haiRLP4T0ZY4XWh7iLP0usgqykT6p9B2RtOf4FPw== dependencies: - colorette "^1.2.1" - line-column "^1.0.2" - nanoid "^3.1.15" + colorette "^1.2.2" + nanoid "^3.1.20" source-map "^0.6.1" prelude-ls@^1.2.1: @@ -12809,9 +11885,9 @@ prettier@2.2.1: integrity sha512-PqyhM2yCjg/oKkFPtTGUojv7gnZAoG80ttl45O6x2Ug/rMJw4wcc9k6aaf2hibP7BGVCCM33gZoGjyvt9mm16Q== pretty-bytes@^5.3.0: - version "5.4.1" - resolved "https://registry.yarnpkg.com/pretty-bytes/-/pretty-bytes-5.4.1.tgz#cd89f79bbcef21e3d21eb0da68ffe93f803e884b" - integrity sha512-s1Iam6Gwz3JI5Hweaz4GoCD1WUNUIyzePFy5+Js2hjwGVt2Z79wNN+ZKOZ2vB6C+Xs6njyB84Z1IthQg8d9LxA== + version "5.6.0" + resolved "https://registry.yarnpkg.com/pretty-bytes/-/pretty-bytes-5.6.0.tgz#356256f643804773c82f64723fe78c92c62beaeb" + integrity sha512-FFw039TmrBqFK8ma/7OL3sDz/VytdtJr044/QUJtH0wK9lb9jLq9tJyIxUwtQJHwar2BqtiA4iCWSwo9JLkzFg== pretty-error@^2.1.1: version "2.1.2" @@ -12821,12 +11897,12 @@ pretty-error@^2.1.1: lodash "^4.17.20" renderkid "^2.0.4" -pretty-format@^26.6.0, pretty-format@^26.6.1: - version "26.6.1" - resolved "https://registry.yarnpkg.com/pretty-format/-/pretty-format-26.6.1.tgz#af9a2f63493a856acddeeb11ba6bcf61989660a8" - integrity sha512-MeqqsP5PYcRBbGMvwzsyBdmAJ4EFX7pWFyl7x4+dMVg5pE0ZDdBIvEH2ergvIO+Gvwv1wh64YuOY9y5LuyY/GA== +pretty-format@^26.6.0, pretty-format@^26.6.2: + version "26.6.2" + resolved "https://registry.yarnpkg.com/pretty-format/-/pretty-format-26.6.2.tgz#e35c2705f14cb7fe2fe94fa078345b444120fc93" + integrity sha512-7AeGuCYNGmycyQbCqd/3PWH4eOoX/OiCa0uphp57NVTeAGdJGaAliecxwBDHYQCIvrW7aDBZCYeNTP/WX69mkg== dependencies: - "@jest/types" "^26.6.1" + "@jest/types" "^26.6.2" ansi-regex "^5.0.0" ansi-styles "^4.0.0" react-is "^17.0.1" @@ -12841,11 +11917,6 @@ process@^0.11.10: resolved "https://registry.yarnpkg.com/process/-/process-0.11.10.tgz#7332300e840161bda3e69a1d1d91a7d4bc16f182" integrity sha1-czIwDoQBYb2j5podHZGn1LwW8YI= -process@~0.5.1: - version "0.5.2" - resolved "https://registry.yarnpkg.com/process/-/process-0.5.2.tgz#1638d8a8e34c2f440a91db95ab9aeb677fc185cf" - integrity sha1-FjjYqONML0QKkduVq5rrZ3/Bhc8= - progress@^2.0.0: version "2.0.3" resolved "https://registry.yarnpkg.com/progress/-/progress-2.0.3.tgz#7e8cf8d8f5b8f239c1bc68beb4eb78567d572ef8" @@ -12913,7 +11984,7 @@ prr@~1.0.1: resolved "https://registry.yarnpkg.com/prr/-/prr-1.0.1.tgz#d3fc114ba06995a45ec6893f484ceb1d78f5f476" integrity sha1-0/wRS6BplaRexok/SEzrHXj19HY= -psl@^1.1.28: +psl@^1.1.28, psl@^1.1.33: version "1.8.0" resolved "https://registry.yarnpkg.com/psl/-/psl-1.8.0.tgz#9326f8bcfb013adcc005fdff056acce020e51c24" integrity sha512-RIdOzyoavK+hA18OGGWDqUTsCLhtA7IcZ/6NCs4fFJaHBDab+pDDmDIByWFRQJq2Cd7r1OoQxBGKOaztq+hjIQ== @@ -13007,16 +12078,26 @@ querystring-es3@^0.2.0: resolved "https://registry.yarnpkg.com/querystring-es3/-/querystring-es3-0.2.1.tgz#9ec61f79049875707d69414596fd907a4d711e73" integrity sha1-nsYfeQSYdXB9aUFFlv2Qek1xHnM= -querystring@0.2.0, querystring@^0.2.0: +querystring@0.2.0: version "0.2.0" resolved "https://registry.yarnpkg.com/querystring/-/querystring-0.2.0.tgz#b209849203bb25df820da756e747005878521620" integrity sha1-sgmEkgO7Jd+CDadW50cAWHhSFiA= +querystring@^0.2.0: + version "0.2.1" + resolved "https://registry.yarnpkg.com/querystring/-/querystring-0.2.1.tgz#40d77615bb09d16902a85c3e38aa8b5ed761c2dd" + integrity sha512-wkvS7mL/JMugcup3/rMitHmd9ecIGd2lhFhK9N3UUQ450h66d1r3Y9nvXzQAW1Lq+wyx61k/1pfKS5KuKiyEbg== + querystringify@^2.1.1: version "2.2.0" resolved "https://registry.yarnpkg.com/querystringify/-/querystringify-2.2.0.tgz#3345941b4153cb9d082d8eee4cda2016a9aef7f6" integrity sha512-FIqgj2EUvTa7R50u0rGsyTftzjYmv/a3hO345bZNrqabNqjtgiDMgmo4mkUjd+nzU5oF3dClKqFIPUKybUyqoQ== +queue-microtask@^1.2.2: + version "1.2.3" + resolved "https://registry.yarnpkg.com/queue-microtask/-/queue-microtask-1.2.3.tgz#4929228bbc724dfac43e0efb058caf7b6cfb6243" + integrity sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A== + quick-lru@^4.0.1: version "4.0.1" resolved "https://registry.yarnpkg.com/quick-lru/-/quick-lru-4.0.1.tgz#5b8878f113a58217848c6482026c73e1ba57727f" @@ -13111,9 +12192,9 @@ react-bootstrap@1.4.3: warning "^4.0.3" react-dev-utils@^11.0.3: - version "11.0.3" - resolved "https://registry.yarnpkg.com/react-dev-utils/-/react-dev-utils-11.0.3.tgz#b61ed499c7d74f447d4faddcc547e5e671e97c08" - integrity sha512-4lEA5gF4OHrcJLMUV1t+4XbNDiJbsAWCH5Z2uqlTqW6dD7Cf5nEASkeXrCI/Mz83sI2o527oBIFKVMXtRf1Vtg== + version "11.0.4" + resolved "https://registry.yarnpkg.com/react-dev-utils/-/react-dev-utils-11.0.4.tgz#a7ccb60257a1ca2e0efe7a83e38e6700d17aa37a" + integrity sha512-dx0LvIGHcOPtKbeiSUM4jqpBl3TcY7CDjZdfOIcKeznE7BWr9dg0iPG90G5yfVQ+p/rGNMXdbfStvzQZEVEi4A== dependencies: "@babel/code-frame" "7.10.4" address "1.1.2" @@ -13167,22 +12248,19 @@ react-input-autosize@^3.0.0: prop-types "^15.5.8" react-intl@^5.10.16: - version "5.10.16" - resolved "https://registry.yarnpkg.com/react-intl/-/react-intl-5.10.16.tgz#2ccde74acd26cbe4f9e6e42765048a7f2f40e645" - integrity sha512-Cp0p9MGGWYNsl3hamJcrRqKid2HZun4MsdIlHV9fdmYVoLOII1+YynH6UBBwLuDlrLi8JrSniH1G1pqUlwQZMw== + version "5.13.5" + resolved "https://registry.yarnpkg.com/react-intl/-/react-intl-5.13.5.tgz#32bb74120b67950fe63329db58aa83cfac73f6c8" + integrity sha512-Ym6knnC04k070vwe3UDcRHQUDE2rGn1PNfmYNhDHVPL6vbusuFbefjnt8ZC1GEjnfo29WUHn/tkGd9SMudzD+g== dependencies: - "@formatjs/ecma402-abstract" "1.5.1" - "@formatjs/intl" "1.6.1" - "@formatjs/intl-displaynames" "4.0.4" - "@formatjs/intl-listformat" "5.0.4" - "@formatjs/intl-relativetimeformat" "8.0.3" + "@formatjs/ecma402-abstract" "1.6.3" + "@formatjs/intl" "1.8.4" + "@formatjs/intl-displaynames" "4.0.11" + "@formatjs/intl-listformat" "5.0.12" "@types/hoist-non-react-statics" "^3.3.1" - fast-memoize "^2.5.2" hoist-non-react-statics "^3.3.2" - intl-messageformat "9.4.3" - intl-messageformat-parser "6.1.3" - shallow-equal "^1.2.1" - tslib "^2.0.1" + intl-messageformat "9.5.3" + intl-messageformat-parser "6.4.3" + tslib "^2.1.0" react-is@^16.3.2, react-is@^16.6.0, react-is@^16.7.0, react-is@^16.8.1, react-is@^16.8.6: version "16.13.1" @@ -13190,9 +12268,9 @@ react-is@^16.3.2, react-is@^16.6.0, react-is@^16.7.0, react-is@^16.8.1, react-is integrity sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ== react-is@^17.0.1: - version "17.0.1" - resolved "https://registry.yarnpkg.com/react-is/-/react-is-17.0.1.tgz#5b3531bd76a645a4c9fb6e693ed36419e3301339" - integrity sha512-NAnt2iGDXohE5LI7uBnLnqvLQMtzhkiAOLXTmv+qnF9Ky7xAPcX8Up/xWIhxvLVGJvuLiNc4xQLtuqDRzb4fSA== + version "17.0.2" + resolved "https://registry.yarnpkg.com/react-is/-/react-is-17.0.2.tgz#e691d4a8e9c789365655539ab372762b0efb54f0" + integrity sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w== react-jw-player@1.19.1: version "1.19.1" @@ -13262,9 +12340,9 @@ react-router-dom@^5.2.0: tiny-warning "^1.0.0" react-router-hash-link@^2.3.1: - version "2.3.1" - resolved "https://registry.yarnpkg.com/react-router-hash-link/-/react-router-hash-link-2.3.1.tgz#2ebe3443caad4d478a76e484458f1974f24ee97c" - integrity sha512-QVYLaBLmRGovSbQv4Tbjqnl9JMEQ8c5rWebkZU16ovgZtpmNIf2znGj3uWaKkAL7lhuYBPcC3OAfhw7lk/QwNw== + version "2.4.0" + resolved "https://registry.yarnpkg.com/react-router-hash-link/-/react-router-hash-link-2.4.0.tgz#216045d9bb826e5f36f873dea8b04874a0708f83" + integrity sha512-HGbB9kfODHKsHvMVsPbqDr057V4xg4TNNRaQcezsFMKitwHaaU51cM2+gDyX45y9YLLPbovELz2rpNx2C3Frng== dependencies: prop-types "^15.7.2" @@ -13351,13 +12429,12 @@ react-scripts@^4.0.3: fsevents "^2.1.3" react-select@^4.0.2: - version "4.0.2" - resolved "https://registry.yarnpkg.com/react-select/-/react-select-4.0.2.tgz#4dcca9f38d6a41e01f2dc7673e244a325e3b4e0e" - integrity sha512-BiihrRpRIBBvNqofNZIBpo08Kw8DBHb/kgpIDW4bxgkttk50Sxf0alEIKobns3U7UJXk/CA4rsFUueQEg9Pm5A== + version "4.3.0" + resolved "https://registry.yarnpkg.com/react-select/-/react-select-4.3.0.tgz#6bde634ae7a378b49f3833c85c126f533483fa2e" + integrity sha512-SBPD1a3TJqE9zoI/jfOLCAoLr/neluaeokjOixr3zZ1vHezkom8K0A9J4QG9IWDqIDE9K/Mv+0y1GjidC2PDtQ== dependencies: - "@babel/runtime" "^7.4.4" + "@babel/runtime" "^7.12.0" "@emotion/cache" "^11.0.0" - "@emotion/css" "^11.0.0" "@emotion/react" "^11.1.1" memoize-one "^5.0.0" prop-types "^15.6.0" @@ -13521,13 +12598,13 @@ regex-parser@^2.2.11: resolved "https://registry.yarnpkg.com/regex-parser/-/regex-parser-2.2.11.tgz#3b37ec9049e19479806e878cabe7c1ca83ccfe58" integrity sha512-jbD/FT0+9MBU2XAZluI7w2OBs1RBi6p9M83nkoZayQXXU9e8Robt69FcZc7wU4eJD/YFTjn1JdCk3rbMJajz8Q== -regexp.prototype.flags@^1.2.0, regexp.prototype.flags@^1.3.0: - version "1.3.0" - resolved "https://registry.yarnpkg.com/regexp.prototype.flags/-/regexp.prototype.flags-1.3.0.tgz#7aba89b3c13a64509dabcf3ca8d9fbb9bdf5cb75" - integrity sha512-2+Q0C5g951OlYlJz6yu5/M33IcsESLlLfsyIaLJaG4FA2r4yP8MvVMJUUP/fVBkSpbbbZlS5gynbEWLipiiXiQ== +regexp.prototype.flags@^1.2.0, regexp.prototype.flags@^1.3.1: + version "1.3.1" + resolved "https://registry.yarnpkg.com/regexp.prototype.flags/-/regexp.prototype.flags-1.3.1.tgz#7ef352ae8d159e758c0eadca6f8fcb4eef07be26" + integrity sha512-JiBdRBq91WlY7uRJ0ds7R+dU02i6LKi8r3BuQhNXn+kmeLN+EfHhfjqMRis1zJxnlu88hq/4dx0P2OP3APRTOA== dependencies: + call-bind "^1.0.2" define-properties "^1.1.3" - es-abstract "^1.17.0-next.1" regexpp@^3.0.0, regexpp@^3.1.0: version "3.1.0" @@ -13547,9 +12624,9 @@ regexpu-core@^4.7.1: unicode-match-property-value-ecmascript "^1.2.0" registry-auth-token@^4.0.0: - version "4.2.0" - resolved "https://registry.yarnpkg.com/registry-auth-token/-/registry-auth-token-4.2.0.tgz#1d37dffda72bbecd0f581e4715540213a65eb7da" - integrity sha512-P+lWzPrsgfN+UEpDS3U8AQKg/UjZX6mQSJueZj3EK+vNESoqBSpBUD3gmu4sF9lOsjXWjF11dQKUqemf3veq1w== + version "4.2.1" + resolved "https://registry.yarnpkg.com/registry-auth-token/-/registry-auth-token-4.2.1.tgz#6d7b4006441918972ccd5fedcd41dc322c79b250" + integrity sha512-6gkSb4U6aWJB4SF2ZvLb76yCBjcvufXBqvvEx1HbmKPkutswjW1xNVRY0+daljIYRbogN7O0etYSlbiaEQyMyw== dependencies: rc "^1.2.8" @@ -13566,9 +12643,9 @@ regjsgen@^0.5.1: integrity sha512-OFFT3MfrH90xIW8OOSyUrk6QHD5E9JOTeGodiJeBS3J6IwlgzJMNE/1bZklWz5oTg+9dCMyEetclvCVXOPoN3A== regjsparser@^0.6.4: - version "0.6.4" - resolved "https://registry.yarnpkg.com/regjsparser/-/regjsparser-0.6.4.tgz#a769f8684308401a66e9b529d2436ff4d0666272" - integrity sha512-64O87/dPDgfk8/RQqC4gkZoGyyWFIEUTTh80CU6CWuK5vkCGyekIx+oKcEIYtP/RAxSQltCZHCNu/mdd7fqlJw== + version "0.6.9" + resolved "https://registry.yarnpkg.com/regjsparser/-/regjsparser-0.6.9.tgz#b489eef7c9a2ce43727627011429cf833a7183e6" + integrity sha512-ZqbNRz1SNjLAiYuwY0zoXW8Ne675IX5q+YHioAGbCw4X96Mjl2+dcX9B2ciaeyYjViDAfvIjFpQjJgLttTEERQ== dependencies: jsesc "~0.5.0" @@ -13577,10 +12654,10 @@ relateurl@^0.2.7: resolved "https://registry.yarnpkg.com/relateurl/-/relateurl-0.2.7.tgz#54dbf377e51440aca90a4cd274600d3ff2d888a9" integrity sha1-VNvzd+UUQKypCkzSdGANP/LYiKk= -relay-compiler@10.0.1: - version "10.0.1" - resolved "https://registry.yarnpkg.com/relay-compiler/-/relay-compiler-10.0.1.tgz#d3029a5121cad52e1e55073210365b827cee5f3b" - integrity sha512-hrTqh81XXxPB4EgvxPmvojICr0wJnRoumxOsMZnS9dmhDHSqcBAT7+C3+rdGm5sSdNH8mbMcZM7YSPDh8ABxQw== +relay-compiler@10.1.0: + version "10.1.0" + resolved "https://registry.yarnpkg.com/relay-compiler/-/relay-compiler-10.1.0.tgz#fb4672cdbe9b54869a3a79759edd8c2d91609cbe" + integrity sha512-HPqc3N3tNgEgUH5+lTr5lnLbgnsZMt+MRiyS0uAVNhuPY2It0X1ZJG+9qdA3L9IqKFUNwVn6zTO7RArjMZbARQ== dependencies: "@babel/core" "^7.0.0" "@babel/generator" "^7.5.0" @@ -13591,21 +12668,21 @@ relay-compiler@10.0.1: babel-preset-fbjs "^3.3.0" chalk "^4.0.0" fb-watchman "^2.0.0" - fbjs "^1.0.0" + fbjs "^3.0.0" glob "^7.1.1" immutable "~3.7.6" nullthrows "^1.1.1" - relay-runtime "10.0.1" + relay-runtime "10.1.0" signedsource "^1.0.0" yargs "^15.3.1" -relay-runtime@10.0.1: - version "10.0.1" - resolved "https://registry.yarnpkg.com/relay-runtime/-/relay-runtime-10.0.1.tgz#c83bd7e6e37234ece2a62a254e37dd199a4f74f9" - integrity sha512-sPYiuosq+5gQ7zXs2EKg2O8qRSsF8vmMYo6SIHEi4juBLg1HrdTEvqcaNztc2ZFmUc4vYZpTbbS4j/TZCtHuyA== +relay-runtime@10.1.0: + version "10.1.0" + resolved "https://registry.yarnpkg.com/relay-runtime/-/relay-runtime-10.1.0.tgz#4753bf36e95e8d862cef33608e3d98b4ed730d16" + integrity sha512-bxznLnQ1ST6APN/cFi7l0FpjbZVchWQjjhj9mAuJBuUqNNCh9uV+UTRhpQF7Q8ycsPp19LHTpVyGhYb0ustuRQ== dependencies: "@babel/runtime" "^7.0.0" - fbjs "^1.0.0" + fbjs "^3.0.0" remark-gfm@^1.0.0: version "1.0.0" @@ -13654,13 +12731,13 @@ remove-trailing-spaces@^1.0.6: integrity sha512-O3vsMYfWighyFbTd8hk8VaSj9UAGENxAtX+//ugIst2RMk5e03h6RoIS+0ylsFxY1gvmPuAY/PO4It+gPEeySA== renderkid@^2.0.4: - version "2.0.4" - resolved "https://registry.yarnpkg.com/renderkid/-/renderkid-2.0.4.tgz#d325e532afb28d3f8796ffee306be8ffd6fc864c" - integrity sha512-K2eXrSOJdq+HuKzlcjOlGoOarUu5SDguDEhE7+Ah4zuOWL40j8A/oHvLlLob9PSTNvVnBd+/q0Er1QfpEuem5g== + version "2.0.5" + resolved "https://registry.yarnpkg.com/renderkid/-/renderkid-2.0.5.tgz#483b1ac59c6601ab30a7a596a5965cabccfdd0a5" + integrity sha512-ccqoLg+HLOHq1vdfYNm4TBeaCDIi1FLt3wGojTDSvdewUv65oTmI3cnT2E4hRjl1gzKZIPK+KZrXzlUYKnR+vQ== dependencies: - css-select "^1.1.0" + css-select "^2.0.2" dom-converter "^0.2" - htmlparser2 "^3.3.0" + htmlparser2 "^3.10.1" lodash "^4.17.20" strip-ansi "^3.0.0" @@ -13674,11 +12751,6 @@ repeat-string@^1.0.0, repeat-string@^1.6.1: resolved "https://registry.yarnpkg.com/repeat-string/-/repeat-string-1.6.1.tgz#8dcae470e1c88abc2d600fff4a776286da75e637" integrity sha1-jcrkcOHIirwtYA//Sndihtp15jc= -replace-ext@1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/replace-ext/-/replace-ext-1.0.0.tgz#de63128373fcbf7c3ccfa4de5a480c45a67958eb" - integrity sha1-3mMSg3P8v3w8z6TeWkgMRaZ5WOs= - replaceall@^0.1.6: version "0.1.6" resolved "https://registry.yarnpkg.com/replaceall/-/replaceall-0.1.6.tgz#81d81ac7aeb72d7f5c4942adf2697a3220688d8e" @@ -13691,7 +12763,7 @@ request-promise-core@1.1.4: dependencies: lodash "^4.17.19" -request-promise-native@^1.0.8: +request-promise-native@^1.0.9: version "1.0.9" resolved "https://registry.yarnpkg.com/request-promise-native/-/request-promise-native-1.0.9.tgz#e407120526a5efdc9a39b28a5679bf47b9d9dc28" integrity sha512-wcW+sIUiWnKgNY0dqCpOZkUbF/I+YPi+f09JZIDa39Ec+q82CpSYniDp+ISgTTbKmnpJWASeJBPZmoxH84wt3g== @@ -13801,7 +12873,7 @@ resolve-url@^0.2.1: resolved "https://registry.yarnpkg.com/resolve-url/-/resolve-url-0.2.1.tgz#2c637fe77c893afd2a663fe21aa9080068e2052a" integrity sha1-LGN/53yJOv0qZj/iGqkIAGjiBSo= -resolve@1.18.1, resolve@^1.10.0, resolve@^1.12.0, resolve@^1.13.1, resolve@^1.14.2, resolve@^1.17.0, resolve@^1.18.1, resolve@^1.3.2, resolve@^1.8.1: +resolve@1.18.1: version "1.18.1" resolved "https://registry.yarnpkg.com/resolve/-/resolve-1.18.1.tgz#018fcb2c5b207d2a6424aee361c5a266da8f4130" integrity sha512-lDfCPaMKfOJXjy0dPayzPdF1phampNWr3qFCjAu+rw/qbQmr5jWH5xN2hwh9QKfw9E5v4hwV7A+jrCmL8yjjqA== @@ -13809,6 +12881,22 @@ resolve@1.18.1, resolve@^1.10.0, resolve@^1.12.0, resolve@^1.13.1, resolve@^1.14 is-core-module "^2.0.0" path-parse "^1.0.6" +resolve@^1.10.0, resolve@^1.12.0, resolve@^1.13.1, resolve@^1.14.2, resolve@^1.17.0, resolve@^1.18.1, resolve@^1.20.0, resolve@^1.3.2, resolve@^1.8.1: + version "1.20.0" + resolved "https://registry.yarnpkg.com/resolve/-/resolve-1.20.0.tgz#629a013fb3f70755d6f0b7935cc1c2c5378b1975" + integrity sha512-wENBPt4ySzg4ybFQW2TT1zMQucPK95HSh/nq2CFTZVOGut2+pQvSsgtda4d26YrYcr067wjbmzOG8byDPBX63A== + dependencies: + is-core-module "^2.2.0" + path-parse "^1.0.6" + +resolve@^2.0.0-next.3: + version "2.0.0-next.3" + resolved "https://registry.yarnpkg.com/resolve/-/resolve-2.0.0-next.3.tgz#d41016293d4a8586a39ca5d9b5f15cbea1f55e46" + integrity sha512-W8LucSynKUIDu9ylraa7ueVZ7hc0uAgJBxVsQSKOXOyle8a93qXhcz+XAXZ8bIq2d6i4Ehddn6Evt+0/UwKk6Q== + dependencies: + is-core-module "^2.2.0" + path-parse "^1.0.6" + responselike@^1.0.2: version "1.0.2" resolved "https://registry.yarnpkg.com/responselike/-/responselike-1.0.2.tgz#918720ef3b631c5642be068f15ade5a46f4ba1e7" @@ -13870,13 +12958,6 @@ rgba-regex@^1.0.0: resolved "https://registry.yarnpkg.com/rgba-regex/-/rgba-regex-1.0.0.tgz#43374e2e2ca0968b0ef1523460b7d730ff22eeb3" integrity sha1-QzdOLiyglosO8VI0YLfXMP8i7rM= -rimraf@2.6.3: - version "2.6.3" - resolved "https://registry.yarnpkg.com/rimraf/-/rimraf-2.6.3.tgz#b2d104fe0d8fb27cf9e0a1cda8262dd3833c6cab" - integrity sha512-mwqeW5XsA2qAejG46gYdENaxXjx9onRNCfn7L0duuP4hCuTIi/QO7PDK07KJfp1d+izWPrzEJDcSqBa0OZQriA== - dependencies: - glob "^7.1.3" - rimraf@^2.5.4, rimraf@^2.6.3: version "2.7.1" resolved "https://registry.yarnpkg.com/rimraf/-/rimraf-2.7.1.tgz#35797f13a7fdadc566142c29d4f07ccad483e3ec" @@ -13945,9 +13026,11 @@ run-async@^2.4.0: integrity sha512-tvVnVv01b8c1RrA6Ep7JkStj85Guv/YrMcwqYQnwjsAS2cTmmPGBBjAjpCW7RrSodNSoE2/qg9O4bceNvUuDgQ== run-parallel@^1.1.9: - version "1.1.10" - resolved "https://registry.yarnpkg.com/run-parallel/-/run-parallel-1.1.10.tgz#60a51b2ae836636c81377df16cb107351bcd13ef" - integrity sha512-zb/1OuZ6flOlH6tQyMPUrE3x3Ulxjlo9WIVXR4yVYi4H9UXQaeIsPbLn2R3O3vQCnDKkAl2qHiuocKKX4Tz/Sw== + version "1.2.0" + resolved "https://registry.yarnpkg.com/run-parallel/-/run-parallel-1.2.0.tgz#66d1368da7bdf921eb9d95bd1a9229e7f21a43ee" + integrity sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA== + dependencies: + queue-microtask "^1.2.2" run-queue@^1.0.0, run-queue@^1.0.3: version "1.0.3" @@ -13957,9 +13040,9 @@ run-queue@^1.0.0, run-queue@^1.0.3: aproba "^1.1.1" rxjs@^6.3.3, rxjs@^6.6.0: - version "6.6.3" - resolved "https://registry.yarnpkg.com/rxjs/-/rxjs-6.6.3.tgz#8ca84635c4daa900c0d3967a6ee7ac60271ee552" - integrity sha512-trsQc+xYYXZ3urjOiJOuCOa5N3jAZ3eiSpQB5hIT8zGlL2QfnHLJ2r7GMkBGuIausdJN1OneaI6gQlsqNHHmZQ== + version "6.6.6" + resolved "https://registry.yarnpkg.com/rxjs/-/rxjs-6.6.6.tgz#14d8417aa5a07c5e633995b525e1e3c0dec03b70" + integrity sha512-/oTwee4N4iWzAMAL9xdGKjkEHmIwupR3oXbQjCKywF1BeFohswF3vZdogbmEF6pZkOsXTzWkrZszrWpQTByYVg== dependencies: tslib "^1.9.0" @@ -13980,7 +13063,7 @@ safe-regex@^1.1.0: dependencies: ret "~0.1.10" -"safer-buffer@>= 2.1.2 < 3", "safer-buffer@>= 2.1.2 < 3.0.0", safer-buffer@^2.0.2, safer-buffer@^2.1.0, safer-buffer@~2.1.0: +"safer-buffer@>= 2.1.2 < 3", safer-buffer@^2.0.2, safer-buffer@^2.1.0, safer-buffer@~2.1.0: version "2.1.2" resolved "https://registry.yarnpkg.com/safer-buffer/-/safer-buffer-2.1.2.tgz#44fa161b0187b9549dd84bb91802f9bd8385cd6a" integrity sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg== @@ -14017,9 +13100,9 @@ sass-loader@^10.0.5: semver "^7.3.2" sass@^1.32.5: - version "1.32.5" - resolved "https://registry.yarnpkg.com/sass/-/sass-1.32.5.tgz#2882d22ad5748c05fa9bff6c3b0ffbc4f4b9e1dc" - integrity sha512-kU1yJ5zUAmPxr7f3q0YXTAd1oZjSR1g3tYyv+xu0HZSl5JiNOaE987eiz7wCUvbm4I9fGWGU2TgApTtcP4GMNQ== + version "1.32.8" + resolved "https://registry.yarnpkg.com/sass/-/sass-1.32.8.tgz#f16a9abd8dc530add8834e506878a2808c037bdc" + integrity sha512-Sl6mIeGpzjIUZqvKnKETfMf0iDAswD9TNlv13A7aAF3XZlRPMq4VvJWBC2N2DXbp94MQVdNSFG6LfF/iOXrPHQ== dependencies: chokidar ">=2.0.0 <4.0.0" @@ -14028,7 +13111,7 @@ sax@>=0.6.0, sax@~1.2.4: resolved "https://registry.yarnpkg.com/sax/-/sax-1.2.4.tgz#2816234e2378bddc4e5354fab5caa895df7100d9" integrity sha512-NqVDv9TpANUjFm0N8uM5GxL36UgKi9/atZw+x7YFnQ8ckwFGKrl4xX4yWtrey3UJm5nP1kUbnYgLopqWNSRhWw== -saxes@^5.0.0: +saxes@^5.0.1: version "5.0.1" resolved "https://registry.yarnpkg.com/saxes/-/saxes-5.0.1.tgz#eebab953fa3b7608dbe94e5dadb15c888fa6696d" integrity sha512-5LBh1Tls8c9xgGjw3QrMwETmTMVk0oFgvrFSvWx62llR2hcEInrKNZ2GZCCuuy2lvWrdl5jhbpeqc5hRYKFOcw== @@ -14036,9 +13119,9 @@ saxes@^5.0.0: xmlchars "^2.2.0" scheduler@^0.20.1: - version "0.20.1" - resolved "https://registry.yarnpkg.com/scheduler/-/scheduler-0.20.1.tgz#da0b907e24026b01181ecbc75efdc7f27b5a000c" - integrity sha512-LKTe+2xNJBNxu/QhHvDR14wUXHRQbVY5ZOYpOGWRzhydZUqrLb2JBvLPY7cAqFmqrWuDED0Mjk7013SZiOz6Bw== + version "0.20.2" + resolved "https://registry.yarnpkg.com/scheduler/-/scheduler-0.20.2.tgz#4baee39436e34aa93b4874bddcbf0fe8b8b50e91" + integrity sha512-2eWfGgAqqWFGqtdMmcL5zCMK1U8KlXv8SQFGglL3CEtd0aDVDWgeF/YoCmvln55m5zSk3J/20hTaSBeSObsQDQ== dependencies: loose-envify "^1.1.0" object-assign "^4.1.1" @@ -14097,7 +13180,7 @@ semver@7.0.0: resolved "https://registry.yarnpkg.com/semver/-/semver-7.0.0.tgz#5f3ca35761e47e05b206c6daff2cf814f0316b8e" integrity sha512-+GB6zVA9LWh6zovYQLALHwv5rb2PHGlJi3lfiqIHxR0uuwCgefcOJc59v9fv1w8GbStwxuuqqAjI9NMAOOgq1A== -semver@7.3.2, semver@^7.2.1, semver@^7.3.2: +semver@7.3.2: version "7.3.2" resolved "https://registry.yarnpkg.com/semver/-/semver-7.3.2.tgz#604962b052b81ed0786aae84389ffba70ffd3938" integrity sha512-OrOb32TeeambH6UrhtShmF7CRDqhL6/5XpPNp2DuRH6+9QLw/orhp72j87v8Qa1ScDkvrrBNpZcDejAirJmfXQ== @@ -14107,6 +13190,13 @@ semver@^6.0.0, semver@^6.1.1, semver@^6.1.2, semver@^6.2.0, semver@^6.3.0: resolved "https://registry.yarnpkg.com/semver/-/semver-6.3.0.tgz#ee0a64c8af5e8ceea67687b133761e1becbd1d3d" integrity sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw== +semver@^7.2.1, semver@^7.3.2, semver@^7.3.4: + version "7.3.5" + resolved "https://registry.yarnpkg.com/semver/-/semver-7.3.5.tgz#0b621c879348d8998e4b0e4be94b3f12e6018ef7" + integrity sha512-PoeGJYh8HK4BTO/a9Tf6ZG3veo/A7ZVsYrSA6J8ny9nb3B1VrpkuN+z9OE5wfE5p6H4LchYZsegiQgbJD94ZFQ== + dependencies: + lru-cache "^6.0.0" + send@0.17.1: version "0.17.1" resolved "https://registry.yarnpkg.com/send/-/send-0.17.1.tgz#c1d8b059f7900f7466dd4938bdc44e11ddb376c8" @@ -14126,6 +13216,15 @@ send@0.17.1: range-parser "~1.2.1" statuses "~1.5.0" +sentence-case@^3.0.4: + version "3.0.4" + resolved "https://registry.yarnpkg.com/sentence-case/-/sentence-case-3.0.4.tgz#3645a7b8c117c787fde8702056225bb62a45131f" + integrity sha512-8LS0JInaQMCRoQ7YUytAo/xUu5W2XnQxV2HI/6uM6U7CITS1RqPElr30V6uIqyMKM9lJGRVFy5/4CuzcixNYSg== + dependencies: + no-case "^3.0.4" + tslib "^2.0.3" + upper-case-first "^2.0.2" + serialize-javascript@^4.0.0: version "4.0.0" resolved "https://registry.yarnpkg.com/serialize-javascript/-/serialize-javascript-4.0.0.tgz#b525e1238489a5ecfc42afacc3fe99e666f4b1aa" @@ -14193,6 +13292,11 @@ setprototypeof@1.1.1: resolved "https://registry.yarnpkg.com/setprototypeof/-/setprototypeof-1.1.1.tgz#7e95acb24aa92f5885e0abef5ba131330d4ae683" integrity sha512-JvdAWfbXeIGaZ9cILp38HntZSFSo3mWg6xGcJJsd+d4aRMOqauag1C63dJfDw7OaMYwEbHMOxEZ1lqVRYP2OAw== +setprototypeof@1.2.0: + version "1.2.0" + resolved "https://registry.yarnpkg.com/setprototypeof/-/setprototypeof-1.2.0.tgz#66c9a24a73f9fc28cbe66b09fed3d33dcaf1b424" + integrity sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw== + sha.js@^2.4.0, sha.js@^2.4.8: version "2.4.11" resolved "https://registry.yarnpkg.com/sha.js/-/sha.js-2.4.11.tgz#37a5cf0b81ecbc6943de109ba2960d1b26584ae7" @@ -14201,11 +13305,6 @@ sha.js@^2.4.0, sha.js@^2.4.8: inherits "^2.0.1" safe-buffer "^5.0.1" -shallow-equal@^1.2.1: - version "1.2.1" - resolved "https://registry.yarnpkg.com/shallow-equal/-/shallow-equal-1.2.1.tgz#4c16abfa56043aa20d050324efa68940b0da79da" - integrity sha512-S4vJDjHHMBaiZuT9NPb616CSmLf618jawtv3sufLl6ivK8WocjAo58cXwbRV1cgqxH0Qbv+iUt6m05eqEa2IRA== - shebang-command@^1.2.0: version "1.2.0" resolved "https://registry.yarnpkg.com/shebang-command/-/shebang-command-1.2.0.tgz#44aac65b695b03398968c39f363fee5deafdf1ea" @@ -14240,13 +13339,14 @@ shellwords@^0.1.1: resolved "https://registry.yarnpkg.com/shellwords/-/shellwords-0.1.1.tgz#d6b9181c1a48d397324c84871efbcfc73fc0654b" integrity sha512-vFwSUfQvqybiICwZY5+DAWIPLKsWO31Q91JSKl3UYv+K5c2QRPzn0qzec6QPu1Qc9eHYItiP3NdJqNVqetYAww== -side-channel@^1.0.2: - version "1.0.3" - resolved "https://registry.yarnpkg.com/side-channel/-/side-channel-1.0.3.tgz#cdc46b057550bbab63706210838df5d4c19519c3" - integrity sha512-A6+ByhlLkksFoUepsGxfj5x1gTSrs+OydsRptUxeNCabQpCFUvcwIczgOigI8vhY/OJCnPnyE9rGiwgvr9cS1g== +side-channel@^1.0.4: + version "1.0.4" + resolved "https://registry.yarnpkg.com/side-channel/-/side-channel-1.0.4.tgz#efce5c8fdc104ee751b25c58d4290011fa5ea2cf" + integrity sha512-q5XPytqFEIKHkGdiMIrY10mvLRvnQh42/+GoBlFW3b2LXLE2xxJpZFdm94we0BaoV3RwJyGqg5wS7epxTv0Zvw== dependencies: - es-abstract "^1.18.0-next.0" - object-inspect "^1.8.0" + call-bind "^1.0.0" + get-intrinsic "^1.0.2" + object-inspect "^1.9.0" signal-exit@^3.0.0, signal-exit@^3.0.2: version "3.0.3" @@ -14280,15 +13380,6 @@ slice-ansi@0.0.4: resolved "https://registry.yarnpkg.com/slice-ansi/-/slice-ansi-0.0.4.tgz#edbf8903f66f7ce2f8eafd6ceed65e264c831b35" integrity sha1-7b+JA/ZvfOL46v1s7tZeJkyDGzU= -slice-ansi@^2.1.0: - version "2.1.0" - resolved "https://registry.yarnpkg.com/slice-ansi/-/slice-ansi-2.1.0.tgz#cacd7693461a637a5788d92a7dd4fba068e81636" - integrity sha512-Qu+VC3EwYLldKa1fCxuuvULvSJOKEgk9pi8dZeCVK7TqBfUNTH4sFkk4joj8afVSfAYgJoSOetjx9QWOJ5mYoQ== - dependencies: - ansi-styles "^3.2.0" - astral-regex "^1.0.0" - is-fullwidth-code-point "^2.0.0" - slice-ansi@^4.0.0: version "4.0.0" resolved "https://registry.yarnpkg.com/slice-ansi/-/slice-ansi-4.0.0.tgz#500e8dd0fd55b05815086255b3195adf2a45fe6b" @@ -14298,6 +13389,14 @@ slice-ansi@^4.0.0: astral-regex "^2.0.0" is-fullwidth-code-point "^3.0.0" +snake-case@^3.0.4: + version "3.0.4" + resolved "https://registry.yarnpkg.com/snake-case/-/snake-case-3.0.4.tgz#4f2bbd568e9935abdfd593f34c691dadb49c452c" + integrity sha512-LAOh4z89bGQvl9pFfNF8V146i7o7/CqFPbqzYgP+yYzDIDeS9HaNFtXABamRW+AQzEVODcvE79ljJ+8a9YSdMg== + dependencies: + dot-case "^3.0.4" + tslib "^2.0.3" + snapdragon-node@^2.0.1: version "2.1.1" resolved "https://registry.yarnpkg.com/snapdragon-node/-/snapdragon-node-2.1.1.tgz#6c175f86ff14bdb0724563e8f3c1b021a286853b" @@ -14357,9 +13456,9 @@ sort-keys@^1.0.0: is-plain-obj "^1.0.0" sort-keys@^4.0.0: - version "4.1.0" - resolved "https://registry.yarnpkg.com/sort-keys/-/sort-keys-4.1.0.tgz#727edc12fee49ce482848db07369ec44e0f3e9f2" - integrity sha512-/sRdxzkkPFUYiCrTr/2t+104nDc9AgDmEpeVYuvOWYQe3Djk1GWO6lVw3Vx2jfh1SsR0eehhd1nvFYlzt5e99w== + version "4.2.0" + resolved "https://registry.yarnpkg.com/sort-keys/-/sort-keys-4.2.0.tgz#6b7638cee42c506fff8c1cecde7376d21315be18" + integrity sha512-aUYIEU/UviqPgc8mHR6IW1EGxkAXpeRETYcrzg8cLAvUPZcpAlleSXHV2mY7G12GphSH6Gzv+4MMVSSkbdteHg== dependencies: is-plain-obj "^2.0.0" @@ -14388,16 +13487,16 @@ source-map-support@^0.5.17, source-map-support@^0.5.6, source-map-support@~0.5.1 source-map "^0.6.0" source-map-url@^0.4.0: - version "0.4.0" - resolved "https://registry.yarnpkg.com/source-map-url/-/source-map-url-0.4.0.tgz#3e935d7ddd73631b97659956d55128e87b5084a3" - integrity sha1-PpNdfd1zYxuXZZlW1VEo6HtQhKM= + version "0.4.1" + resolved "https://registry.yarnpkg.com/source-map-url/-/source-map-url-0.4.1.tgz#0af66605a745a5a2f91cf1bbf8a7afbc283dec56" + integrity sha512-cPiFOTLUKvJFIg4SKVScy4ilPPW6rFgMgfuZJPNoDuMs3nC1HbMUycBoJw77xFIp6z1UJQJOfx6C9GMH80DiTw== source-map@0.6.1, source-map@^0.6.0, source-map@^0.6.1, source-map@~0.6.0, source-map@~0.6.1: version "0.6.1" resolved "https://registry.yarnpkg.com/source-map/-/source-map-0.6.1.tgz#74722af32e9614e9c287a8d0bbde48b5e2f1a263" integrity sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g== -source-map@^0.5.0, source-map@^0.5.6, source-map@^0.5.7: +source-map@^0.5.0, source-map@^0.5.6: version "0.5.7" resolved "https://registry.yarnpkg.com/source-map/-/source-map-0.5.7.tgz#8a039d2d1021d22d1ea14c80d8ea468ba2ef3fcc" integrity sha1-igOdLRAh0i0eoUyA2OpGi6LvP8w= @@ -14434,9 +13533,9 @@ spdx-expression-parse@^3.0.0: spdx-license-ids "^3.0.0" spdx-license-ids@^3.0.0: - version "3.0.6" - resolved "https://registry.yarnpkg.com/spdx-license-ids/-/spdx-license-ids-3.0.6.tgz#c80757383c28abf7296744998cbc106ae8b854ce" - integrity sha512-+orQK83kyMva3WyPf59k1+Y525csj5JejicWut55zeTWANuN17qSiSLUXWtzHeNWORSvT7GLDJ/E/XiIWoXBTw== + version "3.0.7" + resolved "https://registry.yarnpkg.com/spdx-license-ids/-/spdx-license-ids-3.0.7.tgz#e9c18a410e5ed7e12442a549fbd8afa767038d65" + integrity sha512-U+MTEOO0AiDzxwFvoa4JVnMV6mZlJKk2sBLt90s7G0Gd0Mlknc7kxEn3nuDPNZRta7O2uy8oLcZLVT+4sqNZHQ== spdy-transport@^3.0.0: version "3.0.0" @@ -14478,11 +13577,23 @@ split-string@^3.0.1, split-string@^3.0.2: dependencies: extend-shallow "^3.0.0" +sponge-case@^1.0.0: + version "1.0.1" + resolved "https://registry.yarnpkg.com/sponge-case/-/sponge-case-1.0.1.tgz#260833b86453883d974f84854cdb63aecc5aef4c" + integrity sha512-dblb9Et4DAtiZ5YSUZHLl4XhH4uK80GhAZrVXdN4O2P4gQ40Wa5UIOPUHlA/nFd2PLblBZWUioLMMAVrgpoYcA== + dependencies: + tslib "^2.0.3" + sprintf-js@~1.0.2: version "1.0.3" resolved "https://registry.yarnpkg.com/sprintf-js/-/sprintf-js-1.0.3.tgz#04e6926f662895354f3dd015203633b857297e2c" integrity sha1-BOaSb2YolTVPPdAVIDYzuFcpfiw= +sse-z@0.3.0: + version "0.3.0" + resolved "https://registry.yarnpkg.com/sse-z/-/sse-z-0.3.0.tgz#e215db7c303d6c4a4199d80cb63811cc28fa55b9" + integrity sha512-jfcXynl9oAOS9YJ7iqS2JMUEHOlvrRAD+54CENiWnc4xsuVLQVSgmwf7cwOTcBd/uq3XkQKBGojgvEtVXcJ/8w== + sshpk@^1.7.0: version "1.16.1" resolved "https://registry.yarnpkg.com/sshpk/-/sshpk-1.16.1.tgz#fb661c0bef29b39db40769ee39fa70093d6f6877" @@ -14505,10 +13616,10 @@ ssri@^6.0.1: dependencies: figgy-pudding "^3.5.1" -ssri@^8.0.0: - version "8.0.0" - resolved "https://registry.yarnpkg.com/ssri/-/ssri-8.0.0.tgz#79ca74e21f8ceaeddfcb4b90143c458b8d988808" - integrity sha512-aq/pz989nxVYwn16Tsbj1TqFpD5LLrQxHf5zaHuieFV+R0Bbr4y8qUsOA45hXT/N4/9UNXTarBjnjVmjSOVaAA== +ssri@^8.0.1: + version "8.0.1" + resolved "https://registry.yarnpkg.com/ssri/-/ssri-8.0.1.tgz#638e4e439e2ffbd2cd289776d5ca457c4f51a2af" + integrity sha512-97qShzy1AiyxvPNIkLWoGua7xoQzzPjQ0HAH4B0rWKo7SZ6USuPcrUiAFrws0UH8RrbWmgq3LMTObhPIHbbBeQ== dependencies: minipass "^3.1.1" @@ -14518,9 +13629,9 @@ stable@^0.1.8: integrity sha512-ji9qxRnOVfcuLDySj9qzhGSEFVobyt1kIOSkj1qZzYLzq7Tos/oUUWvotUPQLlrsidqsK6tBH89Bc9kL5zHA6w== stack-utils@^2.0.2: - version "2.0.2" - resolved "https://registry.yarnpkg.com/stack-utils/-/stack-utils-2.0.2.tgz#5cf48b4557becb4638d0bc4f21d23f5d19586593" - integrity sha512-0H7QK2ECz3fyZMzQ8rH0j2ykpfbnd20BFtfg/SqVC2+sCTtcw0aDTGB7dk+de4U4uUeuz6nOtJcrkFFLG1B0Rg== + version "2.0.3" + resolved "https://registry.yarnpkg.com/stack-utils/-/stack-utils-2.0.3.tgz#cd5f030126ff116b78ccb3c027fe302713b61277" + integrity sha512-gL//fkxfWUsIlFL2Tl42Cl6+HFALEaB1FU76I/Fy+oZjRreP7OPMXFlGbxM7NQsI0ZpUfw76sHnv0WNYuTb7Iw== dependencies: escape-string-regexp "^2.0.0" @@ -14579,6 +13690,11 @@ stream-shift@^1.0.0: resolved "https://registry.yarnpkg.com/stream-shift/-/stream-shift-1.0.1.tgz#d7088281559ab2778424279b0877da3c392d5a3d" integrity sha512-AiisoFqQ0vbGcZgQPY1cdP2I76glaVA/RauYR4G4thNFgkTqr90yXTo4LYX60Jl+sIlPNHHdGSwo01AvbKUSVQ== +streamsearch@0.1.2: + version "0.1.2" + resolved "https://registry.yarnpkg.com/streamsearch/-/streamsearch-0.1.2.tgz#808b9d0e56fc273d809ba57338e929919a1a9f1a" + integrity sha1-gIudDlb8Jz2Am6VzOOkpkZoanxo= + strict-uri-encode@^1.0.0: version "1.1.0" resolved "https://registry.yarnpkg.com/strict-uri-encode/-/strict-uri-encode-1.1.0.tgz#279b225df1d582b1f54e65addd4352e18faa0713" @@ -14595,9 +13711,9 @@ string-env-interpolation@1.0.1, string-env-interpolation@^1.0.1: integrity sha512-78lwMoCcn0nNu8LszbP1UA7g55OeE4v7rCeWnM5B453rnNr4aq+5it3FEYtZrSEiMvHZOZ9Jlqb0OD0M2VInqg== string-length@^4.0.1: - version "4.0.1" - resolved "https://registry.yarnpkg.com/string-length/-/string-length-4.0.1.tgz#4a973bf31ef77c4edbceadd6af2611996985f8a1" - integrity sha512-PKyXUd0LK0ePjSOnWn34V2uD6acUWev9uy0Ft05k0E8xRW+SKcA0F7eMr7h5xlzfn+4O3N+55rduYyet3Jk+jw== + version "4.0.2" + resolved "https://registry.yarnpkg.com/string-length/-/string-length-4.0.2.tgz#a8a8dc7bd5c1a82b9b3c8b87e125f66871b6e57a" + integrity sha512-+l6rNN5fYHNhZZy41RXsYptCjA2Igmq4EG7kZAYFQI1E1VTXarr6ZPXBg6eq7Y6eK4FEhY6AJlyuFIb/v/S0VQ== dependencies: char-regex "^1.0.2" strip-ansi "^6.0.0" @@ -14633,54 +13749,55 @@ string-width@^3.0.0, string-width@^3.1.0: is-fullwidth-code-point "^2.0.0" strip-ansi "^5.1.0" -string-width@^4.1.0, string-width@^4.2.0: - version "4.2.0" - resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.0.tgz#952182c46cc7b2c313d1596e623992bd163b72b5" - integrity sha512-zUz5JD+tgqtuDjMhwIg5uFVV3dtqZ9yQJlZVfq4I01/K5Paj5UHj7VyrQOJvzawSVlKpObApbfD0Ed6yJc+1eg== +string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.2: + version "4.2.2" + resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.2.tgz#dafd4f9559a7585cfba529c6a0a4f73488ebd4c5" + integrity sha512-XBJbT3N4JhVumXE0eoLU9DCjcaF92KLNqTmFCnG1pf8duUxFGwtP6AD6nkjw9a3IdiRtL3E2w3JDiE/xi3vOeA== dependencies: emoji-regex "^8.0.0" is-fullwidth-code-point "^3.0.0" strip-ansi "^6.0.0" -string.prototype.matchall@^4.0.2: - version "4.0.2" - resolved "https://registry.yarnpkg.com/string.prototype.matchall/-/string.prototype.matchall-4.0.2.tgz#48bb510326fb9fdeb6a33ceaa81a6ea04ef7648e" - integrity sha512-N/jp6O5fMf9os0JU3E72Qhf590RSRZU/ungsL/qJUYVTNv7hTG0P/dbPjxINVN9jpscu3nzYwKESU3P3RY5tOg== +string.prototype.matchall@^4.0.4: + version "4.0.4" + resolved "https://registry.yarnpkg.com/string.prototype.matchall/-/string.prototype.matchall-4.0.4.tgz#608f255e93e072107f5de066f81a2dfb78cf6b29" + integrity sha512-pknFIWVachNcyqRfaQSeu/FUfpvJTe4uskUSZ9Wc1RijsPuzbZ8TyYT8WCNnntCjUEqQ3vUHMAfVj2+wLAisPQ== dependencies: + call-bind "^1.0.2" define-properties "^1.1.3" - es-abstract "^1.17.0" + es-abstract "^1.18.0-next.2" has-symbols "^1.0.1" - internal-slot "^1.0.2" - regexp.prototype.flags "^1.3.0" - side-channel "^1.0.2" + internal-slot "^1.0.3" + regexp.prototype.flags "^1.3.1" + side-channel "^1.0.4" string.prototype.replaceall@^1.0.4: - version "1.0.4" - resolved "https://registry.yarnpkg.com/string.prototype.replaceall/-/string.prototype.replaceall-1.0.4.tgz#34bf32bdaa4d68c5e0289a7e29b3d2c7b72583f0" - integrity sha512-sS2UB5FG8JdWU16mfJdtgCJ+KJtJhuOCdwtD+l1nx3f6N1T/ou4xIq1em4Gh7yV//SqhANolAGrFeAZoIwRQrw== + version "1.0.5" + resolved "https://registry.yarnpkg.com/string.prototype.replaceall/-/string.prototype.replaceall-1.0.5.tgz#3eae8b115c588ece949b14fa2993d86fc8ec87b1" + integrity sha512-YUjdWElI9pgKo7mrPOMKHFZxcAa0v1uqoJkMHtlJW63rMkPLkQH71ao2XNkKY2ksHKHC8ZUFwNjN9Vry+QyCvg== dependencies: - call-bind "^1.0.0" + call-bind "^1.0.2" define-properties "^1.1.3" - es-abstract "^1.18.0-next.1" - get-intrinsic "^1.0.1" + es-abstract "^1.18.0-next.2" + get-intrinsic "^1.1.1" has-symbols "^1.0.1" - is-regex "^1.1.1" + is-regex "^1.1.2" -string.prototype.trimend@^1.0.1: - version "1.0.2" - resolved "https://registry.yarnpkg.com/string.prototype.trimend/-/string.prototype.trimend-1.0.2.tgz#6ddd9a8796bc714b489a3ae22246a208f37bfa46" - integrity sha512-8oAG/hi14Z4nOVP0z6mdiVZ/wqjDtWSLygMigTzAb+7aPEDTleeFf+WrF+alzecxIRkckkJVn+dTlwzJXORATw== +string.prototype.trimend@^1.0.4: + version "1.0.4" + resolved "https://registry.yarnpkg.com/string.prototype.trimend/-/string.prototype.trimend-1.0.4.tgz#e75ae90c2942c63504686c18b287b4a0b1a45f80" + integrity sha512-y9xCjw1P23Awk8EvTpcyL2NIr1j7wJ39f+k6lvRnSMz+mz9CGz9NYPelDk42kOz6+ql8xjfK8oYzy3jAP5QU5A== dependencies: + call-bind "^1.0.2" define-properties "^1.1.3" - es-abstract "^1.18.0-next.1" -string.prototype.trimstart@^1.0.1: - version "1.0.2" - resolved "https://registry.yarnpkg.com/string.prototype.trimstart/-/string.prototype.trimstart-1.0.2.tgz#22d45da81015309cd0cdd79787e8919fc5c613e7" - integrity sha512-7F6CdBTl5zyu30BJFdzSTlSlLPwODC23Od+iLoVH8X6+3fvDPPuBVVj9iaB1GOsSTSIgVfsfm27R2FGrAPznWg== +string.prototype.trimstart@^1.0.4: + version "1.0.4" + resolved "https://registry.yarnpkg.com/string.prototype.trimstart/-/string.prototype.trimstart-1.0.4.tgz#b36399af4ab2999b4c9c648bd7a3fb2bb26feeed" + integrity sha512-jh6e984OBfvxS50tdY2nRZnoC5/mLFKOREQfw8t5yytkoUsJRNxvI/E39qu1sD0OtWI3OC0XgKSmcWwziwYuZw== dependencies: + call-bind "^1.0.2" define-properties "^1.1.3" - es-abstract "^1.18.0-next.1" string_decoder@^1.0.0, string_decoder@^1.1.1: version "1.3.0" @@ -14815,9 +13932,9 @@ stylelint-order@^4.1.0: postcss-sorting "^5.0.1" stylelint@^13.9.0: - version "13.9.0" - resolved "https://registry.yarnpkg.com/stylelint/-/stylelint-13.9.0.tgz#93921ee6e11d4556b9f31131f485dc813b68e32a" - integrity sha512-VVWH2oixOAxpWL1vH+V42ReCzBjW2AeqskSAbi8+3OjV1Xg3VZkmTcAqBZfRRvJeF4BvYuDLXebW3tIHxgZDEg== + version "13.12.0" + resolved "https://registry.yarnpkg.com/stylelint/-/stylelint-13.12.0.tgz#cceb922be0d0c7b7b6926271eea2b90cb924733e" + integrity sha512-P8O1xDy41B7O7iXaSlW+UuFbE5+ZWQDb61ndGDxKIt36fMH50DtlQTbwLpFLf8DikceTAb3r6nPrRv30wBlzXw== dependencies: "@stylelint/postcss-css-in-js" "^0.37.2" "@stylelint/postcss-markdown" "^0.36.2" @@ -14829,7 +13946,7 @@ stylelint@^13.9.0: execall "^2.0.0" fast-glob "^3.2.5" fastest-levenshtein "^1.0.12" - file-entry-cache "^6.0.0" + file-entry-cache "^6.0.1" get-stdin "^8.0.0" global-modules "^2.0.0" globby "^11.0.2" @@ -14838,8 +13955,8 @@ stylelint@^13.9.0: ignore "^5.1.8" import-lazy "^4.0.0" imurmurhash "^0.1.4" - known-css-properties "^0.20.0" - lodash "^4.17.20" + known-css-properties "^0.21.0" + lodash "^4.17.21" log-symbols "^4.0.0" mathml-tag-names "^2.1.3" meow "^9.0.0" @@ -14859,7 +13976,7 @@ stylelint@^13.9.0: resolve-from "^5.0.0" slash "^3.0.0" specificity "^0.4.1" - string-width "^4.2.0" + string-width "^4.2.2" strip-ansi "^6.0.0" style-search "^0.1.0" sugarss "^2.0.0" @@ -14869,11 +13986,11 @@ stylelint@^13.9.0: write-file-atomic "^3.0.3" stylis@^4.0.3: - version "4.0.6" - resolved "https://registry.yarnpkg.com/stylis/-/stylis-4.0.6.tgz#0d8b97b6bc4748bea46f68602b6df27641b3c548" - integrity sha512-1igcUEmYFBEO14uQHAJhCUelTR5jPztfdVKrYxRnDa5D5Dn3w0NxXupJNPr/VV/yRfZYEAco8sTIRZzH3sRYKg== + version "4.0.8" + resolved "https://registry.yarnpkg.com/stylis/-/stylis-4.0.8.tgz#b03cc47dcedcd2dbac93d8224e687c43ceda4e20" + integrity sha512-WCHD2YHu2gp4GN9M8TqD7DZljL/UC5mIFaKyYJRuRyPdnqkTqzTnxCIQ1Z3VgQvz1aPcua5bSS2h0HrcbDUdBg== -subscriptions-transport-ws@0.9.18, subscriptions-transport-ws@^0.9.18: +subscriptions-transport-ws@^0.9.18: version "0.9.18" resolved "https://registry.yarnpkg.com/subscriptions-transport-ws/-/subscriptions-transport-ws-0.9.18.tgz#bcf02320c911fbadb054f7f928e51c6041a37b97" integrity sha512-tztzcBTNoEbuErsVQpTN2xUNN/efAZXyCyL5m3x4t6SKrEiTL2N8SaKWBFWM4u56pL79ULif3zjyeq+oV+nOaA== @@ -14954,6 +14071,13 @@ svgo@^1.0.0, svgo@^1.2.2: unquote "~1.1.1" util.promisify "~1.0.0" +swap-case@^2.0.1: + version "2.0.2" + resolved "https://registry.yarnpkg.com/swap-case/-/swap-case-2.0.2.tgz#671aedb3c9c137e2985ef51c51f9e98445bf70d9" + integrity sha512-kc6S2YS/2yXbtkSMunBtKdah4VFETZ8Oh6ONSmSd9bRxhqTrtARUCBUiWXH3xVPpvR7tz2CSnkuXVE42EcGnMw== + dependencies: + tslib "^2.0.3" + symbol-observable@^1.0.4, symbol-observable@^1.1.0: version "1.2.0" resolved "https://registry.yarnpkg.com/symbol-observable/-/symbol-observable-1.2.0.tgz#c22688aed4eab3cdc2dfeacbb561660560a00804" @@ -14969,15 +14093,13 @@ symbol-tree@^3.2.4: resolved "https://registry.yarnpkg.com/symbol-tree/-/symbol-tree-3.2.4.tgz#430637d248ba77e078883951fb9aa0eed7c63fa2" integrity sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw== -table@^5.2.3: - version "5.4.6" - resolved "https://registry.yarnpkg.com/table/-/table-5.4.6.tgz#1292d19500ce3f86053b05f0e8e7e4a3bb21079e" - integrity sha512-wmEc8m4fjnob4gt5riFRtTu/6+4rSe12TpAELNSqHMfF3IqnA+CH37USM6/YR3qRZv7e56kAEAtd6nKZaxe0Ug== +sync-fetch@0.3.0: + version "0.3.0" + resolved "https://registry.yarnpkg.com/sync-fetch/-/sync-fetch-0.3.0.tgz#77246da949389310ad978ab26790bb05f88d1335" + integrity sha512-dJp4qg+x4JwSEW1HibAuMi0IIrBI3wuQr2GimmqB7OXR50wmwzfdusG+p39R9w3R6aFtZ2mzvxvWKQ3Bd/vx3g== dependencies: - ajv "^6.10.2" - lodash "^4.17.14" - slice-ansi "^2.1.0" - string-width "^3.0.0" + buffer "^5.7.0" + node-fetch "^2.6.1" table@^6.0.4, table@^6.0.7: version "6.0.7" @@ -14995,9 +14117,9 @@ tapable@^1.0.0, tapable@^1.1.3: integrity sha512-4WK/bYZmj8xLr+HUCODHGF1ZFzsYffasLUgEiMBY4fgtltdO6B4WJtlSbPaDTLpYTcGVwM2qLnFTICEcNxs3kA== tar@^6.0.2: - version "6.0.5" - resolved "https://registry.yarnpkg.com/tar/-/tar-6.0.5.tgz#bde815086e10b39f1dcd298e89d596e1535e200f" - integrity sha512-0b4HOimQHj9nXNEAA7zWwMM91Zhhba3pspja6sQbgTpynOJf+bkjBnfybNYzbpLbnwXnbyB4LOREvlyXLkCHSg== + version "6.1.0" + resolved "https://registry.yarnpkg.com/tar/-/tar-6.1.0.tgz#d1724e9bcc04b977b18d5c573b333a2207229a83" + integrity sha512-DUCttfhsnLCjwoDoFcI+B2iJgYa93vBnDUATYEeRx6sntCTdN01VnqsIuTlALXla/LWooNg0yEGeB+Y8WdFxGA== dependencies: chownr "^2.0.0" fs-minipass "^2.0.0" @@ -15068,9 +14190,9 @@ terser@^4.1.2, terser@^4.6.2, terser@^4.6.3: source-map-support "~0.5.12" terser@^5.3.4: - version "5.3.8" - resolved "https://registry.yarnpkg.com/terser/-/terser-5.3.8.tgz#991ae8ba21a3d990579b54aa9af11586197a75dd" - integrity sha512-zVotuHoIfnYjtlurOouTazciEfL7V38QMAOhGqpXDEg6yT13cF4+fEP9b0rrCEQTn+tT46uxgFsTZzhygk+CzQ== + version "5.6.1" + resolved "https://registry.yarnpkg.com/terser/-/terser-5.6.1.tgz#a48eeac5300c0a09b36854bf90d9c26fb201973c" + integrity sha512-yv9YLFQQ+3ZqgWCUk+pvNJwgUTdlIxUk1WTN+RnaFJe2L7ipG2csPT0ra2XRm7Cs8cxN7QXmK1rFzEwYEQkzXw== dependencies: commander "^2.20.0" source-map "~0.7.2" @@ -15145,6 +14267,13 @@ tinycolor2@^1.4.1: resolved "https://registry.yarnpkg.com/tinycolor2/-/tinycolor2-1.4.2.tgz#3f6a4d1071ad07676d7fa472e1fac40a719d8803" integrity sha512-vJhccZPs965sV/L2sU4oRQVAos0pQXwsvTLkWYdqJ+a8Q5kPFzJTuOFwy7UniPli44NKQGAglksjvOcpo95aZA== +title-case@^3.0.2: + version "3.0.3" + resolved "https://registry.yarnpkg.com/title-case/-/title-case-3.0.3.tgz#bc689b46f02e411f1d1e1d081f7c3deca0489982" + integrity sha512-e1zGYRvbffpcHIrnuqT0Dh+gEJtDaxDSoG4JAIpq4oDFyooziLBIiYQv0GBT4FUAnUop5uZ1hiIAj7oAF6sOCA== + dependencies: + tslib "^2.0.3" + tmp@^0.0.33: version "0.0.33" resolved "https://registry.yarnpkg.com/tmp/-/tmp-0.0.33.tgz#6d34335889768d21b2bcda0aa277ced3b1bfadf9" @@ -15222,14 +14351,14 @@ tough-cookie@^2.3.3, tough-cookie@~2.5.0: psl "^1.1.28" punycode "^2.1.1" -tough-cookie@^3.0.1: - version "3.0.1" - resolved "https://registry.yarnpkg.com/tough-cookie/-/tough-cookie-3.0.1.tgz#9df4f57e739c26930a018184887f4adb7dca73b2" - integrity sha512-yQyJ0u4pZsv9D4clxO69OEjLWYw+jbgspjTue4lTQZLfV0c5l1VmK2y1JK8E9ahdpltPOaAThPcp5nKPUgSnsg== +tough-cookie@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/tough-cookie/-/tough-cookie-4.0.0.tgz#d822234eeca882f991f0f908824ad2622ddbece4" + integrity sha512-tHdtEpQCMrc1YLrMaqXXcj6AxhYi/xgit6mZu1+EDWUn+qhUf8wMQoFIy9NXuq23zAwtcB0t/MjACGR18pcRbg== dependencies: - ip-regex "^2.1.0" - psl "^1.1.28" + psl "^1.1.33" punycode "^2.1.1" + universalify "^0.1.2" tr46@^2.0.2: version "2.0.2" @@ -15253,17 +14382,10 @@ tryer@^1.0.1: resolved "https://registry.yarnpkg.com/tryer/-/tryer-1.0.1.tgz#f2c85406800b9b0f74c9f7465b81eaad241252f8" integrity sha512-c3zayb8/kWWpycWYg87P71E1S1ZL6b6IJxfb5fvsUgsf0S2MVGaDhDXXjDMpdCpfWXqptc+4mXwmiy1ypXqRAA== -ts-invariant@^0.4.4: - version "0.4.4" - resolved "https://registry.yarnpkg.com/ts-invariant/-/ts-invariant-0.4.4.tgz#97a523518688f93aafad01b0e80eb803eb2abd86" - integrity sha512-uEtWkFM/sdZvRNNDL3Ehu4WVpwaulhwQszV8mrtcdeE8nN00BV9mAmQ88RkrBhFgl9gMgvjJLAQcZbnPXI9mlA== - dependencies: - tslib "^1.9.3" - -ts-invariant@^0.6.0: - version "0.6.0" - resolved "https://registry.yarnpkg.com/ts-invariant/-/ts-invariant-0.6.0.tgz#44066ecfeb7a806ff1c3b0b283408a337a885412" - integrity sha512-caoafsfgb8QxdrKzFfjKt627m4i8KTtfAiji0DYJfWI4A/S9ORNNpzYuD9br64kyKFgxn9UNaLLbSupam84mCA== +ts-invariant@^0.6.2: + version "0.6.2" + resolved "https://registry.yarnpkg.com/ts-invariant/-/ts-invariant-0.6.2.tgz#2b95c0f25dd9da0c1d3b921e23ee5593133694d4" + integrity sha512-hsVurayufl1gXg8CHtgZkB7X0KtA3TrI3xcJ9xkRr8FeJHnM/TIEQkgBq9XkpduyBWWUdlRIR9xWf4Lxq3LJTg== dependencies: "@types/ungap__global-this" "^0.3.1" "@ungap/global-this" "^0.4.2" @@ -15306,20 +14428,20 @@ tslib@^1.10.0, tslib@^1.14.1, tslib@^1.8.1, tslib@^1.9.0, tslib@^1.9.3: resolved "https://registry.yarnpkg.com/tslib/-/tslib-1.14.1.tgz#cf2d38bdc34a134bcaf1091c41f6619e2f672d00" integrity sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg== -tslib@^2, tslib@^2.0.3, tslib@~2.1.0: +tslib@^2, tslib@^2.0.0, tslib@^2.0.1, tslib@^2.0.3, tslib@^2.1.0, tslib@~2.1.0: version "2.1.0" resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.1.0.tgz#da60860f1c2ecaa5703ab7d39bc05b6bf988b97a" integrity sha512-hcVC3wYEziELGGmEEXue7D75zbwIIVUMWAVbHItGPx0ziyXxrOMQx4rQEVEV45Ut/1IotuEvwqPopzIOkDMf0A== -tslib@^2.0.0, tslib@^2.0.1, tslib@~2.0.1: +tslib@~2.0.1: version "2.0.3" resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.0.3.tgz#8e0741ac45fc0c226e58a17bfc3e64b9bc6ca61c" integrity sha512-uZtkfKblCEQtZKBF6EBXVZeQNl82yqtDQdv+eck8u7tdPxjLu2/lp5/uPW+um2tpuxINHWy3GhiccY7QgEaVHQ== tsutils@^3.17.1: - version "3.17.1" - resolved "https://registry.yarnpkg.com/tsutils/-/tsutils-3.17.1.tgz#ed719917f11ca0dee586272b2ac49e015a2dd759" - integrity sha512-kzeQ5B8H3w60nFY2g8cJIuH7JDpsALXySGtwGJ0p2LSjLgay3NdIpqq5SoOBe46bKDW2iq25irHCr8wjomUS2g== + version "3.21.0" + resolved "https://registry.yarnpkg.com/tsutils/-/tsutils-3.21.0.tgz#b48717d394cea6c1e096983eed58e9d61715b623" + integrity sha512-mHKK3iUXL+3UF6xL5k0PEhKRUBKPBCv/+RkEOpjRWxxx27KKRBmmA60A9pgOUvMi8GKhRMPEmjBRPzs2W7O1OA== dependencies: tslib "^1.8.1" @@ -15374,6 +14496,11 @@ type-fest@^0.18.0: resolved "https://registry.yarnpkg.com/type-fest/-/type-fest-0.18.1.tgz#db4bc151a4a2cf4eebf9add5db75508db6cc841f" integrity sha512-OIAYXk8+ISY+qTOwkHtKqzAuxchoMiD9Udx+FSGQDuiRR+PJKJHc2NJAXlbhkGwTt/4/nKZxELY1w3ReWOL8mw== +type-fest@^0.20.2: + version "0.20.2" + resolved "https://registry.yarnpkg.com/type-fest/-/type-fest-0.20.2.tgz#1bf207f4b28f91583666cb5fbd327887301cd5f4" + integrity sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ== + type-fest@^0.3.1: version "0.3.1" resolved "https://registry.yarnpkg.com/type-fest/-/type-fest-0.3.1.tgz#63d00d204e059474fe5e1b7c011112bbd1dc29e1" @@ -15403,9 +14530,9 @@ type@^1.0.1: integrity sha512-+5nt5AAniqsCnu2cEQQdpzCAh33kVx8n0VoFidKpB1dVVLAN/F+bgVOqOJqOnEnrhp222clB5p3vUlD+1QAnfg== type@^2.0.0: - version "2.1.0" - resolved "https://registry.yarnpkg.com/type/-/type-2.1.0.tgz#9bdc22c648cf8cf86dd23d32336a41cfb6475e3f" - integrity sha512-G9absDWvhAWCV2gmF1zKud3OyC61nZDwWvBL2DApaVFogI07CprggiQAOOjvp2NRjYWFzPyu7vwtDrQFq8jeSA== + version "2.5.0" + resolved "https://registry.yarnpkg.com/type/-/type-2.5.0.tgz#0a2e78c2e77907b252abe5f298c1b01c63f0db3d" + integrity sha512-180WMDQaIMm3+7hGXWf12GtdniDEy7nYcyFMKJn/eZz/6tSLXrUN9V0wKSbMjej0I1WHWbpREDEKHtqPQa9NNw== typedarray-to-buffer@^3.1.5: version "3.1.5" @@ -15419,15 +14546,30 @@ typedarray@^0.0.6: resolved "https://registry.yarnpkg.com/typedarray/-/typedarray-0.0.6.tgz#867ac74e3864187b1d3d47d996a78ec5c8830777" integrity sha1-hnrHTjhkGHsdPUfZlqeOxciDB3c= -typescript@^4.0, typescript@~4.0.5: - version "4.0.5" - resolved "https://registry.yarnpkg.com/typescript/-/typescript-4.0.5.tgz#ae9dddfd1069f1cb5beb3ef3b2170dd7c1332389" - integrity sha512-ywmr/VrTVCmNTJ6iV2LwIrfG1P+lv6luD8sUJs+2eI9NLGigaN+nUQc13iHqisq7bra9lnmUSYqbJvegraBOPQ== +typescript@^4.0: + version "4.2.3" + resolved "https://registry.yarnpkg.com/typescript/-/typescript-4.2.3.tgz#39062d8019912d43726298f09493d598048c1ce3" + integrity sha512-qOcYwxaByStAWrBf4x0fibwZvMRG+r4cQoTjbPtUlrWjBHbmCAww1i448U0GJ+3cNNEtebDteo/cHOR3xJ4wEw== + +typescript@~4.0.5: + version "4.0.7" + resolved "https://registry.yarnpkg.com/typescript/-/typescript-4.0.7.tgz#7168032c43d2a2671c95c07812f69523c79590af" + integrity sha512-yi7M4y74SWvYbnazbn8/bmJmX4Zlej39ZOqwG/8dut/MYoSQ119GY9ZFbbGsD4PFZYWxqik/XsP3vk3+W5H3og== ua-parser-js@^0.7.18: - version "0.7.22" - resolved "https://registry.yarnpkg.com/ua-parser-js/-/ua-parser-js-0.7.22.tgz#960df60a5f911ea8f1c818f3747b99c6e177eae3" - integrity sha512-YUxzMjJ5T71w6a8WWVcMGM6YWOTX27rCoIQgLXiWaxqXSx9D7DNjiGWn1aJIRSQ5qr0xuhra77bSIh6voR/46Q== + version "0.7.24" + resolved "https://registry.yarnpkg.com/ua-parser-js/-/ua-parser-js-0.7.24.tgz#8d3ecea46ed4f1f1d63ec25f17d8568105dc027c" + integrity sha512-yo+miGzQx5gakzVK3QFfN0/L9uVhosXBBO7qmnk7c2iw1IhL212wfA3zbnI54B0obGwC/5NWub/iT9sReMx+Fw== + +unbox-primitive@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/unbox-primitive/-/unbox-primitive-1.0.0.tgz#eeacbc4affa28e9b3d36b5eaeccc50b3251b1d3f" + integrity sha512-P/51NX+JXyxK/aigg1/ZgyccdAxm5K1+n8+tvqSntjOivPt19gvm1VC49RWYetsiub8WViUchdxl/KWHHB0kzA== + dependencies: + function-bind "^1.1.1" + has-bigints "^1.0.0" + has-symbols "^1.0.0" + which-boxed-primitive "^1.0.1" unc-path-regex@^0.1.2: version "0.1.2" @@ -15435,12 +14577,12 @@ unc-path-regex@^0.1.2: integrity sha1-5z3T17DXxe2G+6xrCufYxqadUPo= uncontrollable@^7.0.0: - version "7.1.1" - resolved "https://registry.yarnpkg.com/uncontrollable/-/uncontrollable-7.1.1.tgz#f67fed3ef93637126571809746323a9db815d556" - integrity sha512-EcPYhot3uWTS3w00R32R2+vS8Vr53tttrvMj/yA1uYRhf8hbTG2GyugGqWDY0qIskxn0uTTojVd6wPYW9ZEf8Q== + version "7.2.1" + resolved "https://registry.yarnpkg.com/uncontrollable/-/uncontrollable-7.2.1.tgz#1fa70ba0c57a14d5f78905d533cf63916dc75738" + integrity sha512-svtcfoTADIB0nT9nltgjujTi7BzVmwjZClOmskKu/E8FW9BXzg9os8OLr4f8Dlnk0rYWJIWr4wv9eKUXiQvQwQ== dependencies: "@babel/runtime" "^7.6.3" - "@types/react" "^16.9.11" + "@types/react" ">=16.9.11" invariant "^2.2.4" react-lifecycles-compat "^3.0.4" @@ -15468,9 +14610,9 @@ unicode-property-aliases-ecmascript@^1.0.4: integrity sha512-PqSoPh/pWetQ2phoj5RLiaqIk4kCNwoV3CI+LfGmWLKI3rE3kl1h59XpX2BjgDrmbxD9ARtQobPGU1SguCYuQg== unified@^9.0.0, unified@^9.1.0: - version "9.2.0" - resolved "https://registry.yarnpkg.com/unified/-/unified-9.2.0.tgz#67a62c627c40589edebbf60f53edfd4d822027f8" - integrity sha512-vx2Z0vY+a3YoTj8+pttM3tiJHCwY5UFbYdiWrwBEbHmK8pvsPj2rtAX2BFfgXen8T39CJWblWRDT4L5WGXtDdg== + version "9.2.1" + resolved "https://registry.yarnpkg.com/unified/-/unified-9.2.1.tgz#ae18d5674c114021bfdbdf73865ca60f410215a3" + integrity sha512-juWjuI8Z4xFg8pJbnEZ41b5xjGUWGHqXALmBZ3FC3WX0PIx1CZBIIJ6mXbYMcf6Yw4Fi0rFUTA1cdz/BglbOhA== dependencies: bail "^1.0.0" extend "^3.0.0" @@ -15528,9 +14670,9 @@ unist-util-find-all-after@^3.0.2: unist-util-is "^4.0.0" unist-util-is@^4.0.0: - version "4.0.3" - resolved "https://registry.yarnpkg.com/unist-util-is/-/unist-util-is-4.0.3.tgz#e8b44db55fc20c43752b3346c116344d45d7c91d" - integrity sha512-bTofCFVx0iQM8Jqb1TBDVRIQW03YkD3p66JOd/aCWuqzlLyUtx1ZAGw/u+Zw+SttKvSVcvTiKYbfrtLoLefykw== + version "4.1.0" + resolved "https://registry.yarnpkg.com/unist-util-is/-/unist-util-is-4.1.0.tgz#976e5f462a7a5de73d94b706bac1b90671b57797" + integrity sha512-ZOQSsnce92GrxSqlnEEseX0gi7GH9zTJZ0p9dtu87WRb/37mMPO2Ilx1s/t9vBHrFhbgweUwb+t7cIn5dxPhZg== unist-util-stringify-position@^2.0.0: version "2.0.3" @@ -15569,16 +14711,11 @@ universal-cookie@^4.0.4: "@types/cookie" "^0.3.3" cookie "^0.4.0" -universalify@^0.1.0: +universalify@^0.1.0, universalify@^0.1.2: version "0.1.2" resolved "https://registry.yarnpkg.com/universalify/-/universalify-0.1.2.tgz#b646f69be3942dabcecc9d6639c80dc105efaa66" integrity sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg== -universalify@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/universalify/-/universalify-1.0.0.tgz#b61a1da173e8435b2fe3c67d29b9adf8594bd16d" - integrity sha512-rb6X1W158d7pRQBg5gkR8uPaSfiids68LTJQYOtEUhoJUWBdaQHsuT/EUduxXYxcrt4r5PJ4fuHW1MHT6p0qug== - universalify@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/universalify/-/universalify-2.0.0.tgz#75a4984efedc4b08975c5aeb73f530d02df25717" @@ -15614,14 +14751,14 @@ upath@^1.1.1, upath@^1.1.2, upath@^1.2.0: resolved "https://registry.yarnpkg.com/upath/-/upath-1.2.0.tgz#8f66dbcd55a883acdae4408af8b035a5044c1894" integrity sha512-aZwGpamFO61g3OlfT7OQCHqhGnW43ieH9WZeP7QxN/G/jS4jfqUkZxoryvJgVPEcrl5NL/ggHsSmLMHuH64Lhg== -upper-case@2.0.1, upper-case@^2.0.1: - version "2.0.1" - resolved "https://registry.yarnpkg.com/upper-case/-/upper-case-2.0.1.tgz#6214d05e235dc817822464ccbae85822b3d8665f" - integrity sha512-laAsbea9SY5osxrv7S99vH9xAaJKrw5Qpdh4ENRLcaxipjKsiaBwiAsxfa8X5mObKNTQPsupSq0J/VIxsSJe3A== +upper-case-first@^2.0.1, upper-case-first@^2.0.2: + version "2.0.2" + resolved "https://registry.yarnpkg.com/upper-case-first/-/upper-case-first-2.0.2.tgz#992c3273f882abd19d1e02894cc147117f844324" + integrity sha512-514ppYHBaKwfJRK/pNC6c/OxfGa0obSnAl106u97Ed0I625Nin96KAjttZF6ZL3e1XLtphxnqrOi9iWgm+u+bg== dependencies: - tslib "^1.10.0" + tslib "^2.0.3" -upper-case@^2.0.2: +upper-case@^2.0.1, upper-case@^2.0.2: version "2.0.2" resolved "https://registry.yarnpkg.com/upper-case/-/upper-case-2.0.2.tgz#d89810823faab1df1549b7d97a76f8662bae6f7a" integrity sha512-KgdgDGJt2TpuwBUIjgG6lzw2GWFRCW9Qkfkiv0DxqHHLYJHmtmdUIKcZd8rHgFSjopVTlw6ggzCm1b8MFQwikg== @@ -15629,9 +14766,9 @@ upper-case@^2.0.2: tslib "^2.0.3" uri-js@^4.2.2: - version "4.4.0" - resolved "https://registry.yarnpkg.com/uri-js/-/uri-js-4.4.0.tgz#aa714261de793e8a82347a7bcc9ce74e86f28602" - integrity sha512-B0yRTzYdUCCn9n+F4+Gh4yIDtMQcaJsmYBDsTSG8g/OejKBodLQ2IHfN3bM7jUsRXndopT7OIXWdYqc1fjmV6g== + version "4.4.1" + resolved "https://registry.yarnpkg.com/uri-js/-/uri-js-4.4.1.tgz#9b1a52595225859e55f669d928f88c6c57f2a77e" + integrity sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg== dependencies: punycode "^2.1.0" @@ -15656,15 +14793,7 @@ url-parse-lax@^3.0.0: dependencies: prepend-http "^2.0.0" -url-parse@^1.4.3: - version "1.4.7" - resolved "https://registry.yarnpkg.com/url-parse/-/url-parse-1.4.7.tgz#a8a83535e8c00a316e403a5db4ac1b9b853ae278" - integrity sha512-d3uaVyzDB9tQoSXFvuSUNFibTd9zxd2bkVrDRvF5TmvWWQwqE4lgYJ5m+x1DbecWkw+LK4RNl2CU1hHuOKPVlg== - dependencies: - querystringify "^2.1.1" - requires-port "^1.0.0" - -url-parse@^1.4.7: +url-parse@^1.4.3, url-parse@^1.4.7: version "1.5.1" resolved "https://registry.yarnpkg.com/url-parse/-/url-parse-1.5.1.tgz#d5fa9890af8a5e1f274a2c98376510f6425f6e3b" integrity sha512-HOfCOUJt7iSYzEx/UqgtwKRMC6EU91NFhsCHMv9oM03VJcVo2Qrp8T8kI9D7amFf1cu+/3CEhgb3rF9zL7k85Q== @@ -15685,13 +14814,6 @@ use@^3.1.0: resolved "https://registry.yarnpkg.com/use/-/use-3.1.1.tgz#d50c8cac79a19fbc20f2911f56eb973f4e10070f" integrity sha512-cwESVXlO3url9YWlFW/TA9cshCEhtu7IKJ/p5soJ/gGpj7vbvFrAY/eIioQ6Dw23KjZhYgiIo8HOs1nQ2vr/oQ== -utf-8-validate@^5.0.2: - version "5.0.3" - resolved "https://registry.yarnpkg.com/utf-8-validate/-/utf-8-validate-5.0.3.tgz#3b64e418ad2ff829809025fdfef595eab2f03a27" - integrity sha512-jtJM6fpGv8C1SoH4PtG22pGto6x+Y8uPprW0tw3//gGFhDDTiuksgradgFN6yRayDP4SyZZa6ZMGHLIa17+M8A== - dependencies: - node-gyp-build "^4.2.0" - utif@^2.0.1: version "2.0.1" resolved "https://registry.yarnpkg.com/utif/-/utif-2.0.1.tgz#9e1582d9bbd20011a6588548ed3266298e711759" @@ -15752,19 +14874,19 @@ uuid@^3.3.2, uuid@^3.4.0: integrity sha512-HjSDRw6gZE5JMggctHBcjVak08+KEVhSIiDzFnT9S9aegmp85S/bReBVTb4QTFaRNptJ9kuYaNhnbNEOkbKb/A== uuid@^8.3.0: - version "8.3.1" - resolved "https://registry.yarnpkg.com/uuid/-/uuid-8.3.1.tgz#2ba2e6ca000da60fce5a196954ab241131e05a31" - integrity sha512-FOmRr+FmWEIG8uhZv6C2bTgEVXsHk08kE7mPlrBbEe+c3r9pjceVPgupIfNIhc4yx55H69OXANrUaSuu9eInKg== + version "8.3.2" + resolved "https://registry.yarnpkg.com/uuid/-/uuid-8.3.2.tgz#80d5b5ced271bb9af6c445f21a1a04c606cefbe2" + integrity sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg== v8-compile-cache@^2.0.3, v8-compile-cache@^2.2.0: - version "2.2.0" - resolved "https://registry.yarnpkg.com/v8-compile-cache/-/v8-compile-cache-2.2.0.tgz#9471efa3ef9128d2f7c6a7ca39c4dd6b5055b132" - integrity sha512-gTpR5XQNKFwOd4clxfnhaqvfqMpqEwr4tOtCyz4MtYZX2JYhfr1JvBFKdS+7K/9rfpZR3VLX+YWBbKoxCgS43Q== + version "2.3.0" + resolved "https://registry.yarnpkg.com/v8-compile-cache/-/v8-compile-cache-2.3.0.tgz#2de19618c66dc247dcfb6f99338035d8245a2cee" + integrity sha512-l8lCEmLcLYZh4nbunNZvQCJc5pv7+RCwa8q/LdUx8u7lsWvPDKmpodJAJNwkAhJC//dFY48KuIEmjtd4RViDrA== -v8-to-istanbul@^6.0.1: - version "6.0.1" - resolved "https://registry.yarnpkg.com/v8-to-istanbul/-/v8-to-istanbul-6.0.1.tgz#7ef0e32faa10f841fe4c1b0f8de96ed067c0be1e" - integrity sha512-PzM1WlqquhBvsV+Gco6WSFeg1AGdD53ccMRkFeyHRE/KRZaVacPOmQYP3EeVgDBtKD2BJ8kgynBQ5OtKiHCH+w== +v8-to-istanbul@^7.0.0: + version "7.1.0" + resolved "https://registry.yarnpkg.com/v8-to-istanbul/-/v8-to-istanbul-7.1.0.tgz#5b95cef45c0f83217ec79f8fc7ee1c8b486aee07" + integrity sha512-uXUVqNUCLa0AH1vuVxzi+MI4RfxEOKt9pBgKwHbgH7st8Kv2P1m+jvWNnektzBh5QShF3ODgKmUFCf38LnVz1g== dependencies: "@types/istanbul-lib-coverage" "^2.0.1" convert-source-map "^1.6.0" @@ -15816,13 +14938,12 @@ vfile-message@^2.0.0: unist-util-stringify-position "^2.0.0" vfile@^4.0.0: - version "4.2.0" - resolved "https://registry.yarnpkg.com/vfile/-/vfile-4.2.0.tgz#26c78ac92eb70816b01d4565e003b7e65a2a0e01" - integrity sha512-a/alcwCvtuc8OX92rqqo7PflxiCgXRFjdyoGVuYV+qbgCb0GgZJRvIgCD4+U/Kl1yhaRsaTwksF88xbPyGsgpw== + version "4.2.1" + resolved "https://registry.yarnpkg.com/vfile/-/vfile-4.2.1.tgz#03f1dce28fc625c625bc6514350fbdb00fa9e624" + integrity sha512-O6AE4OskCG5S1emQ/4gl8zK586RqA3srz3nfK/Viy0UPToBc5Trp9BVFb1u0CjsKrAWwnpr4ifM/KBXPWwJbCA== dependencies: "@types/unist" "^2.0.0" is-buffer "^2.0.0" - replace-ext "1.0.0" unist-util-stringify-position "^2.0.0" vfile-message "^2.0.0" @@ -15831,14 +14952,6 @@ vm-browserify@^1.0.1: resolved "https://registry.yarnpkg.com/vm-browserify/-/vm-browserify-1.1.2.tgz#78641c488b8e6ca91a75f511e7a3b32a86e5dda0" integrity sha512-2ham8XPWTONajOR0ohOKOHXkm3+gaBmGut3SRuu75xLd/RRaY6vqgh8NBYYk7+RW3u5AtzPQZG8F10LHkl0lAQ== -vue-template-compiler@^2.6.12: - version "2.6.12" - resolved "https://registry.yarnpkg.com/vue-template-compiler/-/vue-template-compiler-2.6.12.tgz#947ed7196744c8a5285ebe1233fe960437fcc57e" - integrity sha512-OzzZ52zS41YUbkCBfdXShQTe69j1gQDZ9HIX8miuC9C3rBCk9wIRjLiZZLrmX9V+Ftq/YEyv1JaVr5Y/hNtByg== - dependencies: - de-indent "^1.0.2" - he "^1.1.0" - w3c-hr-time@^1.0.2: version "1.0.2" resolved "https://registry.yarnpkg.com/w3c-hr-time/-/w3c-hr-time-1.0.2.tgz#0a89cdf5cc15822df9c360543676963e0cc308cd" @@ -15867,23 +14980,23 @@ warning@^4.0.0, warning@^4.0.3: dependencies: loose-envify "^1.0.0" -watchpack-chokidar2@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/watchpack-chokidar2/-/watchpack-chokidar2-2.0.0.tgz#9948a1866cbbd6cb824dea13a7ed691f6c8ddff0" - integrity sha512-9TyfOyN/zLUbA288wZ8IsMZ+6cbzvsNyEzSBp6e/zkifi6xxbl8SmQ/CxQq32k8NNqrdVEVUVSEf56L4rQ/ZxA== +watchpack-chokidar2@^2.0.1: + version "2.0.1" + resolved "https://registry.yarnpkg.com/watchpack-chokidar2/-/watchpack-chokidar2-2.0.1.tgz#38500072ee6ece66f3769936950ea1771be1c957" + integrity sha512-nCFfBIPKr5Sh61s4LPpy1Wtfi0HE8isJ3d2Yb5/Ppw2P2B/3eVSEBjKfN0fmHJSK14+31KwMKmcrzs2GM4P0Ww== dependencies: chokidar "^2.1.8" watchpack@^1.7.4: - version "1.7.4" - resolved "https://registry.yarnpkg.com/watchpack/-/watchpack-1.7.4.tgz#6e9da53b3c80bb2d6508188f5b200410866cd30b" - integrity sha512-aWAgTW4MoSJzZPAicljkO1hsi1oKj/RRq/OJQh2PKI2UKL04c2Bs+MBOB+BBABHTXJpf9mCwHN7ANCvYsvY2sg== + version "1.7.5" + resolved "https://registry.yarnpkg.com/watchpack/-/watchpack-1.7.5.tgz#1267e6c55e0b9b5be44c2023aed5437a2c26c453" + integrity sha512-9P3MWk6SrKjHsGkLT2KHXdQ/9SNkyoJbabxnKOoJepsvJjJG8uYTR3yTPxPQvNDI3w4Nz1xnE0TLHK4RIVe/MQ== dependencies: graceful-fs "^4.1.2" neo-async "^2.5.0" optionalDependencies: chokidar "^3.4.1" - watchpack-chokidar2 "^2.0.0" + watchpack-chokidar2 "^2.0.1" wbuf@^1.1.0, wbuf@^1.7.3: version "1.7.3" @@ -15903,9 +15016,9 @@ webidl-conversions@^6.1.0: integrity sha512-qBIvFLGiBpLjfwmYAaHPXsn+ho5xZnGvyGvsarywGNc8VyQJUMHJ8OBKGGrPER0okBeMDaan4mNBlgBROxuI8w== webpack-dev-middleware@^3.7.2: - version "3.7.2" - resolved "https://registry.yarnpkg.com/webpack-dev-middleware/-/webpack-dev-middleware-3.7.2.tgz#0019c3db716e3fa5cecbf64f2ab88a74bab331f3" - integrity sha512-1xC42LxbYoqLNAhV6YzTYacicgMZQTqRd27Sim9wn5hJrX3I5nxYy1SxSd4+gjUFsz1dQFj+yEe6zEVmSkeJjw== + version "3.7.3" + resolved "https://registry.yarnpkg.com/webpack-dev-middleware/-/webpack-dev-middleware-3.7.3.tgz#0639372b143262e2b84ab95d3b91a7597061c2c5" + integrity sha512-djelc/zGiz9nZj/U7PTBi2ViorGJXEWo/3ltkPbDyxCXhhEXkW0ce99falaok4TPj+AsxLiXJR0EBOb0zh9fKQ== dependencies: memory-fs "^0.4.1" mime "^2.4.4" @@ -16021,18 +15134,6 @@ websocket-extensions@>=0.1.1: resolved "https://registry.yarnpkg.com/websocket-extensions/-/websocket-extensions-0.1.4.tgz#7f8473bc839dfd87608adb95d7eb075211578a42" integrity sha512-OqedPIGOfsDlo31UNwYbCFMSaO9m9G/0faIHj5/dZFDMFqPTcx6UwqyOy3COEaEOg/9VsGIpdqn62W5KhoKSpg== -websocket@1.0.32: - version "1.0.32" - resolved "https://registry.yarnpkg.com/websocket/-/websocket-1.0.32.tgz#1f16ddab3a21a2d929dec1687ab21cfdc6d3dbb1" - integrity sha512-i4yhcllSP4wrpoPMU2N0TQ/q0O94LRG/eUQjEAamRltjQ1oT1PFFKOG4i877OlJgCG8rw6LrrowJp+TYCEWF7Q== - dependencies: - bufferutil "^4.0.1" - debug "^2.2.0" - es5-ext "^0.10.50" - typedarray-to-buffer "^3.1.5" - utf-8-validate "^5.0.2" - yaeti "^0.0.6" - whatwg-encoding@^1.0.5: version "1.0.5" resolved "https://registry.yarnpkg.com/whatwg-encoding/-/whatwg-encoding-1.0.5.tgz#5abacf777c32166a51d085d6b4f3e7d27113ddb0" @@ -16040,10 +15141,10 @@ whatwg-encoding@^1.0.5: dependencies: iconv-lite "0.4.24" -whatwg-fetch@>=0.10.0, whatwg-fetch@^3.4.1: - version "3.4.1" - resolved "https://registry.yarnpkg.com/whatwg-fetch/-/whatwg-fetch-3.4.1.tgz#e5f871572d6879663fa5674c8f833f15a8425ab3" - integrity sha512-sofZVzE1wKwO+EYPbWfiwzaKovWiZXf4coEzjGP9b2GBVgQRLQUZ2QcuPpQExGDAW5GItpEm6Tl4OU5mywnAoQ== +whatwg-fetch@^3.4.1: + version "3.6.2" + resolved "https://registry.yarnpkg.com/whatwg-fetch/-/whatwg-fetch-3.6.2.tgz#dced24f37f2624ed0281725d51d0e2e3fe677f8c" + integrity sha512-bJlen0FcuU/0EMLrdbJ7zOnW6ITZLrZMIarMUVmdKtsGvZna8vxKYaexICWPfZ8qwf9fzNq+UEIZrnSaApt6RA== whatwg-mimetype@^2.3.0: version "2.3.0" @@ -16059,6 +15160,17 @@ whatwg-url@^8.0.0: tr46 "^2.0.2" webidl-conversions "^6.1.0" +which-boxed-primitive@^1.0.1: + version "1.0.2" + resolved "https://registry.yarnpkg.com/which-boxed-primitive/-/which-boxed-primitive-1.0.2.tgz#13757bc89b209b049fe5d86430e21cf40a89a8e6" + integrity sha512-bwZdv0AKLpplFY2KZRX6TvyuN7ojjr7lwkg6ml0roIy9YeuSr7JS372qlNW18UQYzgYK9ziGcerWqZOmEn9VNg== + dependencies: + is-bigint "^1.0.1" + is-boolean-object "^1.1.0" + is-number-object "^1.0.4" + is-string "^1.0.5" + is-symbol "^1.0.3" + which-module@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/which-module/-/which-module-2.0.0.tgz#d9ef07dce77b9902b8a3a8fa4b31c3e3f7e6e87a" @@ -16312,12 +15424,10 @@ write-json-file@^4.3.0: sort-keys "^4.0.0" write-file-atomic "^3.0.0" -write@1.0.3: - version "1.0.3" - resolved "https://registry.yarnpkg.com/write/-/write-1.0.3.tgz#0800e14523b923a387e415123c865616aae0f5c3" - integrity sha512-/lg70HAjtkUgWPVZhZcm+T4hkL8Zbtp1nFNOn3lRrxnlv50SRBv7cR7RqR+GMsd3hUXy9hWBo4CHTbFTcOYwig== - dependencies: - mkdirp "^0.5.1" +ws@7.4.4, ws@^7.4.4: + version "7.4.4" + resolved "https://registry.yarnpkg.com/ws/-/ws-7.4.4.tgz#383bc9742cb202292c9077ceab6f6047b17f2d59" + integrity sha512-Qm8k8ojNQIMx7S+Zp8u/uHOx7Qazv3Yv4q68MiWWWOJhiwG5W3x7iqmRtJo8xxrciZUY4vRxUTJCKuRnF28ZZw== ws@^5.2.0: version "5.2.2" @@ -16333,17 +15443,12 @@ ws@^6.2.1: dependencies: async-limiter "~1.0.0" -ws@^7.2.3: - version "7.3.1" - resolved "https://registry.yarnpkg.com/ws/-/ws-7.3.1.tgz#d0547bf67f7ce4f12a72dfe31262c68d7dc551c8" - integrity sha512-D3RuNkynyHmEJIpD2qrgVkc9DQ23OrN/moAwZX4L8DfvszsJxpjQuUq3LMx6HoYji9fbIOBY18XWBsAux1ZZUA== - xhr@^2.0.1: - version "2.5.0" - resolved "https://registry.yarnpkg.com/xhr/-/xhr-2.5.0.tgz#bed8d1676d5ca36108667692b74b316c496e49dd" - integrity sha512-4nlO/14t3BNUZRXIXfXe+3N6w3s1KoxcJUUURctd64BLRe67E4gRwp4PjywtDY72fXpZ1y6Ch0VZQRY/gMPzzQ== + version "2.6.0" + resolved "https://registry.yarnpkg.com/xhr/-/xhr-2.6.0.tgz#b69d4395e792b4173d6b7df077f0fc5e4e2b249d" + integrity sha512-/eCGLb5rxjx5e3mF1A7s+pLlR6CGyqWN91fv1JgER5mVWg1MZmlhBvy9kjcsOdRk8RrIujotWyJamfyrp+WIcA== dependencies: - global "~4.3.0" + global "~4.4.0" is-function "^1.0.1" parse-headers "^2.0.0" xtend "^4.0.0" @@ -16382,20 +15487,15 @@ xtend@^4.0.0, xtend@^4.0.1, xtend@~4.0.1: integrity sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ== y18n@^4.0.0: - version "4.0.0" - resolved "https://registry.yarnpkg.com/y18n/-/y18n-4.0.0.tgz#95ef94f85ecc81d007c264e190a120f0a3c8566b" - integrity sha512-r9S/ZyXu/Xu9q1tYlpsLIsa3EeLXXk0VwlxqTcFRfg9EhMW+17kbt9G0NrgCmhGb5vT2hyhJZLfDGx+7+5Uj/w== + version "4.0.1" + resolved "https://registry.yarnpkg.com/y18n/-/y18n-4.0.1.tgz#8db2b83c31c5d75099bb890b23f3094891e247d4" + integrity sha512-wNcy4NvjMYL8gogWWYAO7ZFWFfHcbdbE57tZO8e4cbpj8tfUcwrwqSl3ad8HxpYWCdXcJUCeKKZS62Av1affwQ== y18n@^5.0.5: version "5.0.5" resolved "https://registry.yarnpkg.com/y18n/-/y18n-5.0.5.tgz#8769ec08d03b1ea2df2500acef561743bbb9ab18" integrity sha512-hsRUr4FFrvhhRH12wOdfs38Gy7k2FFzB9qgN9v3aLykRq0dRcdcpz5C9FxdS2NuhOrI/628b/KSTJ3rwHysYSg== -yaeti@^0.0.6: - version "0.0.6" - resolved "https://registry.yarnpkg.com/yaeti/-/yaeti-0.0.6.tgz#f26f484d72684cf42bedfb76970aa1608fbf9577" - integrity sha1-8m9ITXJoTPQr7ft2lwqhYI+/lXc= - yallist@^3.0.2: version "3.1.1" resolved "https://registry.yarnpkg.com/yallist/-/yallist-3.1.1.tgz#dbb7daf9bfd8bac9ab45ebf602b8cbad0d5d08fd" @@ -16412,9 +15512,9 @@ yaml-ast-parser@^0.0.43: integrity sha512-2PTINUwsRqSd+s8XxKaJWQlUuEMHJQyEuh2edBbW8KNJz0SJPwUSD2zRWqezFEdN7IzAgeuYHFUCF7o8zRdZ0A== yaml@^1.10.0, yaml@^1.7.2: - version "1.10.0" - resolved "https://registry.yarnpkg.com/yaml/-/yaml-1.10.0.tgz#3b593add944876077d4d683fee01081bd9fff31e" - integrity sha512-yr2icI4glYaNG+KWONODapy2/jDdMSDnrONSjblABjD9B4Z5LgiircSt8m8sRZFNi08kG9Sm0uSHtEmP3zaEGg== + version "1.10.2" + resolved "https://registry.yarnpkg.com/yaml/-/yaml-1.10.2.tgz#2301c5ffbf12b467de8da2333a459e29e7920e4b" + integrity sha512-r3vXyErRCYJ7wg28yvBY5VSoAF8ZvlcW9/BwUzEtUsjvX/DKs24dIkuwjtuprwJJHsbyUbLApepYTR1BN4uHrg== yargs-parser@^13.1.2: version "13.1.2" @@ -16432,15 +15532,10 @@ yargs-parser@^18.1.2, yargs-parser@^18.1.3: camelcase "^5.0.0" decamelize "^1.2.0" -yargs-parser@^20.2.2: - version "20.2.3" - resolved "https://registry.yarnpkg.com/yargs-parser/-/yargs-parser-20.2.3.tgz#92419ba867b858c868acf8bae9bf74af0dd0ce26" - integrity sha512-emOFRT9WVHw03QSvN5qor9QQT9+sw5vwxfYweivSMHTcAXPefwVae2FjO7JJjj8hCE4CzPOPeFM83VwT29HCww== - -yargs-parser@^20.2.3: - version "20.2.4" - resolved "https://registry.yarnpkg.com/yargs-parser/-/yargs-parser-20.2.4.tgz#b42890f14566796f85ae8e3a25290d205f154a54" - integrity sha512-WOkpgNhPTlE73h4VFAFsOnomJVaovO8VqLDzy5saChRBFQFBoMYirowyW+Q9HB4HFF4Z7VZTiG3iSzJJA29yRA== +yargs-parser@^20.2.2, yargs-parser@^20.2.3: + version "20.2.7" + resolved "https://registry.yarnpkg.com/yargs-parser/-/yargs-parser-20.2.7.tgz#61df85c113edfb5a7a4e36eb8aa60ef423cbc90a" + integrity sha512-FiNkvbeHzB/syOjIUxFDCnhSfzAL8R5vs40MgLFBorXACCOAEaWu0gRZl14vG8MR9AOJIZbmkjhusqBYZ3HTHw== yargs@^13.3.2: version "13.3.2" @@ -16493,6 +15588,11 @@ yn@3.1.1: resolved "https://registry.yarnpkg.com/yn/-/yn-3.1.1.tgz#1e87401a09d767c1d5eab26a6e4c185182d2eb50" integrity sha512-Ux4ygGWsu2c7isFWe8Yu1YluJmqVhxqK2cLXNQA5AcC3QfbGNpM7fu0Y8b/z16pXLnFxZYvWhd3fhBY9DLmC6Q== +yocto-queue@^0.1.0: + version "0.1.0" + resolved "https://registry.yarnpkg.com/yocto-queue/-/yocto-queue-0.1.0.tgz#0294eb3dee05028d31ee1a5fa2c556a6aaf10a1b" + integrity sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q== + yup@^0.32.9: version "0.32.9" resolved "https://registry.yarnpkg.com/yup/-/yup-0.32.9.tgz#9367bec6b1b0e39211ecbca598702e106019d872" From 7acae34ed4e1675d861f8bc5fbf2370d2970cd47 Mon Sep 17 00:00:00 2001 From: peolic <66393006+peolic@users.noreply.github.com> Date: Tue, 30 Mar 2021 06:04:57 +0300 Subject: [PATCH 02/66] Fix performer search columns (#1236) * Fix performer search columns * Update changelog * Move changelog to new version Co-authored-by: WithoutPants <53250216+WithoutPants@users.noreply.github.com> --- pkg/sqlite/performer.go | 2 +- ui/v2.5/src/components/Changelog/Changelog.tsx | 12 +++++++++++- ui/v2.5/src/components/Changelog/versions/v060.md | 2 +- ui/v2.5/src/components/Changelog/versions/v070.md | 2 ++ 4 files changed, 15 insertions(+), 3 deletions(-) create mode 100644 ui/v2.5/src/components/Changelog/versions/v070.md diff --git a/pkg/sqlite/performer.go b/pkg/sqlite/performer.go index cc85da887..342fe8c63 100644 --- a/pkg/sqlite/performer.go +++ b/pkg/sqlite/performer.go @@ -192,7 +192,7 @@ func (qb *performerQueryBuilder) Query(performerFilter *models.PerformerFilterTy ` if q := findFilter.Q; q != nil && *q != "" { - searchColumns := []string{"performers.name", "performers.checksum", "performers.birthdate", "performers.ethnicity"} + searchColumns := []string{"performers.name", "performers.aliases"} clause, thisArgs := getSearchBinding(searchColumns, *q, false) query.addWhere(clause) query.addArg(thisArgs...) diff --git a/ui/v2.5/src/components/Changelog/Changelog.tsx b/ui/v2.5/src/components/Changelog/Changelog.tsx index 7d0434a3c..127a7db4d 100644 --- a/ui/v2.5/src/components/Changelog/Changelog.tsx +++ b/ui/v2.5/src/components/Changelog/Changelog.tsx @@ -9,6 +9,7 @@ import V030 from "./versions/v030.md"; import V040 from "./versions/v040.md"; import V050 from "./versions/v050.md"; import V060 from "./versions/v060.md"; +import V070 from "./versions/v070.md"; import { MarkdownPage } from "../Shared/MarkdownPage"; const Changelog: React.FC = () => { @@ -38,11 +39,20 @@ const Changelog: React.FC = () => { <>

Changelog:

+ + + diff --git a/ui/v2.5/src/components/Changelog/versions/v060.md b/ui/v2.5/src/components/Changelog/versions/v060.md index d7f4278d6..073920e90 100644 --- a/ui/v2.5/src/components/Changelog/versions/v060.md +++ b/ui/v2.5/src/components/Changelog/versions/v060.md @@ -28,4 +28,4 @@ * Fix SQL error when filtering galleries excluding performers or tags. * Fix version checking for armv7 and arm64. * Change "Is NULL" filter to include empty string values. -* Prevent scene card previews playing in full-screen on iOS devices. +* Prevent scene card previews playing in full-screen on iOS devices. \ No newline at end of file diff --git a/ui/v2.5/src/components/Changelog/versions/v070.md b/ui/v2.5/src/components/Changelog/versions/v070.md new file mode 100644 index 000000000..89015261c --- /dev/null +++ b/ui/v2.5/src/components/Changelog/versions/v070.md @@ -0,0 +1,2 @@ +### 🎨 Improvements +* Change performer text query to search by name and alias only. From 496900df42d066912f3574c0fe5814b4bbfacf41 Mon Sep 17 00:00:00 2001 From: peolic <66393006+peolic@users.noreply.github.com> Date: Tue, 30 Mar 2021 06:25:56 +0300 Subject: [PATCH 03/66] Fix inaccurate age calculation (#1237) --- ui/v2.5/src/components/Changelog/versions/v060.md | 2 +- ui/v2.5/src/components/Changelog/versions/v070.md | 1 + ui/v2.5/src/utils/text.ts | 2 +- 3 files changed, 3 insertions(+), 2 deletions(-) diff --git a/ui/v2.5/src/components/Changelog/versions/v060.md b/ui/v2.5/src/components/Changelog/versions/v060.md index 073920e90..d7f4278d6 100644 --- a/ui/v2.5/src/components/Changelog/versions/v060.md +++ b/ui/v2.5/src/components/Changelog/versions/v060.md @@ -28,4 +28,4 @@ * Fix SQL error when filtering galleries excluding performers or tags. * Fix version checking for armv7 and arm64. * Change "Is NULL" filter to include empty string values. -* Prevent scene card previews playing in full-screen on iOS devices. \ No newline at end of file +* Prevent scene card previews playing in full-screen on iOS devices. diff --git a/ui/v2.5/src/components/Changelog/versions/v070.md b/ui/v2.5/src/components/Changelog/versions/v070.md index 89015261c..da685f461 100644 --- a/ui/v2.5/src/components/Changelog/versions/v070.md +++ b/ui/v2.5/src/components/Changelog/versions/v070.md @@ -1,2 +1,3 @@ ### 🎨 Improvements +* Fix incorrect performer age calculation in UI. * Change performer text query to search by name and alias only. diff --git a/ui/v2.5/src/utils/text.ts b/ui/v2.5/src/utils/text.ts index 3a919b6be..1c7407a86 100644 --- a/ui/v2.5/src/utils/text.ts +++ b/ui/v2.5/src/utils/text.ts @@ -79,7 +79,7 @@ const getAge = (dateString?: string | null, fromDateString?: string) => { if ( birthdate.getMonth() > fromDate.getMonth() || (birthdate.getMonth() >= fromDate.getMonth() && - birthdate.getDay() > fromDate.getDay()) + birthdate.getDate() > fromDate.getDate()) ) { age -= 1; } From d5e9030768ceac626d789b356bc26fc6d29d5510 Mon Sep 17 00:00:00 2001 From: WithoutPants <53250216+WithoutPants@users.noreply.github.com> Date: Wed, 31 Mar 2021 14:36:11 +1100 Subject: [PATCH 04/66] Scene queuing (#1214) * Add missing localisation strings * Ignore container error in scene streams * Implement missing FindScenes by ID --- pkg/api/resolver_query_find_scene.go | 17 +- pkg/manager/scene.go | 7 +- .../src/components/Changelog/Changelog.tsx | 1 - .../src/components/Changelog/versions/v070.md | 7 +- .../components/ScenePlayer/ScenePlayer.tsx | 8 + .../src/components/ScenePlayer/styles.scss | 12 +- ui/v2.5/src/components/Scenes/SceneCard.tsx | 46 +++-- .../src/components/Scenes/SceneCardsGrid.tsx | 43 ++++ .../Scenes/SceneDetails/QueueViewer.tsx | 153 ++++++++++++++ .../components/Scenes/SceneDetails/Scene.tsx | 188 +++++++++++++++++- ui/v2.5/src/components/Scenes/SceneList.tsx | 85 ++++---- ui/v2.5/src/components/Scenes/styles.scss | 46 ++++- ui/v2.5/src/core/StashService.ts | 8 + ui/v2.5/src/locale/en-GB.json | 3 +- ui/v2.5/src/locale/en-US.json | 3 +- ui/v2.5/src/models/list-filter/filter.ts | 9 +- ui/v2.5/src/models/sceneQueue.ts | 116 +++++++++++ 17 files changed, 677 insertions(+), 75 deletions(-) create mode 100644 ui/v2.5/src/components/Scenes/SceneCardsGrid.tsx create mode 100644 ui/v2.5/src/components/Scenes/SceneDetails/QueueViewer.tsx create mode 100644 ui/v2.5/src/models/sceneQueue.ts diff --git a/pkg/api/resolver_query_find_scene.go b/pkg/api/resolver_query_find_scene.go index ae8eec249..44a111646 100644 --- a/pkg/api/resolver_query_find_scene.go +++ b/pkg/api/resolver_query_find_scene.go @@ -59,12 +59,25 @@ func (r *queryResolver) FindSceneByHash(ctx context.Context, input models.SceneH return scene, nil } -func (r *queryResolver) FindScenes(ctx context.Context, sceneFilter *models.SceneFilterType, sceneIds []int, filter *models.FindFilterType) (ret *models.FindScenesResultType, err error) { +func (r *queryResolver) FindScenes(ctx context.Context, sceneFilter *models.SceneFilterType, sceneIDs []int, filter *models.FindFilterType) (ret *models.FindScenesResultType, err error) { if err := r.withReadTxn(ctx, func(repo models.ReaderRepository) error { - scenes, total, err := repo.Scene().Query(sceneFilter, filter) + var scenes []*models.Scene + var total int + var err error + + if len(sceneIDs) > 0 { + scenes, err = repo.Scene().FindMany(sceneIDs) + if err == nil { + total = len(scenes) + } + } else { + scenes, total, err = repo.Scene().Query(sceneFilter, filter) + } + if err != nil { return err } + ret = &models.FindScenesResultType{ Count: total, Scenes: scenes, diff --git a/pkg/manager/scene.go b/pkg/manager/scene.go index 52ff02e5f..d40d673e9 100644 --- a/pkg/manager/scene.go +++ b/pkg/manager/scene.go @@ -243,10 +243,9 @@ func GetSceneStreamPaths(scene *models.Scene, directStreamURL string, maxStreami if scene.AudioCodec.Valid { audioCodec = ffmpeg.AudioCodec(scene.AudioCodec.String) } - container, err := GetSceneFileContainer(scene) - if err != nil { - return nil, err - } + + // don't care if we can't get the container + container, _ := GetSceneFileContainer(scene) if HasTranscode(scene, config.GetVideoFileNamingAlgorithm()) || ffmpeg.IsValidAudioForContainer(audioCodec, container) { label := "Direct stream" diff --git a/ui/v2.5/src/components/Changelog/Changelog.tsx b/ui/v2.5/src/components/Changelog/Changelog.tsx index 127a7db4d..a00beacf7 100644 --- a/ui/v2.5/src/components/Changelog/Changelog.tsx +++ b/ui/v2.5/src/components/Changelog/Changelog.tsx @@ -52,7 +52,6 @@ const Changelog: React.FC = () => { date="2021-03-29" openState={openState} setOpenState={setVersionOpenState} - defaultOpen > diff --git a/ui/v2.5/src/components/Changelog/versions/v070.md b/ui/v2.5/src/components/Changelog/versions/v070.md index da685f461..d7f2c5451 100644 --- a/ui/v2.5/src/components/Changelog/versions/v070.md +++ b/ui/v2.5/src/components/Changelog/versions/v070.md @@ -1,3 +1,8 @@ +### ✨ New Features +* Added scene queue. + ### 🎨 Improvements -* Fix incorrect performer age calculation in UI. * Change performer text query to search by name and alias only. + +### 🐛 Bug fixes +* Fix incorrect performer age calculation in UI. \ No newline at end of file diff --git a/ui/v2.5/src/components/ScenePlayer/ScenePlayer.tsx b/ui/v2.5/src/components/ScenePlayer/ScenePlayer.tsx index 038358f5d..054eb3f2e 100644 --- a/ui/v2.5/src/components/ScenePlayer/ScenePlayer.tsx +++ b/ui/v2.5/src/components/ScenePlayer/ScenePlayer.tsx @@ -15,6 +15,7 @@ interface IScenePlayerProps { onReady?: () => void; onSeeked?: () => void; onTime?: () => void; + onComplete?: () => void; config?: GQL.ConfigInterfaceDataFragment; } interface IScenePlayerState { @@ -142,6 +143,12 @@ export class ScenePlayerImpl extends React.Component< } } + private onComplete() { + if (this.props?.onComplete) { + this.props.onComplete(); + } + } + private onScrubberSeek(seconds: number) { this.player.seek(seconds); } @@ -307,6 +314,7 @@ export class ScenePlayerImpl extends React.Component< onReady={this.onReady} onSeeked={this.onSeeked} onTime={this.onTime} + onOneHundredPercent={() => this.onComplete()} /> = ({ interface ISceneCardProps { scene: GQL.SlimSceneDataFragment; + compact?: boolean; selecting?: boolean; selected?: boolean | undefined; zoomIndex?: number; onSelectedChanged?: (selected: boolean, shiftKey: boolean) => void; + onSceneClicked?: () => void; } export const SceneCard: React.FC = ( @@ -286,13 +288,14 @@ export const SceneCard: React.FC = ( function maybeRenderPopoverButtonGroup() { if ( - props.scene.tags.length > 0 || - props.scene.performers.length > 0 || - props.scene.movies.length > 0 || - props.scene.scene_markers.length > 0 || - props.scene?.o_counter || - props.scene.galleries.length > 0 || - props.scene.organized + !props.compact && + (props.scene.tags.length > 0 || + props.scene.performers.length > 0 || + props.scene.movies.length > 0 || + props.scene.scene_markers.length > 0 || + props.scene?.o_counter || + props.scene.galleries.length > 0 || + props.scene.organized) ) { return ( <> @@ -319,6 +322,9 @@ export const SceneCard: React.FC = ( if (props.selecting && props.onSelectedChanged) { props.onSelectedChanged(!props.selected, shiftKey); event.preventDefault(); + } else if (props.onSceneClicked) { + props.onSceneClicked(); + event.preventDefault(); } } @@ -348,10 +354,16 @@ export const SceneCard: React.FC = ( return height > width; } + function zoomIndex() { + if (!props.compact && props.zoomIndex !== undefined) { + return `zoom-${props.zoomIndex}`; + } + } + let shiftKey = false; return ( - + = (
- + + +
{props.scene.date}

diff --git a/ui/v2.5/src/components/Scenes/SceneCardsGrid.tsx b/ui/v2.5/src/components/Scenes/SceneCardsGrid.tsx new file mode 100644 index 000000000..8d9fb68c6 --- /dev/null +++ b/ui/v2.5/src/components/Scenes/SceneCardsGrid.tsx @@ -0,0 +1,43 @@ +import React from "react"; +import * as GQL from "src/core/generated-graphql"; +import { SceneCard } from "./SceneCard"; + +interface ISceneCardsGrid { + scenes: GQL.SlimSceneDataFragment[]; + selectedIds: Set; + zoomIndex: number; + onSelectChange: (id: string, selected: boolean, shiftKey: boolean) => void; + onSceneClick?: (id: string, index: number) => void; +} + +export const SceneCardsGrid: React.FC = ({ + scenes, + selectedIds, + zoomIndex, + onSelectChange, + onSceneClick, +}) => { + function sceneClicked(sceneID: string, index: number) { + if (onSceneClick) { + onSceneClick(sceneID, index); + } + } + + return ( +

+ {scenes.map((scene, index) => ( + 0} + selected={selectedIds.has(scene.id)} + onSelectedChanged={(selected: boolean, shiftKey: boolean) => + onSelectChange(scene.id, selected, shiftKey) + } + onSceneClicked={() => sceneClicked(scene.id, index)} + /> + ))} +
+ ); +}; diff --git a/ui/v2.5/src/components/Scenes/SceneDetails/QueueViewer.tsx b/ui/v2.5/src/components/Scenes/SceneDetails/QueueViewer.tsx new file mode 100644 index 000000000..8ecf615cd --- /dev/null +++ b/ui/v2.5/src/components/Scenes/SceneDetails/QueueViewer.tsx @@ -0,0 +1,153 @@ +import React, { useEffect, useState } from "react"; +import { Link } from "react-router-dom"; +import cx from "classnames"; +import * as GQL from "src/core/generated-graphql"; +import { TextUtils } from "src/utils"; +import { Button, Spinner } from "react-bootstrap"; +import { Icon } from "src/components/Shared"; + +export interface IPlaylistViewer { + scenes?: GQL.SlimSceneDataFragment[]; + currentID?: string; + start?: number; + hasMoreScenes: boolean; + onSceneClicked: (id: string) => void; + onNext: () => void; + onPrevious: () => void; + onRandom: () => void; + onMoreScenes: () => void; + onLessScenes: () => void; +} + +export const QueueViewer: React.FC = ({ + scenes, + currentID, + start, + hasMoreScenes, + onNext, + onPrevious, + onRandom, + onSceneClicked, + onMoreScenes, + onLessScenes, +}) => { + const [lessLoading, setLessLoading] = useState(false); + const [moreLoading, setMoreLoading] = useState(false); + + const currentIndex = scenes?.findIndex((s) => s.id === currentID); + + useEffect(() => { + setLessLoading(false); + setMoreLoading(false); + }, [scenes]); + + function isCurrentScene(scene: GQL.SlimSceneDataFragment) { + return scene.id === currentID; + } + + function handleSceneClick( + event: React.MouseEvent, + id: string + ) { + onSceneClicked(id); + event.preventDefault(); + } + + function lessClicked() { + setLessLoading(true); + onLessScenes(); + } + + function moreClicked() { + setMoreLoading(true); + onMoreScenes(); + } + + function renderPlaylistEntry(scene: GQL.SlimSceneDataFragment) { + return ( +
  • + handleSceneClick(e, scene.id)} + > +
    +
    + {scene.title +
    +
    + + {scene.title ?? TextUtils.fileNameFromPath(scene.path)} + +
    +
    + +
  • + ); + } + + return ( +
    +
    +
    + {(currentIndex ?? 0) > 0 ? ( + + ) : ( + "" + )} + {(currentIndex ?? 0) < (scenes ?? []).length - 1 ? ( + + ) : ( + "" + )} + +
    +
    +
    + {(start ?? 0) > 1 ? ( +
    + +
    + ) : undefined} +
      {(scenes ?? []).map(renderPlaylistEntry)}
    + {hasMoreScenes ? ( +
    + +
    + ) : undefined} +
    +
    + ); +}; diff --git a/ui/v2.5/src/components/Scenes/SceneDetails/Scene.tsx b/ui/v2.5/src/components/Scenes/SceneDetails/Scene.tsx index 3c8ed2fec..599d2502e 100644 --- a/ui/v2.5/src/components/Scenes/SceneDetails/Scene.tsx +++ b/ui/v2.5/src/components/Scenes/SceneDetails/Scene.tsx @@ -12,6 +12,8 @@ import { useSceneStreams, useSceneGenerateScreenshot, useSceneUpdate, + queryFindScenes, + queryFindScenesByID, } from "src/core/StashService"; import { GalleryViewer } from "src/components/Galleries/GalleryViewer"; import { ErrorMessage, LoadingIndicator, Icon } from "src/components/Shared"; @@ -19,6 +21,10 @@ import { useToast } from "src/hooks"; import { ScenePlayer } from "src/components/ScenePlayer"; import { TextUtils, JWUtils } from "src/utils"; import Mousetrap from "mousetrap"; +import { ListFilterModel } from "src/models/list-filter/filter"; +import { FilterMode } from "src/models/list-filter/types"; +import { SceneQueue } from "src/models/sceneQueue"; +import { QueueViewer } from "./QueueViewer"; import { SceneMarkersPanel } from "./SceneMarkersPanel"; import { SceneFileInfoPanel } from "./SceneFileInfoPanel"; import { SceneEditPanel } from "./SceneEditPanel"; @@ -65,8 +71,59 @@ export const Scene: React.FC = () => { const [isDeleteAlertOpen, setIsDeleteAlertOpen] = useState(false); const [isGenerateDialogOpen, setIsGenerateDialogOpen] = useState(false); + const [sceneQueue, setSceneQueue] = useState(new SceneQueue()); + const [queueScenes, setQueueScenes] = useState( + [] + ); + + const [queueTotal, setQueueTotal] = useState(0); + const [queueStart, setQueueStart] = useState(1); + + const [rerenderPlayer, setRerenderPlayer] = useState(false); + const queryParams = queryString.parse(location.search); const autoplay = queryParams?.autoplay === "true"; + const currentQueueIndex = queueScenes.findIndex((s) => s.id === id); + + async function getQueueFilterScenes(filter: ListFilterModel) { + const query = await queryFindScenes(filter); + const { scenes, count } = query.data.findScenes; + setQueueScenes(scenes); + setQueueTotal(count); + setQueueStart((filter.currentPage - 1) * filter.itemsPerPage + 1); + } + + async function getQueueScenes(sceneIDs: number[]) { + const query = await queryFindScenesByID(sceneIDs); + const { scenes, count } = query.data.findScenes; + setQueueScenes(scenes); + setQueueTotal(count); + setQueueStart(1); + } + + // HACK - jwplayer doesn't handle re-rendering when scene changes, so force + // a rerender by not drawing it + useEffect(() => { + if (rerenderPlayer) { + setRerenderPlayer(false); + } + }, [rerenderPlayer]); + + useEffect(() => { + setRerenderPlayer(true); + }, [id]); + + useEffect(() => { + setSceneQueue(SceneQueue.fromQueryParameters(location.search)); + }, [location.search]); + + useEffect(() => { + if (sceneQueue.query) { + getQueueFilterScenes(sceneQueue.query); + } else if (sceneQueue.sceneIDs) { + getQueueScenes(sceneQueue.sceneIDs); + } + }, [sceneQueue]); function getInitialTimestamp() { const params = queryString.parse(location.search); @@ -158,6 +215,99 @@ export const Scene: React.FC = () => { Toast.success({ content: "Generating screenshot" }); } + async function onQueueLessScenes() { + if (!sceneQueue.query || queueStart <= 1) { + return; + } + + const filterCopy = Object.assign( + new ListFilterModel(FilterMode.Scenes), + sceneQueue.query + ); + const newStart = queueStart - filterCopy.itemsPerPage; + filterCopy.currentPage = Math.ceil(newStart / filterCopy.itemsPerPage); + const query = await queryFindScenes(filterCopy); + const { scenes } = query.data.findScenes; + + // prepend scenes to scene list + const newScenes = scenes.concat(queueScenes); + setQueueScenes(newScenes); + setQueueStart(newStart); + } + + function queueHasMoreScenes() { + return queueStart + queueScenes.length - 1 < queueTotal; + } + + async function onQueueMoreScenes() { + if (!sceneQueue.query || !queueHasMoreScenes()) { + return; + } + + const filterCopy = Object.assign( + new ListFilterModel(FilterMode.Scenes), + sceneQueue.query + ); + const newStart = queueStart + queueScenes.length; + filterCopy.currentPage = Math.ceil(newStart / filterCopy.itemsPerPage); + const query = await queryFindScenes(filterCopy); + const { scenes } = query.data.findScenes; + + // append scenes to scene list + const newScenes = scenes.concat(queueScenes); + setQueueScenes(newScenes); + // don't change queue start + } + + function playScene(sceneID: string, page?: number) { + sceneQueue.playScene(history, sceneID, { + newPage: page, + autoPlay: true, + }); + } + + function onQueueNext() { + if (currentQueueIndex >= 0 && currentQueueIndex < queueScenes.length - 1) { + playScene(queueScenes[currentQueueIndex + 1].id); + } + } + + function onQueuePrevious() { + if (currentQueueIndex > 0) { + playScene(queueScenes[currentQueueIndex - 1].id); + } + } + + async function onQueueRandom() { + if (sceneQueue.query) { + const { query } = sceneQueue; + const pages = Math.ceil(queueTotal / query.itemsPerPage); + const page = Math.floor(Math.random() * pages) + 1; + const index = Math.floor(Math.random() * query.itemsPerPage); + const filterCopy = Object.assign( + new ListFilterModel(FilterMode.Scenes), + sceneQueue.query + ); + filterCopy.currentPage = page; + const queryResults = await queryFindScenes(filterCopy); + if (queryResults.data.findScenes.scenes.length > index) { + const { id: sceneID } = queryResults!.data!.findScenes!.scenes[index]; + // navigate to the image player page + playScene(sceneID, page); + } + } else { + const index = Math.floor(Math.random() * queueTotal); + playScene(queueScenes[index].id); + } + } + + function onComplete() { + // load the next scene if we're autoplaying + if (autoplay) { + onQueueNext(); + } + } + function onDeleteDialogClosed(deleted: boolean) { setIsDeleteAlertOpen(false); if (deleted) { @@ -255,6 +405,13 @@ export const Scene: React.FC = () => { Details + {(queueScenes ?? []).length > 0 ? ( + + Queue + + ) : ( + "" + )} Markers @@ -310,6 +467,20 @@ export const Scene: React.FC = () => { + + playScene(sceneID)} + onNext={onQueueNext} + onPrevious={onQueuePrevious} + onRandom={onQueueRandom} + start={queueStart} + hasMoreScenes={queueHasMoreScenes()} + onLessScenes={() => onQueueLessScenes()} + onMoreScenes={() => onQueueMoreScenes()} + /> + {
    - + {!rerenderPlayer ? ( + + ) : undefined}
    ); diff --git a/ui/v2.5/src/components/Scenes/SceneList.tsx b/ui/v2.5/src/components/Scenes/SceneList.tsx index e0667e26a..7dc01c2c6 100644 --- a/ui/v2.5/src/components/Scenes/SceneList.tsx +++ b/ui/v2.5/src/components/Scenes/SceneList.tsx @@ -12,13 +12,14 @@ import { ListFilterModel } from "src/models/list-filter/filter"; import { DisplayMode } from "src/models/list-filter/types"; import { showWhenSelected, PersistanceLevel } from "src/hooks/ListHook"; import Tagger from "src/components/Tagger"; +import { SceneQueue } from "src/models/sceneQueue"; import { WallPanel } from "../Wall/WallPanel"; -import { SceneCard } from "./SceneCard"; import { SceneListTable } from "./SceneListTable"; import { EditScenesDialog } from "./EditScenesDialog"; import { DeleteScenesDialog } from "./DeleteScenesDialog"; import { SceneGenerateDialog } from "./SceneGenerateDialog"; import { ExportDialog } from "../Shared/ExportDialog"; +import { SceneCardsGrid } from "./SceneCardsGrid"; interface ISceneList { filterHook?: (filter: ListFilterModel) => ListFilterModel; @@ -35,6 +36,11 @@ export const SceneList: React.FC = ({ const [isExportAll, setIsExportAll] = useState(false); const otherOperations = [ + { + text: "Play selected", + onClick: playSelected, + isDisplayed: showWhenSelected, + }, { text: "Play Random", onClick: playRandom, @@ -85,6 +91,17 @@ export const SceneList: React.FC = ({ persistState, }); + async function playSelected( + result: FindScenesQueryResult, + filter: ListFilterModel, + selectedIds: Set + ) { + // populate queue and go to first scene + const sceneIDs = Array.from(selectedIds.values()); + const queue = SceneQueue.fromSceneIDList(sceneIDs); + queue.playScene(history, sceneIDs[0], { autoPlay: true }); + } + async function playRandom( result: FindScenesQueryResult, filter: ListFilterModel @@ -93,20 +110,18 @@ export const SceneList: React.FC = ({ if (result.data && result.data.findScenes) { const { count } = result.data.findScenes; - const index = Math.floor(Math.random() * count); + const pages = Math.ceil(count / filter.itemsPerPage); + const page = Math.floor(Math.random() * pages) + 1; + const index = Math.floor(Math.random() * filter.itemsPerPage); const filterCopy = _.cloneDeep(filter); - filterCopy.itemsPerPage = 1; - filterCopy.currentPage = index + 1; - const singleResult = await queryFindScenes(filterCopy); - if ( - singleResult && - singleResult.data && - singleResult.data.findScenes && - singleResult.data.findScenes.scenes.length === 1 - ) { - const { id } = singleResult!.data!.findScenes!.scenes[0]; - // navigate to the scene player page - history.push(`/scenes/${id}?autoplay=true`); + filterCopy.currentPage = page; + filterCopy.sortBy = "random"; + const queryResults = await queryFindScenes(filterCopy); + if (queryResults.data.findScenes.scenes.length > index) { + const { id } = queryResults!.data!.findScenes!.scenes[index]; + // navigate to the image player page + const queue = SceneQueue.fromListFilterModel(filterCopy, index); + queue.playScene(history, id, { autoPlay: true }); } } } @@ -125,6 +140,15 @@ export const SceneList: React.FC = ({ setIsExportDialogOpen(true); } + async function sceneClicked( + sceneId: string, + sceneIndex: number, + filter: ListFilterModel + ) { + const queue = SceneQueue.fromListFilterModel(filter, sceneIndex); + queue.playScene(history, sceneId); + } + function maybeRenderSceneGenerateDialog(selectedIds: Set) { if (isGenerateDialogOpen) { return ( @@ -171,25 +195,6 @@ export const SceneList: React.FC = ({ ); } - function renderSceneCard( - scene: SlimSceneDataFragment, - selectedIds: Set, - zoomIndex: number - ) { - return ( - 0} - selected={selectedIds.has(scene.id)} - onSelectedChanged={(selected: boolean, shiftKey: boolean) => - listData.onSelectChange(scene.id, selected, shiftKey) - } - /> - ); - } - function renderScenes( result: FindScenesQueryResult, filter: ListFilterModel, @@ -201,11 +206,15 @@ export const SceneList: React.FC = ({ } if (filter.displayMode === DisplayMode.Grid) { return ( -
    - {result.data.findScenes.scenes.map((scene) => - renderSceneCard(scene, selectedIds, zoomIndex) - )} -
    + + listData.onSelectChange(id, selected, shiftKey) + } + onSceneClick={(id, index) => sceneClicked(id, index, filter)} + /> ); } if (filter.displayMode === DisplayMode.List) { diff --git a/ui/v2.5/src/components/Scenes/styles.scss b/ui/v2.5/src/components/Scenes/styles.scss index e04288211..cb9c06619 100644 --- a/ui/v2.5/src/components/Scenes/styles.scss +++ b/ui/v2.5/src/components/Scenes/styles.scss @@ -159,6 +159,11 @@ textarea.scene-description { .scene-card, .gallery-card { + a { + color: $text-color; + text-decoration: none; + } + &-check { left: 0.5rem; margin-top: -12px; @@ -280,10 +285,15 @@ textarea.scene-description { } .scene-tabs { + display: flex; + flex-direction: column; max-height: calc(100vh - 4rem); - overflow-wrap: break-word; word-wrap: break-word; + + > div { + flex: 0 1 auto; + } } input[type="range"].filter-slider { @@ -573,3 +583,37 @@ input[type="range"].blue-slider { padding-left: 0; padding-right: 0.25rem; } + +#queue-viewer { + .queue-controls { + display: flex; + flex: 0 1 auto; + justify-content: flex-end; + } + + .thumbnail-container { + height: 50px; + margin-bottom: 5px; + margin-right: 0.75rem; + margin-top: 5px; + min-width: 100px; + width: 100px; + } + + img { + height: 100%; + object-fit: contain; + object-position: center; + width: 100%; + } + + a { + color: $text-color; + font-weight: 500; + text-decoration: none; + } + + .current { + background-color: $secondary; + } +} diff --git a/ui/v2.5/src/core/StashService.ts b/ui/v2.5/src/core/StashService.ts index b5639ac62..10832462d 100644 --- a/ui/v2.5/src/core/StashService.ts +++ b/ui/v2.5/src/core/StashService.ts @@ -76,6 +76,14 @@ export const queryFindScenes = (filter: ListFilterModel) => }, }); +export const queryFindScenesByID = (sceneIDs: number[]) => + client.query({ + query: GQL.FindScenesDocument, + variables: { + scene_ids: sceneIDs, + }, + }); + export const useFindSceneMarkers = (filter: ListFilterModel) => GQL.useFindSceneMarkersQuery({ variables: { diff --git a/ui/v2.5/src/locale/en-GB.json b/ui/v2.5/src/locale/en-GB.json index 698445cc4..0e09f413c 100644 --- a/ui/v2.5/src/locale/en-GB.json +++ b/ui/v2.5/src/locale/en-GB.json @@ -13,5 +13,6 @@ "tags": "Tags", "up-dir": "Up a directory", "favourite": "FAVOURITE", - "sceneTagger": "Scene Tagger" + "sceneTagger": "Scene Tagger", + "donate": "Donate" } diff --git a/ui/v2.5/src/locale/en-US.json b/ui/v2.5/src/locale/en-US.json index fd6fc3098..d6ac76584 100644 --- a/ui/v2.5/src/locale/en-US.json +++ b/ui/v2.5/src/locale/en-US.json @@ -13,5 +13,6 @@ "tags": "Tags", "up-dir": "Up a directory", "favourite": "FAVORITE", - "sceneTagger": "Scene Tagger" + "sceneTagger": "Scene Tagger", + "donate": "Donate" } diff --git a/ui/v2.5/src/models/list-filter/filter.ts b/ui/v2.5/src/models/list-filter/filter.ts index e988057bb..afcb5aadf 100644 --- a/ui/v2.5/src/models/list-filter/filter.ts +++ b/ui/v2.5/src/models/list-filter/filter.ts @@ -390,7 +390,7 @@ export class ListFilterModel { return this.sortBy; } - public makeQueryParameters(): string { + public getQueryParameters() { const encodedCriteria: string[] = []; this.criteria.forEach((criterion) => { const encodedCriterion: Partial = { @@ -425,7 +425,12 @@ export class ListFilterModel { : undefined, c: encodedCriteria, }; - return queryString.stringify(result, { encode: false }); + + return result; + } + + public makeQueryParameters(): string { + return queryString.stringify(this.getQueryParameters(), { encode: false }); } // TODO: These don't support multiple of the same criteria, only the last one set is used. diff --git a/ui/v2.5/src/models/sceneQueue.ts b/ui/v2.5/src/models/sceneQueue.ts new file mode 100644 index 000000000..feb0f9569 --- /dev/null +++ b/ui/v2.5/src/models/sceneQueue.ts @@ -0,0 +1,116 @@ +import queryString from "query-string"; +import { RouteComponentProps } from "react-router-dom"; +import { ListFilterModel } from "./list-filter/filter"; +import { FilterMode } from "./list-filter/types"; + +interface IQueryParameters { + qsort?: string; + qsortd?: string; + qfq?: string; + qfp?: string; + qfc?: string[]; + qs?: string[]; +} + +export interface IPlaySceneOptions { + newPage?: number; + autoPlay?: boolean; +} + +export class SceneQueue { + public query?: ListFilterModel; + public sceneIDs?: number[]; + + public static fromListFilterModel( + filter: ListFilterModel, + currentSceneIndex?: number + ) { + const ret = new SceneQueue(); + + const filterCopy = Object.assign( + new ListFilterModel(filter.filterMode), + filter + ); + filterCopy.itemsPerPage = 40; + + // adjust page to be correct for the index + const filterIndex = + currentSceneIndex !== undefined + ? currentSceneIndex + (filter.currentPage - 1) * filter.itemsPerPage + : 0; + const newPage = Math.floor(filterIndex / filterCopy.itemsPerPage) + 1; + filterCopy.currentPage = newPage; + + ret.query = filterCopy; + return ret; + } + + public static fromSceneIDList(sceneIDs: string[]) { + const ret = new SceneQueue(); + ret.sceneIDs = sceneIDs.map((v) => Number(v)); + return ret; + } + + private makeQueryParameters(page?: number) { + if (this.query) { + const queryParams = this.query.getQueryParameters(); + const translatedParams = { + qfp: queryParams.p ?? 1, + qfc: queryParams.c, + qfq: queryParams.q, + qsort: queryParams.sortby, + qsortd: queryParams.sortdir, + }; + + if (page !== undefined) { + translatedParams.qfp = page; + } + + return queryString.stringify(translatedParams, { encode: false }); + } + + if (this.sceneIDs && this.sceneIDs.length > 0) { + const params = { + qs: this.sceneIDs, + }; + return queryString.stringify(params, { encode: false }); + } + + return ""; + } + + public static fromQueryParameters(params: string) { + const ret = new SceneQueue(); + const parsed = queryString.parse(params) as IQueryParameters; + const translated = { + sortby: parsed.qsort, + sortdir: parsed.qsortd, + q: parsed.qfq, + p: parsed.qfp, + c: parsed.qfc, + }; + + if (parsed.qfp) { + const query = new ListFilterModel( + FilterMode.Scenes, + translated as queryString.ParsedQuery + ); + ret.query = query; + } else if (parsed.qs) { + // must be scene list + ret.sceneIDs = parsed.qs.map((v) => Number(v)); + } + + return ret; + } + + public playScene( + history: RouteComponentProps["history"], + sceneID: string, + options?: IPlaySceneOptions + ) { + const paramStr = this.makeQueryParameters(options?.newPage); + const autoplayParam = options?.autoPlay ? "&autoplay=true" : ""; + history.push(`/scenes/${sceneID}?${paramStr}${autoplayParam}`); + } +} From ccb96c3795d9e035dc9a46c9f2a715da795faeab Mon Sep 17 00:00:00 2001 From: WithoutPants <53250216+WithoutPants@users.noreply.github.com> Date: Wed, 31 Mar 2021 14:54:58 +1100 Subject: [PATCH 05/66] Movie UI refresh (#1227) * Improve movie UI * Return nil when no back image set --- pkg/api/resolver_model_movie.go | 18 + .../src/components/Changelog/versions/v070.md | 1 + .../components/Movies/MovieDetails/Movie.tsx | 484 ++++-------------- .../Movies/MovieDetails/MovieDetailsPanel.tsx | 81 +++ .../Movies/MovieDetails/MovieEditPanel.tsx | 418 +++++++++++++++ ui/v2.5/src/components/Movies/styles.scss | 18 + .../Scenes/SceneDetails/SceneScrapeDialog.tsx | 2 +- ui/v2.5/src/core/StashService.ts | 3 +- ui/v2.5/src/utils/field.tsx | 2 +- 9 files changed, 639 insertions(+), 388 deletions(-) create mode 100644 ui/v2.5/src/components/Movies/MovieDetails/MovieDetailsPanel.tsx create mode 100644 ui/v2.5/src/components/Movies/MovieDetails/MovieEditPanel.tsx diff --git a/pkg/api/resolver_model_movie.go b/pkg/api/resolver_model_movie.go index afd82ab8a..be105eb9e 100644 --- a/pkg/api/resolver_model_movie.go +++ b/pkg/api/resolver_model_movie.go @@ -89,6 +89,24 @@ func (r *movieResolver) FrontImagePath(ctx context.Context, obj *models.Movie) ( } func (r *movieResolver) BackImagePath(ctx context.Context, obj *models.Movie) (*string, error) { + // don't return any thing if there is no back image + var img []byte + if err := r.withReadTxn(ctx, func(repo models.ReaderRepository) error { + var err error + img, err = repo.Movie().GetBackImage(obj.ID) + if err != nil { + return err + } + + return nil + }); err != nil { + return nil, err + } + + if img == nil { + return nil, nil + } + baseURL, _ := ctx.Value(BaseURLCtxKey).(string) backimagePath := urlbuilders.NewMovieURLBuilder(baseURL, obj).GetMovieBackImageURL() return &backimagePath, nil diff --git a/ui/v2.5/src/components/Changelog/versions/v070.md b/ui/v2.5/src/components/Changelog/versions/v070.md index d7f2c5451..c1ab489c7 100644 --- a/ui/v2.5/src/components/Changelog/versions/v070.md +++ b/ui/v2.5/src/components/Changelog/versions/v070.md @@ -2,6 +2,7 @@ * Added scene queue. ### 🎨 Improvements +* Improve Movie UI. * Change performer text query to search by name and alias only. ### 🐛 Bug fixes diff --git a/ui/v2.5/src/components/Movies/MovieDetails/Movie.tsx b/ui/v2.5/src/components/Movies/MovieDetails/Movie.tsx index bd95459af..b2d4fdf38 100644 --- a/ui/v2.5/src/components/Movies/MovieDetails/Movie.tsx +++ b/ui/v2.5/src/components/Movies/MovieDetails/Movie.tsx @@ -1,5 +1,5 @@ -import React, { useEffect, useState, useCallback } from "react"; -import { useIntl } from "react-intl"; +import React, { useEffect, useState } from "react"; +import cx from "classnames"; import Mousetrap from "mousetrap"; import * as GQL from "src/core/generated-graphql"; import { @@ -7,29 +7,19 @@ import { useMovieUpdate, useMovieCreate, useMovieDestroy, - queryScrapeMovieURL, - useListMovieScrapers, } from "src/core/StashService"; import { useParams, useHistory } from "react-router-dom"; import { DetailsEditNavbar, LoadingIndicator, Modal, - StudioSelect, - Icon, } from "src/components/Shared"; import { useToast } from "src/hooks"; -import { Table, Form, Modal as BSModal, Button } from "react-bootstrap"; -import { - TableUtils, - ImageUtils, - EditableTextUtils, - TextUtils, - DurationUtils, -} from "src/utils"; -import { RatingStars } from "src/components/Scenes/SceneDetails/RatingStars"; +import { Modal as BSModal, Button } from "react-bootstrap"; +import { ImageUtils } from "src/utils"; import { MovieScenesPanel } from "./MovieScenesPanel"; -import { MovieScrapeDialog } from "./MovieScrapeDialog"; +import { MovieDetailsPanel } from "./MovieDetailsPanel"; +import { MovieEditPanel } from "./MovieEditPanel"; interface IMovieParams { id?: string; @@ -53,111 +43,32 @@ export const Movie: React.FC = () => { const [backImage, setBackImage] = useState( undefined ); - const [name, setName] = useState(undefined); - const [aliases, setAliases] = useState(undefined); - const [duration, setDuration] = useState(undefined); - const [date, setDate] = useState(undefined); - const [rating, setRating] = useState(undefined); - const [studioId, setStudioId] = useState(); - const [director, setDirector] = useState(undefined); - const [synopsis, setSynopsis] = useState(undefined); - const [url, setUrl] = useState(undefined); // Movie state - const [movie, setMovie] = useState>({}); - const [imagePreview, setImagePreview] = useState( - undefined - ); - const [backimagePreview, setBackImagePreview] = useState( - undefined - ); - const [imageClipboard, setImageClipboard] = useState( undefined ); // Network state const { data, error, loading } = useFindMovie(id); + const movie = data?.findMovie; + const [isLoading, setIsLoading] = useState(false); const [updateMovie] = useMovieUpdate(); - const [createMovie] = useMovieCreate(getMovieInput() as GQL.MovieCreateInput); - const [deleteMovie] = useMovieDestroy( - getMovieInput() as GQL.MovieDestroyInput - ); - - const Scrapers = useListMovieScrapers(); - const [scrapedMovie, setScrapedMovie] = useState< - GQL.ScrapedMovie | undefined - >(); - - const intl = useIntl(); + const [createMovie] = useMovieCreate(); + const [deleteMovie] = useMovieDestroy({ id }); // set up hotkeys useEffect(() => { - if (isEditing) { - Mousetrap.bind("r 0", () => setRating(NaN)); - Mousetrap.bind("r 1", () => setRating(1)); - Mousetrap.bind("r 2", () => setRating(2)); - Mousetrap.bind("r 3", () => setRating(3)); - Mousetrap.bind("r 4", () => setRating(4)); - Mousetrap.bind("r 5", () => setRating(5)); - // Mousetrap.bind("u", (e) => { - // setStudioFocus() - // e.preventDefault(); - // }); - Mousetrap.bind("s s", () => onSave()); - } - Mousetrap.bind("e", () => setIsEditing(true)); Mousetrap.bind("d d", () => onDelete()); return () => { - if (isEditing) { - Mousetrap.unbind("r 0"); - Mousetrap.unbind("r 1"); - Mousetrap.unbind("r 2"); - Mousetrap.unbind("r 3"); - Mousetrap.unbind("r 4"); - Mousetrap.unbind("r 5"); - // Mousetrap.unbind("u"); - Mousetrap.unbind("s s"); - } - Mousetrap.unbind("e"); Mousetrap.unbind("d d"); }; }); - function updateMovieEditState(state: Partial) { - setName(state.name ?? undefined); - setAliases(state.aliases ?? undefined); - setDuration(state.duration ?? undefined); - setDate(state.date ?? undefined); - setRating(state.rating ?? undefined); - setStudioId(state?.studio?.id ?? undefined); - setDirector(state.director ?? undefined); - setSynopsis(state.synopsis ?? undefined); - setUrl(state.url ?? undefined); - } - - const updateMovieData = useCallback( - (movieData: Partial) => { - setFrontImage(undefined); - setBackImage(undefined); - updateMovieEditState(movieData); - setImagePreview(movieData.front_image_path ?? undefined); - setBackImagePreview(movieData.back_image_path ?? undefined); - setMovie(movieData); - }, - [] - ); - - useEffect(() => { - if (data && data.findMovie) { - updateMovieData(data.findMovie); - } - }, [data, updateMovieData]); - function showImageAlert(imageData: string) { setImageClipboard(imageData); setIsImageAlertOpen(true); @@ -165,10 +76,8 @@ export const Movie: React.FC = () => { function setImageFromClipboard(isFrontImage: boolean) { if (isFrontImage) { - setImagePreview(imageClipboard); setFrontImage(imageClipboard); } else { - setBackImagePreview(imageClipboard); setBackImage(imageClipboard); } @@ -176,16 +85,6 @@ export const Movie: React.FC = () => { setIsImageAlertOpen(false); } - function onBackImageLoad(imageData: string) { - setBackImagePreview(imageData); - setBackImage(imageData); - } - - function onFrontImageLoad(imageData: string) { - setImagePreview(imageData); - setFrontImage(imageData); - } - const encodingImage = ImageUtils.usePasteImage(showImageAlert, isEditing); if (!isNew && !isEditing) { @@ -195,41 +94,41 @@ export const Movie: React.FC = () => { } } - function getMovieInput() { - const input: Partial = { - name, - aliases, - duration, - date, - rating: rating ?? null, - studio_id: studioId ?? null, - director, - synopsis, - url, + function getMovieInput( + input: Partial + ) { + const ret: Partial = { + ...input, front_image: frontImage, back_image: backImage, }; if (!isNew) { - (input as GQL.MovieUpdateInput).id = id; + (ret as GQL.MovieUpdateInput).id = id; } - return input; + return ret; } - async function onSave() { + async function onSave( + input: Partial + ) { try { + setIsLoading(true); + if (!isNew) { const result = await updateMovie({ variables: { - input: getMovieInput() as GQL.MovieUpdateInput, + input: getMovieInput(input) as GQL.MovieUpdateInput, }, }); if (result.data?.movieUpdate) { - updateMovieData(result.data.movieUpdate); setIsEditing(false); + history.push(`/movies/${result.data.movieUpdate.id}`); } } else { - const result = await createMovie(); + const result = await createMovie({ + variables: getMovieInput(input) as GQL.MovieCreateInput, + }); if (result.data?.movieCreate?.id) { history.push(`/movies/${result.data.movieCreate.id}`); setIsEditing(false); @@ -237,31 +136,29 @@ export const Movie: React.FC = () => { } } catch (e) { Toast.error(e); + } finally { + setIsLoading(false); } } async function onDelete() { try { + setIsLoading(true); await deleteMovie(); } catch (e) { Toast.error(e); + } finally { + setIsLoading(false); } // redirect to movies page history.push(`/movies`); } - function onFrontImageChange(event: React.FormEvent) { - ImageUtils.onImageChange(event, onFrontImageLoad); - } - - function onBackImageChange(event: React.FormEvent) { - ImageUtils.onImageChange(event, onBackImageLoad); - } - function onToggleEdit() { setIsEditing(!isEditing); - updateMovieData(movie); + setFrontImage(undefined); + setBackImage(undefined); } function renderDeleteAlert() { @@ -272,7 +169,7 @@ export const Movie: React.FC = () => { accept={{ text: "Delete", variant: "danger", onClick: onDelete }} cancel={{ onClick: () => setIsDeleteAlertOpen(false) }} > -

    Are you sure you want to delete {name ?? "movie"}?

    +

    Are you sure you want to delete {movie?.name ?? "movie"}?

    ); } @@ -314,153 +211,42 @@ export const Movie: React.FC = () => { ); } - function updateMovieEditStateFromScraper( - state: Partial - ) { - if (state.name) { - setName(state.name); - } - - if (state.aliases) { - setAliases(state.aliases ?? undefined); - } - - if (state.duration) { - setDuration(DurationUtils.stringToSeconds(state.duration) ?? undefined); - } - - if (state.date) { - setDate(state.date ?? undefined); - } - - if (state.studio && state.studio.id) { - setStudioId(state.studio.id ?? undefined); - } - - if (state.director) { - setDirector(state.director ?? undefined); - } - if (state.synopsis) { - setSynopsis(state.synopsis ?? undefined); - } - if (state.url) { - setUrl(state.url ?? undefined); - } - - // image is a base64 string - // #404: don't overwrite image if it has been modified by the user - // overwrite if not new since it came from a dialog - // otherwise follow existing behaviour - if ( - (!isNew || frontImage === undefined) && - (state as GQL.ScrapedMovieDataFragment).front_image !== undefined - ) { - const imageStr = (state as GQL.ScrapedMovieDataFragment).front_image; - setFrontImage(imageStr ?? undefined); - setImagePreview(imageStr ?? undefined); - } - - if ( - (!isNew || backImage === undefined) && - (state as GQL.ScrapedMovieDataFragment).back_image !== undefined - ) { - const imageStr = (state as GQL.ScrapedMovieDataFragment).back_image; - setBackImage(imageStr ?? undefined); - setBackImagePreview(imageStr ?? undefined); - } - } - - async function onScrapeMovieURL() { - if (!url) return; - setIsLoading(true); - - try { - const result = await queryScrapeMovieURL(url); - if (!result.data || !result.data.scrapeMovieURL) { - return; + function renderFrontImage() { + let image = movie?.front_image_path; + if (isEditing) { + if (frontImage === null) { + image = `${image}&default=true`; + } else if (frontImage) { + image = frontImage; } + } - // if this is a new movie, just dump the data - if (isNew) { - updateMovieEditStateFromScraper(result.data.scrapeMovieURL); - } else { - setScrapedMovie(result.data.scrapeMovieURL); + if (image) { + return ( +
    + Front Cover +
    + ); + } + } + + function renderBackImage() { + let image = movie?.back_image_path; + if (isEditing) { + if (backImage === null) { + image = undefined; + } else if (backImage) { + image = backImage; } - } catch (e) { - Toast.error(e); - } finally { - setIsLoading(false); - } - } - - function urlScrapable(scrapedUrl: string) { - return ( - !!scrapedUrl && - (Scrapers?.data?.listMovieScrapers ?? []).some((s) => - (s?.movie?.urls ?? []).some((u) => scrapedUrl.includes(u)) - ) - ); - } - - function maybeRenderScrapeButton() { - if (!url || !isEditing || !urlScrapable(url)) { - return undefined; - } - return ( - - ); - } - - function maybeRenderScrapeDialog() { - if (!scrapedMovie) { - return; } - const currentMovie = getMovieInput(); - - // Get image paths for scrape gui - currentMovie.front_image = movie.front_image_path; - currentMovie.back_image = movie.back_image_path; - - return ( - { - onScrapeDialogClosed(m); - }} - /> - ); - } - - function onScrapeDialogClosed(p?: GQL.ScrapedMovieDataFragment) { - if (p) { - updateMovieEditStateFromScraper(p); + if (image) { + return ( +
    + Back Cover +
    + ); } - setScrapedMovie(undefined); - } - - function onClearFrontImage() { - setFrontImage(null); - setImagePreview( - movie.front_image_path - ? `${movie.front_image_path}?default=true` - : undefined - ); - } - - function onClearBackImage() { - setBackImage(null); - setBackImagePreview( - movie.back_image_path - ? `${movie.back_image_path}?default=true` - : undefined - ); } if (isLoading) return ; @@ -468,125 +254,55 @@ export const Movie: React.FC = () => { // TODO: CSS class return (
    -
    - {isNew &&

    Add Movie

    } +
    {encodingImage ? ( ) : ( - <> - {name} - {name} - +
    + {renderFrontImage()} + {renderBackImage()} +
    )}
    - - - {TableUtils.renderInputGroup({ - title: "Name", - value: name ?? "", - isEditing: !!isEditing, - onChange: setName, - })} - {TableUtils.renderInputGroup({ - title: "Aliases", - value: aliases, - isEditing, - onChange: setAliases, - })} - {TableUtils.renderDurationInput({ - title: "Duration", - value: duration ? duration.toString() : "", - isEditing, - onChange: (value: string | undefined) => - setDuration(value ? Number.parseInt(value, 10) : undefined), - })} - {TableUtils.renderInputGroup({ - title: `Date ${isEditing ? "(YYYY-MM-DD)" : ""}`, - value: isEditing ? date : TextUtils.formatDate(intl, date), - isEditing, - onChange: setDate, - })} - - - - - {TableUtils.renderInputGroup({ - title: "Director", - value: director, - isEditing, - onChange: setDirector, - })} - - - - - -
    Studio - - setStudioId(items.length > 0 ? items[0]?.id : undefined) - } - ids={studioId ? [studioId] : []} - /> -
    Rating - setRating(value)} - /> -
    - - - URL {maybeRenderScrapeButton()} -
    - {EditableTextUtils.renderInputGroup({ - isEditing, - onChange: setUrl, - value: url, - url: TextUtils.sanitiseURL(url), - })} -
    -
    - - - Synopsis - ) => - setSynopsis(newValue.currentTarget.value) - } - value={synopsis} + {!isEditing && movie ? ( + <> + + {/* HACK - this is also rendered in the MovieEditPanel */} + {}} + onImageChange={() => {}} + onDelete={onDelete} + /> + + ) : ( + - - - + )}
    - {!isNew && ( -
    + + {!isNew && movie && ( +
    )} {renderDeleteAlert()} {renderImageAlert()} - {maybeRenderScrapeDialog()}
    ); }; diff --git a/ui/v2.5/src/components/Movies/MovieDetails/MovieDetailsPanel.tsx b/ui/v2.5/src/components/Movies/MovieDetails/MovieDetailsPanel.tsx new file mode 100644 index 000000000..5f3c27fe0 --- /dev/null +++ b/ui/v2.5/src/components/Movies/MovieDetails/MovieDetailsPanel.tsx @@ -0,0 +1,81 @@ +import React from "react"; +import { useIntl } from "react-intl"; +import * as GQL from "src/core/generated-graphql"; +import { DurationUtils, TextUtils } from "src/utils"; +import { RatingStars } from "src/components/Scenes/SceneDetails/RatingStars"; +import { TextField, URLField } from "src/utils/field"; + +interface IMovieDetailsPanel { + movie: Partial; +} + +export const MovieDetailsPanel: React.FC = ({ movie }) => { + // Network state + const intl = useIntl(); + + function maybeRenderAliases() { + if (movie.aliases) { + return ( +
    + Also known as + {movie.aliases} +
    + ); + } + } + + function renderRatingField() { + if (!movie.rating) { + return; + } + + return ( +
    +
    Rating
    +
    + +
    +
    + ); + } + + // TODO: CSS class + return ( +
    +
    +

    {movie.name}

    +
    + + {maybeRenderAliases()} + +
    + + + + + + {renderRatingField()} + + + + +
    +
    + ); +}; diff --git a/ui/v2.5/src/components/Movies/MovieDetails/MovieEditPanel.tsx b/ui/v2.5/src/components/Movies/MovieDetails/MovieEditPanel.tsx new file mode 100644 index 000000000..4c9a71aa0 --- /dev/null +++ b/ui/v2.5/src/components/Movies/MovieDetails/MovieEditPanel.tsx @@ -0,0 +1,418 @@ +import React, { useEffect, useState } from "react"; +import * as GQL from "src/core/generated-graphql"; +import * as yup from "yup"; +import Mousetrap from "mousetrap"; +import { + queryScrapeMovieURL, + useListMovieScrapers, +} from "src/core/StashService"; +import { + LoadingIndicator, + StudioSelect, + Icon, + DetailsEditNavbar, + DurationInput, +} from "src/components/Shared"; +import { useToast } from "src/hooks"; +import { Form, Button, Col, Row, InputGroup } from "react-bootstrap"; +import { DurationUtils, ImageUtils } from "src/utils"; +import { RatingStars } from "src/components/Scenes/SceneDetails/RatingStars"; +import { useFormik } from "formik"; +import { Prompt } from "react-router-dom"; +import { MovieScrapeDialog } from "./MovieScrapeDialog"; + +interface IMovieEditPanel { + movie?: Partial; + onSubmit: ( + movie: Partial + ) => void; + onCancel: () => void; + onDelete: () => void; + setFrontImage: (image?: string | null) => void; + setBackImage: (image?: string | null) => void; +} + +export const MovieEditPanel: React.FC = ({ + movie, + onSubmit, + onCancel, + onDelete, + setFrontImage, + setBackImage, +}) => { + const Toast = useToast(); + + const isNew = movie === undefined; + + const [isLoading, setIsLoading] = useState(false); + + const Scrapers = useListMovieScrapers(); + const [scrapedMovie, setScrapedMovie] = useState< + GQL.ScrapedMovie | undefined + >(); + + const labelXS = 3; + const labelXL = 3; + const fieldXS = 9; + const fieldXL = 9; + + const schema = yup.object({ + name: yup.string().required(), + aliases: yup.string().optional().nullable(), + duration: yup.string().optional().nullable(), + date: yup + .string() + .optional() + .nullable() + .matches(/^\d{4}-\d{2}-\d{2}$/), + rating: yup.number().optional().nullable(), + studio_id: yup.string().optional().nullable(), + director: yup.string().optional().nullable(), + synopsis: yup.string().optional().nullable(), + url: yup.string().optional().nullable(), + }); + + const initialValues = { + name: movie?.name, + aliases: movie?.aliases, + duration: movie?.duration, + date: movie?.date, + rating: movie?.rating, + studio_id: movie?.studio?.id, + director: movie?.director, + synopsis: movie?.synopsis, + url: movie?.url, + }; + + type InputValues = typeof initialValues; + + const formik = useFormik({ + initialValues, + validationSchema: schema, + onSubmit: (values) => onSubmit(getMovieInput(values)), + }); + + function setRating(v: number) { + formik.setFieldValue("rating", v); + } + + // set up hotkeys + useEffect(() => { + Mousetrap.bind("r 0", () => setRating(NaN)); + Mousetrap.bind("r 1", () => setRating(1)); + Mousetrap.bind("r 2", () => setRating(2)); + Mousetrap.bind("r 3", () => setRating(3)); + Mousetrap.bind("r 4", () => setRating(4)); + Mousetrap.bind("r 5", () => setRating(5)); + // Mousetrap.bind("u", (e) => { + // setStudioFocus() + // e.preventDefault(); + // }); + Mousetrap.bind("s s", () => formik.handleSubmit()); + + return () => { + Mousetrap.unbind("r 0"); + Mousetrap.unbind("r 1"); + Mousetrap.unbind("r 2"); + Mousetrap.unbind("r 3"); + Mousetrap.unbind("r 4"); + Mousetrap.unbind("r 5"); + // Mousetrap.unbind("u"); + Mousetrap.unbind("s s"); + }; + }); + + function getMovieInput(values: InputValues) { + const input: Partial = { + ...values, + rating: values.rating ?? null, + studio_id: values.studio_id ?? null, + }; + + if (movie && movie.id) { + (input as GQL.MovieUpdateInput).id = movie.id; + } + return input; + } + + function updateMovieEditStateFromScraper( + state: Partial + ) { + if (state.name) { + formik.setFieldValue("name", state.name); + } + + if (state.aliases) { + formik.setFieldValue("aliases", state.aliases ?? undefined); + } + + if (state.duration) { + formik.setFieldValue( + "duration", + DurationUtils.stringToSeconds(state.duration) ?? undefined + ); + } + + if (state.date) { + formik.setFieldValue("date", state.date ?? undefined); + } + + if (state.studio && state.studio.id) { + formik.setFieldValue("studio_id", state.studio.id ?? undefined); + } + + if (state.director) { + formik.setFieldValue("director", state.director ?? undefined); + } + if (state.synopsis) { + formik.setFieldValue("synopsis", state.synopsis ?? undefined); + } + if (state.url) { + formik.setFieldValue("url", state.url ?? undefined); + } + + const imageStr = (state as GQL.ScrapedMovieDataFragment).front_image; + setFrontImage(imageStr ?? undefined); + + const backImageStr = (state as GQL.ScrapedMovieDataFragment).back_image; + setBackImage(backImageStr ?? undefined); + } + + async function onScrapeMovieURL() { + const { url } = formik.values; + if (!url) return; + setIsLoading(true); + + try { + const result = await queryScrapeMovieURL(url); + if (!result.data || !result.data.scrapeMovieURL) { + return; + } + + // if this is a new movie, just dump the data + if (isNew) { + updateMovieEditStateFromScraper(result.data.scrapeMovieURL); + } else { + setScrapedMovie(result.data.scrapeMovieURL); + } + } catch (e) { + Toast.error(e); + } finally { + setIsLoading(false); + } + } + + function urlScrapable(scrapedUrl: string) { + return ( + !!scrapedUrl && + (Scrapers?.data?.listMovieScrapers ?? []).some((s) => + (s?.movie?.urls ?? []).some((u) => scrapedUrl.includes(u)) + ) + ); + } + + function maybeRenderScrapeButton() { + const { url } = formik.values; + if (!url || !urlScrapable(url)) { + return undefined; + } + return ( + + ); + } + + function maybeRenderScrapeDialog() { + if (!scrapedMovie) { + return; + } + + const currentMovie = getMovieInput(formik.values); + + // Get image paths for scrape gui + currentMovie.front_image = movie?.front_image_path; + currentMovie.back_image = movie?.back_image_path; + + return ( + { + onScrapeDialogClosed(m); + }} + /> + ); + } + + function onScrapeDialogClosed(p?: GQL.ScrapedMovieDataFragment) { + if (p) { + updateMovieEditStateFromScraper(p); + } + setScrapedMovie(undefined); + } + + function onFrontImageChange(event: React.FormEvent) { + ImageUtils.onImageChange(event, setFrontImage); + } + + function onBackImageChange(event: React.FormEvent) { + ImageUtils.onImageChange(event, setBackImage); + } + + if (isLoading) return ; + + const isEditing = true; + + function renderTextField(field: string, title: string) { + return ( + + + {title} + + + + + + ); + } + + // TODO: CSS class + return ( +
    + {isNew &&

    Add Movie

    } + + + +
    + + + Name + + + + + {formik.errors.name} + + + + + {renderTextField("aliases", "Aliases")} + + + + Duration + + + { + formik.setFieldValue("duration", valueAsNumber); + }} + /> + + + + {renderTextField("date", "Date (YYYY-MM-DD)")} + + + + Studio + + + + formik.setFieldValue( + "studio_id", + items.length > 0 ? items[0]?.id : undefined + ) + } + ids={formik.values.studio_id ? [formik.values.studio_id] : []} + /> + + + + {renderTextField("director", "Director")} + + + + Rating + + + formik.setFieldValue("rating", value)} + /> + + + + + + URL + + + + + {maybeRenderScrapeButton()} + + + + + + + Synopsis + + + + + +
    + + formik.handleSubmit()} + onImageChange={onFrontImageChange} + onImageChangeURL={setFrontImage} + onClearImage={() => { + setFrontImage(null); + }} + onBackImageChange={onBackImageChange} + onBackImageChangeURL={setBackImage} + onClearBackImage={() => { + setBackImage(null); + }} + onDelete={onDelete} + /> + + {maybeRenderScrapeDialog()} +
    + ); +}; diff --git a/ui/v2.5/src/components/Movies/styles.scss b/ui/v2.5/src/components/Movies/styles.scss index 45fb9c14b..c87122a55 100644 --- a/ui/v2.5/src/components/Movies/styles.scss +++ b/ui/v2.5/src/components/Movies/styles.scss @@ -18,3 +18,21 @@ width: 100%; } } + +.movie-images { + align-items: center; + display: flex; + flex-direction: row; + justify-content: space-evenly; + margin: 1rem; + max-width: 100%; + + .movie-image-container { + margin: 1rem; + } + + img { + max-width: 100%; + object-fit: contain; + } +} diff --git a/ui/v2.5/src/components/Scenes/SceneDetails/SceneScrapeDialog.tsx b/ui/v2.5/src/components/Scenes/SceneDetails/SceneScrapeDialog.tsx index b2ed6f860..77838220c 100644 --- a/ui/v2.5/src/components/Scenes/SceneDetails/SceneScrapeDialog.tsx +++ b/ui/v2.5/src/components/Scenes/SceneDetails/SceneScrapeDialog.tsx @@ -317,7 +317,7 @@ export const SceneScrapeDialog: React.FC = ( const [createStudio] = useStudioCreate({ name: "" }); const [createPerformer] = usePerformerCreate(); - const [createMovie] = useMovieCreate({ name: "" }); + const [createMovie] = useMovieCreate(); const [createTag] = useTagCreate({ name: "" }); const Toast = useToast(); diff --git a/ui/v2.5/src/core/StashService.ts b/ui/v2.5/src/core/StashService.ts index 10832462d..b35da8123 100644 --- a/ui/v2.5/src/core/StashService.ts +++ b/ui/v2.5/src/core/StashService.ts @@ -602,9 +602,8 @@ export const movieMutationImpactedQueries = [ GQL.AllMoviesForFilterDocument, ]; -export const useMovieCreate = (input: GQL.MovieCreateInput) => +export const useMovieCreate = () => GQL.useMovieCreateMutation({ - variables: input, update: deleteCache([ GQL.FindMoviesDocument, GQL.AllMoviesForFilterDocument, diff --git a/ui/v2.5/src/utils/field.tsx b/ui/v2.5/src/utils/field.tsx index 9833d87c9..14fd5b9e9 100644 --- a/ui/v2.5/src/utils/field.tsx +++ b/ui/v2.5/src/utils/field.tsx @@ -29,7 +29,7 @@ export const URLField: React.FC = ({ name, value, url }) => { return null; } return ( -
    +
    {name}:
    {url ? ( From 2c2e56d33aaa8121ce5d0495d89580de18c843ba Mon Sep 17 00:00:00 2001 From: UncleRoger33 <66418211+UncleRoger33@users.noreply.github.com> Date: Wed, 31 Mar 2021 07:55:15 +0300 Subject: [PATCH 06/66] Add format to performer field placeholder (#1232) * Update README.md Extra letter "p" in the title removed and "(FAQ)" suffix added. Co-authored-by: peolic <66393006+peolic@users.noreply.github.com> --- README.md | 2 +- .../Performers/PerformerDetails/PerformerEditPanel.tsx | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 6d0513996..5282f936c 100644 --- a/README.md +++ b/README.md @@ -76,7 +76,7 @@ There is a [directory of themes](https://github.com/stashapp/stash/wiki/Themes) ## CSS Customization You can make Stash interface fit your desired style with [Custom CSS snippets](https://github.com/stashapp/stash/wiki/Custom-CSS-snippets) and [CSS Tweaks](https://github.com/stashapp/stash/wiki/CSS-Tweaks). -# Suppport +# Support (FAQ) Answers to frequently asked questions can be found [on our Wiki](https://github.com/stashapp/stash/wiki/FAQ) diff --git a/ui/v2.5/src/components/Performers/PerformerDetails/PerformerEditPanel.tsx b/ui/v2.5/src/components/Performers/PerformerDetails/PerformerEditPanel.tsx index e4ebe7c08..0f0ddc0eb 100644 --- a/ui/v2.5/src/components/Performers/PerformerDetails/PerformerEditPanel.tsx +++ b/ui/v2.5/src/components/Performers/PerformerDetails/PerformerEditPanel.tsx @@ -725,7 +725,7 @@ export const PerformerEditPanel: React.FC = ({ ); } - function renderTextField(field: string, title: string) { + function renderTextField(field: string, title: string, placeholder?: string) { return ( @@ -734,7 +734,7 @@ export const PerformerEditPanel: React.FC = ({ @@ -805,7 +805,7 @@ export const PerformerEditPanel: React.FC = ({ - {renderTextField("birthdate", "Birthdate")} + {renderTextField("birthdate", "Birthdate", "YYYY-MM-DD")} {renderTextField("country", "Country")} {renderTextField("ethnicity", "Ethnicity")} {renderTextField("eye_color", "Eye Color")} From 1412b554a07abfa39efd2bab073231008540d61a Mon Sep 17 00:00:00 2001 From: WithoutPants <53250216+WithoutPants@users.noreply.github.com> Date: Wed, 31 Mar 2021 16:08:52 +1100 Subject: [PATCH 07/66] Api key (#1241) --- go.mod | 1 + go.sum | 1 + graphql/documents/data/config.graphql | 1 + graphql/documents/mutations/config.graphql | 6 +- graphql/schema/schema.graphql | 3 + graphql/schema/types/config.graphql | 6 + pkg/api/resolver_mutation_configure.go | 21 +++ pkg/api/resolver_query_configuration.go | 1 + pkg/api/server.go | 22 ++- pkg/manager/apikey.go | 55 +++++++ pkg/manager/config/config.go | 8 +- .../src/components/Changelog/versions/v070.md | 1 + .../Settings/SettingsConfigurationPanel.tsx | 66 +++++++- ui/v2.5/src/core/StashService.ts | 6 + ui/v2.5/src/docs/en/Configuration.md | 6 + vendor/github.com/dgrijalva/jwt-go/.gitignore | 4 + .../github.com/dgrijalva/jwt-go/.travis.yml | 13 ++ vendor/github.com/dgrijalva/jwt-go/LICENSE | 8 + .../dgrijalva/jwt-go/MIGRATION_GUIDE.md | 97 ++++++++++++ vendor/github.com/dgrijalva/jwt-go/README.md | 100 ++++++++++++ .../dgrijalva/jwt-go/VERSION_HISTORY.md | 118 ++++++++++++++ vendor/github.com/dgrijalva/jwt-go/claims.go | 134 ++++++++++++++++ vendor/github.com/dgrijalva/jwt-go/doc.go | 4 + vendor/github.com/dgrijalva/jwt-go/ecdsa.go | 148 ++++++++++++++++++ .../dgrijalva/jwt-go/ecdsa_utils.go | 67 ++++++++ vendor/github.com/dgrijalva/jwt-go/errors.go | 59 +++++++ vendor/github.com/dgrijalva/jwt-go/hmac.go | 95 +++++++++++ .../github.com/dgrijalva/jwt-go/map_claims.go | 94 +++++++++++ vendor/github.com/dgrijalva/jwt-go/none.go | 52 ++++++ vendor/github.com/dgrijalva/jwt-go/parser.go | 148 ++++++++++++++++++ vendor/github.com/dgrijalva/jwt-go/rsa.go | 101 ++++++++++++ vendor/github.com/dgrijalva/jwt-go/rsa_pss.go | 126 +++++++++++++++ .../github.com/dgrijalva/jwt-go/rsa_utils.go | 101 ++++++++++++ .../dgrijalva/jwt-go/signing_method.go | 35 +++++ vendor/github.com/dgrijalva/jwt-go/token.go | 108 +++++++++++++ vendor/modules.txt | 2 + 36 files changed, 1811 insertions(+), 7 deletions(-) create mode 100644 pkg/manager/apikey.go create mode 100644 vendor/github.com/dgrijalva/jwt-go/.gitignore create mode 100644 vendor/github.com/dgrijalva/jwt-go/.travis.yml create mode 100644 vendor/github.com/dgrijalva/jwt-go/LICENSE create mode 100644 vendor/github.com/dgrijalva/jwt-go/MIGRATION_GUIDE.md create mode 100644 vendor/github.com/dgrijalva/jwt-go/README.md create mode 100644 vendor/github.com/dgrijalva/jwt-go/VERSION_HISTORY.md create mode 100644 vendor/github.com/dgrijalva/jwt-go/claims.go create mode 100644 vendor/github.com/dgrijalva/jwt-go/doc.go create mode 100644 vendor/github.com/dgrijalva/jwt-go/ecdsa.go create mode 100644 vendor/github.com/dgrijalva/jwt-go/ecdsa_utils.go create mode 100644 vendor/github.com/dgrijalva/jwt-go/errors.go create mode 100644 vendor/github.com/dgrijalva/jwt-go/hmac.go create mode 100644 vendor/github.com/dgrijalva/jwt-go/map_claims.go create mode 100644 vendor/github.com/dgrijalva/jwt-go/none.go create mode 100644 vendor/github.com/dgrijalva/jwt-go/parser.go create mode 100644 vendor/github.com/dgrijalva/jwt-go/rsa.go create mode 100644 vendor/github.com/dgrijalva/jwt-go/rsa_pss.go create mode 100644 vendor/github.com/dgrijalva/jwt-go/rsa_utils.go create mode 100644 vendor/github.com/dgrijalva/jwt-go/signing_method.go create mode 100644 vendor/github.com/dgrijalva/jwt-go/token.go diff --git a/go.mod b/go.mod index 50213e41c..2c32d3e80 100644 --- a/go.mod +++ b/go.mod @@ -6,6 +6,7 @@ require ( github.com/antchfx/htmlquery v1.2.3 github.com/chromedp/cdproto v0.0.0-20200608134039-8a80cdaf865c github.com/chromedp/chromedp v0.5.3 + github.com/dgrijalva/jwt-go v3.2.0+incompatible github.com/disintegration/imaging v1.6.0 github.com/fvbommel/sortorder v1.0.2 github.com/go-chi/chi v4.0.2+incompatible diff --git a/go.sum b/go.sum index 744bb3712..9c3bf7c37 100644 --- a/go.sum +++ b/go.sum @@ -99,6 +99,7 @@ github.com/cznic/zappy v0.0.0-20160723133515-2533cb5b45cc/go.mod h1:Y1SNZ4dRUOKX github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/dgrijalva/jwt-go v3.2.0+incompatible h1:7qlOGliEKZXTDg6OTjfoBKDXWrumCAMpl/TFQ4/5kLM= github.com/dgrijalva/jwt-go v3.2.0+incompatible/go.mod h1:E3ru+11k8xSBh+hMPgOLZmtrrCbhqsmaPHjLKYnJCaQ= github.com/dgryski/go-sip13 v0.0.0-20181026042036-e10d5fee7954/go.mod h1:vAd38F8PWV+bWy6jNmig1y/TA+kYO4g3RSRF0IAv0no= github.com/dgryski/trifles v0.0.0-20190318185328-a8d75aae118c/go.mod h1:if7Fbed8SFyPtHLHbg49SI7NAdJiC5WIA09pe59rfAA= diff --git a/graphql/documents/data/config.graphql b/graphql/documents/data/config.graphql index 07330d63b..250c937b4 100644 --- a/graphql/documents/data/config.graphql +++ b/graphql/documents/data/config.graphql @@ -17,6 +17,7 @@ fragment ConfigGeneralData on ConfigGeneralResult { previewPreset maxTranscodeSize maxStreamingTranscodeSize + apiKey username password maxSessionAge diff --git a/graphql/documents/mutations/config.graphql b/graphql/documents/mutations/config.graphql index 4e273f418..3b2dc6cb2 100644 --- a/graphql/documents/mutations/config.graphql +++ b/graphql/documents/mutations/config.graphql @@ -8,4 +8,8 @@ mutation ConfigureInterface($input: ConfigInterfaceInput!) { configureInterface(input: $input) { ...ConfigInterfaceData } -} \ No newline at end of file +} + +mutation GenerateAPIKey($input: GenerateAPIKeyInput!) { + generateAPIKey(input: $input) +} diff --git a/graphql/schema/schema.graphql b/graphql/schema/schema.graphql index 7286f4591..29a1e5681 100644 --- a/graphql/schema/schema.graphql +++ b/graphql/schema/schema.graphql @@ -190,6 +190,9 @@ type Mutation { configureGeneral(input: ConfigGeneralInput!): ConfigGeneralResult! configureInterface(input: ConfigInterfaceInput!): ConfigInterfaceResult! + """Generate and set (or clear) API key""" + generateAPIKey(input: GenerateAPIKeyInput!): String! + """Returns a link to download the result""" exportObjects(input: ExportObjectsInput!): String diff --git a/graphql/schema/types/config.graphql b/graphql/schema/types/config.graphql index 24b243138..df1c3415e 100644 --- a/graphql/schema/types/config.graphql +++ b/graphql/schema/types/config.graphql @@ -120,6 +120,8 @@ type ConfigGeneralResult { maxTranscodeSize: StreamingResolutionEnum """Max streaming transcode size""" maxStreamingTranscodeSize: StreamingResolutionEnum + """API Key""" + apiKey: String! """Username""" username: String! """Password""" @@ -225,3 +227,7 @@ type StashConfig { excludeVideo: Boolean! excludeImage: Boolean! } + +input GenerateAPIKeyInput { + clear: Boolean +} diff --git a/pkg/api/resolver_mutation_configure.go b/pkg/api/resolver_mutation_configure.go index 34b416094..40b71c63f 100644 --- a/pkg/api/resolver_mutation_configure.go +++ b/pkg/api/resolver_mutation_configure.go @@ -223,3 +223,24 @@ func (r *mutationResolver) ConfigureInterface(ctx context.Context, input models. return makeConfigInterfaceResult(), nil } + +func (r *mutationResolver) GenerateAPIKey(ctx context.Context, input models.GenerateAPIKeyInput) (string, error) { + var newAPIKey string + if input.Clear == nil || !*input.Clear { + username := config.GetUsername() + if username != "" { + var err error + newAPIKey, err = manager.GenerateAPIKey(username) + if err != nil { + return "", err + } + } + } + + config.Set(config.ApiKey, newAPIKey) + if err := config.Write(); err != nil { + return newAPIKey, err + } + + return newAPIKey, nil +} diff --git a/pkg/api/resolver_query_configuration.go b/pkg/api/resolver_query_configuration.go index cd4bbaff4..cc8de2a3e 100644 --- a/pkg/api/resolver_query_configuration.go +++ b/pkg/api/resolver_query_configuration.go @@ -59,6 +59,7 @@ func makeConfigGeneralResult() *models.ConfigGeneralResult { PreviewPreset: config.GetPreviewPreset(), MaxTranscodeSize: &maxTranscodeSize, MaxStreamingTranscodeSize: &maxStreamingTranscodeSize, + APIKey: config.GetAPIKey(), Username: config.GetUsername(), Password: config.GetPasswordHash(), MaxSessionAge: config.GetMaxSessionAge(), diff --git a/pkg/api/server.go b/pkg/api/server.go index 331e203f3..605e108d9 100644 --- a/pkg/api/server.go +++ b/pkg/api/server.go @@ -41,6 +41,8 @@ var uiBox *packr.Box var setupUIBox *packr.Box var loginUIBox *packr.Box +const ApiKeyHeader = "ApiKey" + func allowUnauthenticated(r *http.Request) bool { return strings.HasPrefix(r.URL.Path, "/login") || r.URL.Path == "/css" } @@ -52,10 +54,24 @@ func authenticateHandler() func(http.Handler) http.Handler { // translate api key into current user, if present userID := "" + apiKey := r.Header.Get(ApiKeyHeader) var err error - // handle session - userID, err = getSessionUserID(w, r) + if apiKey != "" { + // match against configured API and set userID to the + // configured username. In future, we'll want to + // get the username from the key. + if config.GetAPIKey() != apiKey { + w.Header().Add("WWW-Authenticate", `FormBased`) + w.WriteHeader(http.StatusUnauthorized) + return + } + + userID = config.GetUsername() + } else { + // handle session + userID, err = getSessionUserID(w, r) + } if err != nil { w.WriteHeader(http.StatusInternalServerError) @@ -65,8 +81,6 @@ func authenticateHandler() func(http.Handler) http.Handler { // handle redirect if no user and user is required if userID == "" && config.HasCredentials() && !allowUnauthenticated(r) { - // always allow - // if we don't have a userID, then redirect // if graphql was requested, we just return a forbidden error if r.URL.Path == "/graphql" { diff --git a/pkg/manager/apikey.go b/pkg/manager/apikey.go new file mode 100644 index 000000000..e6423d362 --- /dev/null +++ b/pkg/manager/apikey.go @@ -0,0 +1,55 @@ +package manager + +import ( + "errors" + "time" + + "github.com/dgrijalva/jwt-go" + "github.com/stashapp/stash/pkg/manager/config" +) + +var ErrInvalidToken = errors.New("invalid apikey") + +const APIKeySubject = "APIKey" + +type APIKeyClaims struct { + UserID string `json:"uid"` + jwt.StandardClaims +} + +func GenerateAPIKey(userID string) (string, error) { + claims := &APIKeyClaims{ + UserID: userID, + StandardClaims: jwt.StandardClaims{ + Subject: APIKeySubject, + IssuedAt: time.Now().Unix(), + }, + } + + token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) + + ss, err := token.SignedString(config.GetJWTSignKey()) + if err != nil { + return "", err + } + + return ss, nil +} + +// GetUserIDFromAPIKey validates the provided api key and returns the user ID +func GetUserIDFromAPIKey(apiKey string) (string, error) { + claims := &APIKeyClaims{} + token, err := jwt.ParseWithClaims(apiKey, claims, func(t *jwt.Token) (interface{}, error) { + return config.GetJWTSignKey(), nil + }) + + if err != nil { + return "", err + } + + if !token.Valid { + return "", ErrInvalidToken + } + + return claims.UserID, nil +} diff --git a/pkg/manager/config/config.go b/pkg/manager/config/config.go index a858d6722..ea63cd06d 100644 --- a/pkg/manager/config/config.go +++ b/pkg/manager/config/config.go @@ -1,9 +1,10 @@ package config import ( - "golang.org/x/crypto/bcrypt" "runtime" + "golang.org/x/crypto/bcrypt" + "errors" "io/ioutil" "path/filepath" @@ -20,6 +21,7 @@ const Cache = "cache" const Generated = "generated" const Metadata = "metadata" const Downloads = "downloads" +const ApiKey = "api_key" const Username = "username" const Password = "password" const MaxSessionAge = "max_session_age" @@ -398,6 +400,10 @@ func GetMaxStreamingTranscodeSize() models.StreamingResolutionEnum { return models.StreamingResolutionEnum(ret) } +func GetAPIKey() string { + return viper.GetString(ApiKey) +} + func GetUsername() string { return viper.GetString(Username) } diff --git a/ui/v2.5/src/components/Changelog/versions/v070.md b/ui/v2.5/src/components/Changelog/versions/v070.md index c1ab489c7..09ca8dee5 100644 --- a/ui/v2.5/src/components/Changelog/versions/v070.md +++ b/ui/v2.5/src/components/Changelog/versions/v070.md @@ -1,4 +1,5 @@ ### ✨ New Features +* Support access to system without logging in via API key. * Added scene queue. ### 🎨 Improvements diff --git a/ui/v2.5/src/components/Settings/SettingsConfigurationPanel.tsx b/ui/v2.5/src/components/Settings/SettingsConfigurationPanel.tsx index 6070765f3..b11283ccb 100644 --- a/ui/v2.5/src/components/Settings/SettingsConfigurationPanel.tsx +++ b/ui/v2.5/src/components/Settings/SettingsConfigurationPanel.tsx @@ -1,7 +1,11 @@ import React, { useEffect, useState } from "react"; import { Button, Form, InputGroup } from "react-bootstrap"; import * as GQL from "src/core/generated-graphql"; -import { useConfiguration, useConfigureGeneral } from "src/core/StashService"; +import { + useConfiguration, + useConfigureGeneral, + useGenerateAPIKey, +} from "src/core/StashService"; import { useToast } from "src/hooks"; import { Icon, LoadingIndicator } from "src/components/Shared"; import StashBoxConfiguration, { @@ -130,6 +134,8 @@ export const SettingsConfigurationPanel: React.FC = () => { const { data, error, loading } = useConfiguration(); + const [generateAPIKey] = useGenerateAPIKey(); + const [updateGeneralConfig] = useConfigureGeneral({ stashes: stashes.map((s) => ({ path: s.path, @@ -238,6 +244,32 @@ export const SettingsConfigurationPanel: React.FC = () => { } } + async function onGenerateAPIKey() { + try { + await generateAPIKey({ + variables: { + input: {}, + }, + }); + } catch (e) { + Toast.error(e); + } + } + + async function onClearAPIKey() { + try { + await generateAPIKey({ + variables: { + input: { + clear: true, + }, + }, + }); + } catch (e) { + Toast.error(e); + } + } + async function onSave() { try { const result = await updateGeneralConfig(); @@ -775,6 +807,38 @@ export const SettingsConfigurationPanel: React.FC = () => { + +
    API Key
    + + + + + + + + + API key for external systems. Only required when username/password + is configured. Username must be saved before generating API key. + +
    +
    Maximum Session Age
    update: deleteCache([GQL.ConfigurationDocument]), }); +export const useGenerateAPIKey = () => + GQL.useGenerateApiKeyMutation({ + refetchQueries: getQueryNames([GQL.ConfigurationDocument]), + update: deleteCache([GQL.ConfigurationDocument]), + }); + export const useMetadataUpdate = () => GQL.useMetadataUpdateSubscription(); export const useLoggingSubscribe = () => GQL.useLoggingSubscribeSubscription(); diff --git a/ui/v2.5/src/docs/en/Configuration.md b/ui/v2.5/src/docs/en/Configuration.md index ac5f7d596..ed5ab5edb 100644 --- a/ui/v2.5/src/docs/en/Configuration.md +++ b/ui/v2.5/src/docs/en/Configuration.md @@ -91,6 +91,12 @@ Some scrapers require a Chrome instance to function correctly. If left empty, st By default, stash is not configured with any sort of password protection. To enable password protection, both `Username` and `Password` must be populated. Note that when entering a new username and password where none was set previously, the system will immediately request these credentials to log you in. +## API key + +If password protection is enabled, you may also generate an API key. An API key is used by external systems to access your stash system without needing to login first. + +External systems using the API key must set the `ApiKey` header value to the configured API key in order to bypass the login requirement. + ### Logging out The logout button is situated in the upper-right part of the screen when you are logged in. diff --git a/vendor/github.com/dgrijalva/jwt-go/.gitignore b/vendor/github.com/dgrijalva/jwt-go/.gitignore new file mode 100644 index 000000000..80bed650e --- /dev/null +++ b/vendor/github.com/dgrijalva/jwt-go/.gitignore @@ -0,0 +1,4 @@ +.DS_Store +bin + + diff --git a/vendor/github.com/dgrijalva/jwt-go/.travis.yml b/vendor/github.com/dgrijalva/jwt-go/.travis.yml new file mode 100644 index 000000000..1027f56cd --- /dev/null +++ b/vendor/github.com/dgrijalva/jwt-go/.travis.yml @@ -0,0 +1,13 @@ +language: go + +script: + - go vet ./... + - go test -v ./... + +go: + - 1.3 + - 1.4 + - 1.5 + - 1.6 + - 1.7 + - tip diff --git a/vendor/github.com/dgrijalva/jwt-go/LICENSE b/vendor/github.com/dgrijalva/jwt-go/LICENSE new file mode 100644 index 000000000..df83a9c2f --- /dev/null +++ b/vendor/github.com/dgrijalva/jwt-go/LICENSE @@ -0,0 +1,8 @@ +Copyright (c) 2012 Dave Grijalva + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + diff --git a/vendor/github.com/dgrijalva/jwt-go/MIGRATION_GUIDE.md b/vendor/github.com/dgrijalva/jwt-go/MIGRATION_GUIDE.md new file mode 100644 index 000000000..7fc1f793c --- /dev/null +++ b/vendor/github.com/dgrijalva/jwt-go/MIGRATION_GUIDE.md @@ -0,0 +1,97 @@ +## Migration Guide from v2 -> v3 + +Version 3 adds several new, frequently requested features. To do so, it introduces a few breaking changes. We've worked to keep these as minimal as possible. This guide explains the breaking changes and how you can quickly update your code. + +### `Token.Claims` is now an interface type + +The most requested feature from the 2.0 verison of this library was the ability to provide a custom type to the JSON parser for claims. This was implemented by introducing a new interface, `Claims`, to replace `map[string]interface{}`. We also included two concrete implementations of `Claims`: `MapClaims` and `StandardClaims`. + +`MapClaims` is an alias for `map[string]interface{}` with built in validation behavior. It is the default claims type when using `Parse`. The usage is unchanged except you must type cast the claims property. + +The old example for parsing a token looked like this.. + +```go + if token, err := jwt.Parse(tokenString, keyLookupFunc); err == nil { + fmt.Printf("Token for user %v expires %v", token.Claims["user"], token.Claims["exp"]) + } +``` + +is now directly mapped to... + +```go + if token, err := jwt.Parse(tokenString, keyLookupFunc); err == nil { + claims := token.Claims.(jwt.MapClaims) + fmt.Printf("Token for user %v expires %v", claims["user"], claims["exp"]) + } +``` + +`StandardClaims` is designed to be embedded in your custom type. You can supply a custom claims type with the new `ParseWithClaims` function. Here's an example of using a custom claims type. + +```go + type MyCustomClaims struct { + User string + *StandardClaims + } + + if token, err := jwt.ParseWithClaims(tokenString, &MyCustomClaims{}, keyLookupFunc); err == nil { + claims := token.Claims.(*MyCustomClaims) + fmt.Printf("Token for user %v expires %v", claims.User, claims.StandardClaims.ExpiresAt) + } +``` + +### `ParseFromRequest` has been moved + +To keep this library focused on the tokens without becoming overburdened with complex request processing logic, `ParseFromRequest` and its new companion `ParseFromRequestWithClaims` have been moved to a subpackage, `request`. The method signatues have also been augmented to receive a new argument: `Extractor`. + +`Extractors` do the work of picking the token string out of a request. The interface is simple and composable. + +This simple parsing example: + +```go + if token, err := jwt.ParseFromRequest(tokenString, req, keyLookupFunc); err == nil { + fmt.Printf("Token for user %v expires %v", token.Claims["user"], token.Claims["exp"]) + } +``` + +is directly mapped to: + +```go + if token, err := request.ParseFromRequest(req, request.OAuth2Extractor, keyLookupFunc); err == nil { + claims := token.Claims.(jwt.MapClaims) + fmt.Printf("Token for user %v expires %v", claims["user"], claims["exp"]) + } +``` + +There are several concrete `Extractor` types provided for your convenience: + +* `HeaderExtractor` will search a list of headers until one contains content. +* `ArgumentExtractor` will search a list of keys in request query and form arguments until one contains content. +* `MultiExtractor` will try a list of `Extractors` in order until one returns content. +* `AuthorizationHeaderExtractor` will look in the `Authorization` header for a `Bearer` token. +* `OAuth2Extractor` searches the places an OAuth2 token would be specified (per the spec): `Authorization` header and `access_token` argument +* `PostExtractionFilter` wraps an `Extractor`, allowing you to process the content before it's parsed. A simple example is stripping the `Bearer ` text from a header + + +### RSA signing methods no longer accept `[]byte` keys + +Due to a [critical vulnerability](https://auth0.com/blog/2015/03/31/critical-vulnerabilities-in-json-web-token-libraries/), we've decided the convenience of accepting `[]byte` instead of `rsa.PublicKey` or `rsa.PrivateKey` isn't worth the risk of misuse. + +To replace this behavior, we've added two helper methods: `ParseRSAPrivateKeyFromPEM(key []byte) (*rsa.PrivateKey, error)` and `ParseRSAPublicKeyFromPEM(key []byte) (*rsa.PublicKey, error)`. These are just simple helpers for unpacking PEM encoded PKCS1 and PKCS8 keys. If your keys are encoded any other way, all you need to do is convert them to the `crypto/rsa` package's types. + +```go + func keyLookupFunc(*Token) (interface{}, error) { + // Don't forget to validate the alg is what you expect: + if _, ok := token.Method.(*jwt.SigningMethodRSA); !ok { + return nil, fmt.Errorf("Unexpected signing method: %v", token.Header["alg"]) + } + + // Look up key + key, err := lookupPublicKey(token.Header["kid"]) + if err != nil { + return nil, err + } + + // Unpack key from PEM encoded PKCS8 + return jwt.ParseRSAPublicKeyFromPEM(key) + } +``` diff --git a/vendor/github.com/dgrijalva/jwt-go/README.md b/vendor/github.com/dgrijalva/jwt-go/README.md new file mode 100644 index 000000000..d358d881b --- /dev/null +++ b/vendor/github.com/dgrijalva/jwt-go/README.md @@ -0,0 +1,100 @@ +# jwt-go + +[![Build Status](https://travis-ci.org/dgrijalva/jwt-go.svg?branch=master)](https://travis-ci.org/dgrijalva/jwt-go) +[![GoDoc](https://godoc.org/github.com/dgrijalva/jwt-go?status.svg)](https://godoc.org/github.com/dgrijalva/jwt-go) + +A [go](http://www.golang.org) (or 'golang' for search engine friendliness) implementation of [JSON Web Tokens](http://self-issued.info/docs/draft-ietf-oauth-json-web-token.html) + +**NEW VERSION COMING:** There have been a lot of improvements suggested since the version 3.0.0 released in 2016. I'm working now on cutting two different releases: 3.2.0 will contain any non-breaking changes or enhancements. 4.0.0 will follow shortly which will include breaking changes. See the 4.0.0 milestone to get an idea of what's coming. If you have other ideas, or would like to participate in 4.0.0, now's the time. If you depend on this library and don't want to be interrupted, I recommend you use your dependency mangement tool to pin to version 3. + +**SECURITY NOTICE:** Some older versions of Go have a security issue in the cryotp/elliptic. Recommendation is to upgrade to at least 1.8.3. See issue #216 for more detail. + +**SECURITY NOTICE:** It's important that you [validate the `alg` presented is what you expect](https://auth0.com/blog/2015/03/31/critical-vulnerabilities-in-json-web-token-libraries/). This library attempts to make it easy to do the right thing by requiring key types match the expected alg, but you should take the extra step to verify it in your usage. See the examples provided. + +## What the heck is a JWT? + +JWT.io has [a great introduction](https://jwt.io/introduction) to JSON Web Tokens. + +In short, it's a signed JSON object that does something useful (for example, authentication). It's commonly used for `Bearer` tokens in Oauth 2. A token is made of three parts, separated by `.`'s. The first two parts are JSON objects, that have been [base64url](http://tools.ietf.org/html/rfc4648) encoded. The last part is the signature, encoded the same way. + +The first part is called the header. It contains the necessary information for verifying the last part, the signature. For example, which encryption method was used for signing and what key was used. + +The part in the middle is the interesting bit. It's called the Claims and contains the actual stuff you care about. Refer to [the RFC](http://self-issued.info/docs/draft-jones-json-web-token.html) for information about reserved keys and the proper way to add your own. + +## What's in the box? + +This library supports the parsing and verification as well as the generation and signing of JWTs. Current supported signing algorithms are HMAC SHA, RSA, RSA-PSS, and ECDSA, though hooks are present for adding your own. + +## Examples + +See [the project documentation](https://godoc.org/github.com/dgrijalva/jwt-go) for examples of usage: + +* [Simple example of parsing and validating a token](https://godoc.org/github.com/dgrijalva/jwt-go#example-Parse--Hmac) +* [Simple example of building and signing a token](https://godoc.org/github.com/dgrijalva/jwt-go#example-New--Hmac) +* [Directory of Examples](https://godoc.org/github.com/dgrijalva/jwt-go#pkg-examples) + +## Extensions + +This library publishes all the necessary components for adding your own signing methods. Simply implement the `SigningMethod` interface and register a factory method using `RegisterSigningMethod`. + +Here's an example of an extension that integrates with the Google App Engine signing tools: https://github.com/someone1/gcp-jwt-go + +## Compliance + +This library was last reviewed to comply with [RTF 7519](http://www.rfc-editor.org/info/rfc7519) dated May 2015 with a few notable differences: + +* In order to protect against accidental use of [Unsecured JWTs](http://self-issued.info/docs/draft-ietf-oauth-json-web-token.html#UnsecuredJWT), tokens using `alg=none` will only be accepted if the constant `jwt.UnsafeAllowNoneSignatureType` is provided as the key. + +## Project Status & Versioning + +This library is considered production ready. Feedback and feature requests are appreciated. The API should be considered stable. There should be very few backwards-incompatible changes outside of major version updates (and only with good reason). + +This project uses [Semantic Versioning 2.0.0](http://semver.org). Accepted pull requests will land on `master`. Periodically, versions will be tagged from `master`. You can find all the releases on [the project releases page](https://github.com/dgrijalva/jwt-go/releases). + +While we try to make it obvious when we make breaking changes, there isn't a great mechanism for pushing announcements out to users. You may want to use this alternative package include: `gopkg.in/dgrijalva/jwt-go.v3`. It will do the right thing WRT semantic versioning. + +**BREAKING CHANGES:*** +* Version 3.0.0 includes _a lot_ of changes from the 2.x line, including a few that break the API. We've tried to break as few things as possible, so there should just be a few type signature changes. A full list of breaking changes is available in `VERSION_HISTORY.md`. See `MIGRATION_GUIDE.md` for more information on updating your code. + +## Usage Tips + +### Signing vs Encryption + +A token is simply a JSON object that is signed by its author. this tells you exactly two things about the data: + +* The author of the token was in the possession of the signing secret +* The data has not been modified since it was signed + +It's important to know that JWT does not provide encryption, which means anyone who has access to the token can read its contents. If you need to protect (encrypt) the data, there is a companion spec, `JWE`, that provides this functionality. JWE is currently outside the scope of this library. + +### Choosing a Signing Method + +There are several signing methods available, and you should probably take the time to learn about the various options before choosing one. The principal design decision is most likely going to be symmetric vs asymmetric. + +Symmetric signing methods, such as HSA, use only a single secret. This is probably the simplest signing method to use since any `[]byte` can be used as a valid secret. They are also slightly computationally faster to use, though this rarely is enough to matter. Symmetric signing methods work the best when both producers and consumers of tokens are trusted, or even the same system. Since the same secret is used to both sign and validate tokens, you can't easily distribute the key for validation. + +Asymmetric signing methods, such as RSA, use different keys for signing and verifying tokens. This makes it possible to produce tokens with a private key, and allow any consumer to access the public key for verification. + +### Signing Methods and Key Types + +Each signing method expects a different object type for its signing keys. See the package documentation for details. Here are the most common ones: + +* The [HMAC signing method](https://godoc.org/github.com/dgrijalva/jwt-go#SigningMethodHMAC) (`HS256`,`HS384`,`HS512`) expect `[]byte` values for signing and validation +* The [RSA signing method](https://godoc.org/github.com/dgrijalva/jwt-go#SigningMethodRSA) (`RS256`,`RS384`,`RS512`) expect `*rsa.PrivateKey` for signing and `*rsa.PublicKey` for validation +* The [ECDSA signing method](https://godoc.org/github.com/dgrijalva/jwt-go#SigningMethodECDSA) (`ES256`,`ES384`,`ES512`) expect `*ecdsa.PrivateKey` for signing and `*ecdsa.PublicKey` for validation + +### JWT and OAuth + +It's worth mentioning that OAuth and JWT are not the same thing. A JWT token is simply a signed JSON object. It can be used anywhere such a thing is useful. There is some confusion, though, as JWT is the most common type of bearer token used in OAuth2 authentication. + +Without going too far down the rabbit hole, here's a description of the interaction of these technologies: + +* OAuth is a protocol for allowing an identity provider to be separate from the service a user is logging in to. For example, whenever you use Facebook to log into a different service (Yelp, Spotify, etc), you are using OAuth. +* OAuth defines several options for passing around authentication data. One popular method is called a "bearer token". A bearer token is simply a string that _should_ only be held by an authenticated user. Thus, simply presenting this token proves your identity. You can probably derive from here why a JWT might make a good bearer token. +* Because bearer tokens are used for authentication, it's important they're kept secret. This is why transactions that use bearer tokens typically happen over SSL. + +## More + +Documentation can be found [on godoc.org](http://godoc.org/github.com/dgrijalva/jwt-go). + +The command line utility included in this project (cmd/jwt) provides a straightforward example of token creation and parsing as well as a useful tool for debugging your own integration. You'll also find several implementation examples in the documentation. diff --git a/vendor/github.com/dgrijalva/jwt-go/VERSION_HISTORY.md b/vendor/github.com/dgrijalva/jwt-go/VERSION_HISTORY.md new file mode 100644 index 000000000..637029831 --- /dev/null +++ b/vendor/github.com/dgrijalva/jwt-go/VERSION_HISTORY.md @@ -0,0 +1,118 @@ +## `jwt-go` Version History + +#### 3.2.0 + +* Added method `ParseUnverified` to allow users to split up the tasks of parsing and validation +* HMAC signing method returns `ErrInvalidKeyType` instead of `ErrInvalidKey` where appropriate +* Added options to `request.ParseFromRequest`, which allows for an arbitrary list of modifiers to parsing behavior. Initial set include `WithClaims` and `WithParser`. Existing usage of this function will continue to work as before. +* Deprecated `ParseFromRequestWithClaims` to simplify API in the future. + +#### 3.1.0 + +* Improvements to `jwt` command line tool +* Added `SkipClaimsValidation` option to `Parser` +* Documentation updates + +#### 3.0.0 + +* **Compatibility Breaking Changes**: See MIGRATION_GUIDE.md for tips on updating your code + * Dropped support for `[]byte` keys when using RSA signing methods. This convenience feature could contribute to security vulnerabilities involving mismatched key types with signing methods. + * `ParseFromRequest` has been moved to `request` subpackage and usage has changed + * The `Claims` property on `Token` is now type `Claims` instead of `map[string]interface{}`. The default value is type `MapClaims`, which is an alias to `map[string]interface{}`. This makes it possible to use a custom type when decoding claims. +* Other Additions and Changes + * Added `Claims` interface type to allow users to decode the claims into a custom type + * Added `ParseWithClaims`, which takes a third argument of type `Claims`. Use this function instead of `Parse` if you have a custom type you'd like to decode into. + * Dramatically improved the functionality and flexibility of `ParseFromRequest`, which is now in the `request` subpackage + * Added `ParseFromRequestWithClaims` which is the `FromRequest` equivalent of `ParseWithClaims` + * Added new interface type `Extractor`, which is used for extracting JWT strings from http requests. Used with `ParseFromRequest` and `ParseFromRequestWithClaims`. + * Added several new, more specific, validation errors to error type bitmask + * Moved examples from README to executable example files + * Signing method registry is now thread safe + * Added new property to `ValidationError`, which contains the raw error returned by calls made by parse/verify (such as those returned by keyfunc or json parser) + +#### 2.7.0 + +This will likely be the last backwards compatible release before 3.0.0, excluding essential bug fixes. + +* Added new option `-show` to the `jwt` command that will just output the decoded token without verifying +* Error text for expired tokens includes how long it's been expired +* Fixed incorrect error returned from `ParseRSAPublicKeyFromPEM` +* Documentation updates + +#### 2.6.0 + +* Exposed inner error within ValidationError +* Fixed validation errors when using UseJSONNumber flag +* Added several unit tests + +#### 2.5.0 + +* Added support for signing method none. You shouldn't use this. The API tries to make this clear. +* Updated/fixed some documentation +* Added more helpful error message when trying to parse tokens that begin with `BEARER ` + +#### 2.4.0 + +* Added new type, Parser, to allow for configuration of various parsing parameters + * You can now specify a list of valid signing methods. Anything outside this set will be rejected. + * You can now opt to use the `json.Number` type instead of `float64` when parsing token JSON +* Added support for [Travis CI](https://travis-ci.org/dgrijalva/jwt-go) +* Fixed some bugs with ECDSA parsing + +#### 2.3.0 + +* Added support for ECDSA signing methods +* Added support for RSA PSS signing methods (requires go v1.4) + +#### 2.2.0 + +* Gracefully handle a `nil` `Keyfunc` being passed to `Parse`. Result will now be the parsed token and an error, instead of a panic. + +#### 2.1.0 + +Backwards compatible API change that was missed in 2.0.0. + +* The `SignedString` method on `Token` now takes `interface{}` instead of `[]byte` + +#### 2.0.0 + +There were two major reasons for breaking backwards compatibility with this update. The first was a refactor required to expand the width of the RSA and HMAC-SHA signing implementations. There will likely be no required code changes to support this change. + +The second update, while unfortunately requiring a small change in integration, is required to open up this library to other signing methods. Not all keys used for all signing methods have a single standard on-disk representation. Requiring `[]byte` as the type for all keys proved too limiting. Additionally, this implementation allows for pre-parsed tokens to be reused, which might matter in an application that parses a high volume of tokens with a small set of keys. Backwards compatibilty has been maintained for passing `[]byte` to the RSA signing methods, but they will also accept `*rsa.PublicKey` and `*rsa.PrivateKey`. + +It is likely the only integration change required here will be to change `func(t *jwt.Token) ([]byte, error)` to `func(t *jwt.Token) (interface{}, error)` when calling `Parse`. + +* **Compatibility Breaking Changes** + * `SigningMethodHS256` is now `*SigningMethodHMAC` instead of `type struct` + * `SigningMethodRS256` is now `*SigningMethodRSA` instead of `type struct` + * `KeyFunc` now returns `interface{}` instead of `[]byte` + * `SigningMethod.Sign` now takes `interface{}` instead of `[]byte` for the key + * `SigningMethod.Verify` now takes `interface{}` instead of `[]byte` for the key +* Renamed type `SigningMethodHS256` to `SigningMethodHMAC`. Specific sizes are now just instances of this type. + * Added public package global `SigningMethodHS256` + * Added public package global `SigningMethodHS384` + * Added public package global `SigningMethodHS512` +* Renamed type `SigningMethodRS256` to `SigningMethodRSA`. Specific sizes are now just instances of this type. + * Added public package global `SigningMethodRS256` + * Added public package global `SigningMethodRS384` + * Added public package global `SigningMethodRS512` +* Moved sample private key for HMAC tests from an inline value to a file on disk. Value is unchanged. +* Refactored the RSA implementation to be easier to read +* Exposed helper methods `ParseRSAPrivateKeyFromPEM` and `ParseRSAPublicKeyFromPEM` + +#### 1.0.2 + +* Fixed bug in parsing public keys from certificates +* Added more tests around the parsing of keys for RS256 +* Code refactoring in RS256 implementation. No functional changes + +#### 1.0.1 + +* Fixed panic if RS256 signing method was passed an invalid key + +#### 1.0.0 + +* First versioned release +* API stabilized +* Supports creating, signing, parsing, and validating JWT tokens +* Supports RS256 and HS256 signing methods \ No newline at end of file diff --git a/vendor/github.com/dgrijalva/jwt-go/claims.go b/vendor/github.com/dgrijalva/jwt-go/claims.go new file mode 100644 index 000000000..f0228f02e --- /dev/null +++ b/vendor/github.com/dgrijalva/jwt-go/claims.go @@ -0,0 +1,134 @@ +package jwt + +import ( + "crypto/subtle" + "fmt" + "time" +) + +// For a type to be a Claims object, it must just have a Valid method that determines +// if the token is invalid for any supported reason +type Claims interface { + Valid() error +} + +// Structured version of Claims Section, as referenced at +// https://tools.ietf.org/html/rfc7519#section-4.1 +// See examples for how to use this with your own claim types +type StandardClaims struct { + Audience string `json:"aud,omitempty"` + ExpiresAt int64 `json:"exp,omitempty"` + Id string `json:"jti,omitempty"` + IssuedAt int64 `json:"iat,omitempty"` + Issuer string `json:"iss,omitempty"` + NotBefore int64 `json:"nbf,omitempty"` + Subject string `json:"sub,omitempty"` +} + +// Validates time based claims "exp, iat, nbf". +// There is no accounting for clock skew. +// As well, if any of the above claims are not in the token, it will still +// be considered a valid claim. +func (c StandardClaims) Valid() error { + vErr := new(ValidationError) + now := TimeFunc().Unix() + + // The claims below are optional, by default, so if they are set to the + // default value in Go, let's not fail the verification for them. + if c.VerifyExpiresAt(now, false) == false { + delta := time.Unix(now, 0).Sub(time.Unix(c.ExpiresAt, 0)) + vErr.Inner = fmt.Errorf("token is expired by %v", delta) + vErr.Errors |= ValidationErrorExpired + } + + if c.VerifyIssuedAt(now, false) == false { + vErr.Inner = fmt.Errorf("Token used before issued") + vErr.Errors |= ValidationErrorIssuedAt + } + + if c.VerifyNotBefore(now, false) == false { + vErr.Inner = fmt.Errorf("token is not valid yet") + vErr.Errors |= ValidationErrorNotValidYet + } + + if vErr.valid() { + return nil + } + + return vErr +} + +// Compares the aud claim against cmp. +// If required is false, this method will return true if the value matches or is unset +func (c *StandardClaims) VerifyAudience(cmp string, req bool) bool { + return verifyAud(c.Audience, cmp, req) +} + +// Compares the exp claim against cmp. +// If required is false, this method will return true if the value matches or is unset +func (c *StandardClaims) VerifyExpiresAt(cmp int64, req bool) bool { + return verifyExp(c.ExpiresAt, cmp, req) +} + +// Compares the iat claim against cmp. +// If required is false, this method will return true if the value matches or is unset +func (c *StandardClaims) VerifyIssuedAt(cmp int64, req bool) bool { + return verifyIat(c.IssuedAt, cmp, req) +} + +// Compares the iss claim against cmp. +// If required is false, this method will return true if the value matches or is unset +func (c *StandardClaims) VerifyIssuer(cmp string, req bool) bool { + return verifyIss(c.Issuer, cmp, req) +} + +// Compares the nbf claim against cmp. +// If required is false, this method will return true if the value matches or is unset +func (c *StandardClaims) VerifyNotBefore(cmp int64, req bool) bool { + return verifyNbf(c.NotBefore, cmp, req) +} + +// ----- helpers + +func verifyAud(aud string, cmp string, required bool) bool { + if aud == "" { + return !required + } + if subtle.ConstantTimeCompare([]byte(aud), []byte(cmp)) != 0 { + return true + } else { + return false + } +} + +func verifyExp(exp int64, now int64, required bool) bool { + if exp == 0 { + return !required + } + return now <= exp +} + +func verifyIat(iat int64, now int64, required bool) bool { + if iat == 0 { + return !required + } + return now >= iat +} + +func verifyIss(iss string, cmp string, required bool) bool { + if iss == "" { + return !required + } + if subtle.ConstantTimeCompare([]byte(iss), []byte(cmp)) != 0 { + return true + } else { + return false + } +} + +func verifyNbf(nbf int64, now int64, required bool) bool { + if nbf == 0 { + return !required + } + return now >= nbf +} diff --git a/vendor/github.com/dgrijalva/jwt-go/doc.go b/vendor/github.com/dgrijalva/jwt-go/doc.go new file mode 100644 index 000000000..a86dc1a3b --- /dev/null +++ b/vendor/github.com/dgrijalva/jwt-go/doc.go @@ -0,0 +1,4 @@ +// Package jwt is a Go implementation of JSON Web Tokens: http://self-issued.info/docs/draft-jones-json-web-token.html +// +// See README.md for more info. +package jwt diff --git a/vendor/github.com/dgrijalva/jwt-go/ecdsa.go b/vendor/github.com/dgrijalva/jwt-go/ecdsa.go new file mode 100644 index 000000000..f97738124 --- /dev/null +++ b/vendor/github.com/dgrijalva/jwt-go/ecdsa.go @@ -0,0 +1,148 @@ +package jwt + +import ( + "crypto" + "crypto/ecdsa" + "crypto/rand" + "errors" + "math/big" +) + +var ( + // Sadly this is missing from crypto/ecdsa compared to crypto/rsa + ErrECDSAVerification = errors.New("crypto/ecdsa: verification error") +) + +// Implements the ECDSA family of signing methods signing methods +// Expects *ecdsa.PrivateKey for signing and *ecdsa.PublicKey for verification +type SigningMethodECDSA struct { + Name string + Hash crypto.Hash + KeySize int + CurveBits int +} + +// Specific instances for EC256 and company +var ( + SigningMethodES256 *SigningMethodECDSA + SigningMethodES384 *SigningMethodECDSA + SigningMethodES512 *SigningMethodECDSA +) + +func init() { + // ES256 + SigningMethodES256 = &SigningMethodECDSA{"ES256", crypto.SHA256, 32, 256} + RegisterSigningMethod(SigningMethodES256.Alg(), func() SigningMethod { + return SigningMethodES256 + }) + + // ES384 + SigningMethodES384 = &SigningMethodECDSA{"ES384", crypto.SHA384, 48, 384} + RegisterSigningMethod(SigningMethodES384.Alg(), func() SigningMethod { + return SigningMethodES384 + }) + + // ES512 + SigningMethodES512 = &SigningMethodECDSA{"ES512", crypto.SHA512, 66, 521} + RegisterSigningMethod(SigningMethodES512.Alg(), func() SigningMethod { + return SigningMethodES512 + }) +} + +func (m *SigningMethodECDSA) Alg() string { + return m.Name +} + +// Implements the Verify method from SigningMethod +// For this verify method, key must be an ecdsa.PublicKey struct +func (m *SigningMethodECDSA) Verify(signingString, signature string, key interface{}) error { + var err error + + // Decode the signature + var sig []byte + if sig, err = DecodeSegment(signature); err != nil { + return err + } + + // Get the key + var ecdsaKey *ecdsa.PublicKey + switch k := key.(type) { + case *ecdsa.PublicKey: + ecdsaKey = k + default: + return ErrInvalidKeyType + } + + if len(sig) != 2*m.KeySize { + return ErrECDSAVerification + } + + r := big.NewInt(0).SetBytes(sig[:m.KeySize]) + s := big.NewInt(0).SetBytes(sig[m.KeySize:]) + + // Create hasher + if !m.Hash.Available() { + return ErrHashUnavailable + } + hasher := m.Hash.New() + hasher.Write([]byte(signingString)) + + // Verify the signature + if verifystatus := ecdsa.Verify(ecdsaKey, hasher.Sum(nil), r, s); verifystatus == true { + return nil + } else { + return ErrECDSAVerification + } +} + +// Implements the Sign method from SigningMethod +// For this signing method, key must be an ecdsa.PrivateKey struct +func (m *SigningMethodECDSA) Sign(signingString string, key interface{}) (string, error) { + // Get the key + var ecdsaKey *ecdsa.PrivateKey + switch k := key.(type) { + case *ecdsa.PrivateKey: + ecdsaKey = k + default: + return "", ErrInvalidKeyType + } + + // Create the hasher + if !m.Hash.Available() { + return "", ErrHashUnavailable + } + + hasher := m.Hash.New() + hasher.Write([]byte(signingString)) + + // Sign the string and return r, s + if r, s, err := ecdsa.Sign(rand.Reader, ecdsaKey, hasher.Sum(nil)); err == nil { + curveBits := ecdsaKey.Curve.Params().BitSize + + if m.CurveBits != curveBits { + return "", ErrInvalidKey + } + + keyBytes := curveBits / 8 + if curveBits%8 > 0 { + keyBytes += 1 + } + + // We serialize the outpus (r and s) into big-endian byte arrays and pad + // them with zeros on the left to make sure the sizes work out. Both arrays + // must be keyBytes long, and the output must be 2*keyBytes long. + rBytes := r.Bytes() + rBytesPadded := make([]byte, keyBytes) + copy(rBytesPadded[keyBytes-len(rBytes):], rBytes) + + sBytes := s.Bytes() + sBytesPadded := make([]byte, keyBytes) + copy(sBytesPadded[keyBytes-len(sBytes):], sBytes) + + out := append(rBytesPadded, sBytesPadded...) + + return EncodeSegment(out), nil + } else { + return "", err + } +} diff --git a/vendor/github.com/dgrijalva/jwt-go/ecdsa_utils.go b/vendor/github.com/dgrijalva/jwt-go/ecdsa_utils.go new file mode 100644 index 000000000..d19624b72 --- /dev/null +++ b/vendor/github.com/dgrijalva/jwt-go/ecdsa_utils.go @@ -0,0 +1,67 @@ +package jwt + +import ( + "crypto/ecdsa" + "crypto/x509" + "encoding/pem" + "errors" +) + +var ( + ErrNotECPublicKey = errors.New("Key is not a valid ECDSA public key") + ErrNotECPrivateKey = errors.New("Key is not a valid ECDSA private key") +) + +// Parse PEM encoded Elliptic Curve Private Key Structure +func ParseECPrivateKeyFromPEM(key []byte) (*ecdsa.PrivateKey, error) { + var err error + + // Parse PEM block + var block *pem.Block + if block, _ = pem.Decode(key); block == nil { + return nil, ErrKeyMustBePEMEncoded + } + + // Parse the key + var parsedKey interface{} + if parsedKey, err = x509.ParseECPrivateKey(block.Bytes); err != nil { + return nil, err + } + + var pkey *ecdsa.PrivateKey + var ok bool + if pkey, ok = parsedKey.(*ecdsa.PrivateKey); !ok { + return nil, ErrNotECPrivateKey + } + + return pkey, nil +} + +// Parse PEM encoded PKCS1 or PKCS8 public key +func ParseECPublicKeyFromPEM(key []byte) (*ecdsa.PublicKey, error) { + var err error + + // Parse PEM block + var block *pem.Block + if block, _ = pem.Decode(key); block == nil { + return nil, ErrKeyMustBePEMEncoded + } + + // Parse the key + var parsedKey interface{} + if parsedKey, err = x509.ParsePKIXPublicKey(block.Bytes); err != nil { + if cert, err := x509.ParseCertificate(block.Bytes); err == nil { + parsedKey = cert.PublicKey + } else { + return nil, err + } + } + + var pkey *ecdsa.PublicKey + var ok bool + if pkey, ok = parsedKey.(*ecdsa.PublicKey); !ok { + return nil, ErrNotECPublicKey + } + + return pkey, nil +} diff --git a/vendor/github.com/dgrijalva/jwt-go/errors.go b/vendor/github.com/dgrijalva/jwt-go/errors.go new file mode 100644 index 000000000..1c93024aa --- /dev/null +++ b/vendor/github.com/dgrijalva/jwt-go/errors.go @@ -0,0 +1,59 @@ +package jwt + +import ( + "errors" +) + +// Error constants +var ( + ErrInvalidKey = errors.New("key is invalid") + ErrInvalidKeyType = errors.New("key is of invalid type") + ErrHashUnavailable = errors.New("the requested hash function is unavailable") +) + +// The errors that might occur when parsing and validating a token +const ( + ValidationErrorMalformed uint32 = 1 << iota // Token is malformed + ValidationErrorUnverifiable // Token could not be verified because of signing problems + ValidationErrorSignatureInvalid // Signature validation failed + + // Standard Claim validation errors + ValidationErrorAudience // AUD validation failed + ValidationErrorExpired // EXP validation failed + ValidationErrorIssuedAt // IAT validation failed + ValidationErrorIssuer // ISS validation failed + ValidationErrorNotValidYet // NBF validation failed + ValidationErrorId // JTI validation failed + ValidationErrorClaimsInvalid // Generic claims validation error +) + +// Helper for constructing a ValidationError with a string error message +func NewValidationError(errorText string, errorFlags uint32) *ValidationError { + return &ValidationError{ + text: errorText, + Errors: errorFlags, + } +} + +// The error from Parse if token is not valid +type ValidationError struct { + Inner error // stores the error returned by external dependencies, i.e.: KeyFunc + Errors uint32 // bitfield. see ValidationError... constants + text string // errors that do not have a valid error just have text +} + +// Validation error is an error type +func (e ValidationError) Error() string { + if e.Inner != nil { + return e.Inner.Error() + } else if e.text != "" { + return e.text + } else { + return "token is invalid" + } +} + +// No errors +func (e *ValidationError) valid() bool { + return e.Errors == 0 +} diff --git a/vendor/github.com/dgrijalva/jwt-go/hmac.go b/vendor/github.com/dgrijalva/jwt-go/hmac.go new file mode 100644 index 000000000..addbe5d40 --- /dev/null +++ b/vendor/github.com/dgrijalva/jwt-go/hmac.go @@ -0,0 +1,95 @@ +package jwt + +import ( + "crypto" + "crypto/hmac" + "errors" +) + +// Implements the HMAC-SHA family of signing methods signing methods +// Expects key type of []byte for both signing and validation +type SigningMethodHMAC struct { + Name string + Hash crypto.Hash +} + +// Specific instances for HS256 and company +var ( + SigningMethodHS256 *SigningMethodHMAC + SigningMethodHS384 *SigningMethodHMAC + SigningMethodHS512 *SigningMethodHMAC + ErrSignatureInvalid = errors.New("signature is invalid") +) + +func init() { + // HS256 + SigningMethodHS256 = &SigningMethodHMAC{"HS256", crypto.SHA256} + RegisterSigningMethod(SigningMethodHS256.Alg(), func() SigningMethod { + return SigningMethodHS256 + }) + + // HS384 + SigningMethodHS384 = &SigningMethodHMAC{"HS384", crypto.SHA384} + RegisterSigningMethod(SigningMethodHS384.Alg(), func() SigningMethod { + return SigningMethodHS384 + }) + + // HS512 + SigningMethodHS512 = &SigningMethodHMAC{"HS512", crypto.SHA512} + RegisterSigningMethod(SigningMethodHS512.Alg(), func() SigningMethod { + return SigningMethodHS512 + }) +} + +func (m *SigningMethodHMAC) Alg() string { + return m.Name +} + +// Verify the signature of HSXXX tokens. Returns nil if the signature is valid. +func (m *SigningMethodHMAC) Verify(signingString, signature string, key interface{}) error { + // Verify the key is the right type + keyBytes, ok := key.([]byte) + if !ok { + return ErrInvalidKeyType + } + + // Decode signature, for comparison + sig, err := DecodeSegment(signature) + if err != nil { + return err + } + + // Can we use the specified hashing method? + if !m.Hash.Available() { + return ErrHashUnavailable + } + + // This signing method is symmetric, so we validate the signature + // by reproducing the signature from the signing string and key, then + // comparing that against the provided signature. + hasher := hmac.New(m.Hash.New, keyBytes) + hasher.Write([]byte(signingString)) + if !hmac.Equal(sig, hasher.Sum(nil)) { + return ErrSignatureInvalid + } + + // No validation errors. Signature is good. + return nil +} + +// Implements the Sign method from SigningMethod for this signing method. +// Key must be []byte +func (m *SigningMethodHMAC) Sign(signingString string, key interface{}) (string, error) { + if keyBytes, ok := key.([]byte); ok { + if !m.Hash.Available() { + return "", ErrHashUnavailable + } + + hasher := hmac.New(m.Hash.New, keyBytes) + hasher.Write([]byte(signingString)) + + return EncodeSegment(hasher.Sum(nil)), nil + } + + return "", ErrInvalidKeyType +} diff --git a/vendor/github.com/dgrijalva/jwt-go/map_claims.go b/vendor/github.com/dgrijalva/jwt-go/map_claims.go new file mode 100644 index 000000000..291213c46 --- /dev/null +++ b/vendor/github.com/dgrijalva/jwt-go/map_claims.go @@ -0,0 +1,94 @@ +package jwt + +import ( + "encoding/json" + "errors" + // "fmt" +) + +// Claims type that uses the map[string]interface{} for JSON decoding +// This is the default claims type if you don't supply one +type MapClaims map[string]interface{} + +// Compares the aud claim against cmp. +// If required is false, this method will return true if the value matches or is unset +func (m MapClaims) VerifyAudience(cmp string, req bool) bool { + aud, _ := m["aud"].(string) + return verifyAud(aud, cmp, req) +} + +// Compares the exp claim against cmp. +// If required is false, this method will return true if the value matches or is unset +func (m MapClaims) VerifyExpiresAt(cmp int64, req bool) bool { + switch exp := m["exp"].(type) { + case float64: + return verifyExp(int64(exp), cmp, req) + case json.Number: + v, _ := exp.Int64() + return verifyExp(v, cmp, req) + } + return req == false +} + +// Compares the iat claim against cmp. +// If required is false, this method will return true if the value matches or is unset +func (m MapClaims) VerifyIssuedAt(cmp int64, req bool) bool { + switch iat := m["iat"].(type) { + case float64: + return verifyIat(int64(iat), cmp, req) + case json.Number: + v, _ := iat.Int64() + return verifyIat(v, cmp, req) + } + return req == false +} + +// Compares the iss claim against cmp. +// If required is false, this method will return true if the value matches or is unset +func (m MapClaims) VerifyIssuer(cmp string, req bool) bool { + iss, _ := m["iss"].(string) + return verifyIss(iss, cmp, req) +} + +// Compares the nbf claim against cmp. +// If required is false, this method will return true if the value matches or is unset +func (m MapClaims) VerifyNotBefore(cmp int64, req bool) bool { + switch nbf := m["nbf"].(type) { + case float64: + return verifyNbf(int64(nbf), cmp, req) + case json.Number: + v, _ := nbf.Int64() + return verifyNbf(v, cmp, req) + } + return req == false +} + +// Validates time based claims "exp, iat, nbf". +// There is no accounting for clock skew. +// As well, if any of the above claims are not in the token, it will still +// be considered a valid claim. +func (m MapClaims) Valid() error { + vErr := new(ValidationError) + now := TimeFunc().Unix() + + if m.VerifyExpiresAt(now, false) == false { + vErr.Inner = errors.New("Token is expired") + vErr.Errors |= ValidationErrorExpired + } + + if m.VerifyIssuedAt(now, false) == false { + vErr.Inner = errors.New("Token used before issued") + vErr.Errors |= ValidationErrorIssuedAt + } + + if m.VerifyNotBefore(now, false) == false { + vErr.Inner = errors.New("Token is not valid yet") + vErr.Errors |= ValidationErrorNotValidYet + } + + if vErr.valid() { + return nil + } + + return vErr +} diff --git a/vendor/github.com/dgrijalva/jwt-go/none.go b/vendor/github.com/dgrijalva/jwt-go/none.go new file mode 100644 index 000000000..f04d189d0 --- /dev/null +++ b/vendor/github.com/dgrijalva/jwt-go/none.go @@ -0,0 +1,52 @@ +package jwt + +// Implements the none signing method. This is required by the spec +// but you probably should never use it. +var SigningMethodNone *signingMethodNone + +const UnsafeAllowNoneSignatureType unsafeNoneMagicConstant = "none signing method allowed" + +var NoneSignatureTypeDisallowedError error + +type signingMethodNone struct{} +type unsafeNoneMagicConstant string + +func init() { + SigningMethodNone = &signingMethodNone{} + NoneSignatureTypeDisallowedError = NewValidationError("'none' signature type is not allowed", ValidationErrorSignatureInvalid) + + RegisterSigningMethod(SigningMethodNone.Alg(), func() SigningMethod { + return SigningMethodNone + }) +} + +func (m *signingMethodNone) Alg() string { + return "none" +} + +// Only allow 'none' alg type if UnsafeAllowNoneSignatureType is specified as the key +func (m *signingMethodNone) Verify(signingString, signature string, key interface{}) (err error) { + // Key must be UnsafeAllowNoneSignatureType to prevent accidentally + // accepting 'none' signing method + if _, ok := key.(unsafeNoneMagicConstant); !ok { + return NoneSignatureTypeDisallowedError + } + // If signing method is none, signature must be an empty string + if signature != "" { + return NewValidationError( + "'none' signing method with non-empty signature", + ValidationErrorSignatureInvalid, + ) + } + + // Accept 'none' signing method. + return nil +} + +// Only allow 'none' signing if UnsafeAllowNoneSignatureType is specified as the key +func (m *signingMethodNone) Sign(signingString string, key interface{}) (string, error) { + if _, ok := key.(unsafeNoneMagicConstant); ok { + return "", nil + } + return "", NoneSignatureTypeDisallowedError +} diff --git a/vendor/github.com/dgrijalva/jwt-go/parser.go b/vendor/github.com/dgrijalva/jwt-go/parser.go new file mode 100644 index 000000000..d6901d9ad --- /dev/null +++ b/vendor/github.com/dgrijalva/jwt-go/parser.go @@ -0,0 +1,148 @@ +package jwt + +import ( + "bytes" + "encoding/json" + "fmt" + "strings" +) + +type Parser struct { + ValidMethods []string // If populated, only these methods will be considered valid + UseJSONNumber bool // Use JSON Number format in JSON decoder + SkipClaimsValidation bool // Skip claims validation during token parsing +} + +// Parse, validate, and return a token. +// keyFunc will receive the parsed token and should return the key for validating. +// If everything is kosher, err will be nil +func (p *Parser) Parse(tokenString string, keyFunc Keyfunc) (*Token, error) { + return p.ParseWithClaims(tokenString, MapClaims{}, keyFunc) +} + +func (p *Parser) ParseWithClaims(tokenString string, claims Claims, keyFunc Keyfunc) (*Token, error) { + token, parts, err := p.ParseUnverified(tokenString, claims) + if err != nil { + return token, err + } + + // Verify signing method is in the required set + if p.ValidMethods != nil { + var signingMethodValid = false + var alg = token.Method.Alg() + for _, m := range p.ValidMethods { + if m == alg { + signingMethodValid = true + break + } + } + if !signingMethodValid { + // signing method is not in the listed set + return token, NewValidationError(fmt.Sprintf("signing method %v is invalid", alg), ValidationErrorSignatureInvalid) + } + } + + // Lookup key + var key interface{} + if keyFunc == nil { + // keyFunc was not provided. short circuiting validation + return token, NewValidationError("no Keyfunc was provided.", ValidationErrorUnverifiable) + } + if key, err = keyFunc(token); err != nil { + // keyFunc returned an error + if ve, ok := err.(*ValidationError); ok { + return token, ve + } + return token, &ValidationError{Inner: err, Errors: ValidationErrorUnverifiable} + } + + vErr := &ValidationError{} + + // Validate Claims + if !p.SkipClaimsValidation { + if err := token.Claims.Valid(); err != nil { + + // If the Claims Valid returned an error, check if it is a validation error, + // If it was another error type, create a ValidationError with a generic ClaimsInvalid flag set + if e, ok := err.(*ValidationError); !ok { + vErr = &ValidationError{Inner: err, Errors: ValidationErrorClaimsInvalid} + } else { + vErr = e + } + } + } + + // Perform validation + token.Signature = parts[2] + if err = token.Method.Verify(strings.Join(parts[0:2], "."), token.Signature, key); err != nil { + vErr.Inner = err + vErr.Errors |= ValidationErrorSignatureInvalid + } + + if vErr.valid() { + token.Valid = true + return token, nil + } + + return token, vErr +} + +// WARNING: Don't use this method unless you know what you're doing +// +// This method parses the token but doesn't validate the signature. It's only +// ever useful in cases where you know the signature is valid (because it has +// been checked previously in the stack) and you want to extract values from +// it. +func (p *Parser) ParseUnverified(tokenString string, claims Claims) (token *Token, parts []string, err error) { + parts = strings.Split(tokenString, ".") + if len(parts) != 3 { + return nil, parts, NewValidationError("token contains an invalid number of segments", ValidationErrorMalformed) + } + + token = &Token{Raw: tokenString} + + // parse Header + var headerBytes []byte + if headerBytes, err = DecodeSegment(parts[0]); err != nil { + if strings.HasPrefix(strings.ToLower(tokenString), "bearer ") { + return token, parts, NewValidationError("tokenstring should not contain 'bearer '", ValidationErrorMalformed) + } + return token, parts, &ValidationError{Inner: err, Errors: ValidationErrorMalformed} + } + if err = json.Unmarshal(headerBytes, &token.Header); err != nil { + return token, parts, &ValidationError{Inner: err, Errors: ValidationErrorMalformed} + } + + // parse Claims + var claimBytes []byte + token.Claims = claims + + if claimBytes, err = DecodeSegment(parts[1]); err != nil { + return token, parts, &ValidationError{Inner: err, Errors: ValidationErrorMalformed} + } + dec := json.NewDecoder(bytes.NewBuffer(claimBytes)) + if p.UseJSONNumber { + dec.UseNumber() + } + // JSON Decode. Special case for map type to avoid weird pointer behavior + if c, ok := token.Claims.(MapClaims); ok { + err = dec.Decode(&c) + } else { + err = dec.Decode(&claims) + } + // Handle decode error + if err != nil { + return token, parts, &ValidationError{Inner: err, Errors: ValidationErrorMalformed} + } + + // Lookup signature method + if method, ok := token.Header["alg"].(string); ok { + if token.Method = GetSigningMethod(method); token.Method == nil { + return token, parts, NewValidationError("signing method (alg) is unavailable.", ValidationErrorUnverifiable) + } + } else { + return token, parts, NewValidationError("signing method (alg) is unspecified.", ValidationErrorUnverifiable) + } + + return token, parts, nil +} diff --git a/vendor/github.com/dgrijalva/jwt-go/rsa.go b/vendor/github.com/dgrijalva/jwt-go/rsa.go new file mode 100644 index 000000000..e4caf1ca4 --- /dev/null +++ b/vendor/github.com/dgrijalva/jwt-go/rsa.go @@ -0,0 +1,101 @@ +package jwt + +import ( + "crypto" + "crypto/rand" + "crypto/rsa" +) + +// Implements the RSA family of signing methods signing methods +// Expects *rsa.PrivateKey for signing and *rsa.PublicKey for validation +type SigningMethodRSA struct { + Name string + Hash crypto.Hash +} + +// Specific instances for RS256 and company +var ( + SigningMethodRS256 *SigningMethodRSA + SigningMethodRS384 *SigningMethodRSA + SigningMethodRS512 *SigningMethodRSA +) + +func init() { + // RS256 + SigningMethodRS256 = &SigningMethodRSA{"RS256", crypto.SHA256} + RegisterSigningMethod(SigningMethodRS256.Alg(), func() SigningMethod { + return SigningMethodRS256 + }) + + // RS384 + SigningMethodRS384 = &SigningMethodRSA{"RS384", crypto.SHA384} + RegisterSigningMethod(SigningMethodRS384.Alg(), func() SigningMethod { + return SigningMethodRS384 + }) + + // RS512 + SigningMethodRS512 = &SigningMethodRSA{"RS512", crypto.SHA512} + RegisterSigningMethod(SigningMethodRS512.Alg(), func() SigningMethod { + return SigningMethodRS512 + }) +} + +func (m *SigningMethodRSA) Alg() string { + return m.Name +} + +// Implements the Verify method from SigningMethod +// For this signing method, must be an *rsa.PublicKey structure. +func (m *SigningMethodRSA) Verify(signingString, signature string, key interface{}) error { + var err error + + // Decode the signature + var sig []byte + if sig, err = DecodeSegment(signature); err != nil { + return err + } + + var rsaKey *rsa.PublicKey + var ok bool + + if rsaKey, ok = key.(*rsa.PublicKey); !ok { + return ErrInvalidKeyType + } + + // Create hasher + if !m.Hash.Available() { + return ErrHashUnavailable + } + hasher := m.Hash.New() + hasher.Write([]byte(signingString)) + + // Verify the signature + return rsa.VerifyPKCS1v15(rsaKey, m.Hash, hasher.Sum(nil), sig) +} + +// Implements the Sign method from SigningMethod +// For this signing method, must be an *rsa.PrivateKey structure. +func (m *SigningMethodRSA) Sign(signingString string, key interface{}) (string, error) { + var rsaKey *rsa.PrivateKey + var ok bool + + // Validate type of key + if rsaKey, ok = key.(*rsa.PrivateKey); !ok { + return "", ErrInvalidKey + } + + // Create the hasher + if !m.Hash.Available() { + return "", ErrHashUnavailable + } + + hasher := m.Hash.New() + hasher.Write([]byte(signingString)) + + // Sign the string and return the encoded bytes + if sigBytes, err := rsa.SignPKCS1v15(rand.Reader, rsaKey, m.Hash, hasher.Sum(nil)); err == nil { + return EncodeSegment(sigBytes), nil + } else { + return "", err + } +} diff --git a/vendor/github.com/dgrijalva/jwt-go/rsa_pss.go b/vendor/github.com/dgrijalva/jwt-go/rsa_pss.go new file mode 100644 index 000000000..10ee9db8a --- /dev/null +++ b/vendor/github.com/dgrijalva/jwt-go/rsa_pss.go @@ -0,0 +1,126 @@ +// +build go1.4 + +package jwt + +import ( + "crypto" + "crypto/rand" + "crypto/rsa" +) + +// Implements the RSAPSS family of signing methods signing methods +type SigningMethodRSAPSS struct { + *SigningMethodRSA + Options *rsa.PSSOptions +} + +// Specific instances for RS/PS and company +var ( + SigningMethodPS256 *SigningMethodRSAPSS + SigningMethodPS384 *SigningMethodRSAPSS + SigningMethodPS512 *SigningMethodRSAPSS +) + +func init() { + // PS256 + SigningMethodPS256 = &SigningMethodRSAPSS{ + &SigningMethodRSA{ + Name: "PS256", + Hash: crypto.SHA256, + }, + &rsa.PSSOptions{ + SaltLength: rsa.PSSSaltLengthAuto, + Hash: crypto.SHA256, + }, + } + RegisterSigningMethod(SigningMethodPS256.Alg(), func() SigningMethod { + return SigningMethodPS256 + }) + + // PS384 + SigningMethodPS384 = &SigningMethodRSAPSS{ + &SigningMethodRSA{ + Name: "PS384", + Hash: crypto.SHA384, + }, + &rsa.PSSOptions{ + SaltLength: rsa.PSSSaltLengthAuto, + Hash: crypto.SHA384, + }, + } + RegisterSigningMethod(SigningMethodPS384.Alg(), func() SigningMethod { + return SigningMethodPS384 + }) + + // PS512 + SigningMethodPS512 = &SigningMethodRSAPSS{ + &SigningMethodRSA{ + Name: "PS512", + Hash: crypto.SHA512, + }, + &rsa.PSSOptions{ + SaltLength: rsa.PSSSaltLengthAuto, + Hash: crypto.SHA512, + }, + } + RegisterSigningMethod(SigningMethodPS512.Alg(), func() SigningMethod { + return SigningMethodPS512 + }) +} + +// Implements the Verify method from SigningMethod +// For this verify method, key must be an rsa.PublicKey struct +func (m *SigningMethodRSAPSS) Verify(signingString, signature string, key interface{}) error { + var err error + + // Decode the signature + var sig []byte + if sig, err = DecodeSegment(signature); err != nil { + return err + } + + var rsaKey *rsa.PublicKey + switch k := key.(type) { + case *rsa.PublicKey: + rsaKey = k + default: + return ErrInvalidKey + } + + // Create hasher + if !m.Hash.Available() { + return ErrHashUnavailable + } + hasher := m.Hash.New() + hasher.Write([]byte(signingString)) + + return rsa.VerifyPSS(rsaKey, m.Hash, hasher.Sum(nil), sig, m.Options) +} + +// Implements the Sign method from SigningMethod +// For this signing method, key must be an rsa.PrivateKey struct +func (m *SigningMethodRSAPSS) Sign(signingString string, key interface{}) (string, error) { + var rsaKey *rsa.PrivateKey + + switch k := key.(type) { + case *rsa.PrivateKey: + rsaKey = k + default: + return "", ErrInvalidKeyType + } + + // Create the hasher + if !m.Hash.Available() { + return "", ErrHashUnavailable + } + + hasher := m.Hash.New() + hasher.Write([]byte(signingString)) + + // Sign the string and return the encoded bytes + if sigBytes, err := rsa.SignPSS(rand.Reader, rsaKey, m.Hash, hasher.Sum(nil), m.Options); err == nil { + return EncodeSegment(sigBytes), nil + } else { + return "", err + } +} diff --git a/vendor/github.com/dgrijalva/jwt-go/rsa_utils.go b/vendor/github.com/dgrijalva/jwt-go/rsa_utils.go new file mode 100644 index 000000000..a5ababf95 --- /dev/null +++ b/vendor/github.com/dgrijalva/jwt-go/rsa_utils.go @@ -0,0 +1,101 @@ +package jwt + +import ( + "crypto/rsa" + "crypto/x509" + "encoding/pem" + "errors" +) + +var ( + ErrKeyMustBePEMEncoded = errors.New("Invalid Key: Key must be PEM encoded PKCS1 or PKCS8 private key") + ErrNotRSAPrivateKey = errors.New("Key is not a valid RSA private key") + ErrNotRSAPublicKey = errors.New("Key is not a valid RSA public key") +) + +// Parse PEM encoded PKCS1 or PKCS8 private key +func ParseRSAPrivateKeyFromPEM(key []byte) (*rsa.PrivateKey, error) { + var err error + + // Parse PEM block + var block *pem.Block + if block, _ = pem.Decode(key); block == nil { + return nil, ErrKeyMustBePEMEncoded + } + + var parsedKey interface{} + if parsedKey, err = x509.ParsePKCS1PrivateKey(block.Bytes); err != nil { + if parsedKey, err = x509.ParsePKCS8PrivateKey(block.Bytes); err != nil { + return nil, err + } + } + + var pkey *rsa.PrivateKey + var ok bool + if pkey, ok = parsedKey.(*rsa.PrivateKey); !ok { + return nil, ErrNotRSAPrivateKey + } + + return pkey, nil +} + +// Parse PEM encoded PKCS1 or PKCS8 private key protected with password +func ParseRSAPrivateKeyFromPEMWithPassword(key []byte, password string) (*rsa.PrivateKey, error) { + var err error + + // Parse PEM block + var block *pem.Block + if block, _ = pem.Decode(key); block == nil { + return nil, ErrKeyMustBePEMEncoded + } + + var parsedKey interface{} + + var blockDecrypted []byte + if blockDecrypted, err = x509.DecryptPEMBlock(block, []byte(password)); err != nil { + return nil, err + } + + if parsedKey, err = x509.ParsePKCS1PrivateKey(blockDecrypted); err != nil { + if parsedKey, err = x509.ParsePKCS8PrivateKey(blockDecrypted); err != nil { + return nil, err + } + } + + var pkey *rsa.PrivateKey + var ok bool + if pkey, ok = parsedKey.(*rsa.PrivateKey); !ok { + return nil, ErrNotRSAPrivateKey + } + + return pkey, nil +} + +// Parse PEM encoded PKCS1 or PKCS8 public key +func ParseRSAPublicKeyFromPEM(key []byte) (*rsa.PublicKey, error) { + var err error + + // Parse PEM block + var block *pem.Block + if block, _ = pem.Decode(key); block == nil { + return nil, ErrKeyMustBePEMEncoded + } + + // Parse the key + var parsedKey interface{} + if parsedKey, err = x509.ParsePKIXPublicKey(block.Bytes); err != nil { + if cert, err := x509.ParseCertificate(block.Bytes); err == nil { + parsedKey = cert.PublicKey + } else { + return nil, err + } + } + + var pkey *rsa.PublicKey + var ok bool + if pkey, ok = parsedKey.(*rsa.PublicKey); !ok { + return nil, ErrNotRSAPublicKey + } + + return pkey, nil +} diff --git a/vendor/github.com/dgrijalva/jwt-go/signing_method.go b/vendor/github.com/dgrijalva/jwt-go/signing_method.go new file mode 100644 index 000000000..ed1f212b2 --- /dev/null +++ b/vendor/github.com/dgrijalva/jwt-go/signing_method.go @@ -0,0 +1,35 @@ +package jwt + +import ( + "sync" +) + +var signingMethods = map[string]func() SigningMethod{} +var signingMethodLock = new(sync.RWMutex) + +// Implement SigningMethod to add new methods for signing or verifying tokens. +type SigningMethod interface { + Verify(signingString, signature string, key interface{}) error // Returns nil if signature is valid + Sign(signingString string, key interface{}) (string, error) // Returns encoded signature or error + Alg() string // returns the alg identifier for this method (example: 'HS256') +} + +// Register the "alg" name and a factory function for signing method. +// This is typically done during init() in the method's implementation +func RegisterSigningMethod(alg string, f func() SigningMethod) { + signingMethodLock.Lock() + defer signingMethodLock.Unlock() + + signingMethods[alg] = f +} + +// Get a signing method from an "alg" string +func GetSigningMethod(alg string) (method SigningMethod) { + signingMethodLock.RLock() + defer signingMethodLock.RUnlock() + + if methodF, ok := signingMethods[alg]; ok { + method = methodF() + } + return +} diff --git a/vendor/github.com/dgrijalva/jwt-go/token.go b/vendor/github.com/dgrijalva/jwt-go/token.go new file mode 100644 index 000000000..d637e0867 --- /dev/null +++ b/vendor/github.com/dgrijalva/jwt-go/token.go @@ -0,0 +1,108 @@ +package jwt + +import ( + "encoding/base64" + "encoding/json" + "strings" + "time" +) + +// TimeFunc provides the current time when parsing token to validate "exp" claim (expiration time). +// You can override it to use another time value. This is useful for testing or if your +// server uses a different time zone than your tokens. +var TimeFunc = time.Now + +// Parse methods use this callback function to supply +// the key for verification. The function receives the parsed, +// but unverified Token. This allows you to use properties in the +// Header of the token (such as `kid`) to identify which key to use. +type Keyfunc func(*Token) (interface{}, error) + +// A JWT Token. Different fields will be used depending on whether you're +// creating or parsing/verifying a token. +type Token struct { + Raw string // The raw token. Populated when you Parse a token + Method SigningMethod // The signing method used or to be used + Header map[string]interface{} // The first segment of the token + Claims Claims // The second segment of the token + Signature string // The third segment of the token. Populated when you Parse a token + Valid bool // Is the token valid? Populated when you Parse/Verify a token +} + +// Create a new Token. Takes a signing method +func New(method SigningMethod) *Token { + return NewWithClaims(method, MapClaims{}) +} + +func NewWithClaims(method SigningMethod, claims Claims) *Token { + return &Token{ + Header: map[string]interface{}{ + "typ": "JWT", + "alg": method.Alg(), + }, + Claims: claims, + Method: method, + } +} + +// Get the complete, signed token +func (t *Token) SignedString(key interface{}) (string, error) { + var sig, sstr string + var err error + if sstr, err = t.SigningString(); err != nil { + return "", err + } + if sig, err = t.Method.Sign(sstr, key); err != nil { + return "", err + } + return strings.Join([]string{sstr, sig}, "."), nil +} + +// Generate the signing string. This is the +// most expensive part of the whole deal. Unless you +// need this for something special, just go straight for +// the SignedString. +func (t *Token) SigningString() (string, error) { + var err error + parts := make([]string, 2) + for i, _ := range parts { + var jsonValue []byte + if i == 0 { + if jsonValue, err = json.Marshal(t.Header); err != nil { + return "", err + } + } else { + if jsonValue, err = json.Marshal(t.Claims); err != nil { + return "", err + } + } + + parts[i] = EncodeSegment(jsonValue) + } + return strings.Join(parts, "."), nil +} + +// Parse, validate, and return a token. +// keyFunc will receive the parsed token and should return the key for validating. +// If everything is kosher, err will be nil +func Parse(tokenString string, keyFunc Keyfunc) (*Token, error) { + return new(Parser).Parse(tokenString, keyFunc) +} + +func ParseWithClaims(tokenString string, claims Claims, keyFunc Keyfunc) (*Token, error) { + return new(Parser).ParseWithClaims(tokenString, claims, keyFunc) +} + +// Encode JWT specific base64url encoding with padding stripped +func EncodeSegment(seg []byte) string { + return strings.TrimRight(base64.URLEncoding.EncodeToString(seg), "=") +} + +// Decode JWT specific base64url encoding with padding stripped +func DecodeSegment(seg string) ([]byte, error) { + if l := len(seg) % 4; l > 0 { + seg += strings.Repeat("=", 4-l) + } + + return base64.URLEncoding.DecodeString(seg) +} diff --git a/vendor/modules.txt b/vendor/modules.txt index f298753e6..30d132c26 100644 --- a/vendor/modules.txt +++ b/vendor/modules.txt @@ -94,6 +94,8 @@ github.com/chromedp/chromedp/kb github.com/cpuguy83/go-md2man/v2/md2man # github.com/davecgh/go-spew v1.1.1 github.com/davecgh/go-spew/spew +# github.com/dgrijalva/jwt-go v3.2.0+incompatible +github.com/dgrijalva/jwt-go # github.com/disintegration/imaging v1.6.0 github.com/disintegration/imaging # github.com/fsnotify/fsnotify v1.4.7 From 35718ce59aba6e6c09cfc6f0e40cb9cca0b6e802 Mon Sep 17 00:00:00 2001 From: peolic <66393006+peolic@users.noreply.github.com> Date: Thu, 1 Apr 2021 08:10:56 +0300 Subject: [PATCH 08/66] Disable sounds on scene/marker wall previews by default (#1247) --- pkg/manager/config/config.go | 2 +- ui/v2.5/src/components/Changelog/versions/v070.md | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/pkg/manager/config/config.go b/pkg/manager/config/config.go index ea63cd06d..46fd0c2c5 100644 --- a/pkg/manager/config/config.go +++ b/pkg/manager/config/config.go @@ -494,7 +494,7 @@ func GetMenuItems() []string { } func GetSoundOnPreview() bool { - viper.SetDefault(SoundOnPreview, true) + viper.SetDefault(SoundOnPreview, false) return viper.GetBool(SoundOnPreview) } diff --git a/ui/v2.5/src/components/Changelog/versions/v070.md b/ui/v2.5/src/components/Changelog/versions/v070.md index 09ca8dee5..0e204e29a 100644 --- a/ui/v2.5/src/components/Changelog/versions/v070.md +++ b/ui/v2.5/src/components/Changelog/versions/v070.md @@ -3,8 +3,9 @@ * Added scene queue. ### 🎨 Improvements +* Disable sounds on scene/marker wall previews by default. * Improve Movie UI. * Change performer text query to search by name and alias only. ### 🐛 Bug fixes -* Fix incorrect performer age calculation in UI. \ No newline at end of file +* Fix incorrect performer age calculation in UI. From 2c1300cae0c5b53d4ad2a553f40c395d8f322a93 Mon Sep 17 00:00:00 2001 From: bnkai <48220860+bnkai@users.noreply.github.com> Date: Thu, 1 Apr 2021 08:43:42 +0300 Subject: [PATCH 09/66] Upgrade x/image (#1248) --- go.mod | 2 +- go.sum | 4 +- .../src/components/Changelog/versions/v070.md | 1 + vendor/golang.org/x/image/bmp/reader.go | 6 + vendor/golang.org/x/image/ccitt/reader.go | 260 +++++++++++++----- vendor/golang.org/x/image/ccitt/table.go | 41 +-- vendor/golang.org/x/image/tiff/fuzz.go | 1 + vendor/golang.org/x/image/tiff/lzw/reader.go | 2 +- vendor/golang.org/x/image/tiff/reader.go | 3 + vendor/golang.org/x/image/webp/decode.go | 17 +- vendor/modules.txt | 2 +- 11 files changed, 233 insertions(+), 106 deletions(-) diff --git a/go.mod b/go.mod index 2c32d3e80..c4d9865bf 100644 --- a/go.mod +++ b/go.mod @@ -32,7 +32,7 @@ require ( github.com/vektah/gqlparser/v2 v2.0.1 github.com/vektra/mockery/v2 v2.2.1 golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9 - golang.org/x/image v0.0.0-20190802002840-cff245a6509b + golang.org/x/image v0.0.0-20210220032944-ac19c3e999fb golang.org/x/net v0.0.0-20200822124328-c89045814202 golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd golang.org/x/tools v0.0.0-20200915031644-64986481280e // indirect diff --git a/go.sum b/go.sum index 9c3bf7c37..d08261d70 100644 --- a/go.sum +++ b/go.sum @@ -539,8 +539,6 @@ github.com/mattn/go-sqlite3 v1.9.0 h1:pDRiWfl+++eC2FEFRy6jXmQlvp4Yh3z1MJKg4UeYM/ github.com/mattn/go-sqlite3 v1.9.0/go.mod h1:FPy6KqzDD04eiIsT53CuJW3U88zkxoIYsOqkbpncsNc= github.com/mattn/go-sqlite3 v1.10.0 h1:jbhqpg7tQe4SupckyijYiy0mJJ/pRyHvXf7JdWK860o= github.com/mattn/go-sqlite3 v1.10.0/go.mod h1:FPy6KqzDD04eiIsT53CuJW3U88zkxoIYsOqkbpncsNc= -github.com/mattn/go-sqlite3 v1.13.0 h1:LnJI81JidiW9r7pS/hXe6cFeO5EXNq7KbfvoJLRI69c= -github.com/mattn/go-sqlite3 v1.13.0/go.mod h1:FPy6KqzDD04eiIsT53CuJW3U88zkxoIYsOqkbpncsNc= github.com/mattn/go-sqlite3 v1.14.6 h1:dNPt6NO46WmLVt2DLNpwczCmdV5boIZ6g/tlDrlRUbg= github.com/mattn/go-sqlite3 v1.14.6/go.mod h1:NyWgC/yNuGj7Q9rpYnZvas74GogHl5/Z4A/KQRfk6bU= github.com/matttproud/golang_protobuf_extensions v1.0.1/go.mod h1:D8He9yQNgCq6Z5Ld7szi9bcBfOoFv/3dc6xSMkL2PC0= @@ -805,6 +803,8 @@ golang.org/x/image v0.0.0-20180708004352-c73c2afc3b81/go.mod h1:ux5Hcp/YLpHSI86h golang.org/x/image v0.0.0-20190227222117-0694c2d4d067/go.mod h1:kZ7UVZpmo3dzQBMxlp+ypCbDeSB+sBbTgSJuh5dn5js= golang.org/x/image v0.0.0-20190802002840-cff245a6509b h1:+qEpEAPhDZ1o0x3tHzZTQDArnOixOzGD9HUJfcg0mb4= golang.org/x/image v0.0.0-20190802002840-cff245a6509b/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0= +golang.org/x/image v0.0.0-20210220032944-ac19c3e999fb h1:fqpd0EBDzlHRCjiphRR5Zo/RSWWQlWv34418dnEixWk= +golang.org/x/image v0.0.0-20210220032944-ac19c3e999fb/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0= golang.org/x/lint v0.0.0-20180702182130-06c8688daad7/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU= diff --git a/ui/v2.5/src/components/Changelog/versions/v070.md b/ui/v2.5/src/components/Changelog/versions/v070.md index 0e204e29a..82964822c 100644 --- a/ui/v2.5/src/components/Changelog/versions/v070.md +++ b/ui/v2.5/src/components/Changelog/versions/v070.md @@ -8,4 +8,5 @@ * Change performer text query to search by name and alias only. ### 🐛 Bug fixes +* Fix processing some webp files. * Fix incorrect performer age calculation in UI. diff --git a/vendor/golang.org/x/image/bmp/reader.go b/vendor/golang.org/x/image/bmp/reader.go index c10a022f6..52e25205c 100644 --- a/vendor/golang.org/x/image/bmp/reader.go +++ b/vendor/golang.org/x/image/bmp/reader.go @@ -144,6 +144,9 @@ func decodeConfig(r io.Reader) (config image.Config, bitsPerPixel int, topDown b ) var b [1024]byte if _, err := io.ReadFull(r, b[:fileHeaderLen+4]); err != nil { + if err == io.EOF { + err = io.ErrUnexpectedEOF + } return image.Config{}, 0, false, err } if string(b[:2]) != "BM" { @@ -155,6 +158,9 @@ func decodeConfig(r io.Reader) (config image.Config, bitsPerPixel int, topDown b return image.Config{}, 0, false, ErrUnsupported } if _, err := io.ReadFull(r, b[fileHeaderLen+4:fileHeaderLen+infoLen]); err != nil { + if err == io.EOF { + err = io.ErrUnexpectedEOF + } return image.Config{}, 0, false, err } width := int(int32(readUint32(b[18:22]))) diff --git a/vendor/golang.org/x/image/ccitt/reader.go b/vendor/golang.org/x/image/ccitt/reader.go index 62986f977..340de0536 100644 --- a/vendor/golang.org/x/image/ccitt/reader.go +++ b/vendor/golang.org/x/image/ccitt/reader.go @@ -16,6 +16,7 @@ import ( ) var ( + errIncompleteCode = errors.New("ccitt: incomplete code") errInvalidBounds = errors.New("ccitt: invalid bounds") errInvalidCode = errors.New("ccitt: invalid code") errInvalidMode = errors.New("ccitt: invalid mode") @@ -48,6 +49,10 @@ const ( Group4 ) +// AutoDetectHeight is passed as the height argument to NewReader to indicate +// that the image height (the number of rows) is not known in advance. +const AutoDetectHeight = -1 + // Options are optional parameters. type Options struct { // Align means that some variable-bit-width codes are byte-aligned. @@ -73,6 +78,56 @@ func reverseBitsWithinBytes(b []byte) { } } +// highBits writes to dst (1 bit per pixel, most significant bit first) the +// high (0x80) bits from src (1 byte per pixel). It returns the number of bytes +// written and read such that dst[:d] is the packed form of src[:s]. +// +// For example, if src starts with the 8 bytes [0x7D, 0x7E, 0x7F, 0x80, 0x81, +// 0x82, 0x00, 0xFF] then 0x1D will be written to dst[0]. +// +// If src has (8 * len(dst)) or more bytes then only len(dst) bytes are +// written, (8 * len(dst)) bytes are read, and invert is ignored. +// +// Otherwise, if len(src) is not a multiple of 8 then the final byte written to +// dst is padded with 1 bits (if invert is true) or 0 bits. If inverted, the 1s +// are typically temporary, e.g. they will be flipped back to 0s by an +// invertBytes call in the highBits caller, reader.Read. +func highBits(dst []byte, src []byte, invert bool) (d int, s int) { + // Pack as many complete groups of 8 src bytes as we can. + n := len(src) / 8 + if n > len(dst) { + n = len(dst) + } + dstN := dst[:n] + for i := range dstN { + src8 := src[i*8 : i*8+8] + dstN[i] = ((src8[0] & 0x80) >> 0) | + ((src8[1] & 0x80) >> 1) | + ((src8[2] & 0x80) >> 2) | + ((src8[3] & 0x80) >> 3) | + ((src8[4] & 0x80) >> 4) | + ((src8[5] & 0x80) >> 5) | + ((src8[6] & 0x80) >> 6) | + ((src8[7] & 0x80) >> 7) + } + d, s = n, 8*n + dst, src = dst[d:], src[s:] + + // Pack up to 7 remaining src bytes, if there's room in dst. + if (len(dst) > 0) && (len(src) > 0) { + dstByte := byte(0) + if invert { + dstByte = 0xFF >> uint(len(src)) + } + for n, srcByte := range src { + dstByte |= (srcByte & 0x80) >> uint(n) + } + dst[0] = dstByte + d, s = d+1, s+len(src) + } + return d, s +} + type bitReader struct { r io.Reader @@ -84,7 +139,7 @@ type bitReader struct { // order is whether to process r's bytes LSB first or MSB first. order Order - // The low nBits bits of the bits field hold upcoming bits in LSB order. + // The high nBits bits of the bits field hold upcoming bits in MSB order. bits uint64 nBits uint32 @@ -96,7 +151,7 @@ type bitReader struct { func (b *bitReader) alignToByteBoundary() { n := b.nBits & 7 - b.bits >>= n + b.bits <<= n b.nBits -= n } @@ -108,11 +163,11 @@ func (b *bitReader) alignToByteBoundary() { // bitReader.nBits value above nextBitMaxNBits. const nextBitMaxNBits = 31 -func (b *bitReader) nextBit() (uint32, error) { +func (b *bitReader) nextBit() (uint64, error) { for { if b.nBits > 0 { - bit := uint32(b.bits) & 1 - b.bits >>= 1 + bit := b.bits >> 63 + b.bits <<= 1 b.nBits-- return bit, nil } @@ -124,12 +179,12 @@ func (b *bitReader) nextBit() (uint32, error) { // checks that the generated maxCodeLength constant fits. // // If changing the Uint32 call, also change nextBitMaxNBits. - b.bits = uint64(binary.LittleEndian.Uint32(b.bytes[b.br:])) + b.bits = uint64(binary.BigEndian.Uint32(b.bytes[b.br:])) << 32 b.br += 4 b.nBits = 32 continue } else if available > 0 { - b.bits = uint64(b.bytes[b.br]) + b.bits = uint64(b.bytes[b.br]) << (7 * 8) b.br++ b.nBits = 8 continue @@ -144,34 +199,67 @@ func (b *bitReader) nextBit() (uint32, error) { b.bw = uint32(n) b.readErr = err - if b.order != LSB { + if b.order != MSB { reverseBitsWithinBytes(b.bytes[:b.bw]) } } } func decode(b *bitReader, decodeTable [][2]int16) (uint32, error) { - nBitsRead, bitsRead, state := uint32(0), uint32(0), int32(1) + nBitsRead, bitsRead, state := uint32(0), uint64(0), int32(1) for { bit, err := b.nextBit() if err != nil { + if err == io.EOF { + err = errIncompleteCode + } return 0, err } - bitsRead |= bit << nBitsRead + bitsRead |= bit << (63 - nBitsRead) nBitsRead++ + // The "&1" is redundant, but can eliminate a bounds check. state = int32(decodeTable[state][bit&1]) if state < 0 { return uint32(^state), nil } else if state == 0 { // Unread the bits we've read, then return errInvalidCode. - b.bits = (b.bits << nBitsRead) | uint64(bitsRead) + b.bits = (b.bits >> nBitsRead) | bitsRead b.nBits += nBitsRead return 0, errInvalidCode } } } +// decodeEOL decodes the 12-bit EOL code 0000_0000_0001. +func decodeEOL(b *bitReader) error { + nBitsRead, bitsRead := uint32(0), uint64(0) + for { + bit, err := b.nextBit() + if err != nil { + if err == io.EOF { + err = errMissingEOL + } + return err + } + bitsRead |= bit << (63 - nBitsRead) + nBitsRead++ + + if nBitsRead < 12 { + if bit&1 == 0 { + continue + } + } else if bit&1 != 0 { + return nil + } + + // Unread the bits we've read, then return errMissingEOL. + b.bits = (b.bits >> nBitsRead) | bitsRead + b.nBits += nBitsRead + return errMissingEOL + } +} + type reader struct { br bitReader subFormat SubFormat @@ -181,7 +269,10 @@ type reader struct { // rowsRemaining starts at the image height in pixels, when the reader is // driven through the io.Reader interface, and decrements to zero as rows - // are decoded. When driven through DecodeIntoGray, this field is unused. + // are decoded. Alternatively, it may be negative if the image height is + // not known in advance at the time of the NewReader call. + // + // When driven through DecodeIntoGray, this field is unused. rowsRemaining int // curr and prev hold the current and previous rows. Each element is either @@ -219,6 +310,19 @@ type reader struct { // seenStartOfImage is whether we've called the startDecode method. seenStartOfImage bool + // truncated is whether the input is missing the final 6 consecutive EOL's + // (for Group3) or 2 consecutive EOL's (for Group4). Omitting that trailer + // (but otherwise padding to a byte boundary, with either all 0 bits or all + // 1 bits) is invalid according to the spec, but happens in practice when + // exporting from Adobe Acrobat to TIFF + CCITT. This package silently + // ignores the format error for CCITT input that has been truncated in that + // fashion, returning the full decoded image. + // + // Detecting trailer truncation (just after the final row of pixels) + // requires knowing which row is the final row, and therefore does not + // trigger if the image height is not known in advance. + truncated bool + // readErr is a sticky error for the Read method. readErr error } @@ -244,38 +348,56 @@ func (z *reader) Read(p []byte) (int, error) { // Decode the next row, if necessary. if z.atStartOfRow { - if z.rowsRemaining <= 0 { - if z.readErr = z.finishDecode(); z.readErr != nil { + if z.rowsRemaining < 0 { + // We do not know the image height in advance. See if the next + // code is an EOL. If it is, it is consumed. If it isn't, the + // bitReader shouldn't advance along the bit stream, and we + // simply decode another row of pixel data. + // + // For the Group4 subFormat, we may need to align to a byte + // boundary. For the Group3 subFormat, the previous z.decodeRow + // call (or z.startDecode call) has already consumed one of the + // 6 consecutive EOL's. The next EOL is actually the second of + // 6, in the middle, and we shouldn't align at that point. + if z.align && (z.subFormat == Group4) { + z.br.alignToByteBoundary() + } + + if err := z.decodeEOL(); err == errMissingEOL { + // No-op. It's another row of pixel data. + } else if err != nil { + z.readErr = err + break + } else { + if z.readErr = z.finishDecode(true); z.readErr != nil { + break + } + z.readErr = io.EOF + break + } + + } else if z.rowsRemaining == 0 { + // We do know the image height in advance, and we have already + // decoded exactly that many rows. + if z.readErr = z.finishDecode(false); z.readErr != nil { break } z.readErr = io.EOF break - } - if z.readErr = z.decodeRow(); z.readErr != nil { - break - } - z.rowsRemaining-- - } - // Pack from z.curr (1 byte per pixel) to p (1 bit per pixel), up to 8 - // elements per iteration. - i := 0 - for ; i < len(p); i++ { - numToPack := len(z.curr) - z.ri - if numToPack <= 0 { - break - } else if numToPack > 8 { - numToPack = 8 + } else { + z.rowsRemaining-- } - byteValue := byte(0) - for j := 0; j < numToPack; j++ { - byteValue |= (z.curr[z.ri] & 0x80) >> uint(j) - z.ri++ + if z.readErr = z.decodeRow(z.rowsRemaining == 0); z.readErr != nil { + break } - p[i] = byteValue } - p = p[i:] + + // Pack from z.curr (1 byte per pixel) to p (1 bit per pixel). + packD, packS := highBits(p, z.curr[z.ri:], z.invert) + p = p[packD:] + z.ri += packS // Prepare to decode the next row, if necessary. if z.ri == len(z.curr) { @@ -285,7 +407,6 @@ func (z *reader) Read(p []byte) (int, error) { } n := len(originalP) - len(p) - // TODO: when invert is true, should the end-of-row padding bits be 0 or 1? if z.invert { invertBytes(originalP[:n]) } @@ -317,32 +438,44 @@ func (z *reader) startDecode() error { return nil } -func (z *reader) finishDecode() error { +func (z *reader) finishDecode(alreadySeenEOL bool) error { numberOfEOLs := 0 switch z.subFormat { case Group3: + if z.truncated { + return nil + } // The stream ends with a RTC (Return To Control) of 6 consecutive // EOL's, but we should have already just seen an EOL, either in // z.startDecode (for a zero-height image) or in z.decodeRow. numberOfEOLs = 5 case Group4: - // The stream ends with two EOL's, the first of which is possibly - // byte-aligned. - numberOfEOLs = 2 - if err := z.decodeEOL(); err == nil { - numberOfEOLs-- - } else if err == errInvalidCode { - // Try again, this time starting from a byte boundary. + autoDetectHeight := z.rowsRemaining < 0 + if autoDetectHeight { + // Aligning to a byte boundary was already handled by reader.Read. + } else if z.align { z.br.alignToByteBoundary() - } else { + } + // The stream ends with two EOL's. If the first one is missing, and we + // had an explicit image height, we just assume that the trailing two + // EOL's were truncated and return a nil error. + if err := z.decodeEOL(); err != nil { + if (err == errMissingEOL) && !autoDetectHeight { + z.truncated = true + return nil + } return err } + numberOfEOLs = 1 default: return errUnsupportedSubFormat } + if alreadySeenEOL { + numberOfEOLs-- + } for ; numberOfEOLs > 0; numberOfEOLs-- { if err := z.decodeEOL(); err != nil { return err @@ -352,23 +485,18 @@ func (z *reader) finishDecode() error { } func (z *reader) decodeEOL() error { - // TODO: EOL doesn't have to be in the modeDecodeTable. It could be in its - // own table, or we could just hard-code it, especially if we might need to - // cater for optional byte-alignment, or an arbitrary number (potentially - // more than 8) of 0-valued padding bits. - if mode, err := decode(&z.br, modeDecodeTable[:]); err != nil { - return err - } else if mode != modeEOL { - return errMissingEOL - } - return nil + return decodeEOL(&z.br) } -func (z *reader) decodeRow() error { +func (z *reader) decodeRow(finalRow bool) error { z.wi = 0 z.atStartOfRow = true z.penColorIsWhite = true + if z.align { + z.br.alignToByteBoundary() + } + switch z.subFormat { case Group3: for ; z.wi < len(z.curr); z.atStartOfRow = false { @@ -376,13 +504,14 @@ func (z *reader) decodeRow() error { return err } } - return z.decodeEOL() + err := z.decodeEOL() + if finalRow && (err == errMissingEOL) { + z.truncated = true + return nil + } + return err case Group4: - if z.align { - z.br.alignToByteBoundary() - } - for ; z.wi < len(z.curr); z.atStartOfRow = false { mode, err := decode(&z.br, modeDecodeTable[:]) if err != nil { @@ -620,13 +749,13 @@ func DecodeIntoGray(dst *image.Gray, r io.Reader, order Order, sf SubFormat, opt for y := bounds.Min.Y; y < bounds.Max.Y; y++ { p := (y - bounds.Min.Y) * dst.Stride z.curr = dst.Pix[p : p+width] - if err := z.decodeRow(); err != nil { + if err := z.decodeRow(y+1 == bounds.Max.Y); err != nil { return err } z.curr, z.prev = nil, z.curr } - if err := z.finishDecode(); err != nil { + if err := z.finishDecode(false); err != nil { return err } @@ -643,9 +772,12 @@ func DecodeIntoGray(dst *image.Gray, r io.Reader, order Order, sf SubFormat, opt // NewReader returns an io.Reader that decodes the CCITT-formatted data in r. // The resultant byte stream is one bit per pixel (MSB first), with 1 meaning // white and 0 meaning black. Each row in the result is byte-aligned. +// +// A negative height, such as passing AutoDetectHeight, means that the image +// height is not known in advance. A negative width is invalid. func NewReader(r io.Reader, order Order, sf SubFormat, width int, height int, opts *Options) io.Reader { readErr := error(nil) - if (width < 0) || (height < 0) { + if width < 0 { readErr = errInvalidBounds } else if width > maxWidth { readErr = errUnsupportedWidth diff --git a/vendor/golang.org/x/image/ccitt/table.go b/vendor/golang.org/x/image/ccitt/table.go index f01cc12b5..ef7ea9d40 100644 --- a/vendor/golang.org/x/image/ccitt/table.go +++ b/vendor/golang.org/x/image/ccitt/table.go @@ -31,17 +31,7 @@ package ccitt // modeDecodeTable represents Table 1 and the End-of-Line code. // -// +=XXXXX -// b015 +-+ -// | +=v0010 -// b014 +-+ -// | +=XXXXX -// b013 +-+ -// | +=XXXXX -// b012 +-+ -// | +=XXXXX -// b011 +-+ -// | +=XXXXX +// +=XXXXX // b009 +-+ // | +=v0009 // b007 +-+ @@ -72,13 +62,8 @@ var modeDecodeTable = [...][2]int16{ 6: {7, 8}, 7: {9, 10}, 8: {^7, ^4}, - 9: {11, ^9}, + 9: {0, ^9}, 10: {^8, ^5}, - 11: {12, 0}, - 12: {13, 0}, - 13: {14, 0}, - 14: {15, 0}, - 15: {0, ^10}, } // whiteDecodeTable represents Tables 2 and 3 for a white run. @@ -733,17 +718,16 @@ type bitString struct { // modeEncodeTable represents Table 1 and the End-of-Line code. var modeEncodeTable = [...]bitString{ - 0: {0x0001, 4}, // "0001" - 1: {0x0001, 3}, // "001" - 2: {0x0001, 1}, // "1" - 3: {0x0003, 3}, // "011" - 4: {0x0003, 6}, // "000011" - 5: {0x0003, 7}, // "0000011" - 6: {0x0002, 3}, // "010" - 7: {0x0002, 6}, // "000010" - 8: {0x0002, 7}, // "0000010" - 9: {0x0001, 7}, // "0000001" - 10: {0x0001, 12}, // "000000000001" + 0: {0x0001, 4}, // "0001" + 1: {0x0001, 3}, // "001" + 2: {0x0001, 1}, // "1" + 3: {0x0003, 3}, // "011" + 4: {0x0003, 6}, // "000011" + 5: {0x0003, 7}, // "0000011" + 6: {0x0002, 3}, // "010" + 7: {0x0002, 6}, // "000010" + 8: {0x0002, 7}, // "0000010" + 9: {0x0001, 7}, // "0000001" } // whiteEncodeTable2 represents Table 2 for a white run. @@ -983,7 +967,6 @@ const ( modeVL2 // Vertical-Left-2 modeVL3 // Vertical-Left-3 modeExt // Extension - modeEOL // End-of-Line ) // COPY PASTE table.go END diff --git a/vendor/golang.org/x/image/tiff/fuzz.go b/vendor/golang.org/x/image/tiff/fuzz.go index ec52c7882..b27c54004 100644 --- a/vendor/golang.org/x/image/tiff/fuzz.go +++ b/vendor/golang.org/x/image/tiff/fuzz.go @@ -2,6 +2,7 @@ // Use of this source code is governed by a BSD-style // license that can be found in the LICENSE file. +//go:build gofuzz // +build gofuzz package tiff diff --git a/vendor/golang.org/x/image/tiff/lzw/reader.go b/vendor/golang.org/x/image/tiff/lzw/reader.go index 51ae39f8a..78204ba92 100644 --- a/vendor/golang.org/x/image/tiff/lzw/reader.go +++ b/vendor/golang.org/x/image/tiff/lzw/reader.go @@ -178,7 +178,7 @@ loop: break loop case code <= d.hi: c, i := code, len(d.output)-1 - if code == d.hi { + if code == d.hi && d.last != decoderInvalidCode { // code == hi is a special case which expands to the last expansion // followed by the head of the last expansion. To find the head, we walk // the prefix chain until we find a literal code. diff --git a/vendor/golang.org/x/image/tiff/reader.go b/vendor/golang.org/x/image/tiff/reader.go index c26ec36bb..de73f4b99 100644 --- a/vendor/golang.org/x/image/tiff/reader.go +++ b/vendor/golang.org/x/image/tiff/reader.go @@ -404,6 +404,9 @@ func newDecoder(r io.Reader) (*decoder, error) { p := make([]byte, 8) if _, err := d.r.ReadAt(p, 0); err != nil { + if err == io.EOF { + err = io.ErrUnexpectedEOF + } return nil, err } switch string(p[0:4]) { diff --git a/vendor/golang.org/x/image/webp/decode.go b/vendor/golang.org/x/image/webp/decode.go index f77a4ebf8..d6eefd596 100644 --- a/vendor/golang.org/x/image/webp/decode.go +++ b/vendor/golang.org/x/image/webp/decode.go @@ -126,22 +126,23 @@ func decode(r io.Reader, configOnly bool) (image.Image, image.Config, error) { alphaBit = 1 << 4 iccProfileBit = 1 << 5 ) - if buf[0] != alphaBit { - return nil, image.Config{}, errors.New("webp: non-Alpha VP8X is not implemented") - } + wantAlpha = (buf[0] & alphaBit) != 0 widthMinusOne = uint32(buf[4]) | uint32(buf[5])<<8 | uint32(buf[6])<<16 heightMinusOne = uint32(buf[7]) | uint32(buf[8])<<8 | uint32(buf[9])<<16 if configOnly { + if wantAlpha { + return nil, image.Config{ + ColorModel: color.NYCbCrAModel, + Width: int(widthMinusOne) + 1, + Height: int(heightMinusOne) + 1, + }, nil + } return nil, image.Config{ - ColorModel: color.NYCbCrAModel, + ColorModel: color.YCbCrModel, Width: int(widthMinusOne) + 1, Height: int(heightMinusOne) + 1, }, nil } - wantAlpha = true - - default: - return nil, image.Config{}, errInvalidFormat } } } diff --git a/vendor/modules.txt b/vendor/modules.txt index 30d132c26..b9ea5e2e0 100644 --- a/vendor/modules.txt +++ b/vendor/modules.txt @@ -307,7 +307,7 @@ github.com/vektra/mockery/v2/pkg/logging golang.org/x/crypto/bcrypt golang.org/x/crypto/blowfish golang.org/x/crypto/ssh/terminal -# golang.org/x/image v0.0.0-20190802002840-cff245a6509b +# golang.org/x/image v0.0.0-20210220032944-ac19c3e999fb golang.org/x/image/bmp golang.org/x/image/ccitt golang.org/x/image/riff From 76714653342887f249159cd14b5554882455807f Mon Sep 17 00:00:00 2001 From: peolic <66393006+peolic@users.noreply.github.com> Date: Fri, 2 Apr 2021 02:09:10 +0300 Subject: [PATCH 10/66] Fix performer age timezone issues (#1251) * Parse date string manually --- ui/v2.5/src/utils/text.ts | 21 +++++++++++++++++++-- 1 file changed, 19 insertions(+), 2 deletions(-) diff --git a/ui/v2.5/src/utils/text.ts b/ui/v2.5/src/utils/text.ts index 1c7407a86..b69c440dc 100644 --- a/ui/v2.5/src/utils/text.ts +++ b/ui/v2.5/src/utils/text.ts @@ -69,11 +69,27 @@ const fileNameFromPath = (path: string) => { return path.replace(/^.*[\\/]/, ""); }; +const stringToDate = (dateString: string) => { + if (!dateString) return null; + + const parts = dateString.split("-"); + // Invalid date string + if (parts.length !== 3) return null; + + const year = Number(parts[0]); + const monthIndex = Math.max(0, Number(parts[1]) - 1); + const day = Number(parts[2]); + + return new Date(year, monthIndex, day, 0, 0, 0, 0); +}; + const getAge = (dateString?: string | null, fromDateString?: string) => { if (!dateString) return 0; - const birthdate = new Date(dateString); - const fromDate = fromDateString ? new Date(fromDateString) : new Date(); + const birthdate = stringToDate(dateString); + const fromDate = fromDateString ? stringToDate(fromDateString) : new Date(); + + if (!birthdate || !fromDate) return 0; let age = fromDate.getFullYear() - birthdate.getFullYear(); if ( @@ -176,6 +192,7 @@ const TextUtils = { fileSizeFractionalDigits, secondsToTimestamp, fileNameFromPath, + stringToDate, age: getAge, bitRate, resolution, From 72b027a887b04a1d1bb064ec32716ac29a5f82bd Mon Sep 17 00:00:00 2001 From: julien0221 <68500525+julien0221@users.noreply.github.com> Date: Tue, 6 Apr 2021 23:32:20 +0100 Subject: [PATCH 11/66] Added random for studios, movies and tags (#1250) --- ui/v2.5/src/components/Changelog/versions/v070.md | 1 + ui/v2.5/src/models/list-filter/filter.ts | 12 +++++++++--- 2 files changed, 10 insertions(+), 3 deletions(-) diff --git a/ui/v2.5/src/components/Changelog/versions/v070.md b/ui/v2.5/src/components/Changelog/versions/v070.md index 82964822c..6b00a36dc 100644 --- a/ui/v2.5/src/components/Changelog/versions/v070.md +++ b/ui/v2.5/src/components/Changelog/versions/v070.md @@ -3,6 +3,7 @@ * Added scene queue. ### 🎨 Improvements +* Add random sorting option for galleries, studios, movies and tags. * Disable sounds on scene/marker wall previews by default. * Improve Movie UI. * Change performer text query to search by name and alias only. diff --git a/ui/v2.5/src/models/list-filter/filter.ts b/ui/v2.5/src/models/list-filter/filter.ts index afcb5aadf..68306518d 100644 --- a/ui/v2.5/src/models/list-filter/filter.ts +++ b/ui/v2.5/src/models/list-filter/filter.ts @@ -219,7 +219,7 @@ export class ListFilterModel { } case FilterMode.Studios: this.sortBy = "name"; - this.sortByOptions = ["name", "scenes_count"]; + this.sortByOptions = ["name", "scenes_count", "random"]; this.displayModeOptions = [DisplayMode.Grid]; this.criterionOptions = [ new NoneCriterionOption(), @@ -229,7 +229,7 @@ export class ListFilterModel { break; case FilterMode.Movies: this.sortBy = "name"; - this.sortByOptions = ["name", "scenes_count"]; + this.sortByOptions = ["name", "scenes_count", "random"]; this.displayModeOptions = [DisplayMode.Grid]; this.criterionOptions = [ new NoneCriterionOption(), @@ -239,7 +239,12 @@ export class ListFilterModel { break; case FilterMode.Galleries: this.sortBy = "path"; - this.sortByOptions = ["path", "file_mod_time", "images_count"]; + this.sortByOptions = [ + "path", + "file_mod_time", + "images_count", + "random", + ]; this.displayModeOptions = [DisplayMode.Grid, DisplayMode.List]; this.criterionOptions = [ new NoneCriterionOption(), @@ -286,6 +291,7 @@ export class ListFilterModel { "images_count", "galleries_count", "performers_count", + "random", /* "scene_markers_count" */ ]; this.displayModeOptions = [DisplayMode.Grid, DisplayMode.List]; From d8ba4a08c0b2340ef052c4bc97ba2735179a45d4 Mon Sep 17 00:00:00 2001 From: peolic <66393006+peolic@users.noreply.github.com> Date: Wed, 7 Apr 2021 01:58:41 +0300 Subject: [PATCH 12/66] Update various GQL `image` fields' comments (#1271) --- graphql/schema/types/movie.graphql | 6 ++++-- graphql/schema/types/performer.graphql | 4 ++-- graphql/schema/types/scene.graphql | 2 +- graphql/schema/types/scraped-movie.graphql | 3 ++- graphql/schema/types/scraped-performer.graphql | 2 +- graphql/schema/types/scraper.graphql | 2 +- graphql/schema/types/studio.graphql | 4 ++-- graphql/schema/types/tag.graphql | 4 ++-- 8 files changed, 15 insertions(+), 12 deletions(-) diff --git a/graphql/schema/types/movie.graphql b/graphql/schema/types/movie.graphql index 0b41af0c8..cc25b9e02 100644 --- a/graphql/schema/types/movie.graphql +++ b/graphql/schema/types/movie.graphql @@ -28,8 +28,9 @@ input MovieCreateInput { director: String synopsis: String url: String - """This should be base64 encoded""" + """This should be a URL or a base64 encoded data URL""" front_image: String + """This should be a URL or a base64 encoded data URL""" back_image: String } @@ -44,8 +45,9 @@ input MovieUpdateInput { director: String synopsis: String url: String - """This should be base64 encoded""" + """This should be a URL or a base64 encoded data URL""" front_image: String + """This should be a URL or a base64 encoded data URL""" back_image: String } diff --git a/graphql/schema/types/performer.graphql b/graphql/schema/types/performer.graphql index 1324f4f81..e9ea68519 100644 --- a/graphql/schema/types/performer.graphql +++ b/graphql/schema/types/performer.graphql @@ -54,7 +54,7 @@ input PerformerCreateInput { instagram: String favorite: Boolean tag_ids: [ID!] - """This should be base64 encoded""" + """This should be a URL or a base64 encoded data URL""" image: String stash_ids: [StashIDInput!] } @@ -79,7 +79,7 @@ input PerformerUpdateInput { instagram: String favorite: Boolean tag_ids: [ID!] - """This should be base64 encoded""" + """This should be a URL or a base64 encoded data URL""" image: String stash_ids: [StashIDInput!] } diff --git a/graphql/schema/types/scene.graphql b/graphql/schema/types/scene.graphql index c72ae17ef..f37c0bfd9 100644 --- a/graphql/schema/types/scene.graphql +++ b/graphql/schema/types/scene.graphql @@ -67,7 +67,7 @@ input SceneUpdateInput { performer_ids: [ID!] movies: [SceneMovieInput!] tag_ids: [ID!] - """This should be base64 encoded""" + """This should be a URL or a base64 encoded data URL""" cover_image: String stash_ids: [StashIDInput!] } diff --git a/graphql/schema/types/scraped-movie.graphql b/graphql/schema/types/scraped-movie.graphql index ac221fb88..d1546dfb9 100644 --- a/graphql/schema/types/scraped-movie.graphql +++ b/graphql/schema/types/scraped-movie.graphql @@ -17,8 +17,9 @@ type ScrapedMovie { synopsis: String studio: ScrapedMovieStudio - """This should be base64 encoded""" + """This should be a base64 encoded data URL""" front_image: String + """This should be a base64 encoded data URL""" back_image: String } diff --git a/graphql/schema/types/scraped-performer.graphql b/graphql/schema/types/scraped-performer.graphql index eadc19160..9f4583600 100644 --- a/graphql/schema/types/scraped-performer.graphql +++ b/graphql/schema/types/scraped-performer.graphql @@ -19,7 +19,7 @@ type ScrapedPerformer { # Should be ScrapedPerformerTag - but would be identical types tags: [ScrapedSceneTag!] - """This should be base64 encoded""" + """This should be a base64 encoded data URL""" image: String } diff --git a/graphql/schema/types/scraper.graphql b/graphql/schema/types/scraper.graphql index 65a474600..929c13f2b 100644 --- a/graphql/schema/types/scraper.graphql +++ b/graphql/schema/types/scraper.graphql @@ -85,7 +85,7 @@ type ScrapedScene { url: String date: String - """This should be base64 encoded""" + """This should be a base64 encoded data URL""" image: String file: SceneFileType # Resolver diff --git a/graphql/schema/types/studio.graphql b/graphql/schema/types/studio.graphql index 051776e03..8eca28659 100644 --- a/graphql/schema/types/studio.graphql +++ b/graphql/schema/types/studio.graphql @@ -15,7 +15,7 @@ input StudioCreateInput { name: String! url: String parent_id: ID - """This should be base64 encoded""" + """This should be a URL or a base64 encoded data URL""" image: String stash_ids: [StashIDInput!] } @@ -25,7 +25,7 @@ input StudioUpdateInput { name: String url: String parent_id: ID, - """This should be base64 encoded""" + """This should be a URL or a base64 encoded data URL""" image: String stash_ids: [StashIDInput!] } diff --git a/graphql/schema/types/tag.graphql b/graphql/schema/types/tag.graphql index 2cb6765c8..2f1f0d0d5 100644 --- a/graphql/schema/types/tag.graphql +++ b/graphql/schema/types/tag.graphql @@ -11,7 +11,7 @@ type Tag { input TagCreateInput { name: String! - """This should be base64 encoded""" + """This should be a URL or a base64 encoded data URL""" image: String } @@ -19,7 +19,7 @@ input TagUpdateInput { id: ID! name: String! - """This should be base64 encoded""" + """This should be a URL or a base64 encoded data URL""" image: String } From 2edcdeaeb97198072cdb84527b1d359a2d659bdb Mon Sep 17 00:00:00 2001 From: bnkai <48220860+bnkai@users.noreply.github.com> Date: Wed, 7 Apr 2021 02:09:04 +0300 Subject: [PATCH 13/66] Support today, yesterday when using parseDate in scrapers (#1261) --- pkg/scraper/mapped.go | 12 +++++++++++- ui/v2.5/src/components/Changelog/versions/v070.md | 1 + ui/v2.5/src/docs/en/Scraping.md | 2 +- 3 files changed, 13 insertions(+), 2 deletions(-) diff --git a/pkg/scraper/mapped.go b/pkg/scraper/mapped.go index 6b25e4850..f6c076e66 100644 --- a/pkg/scraper/mapped.go +++ b/pkg/scraper/mapped.go @@ -362,6 +362,17 @@ type postProcessParseDate string func (p *postProcessParseDate) Apply(value string, q mappedQuery) string { parseDate := string(*p) + const internalDateFormat = "2006-01-02" + + value = strings.ToLower(value) + if value == "today" || value == "yesterday" { // handle today, yesterday + dt := time.Now() + if value == "yesterday" { // subtract 1 day from now + dt = dt.AddDate(0, 0, -1) + } + return dt.Format(internalDateFormat) + } + if parseDate == "" { return value } @@ -375,7 +386,6 @@ func (p *postProcessParseDate) Apply(value string, q mappedQuery) string { } // convert it into our date format - const internalDateFormat = "2006-01-02" return parsedValue.Format(internalDateFormat) } diff --git a/ui/v2.5/src/components/Changelog/versions/v070.md b/ui/v2.5/src/components/Changelog/versions/v070.md index 6b00a36dc..249ab61a1 100644 --- a/ui/v2.5/src/components/Changelog/versions/v070.md +++ b/ui/v2.5/src/components/Changelog/versions/v070.md @@ -3,6 +3,7 @@ * Added scene queue. ### 🎨 Improvements +* Support `today` and `yesterday` for `parseDate` in scrapers. * Add random sorting option for galleries, studios, movies and tags. * Disable sounds on scene/marker wall previews by default. * Improve Movie UI. diff --git a/ui/v2.5/src/docs/en/Scraping.md b/ui/v2.5/src/docs/en/Scraping.md index edf73bc03..6f47d0070 100644 --- a/ui/v2.5/src/docs/en/Scraping.md +++ b/ui/v2.5/src/docs/en/Scraping.md @@ -360,7 +360,7 @@ performer: ``` Gets the contents of the selected div element, and sets the returned value to `Female` if the scraped value is `F`; `Male` if the scraped value is `M`. -* `parseDate`: if present, the value is the date format using go's reference date (2006-01-02). For example, if an example date was `14-Mar-2003`, then the date format would be `02-Jan-2006`. See the [time.Parse documentation](https://golang.org/pkg/time/#Parse) for details. When present, the scraper will convert the input string into a date, then convert it to the string format used by stash (`YYYY-MM-DD`). +* `parseDate`: if present, the value is the date format using go's reference date (2006-01-02). For example, if an example date was `14-Mar-2003`, then the date format would be `02-Jan-2006`. See the [time.Parse documentation](https://golang.org/pkg/time/#Parse) for details. When present, the scraper will convert the input string into a date, then convert it to the string format used by stash (`YYYY-MM-DD`). Strings "Today", "Yesterday" are matched (case insensitive) and converted by the scraper so you don't need to edit/replace them. * `replace`: contains an array of sub-objects. Each sub-object must have a `regex` and `with` field. The `regex` field is the regex pattern to replace, and `with` is the string to replace it with. `$` is used to reference capture groups - `$1` is the first capture group, `$2` the second and so on. Replacements are performed in order of the array. From 4462b3cc8e50eb6a2ba84142a762f771f98f2528 Mon Sep 17 00:00:00 2001 From: stashist <81412921+stashist@users.noreply.github.com> Date: Fri, 9 Apr 2021 02:06:02 +0200 Subject: [PATCH 14/66] Handle /healthz for liveness checks. (#1264) --- pkg/api/server.go | 1 + ui/v2.5/src/components/Changelog/versions/v070.md | 1 + 2 files changed, 2 insertions(+) diff --git a/pkg/api/server.go b/pkg/api/server.go index 605e108d9..e4e3a1fea 100644 --- a/pkg/api/server.go +++ b/pkg/api/server.go @@ -124,6 +124,7 @@ func Start() { r := chi.NewRouter() + r.Use(middleware.Heartbeat("/healthz")) r.Use(authenticateHandler()) r.Use(middleware.Recoverer) diff --git a/ui/v2.5/src/components/Changelog/versions/v070.md b/ui/v2.5/src/components/Changelog/versions/v070.md index 249ab61a1..f6144bfee 100644 --- a/ui/v2.5/src/components/Changelog/versions/v070.md +++ b/ui/v2.5/src/components/Changelog/versions/v070.md @@ -3,6 +3,7 @@ * Added scene queue. ### 🎨 Improvements +* Add HTTP endpoint for health checking at /healthz. * Support `today` and `yesterday` for `parseDate` in scrapers. * Add random sorting option for galleries, studios, movies and tags. * Disable sounds on scene/marker wall previews by default. From 60af076fffaca66ba80827f1e113370f62a93687 Mon Sep 17 00:00:00 2001 From: peolic <66393006+peolic@users.noreply.github.com> Date: Fri, 9 Apr 2021 07:41:28 +0300 Subject: [PATCH 15/66] Fix "Clear Image" button (#1249) * Fix "Clear Image" button (Performer Edit) * Fix "Clear Image" button (New Performer) * Fix "Clear Image" button (Edit Studio) * Fix "Clear Image" button (Edit Tag) --- ui/v2.5/src/components/Changelog/versions/v070.md | 1 + .../src/components/Performers/PerformerDetails/Performer.tsx | 2 +- ui/v2.5/src/components/Studios/StudioDetails/Studio.tsx | 2 +- ui/v2.5/src/components/Tags/TagDetails/Tag.tsx | 2 +- 4 files changed, 4 insertions(+), 3 deletions(-) diff --git a/ui/v2.5/src/components/Changelog/versions/v070.md b/ui/v2.5/src/components/Changelog/versions/v070.md index f6144bfee..e3452b69a 100644 --- a/ui/v2.5/src/components/Changelog/versions/v070.md +++ b/ui/v2.5/src/components/Changelog/versions/v070.md @@ -11,5 +11,6 @@ * Change performer text query to search by name and alias only. ### 🐛 Bug fixes +* Fix `Clear Image` button not updating image preview. * Fix processing some webp files. * Fix incorrect performer age calculation in UI. diff --git a/ui/v2.5/src/components/Performers/PerformerDetails/Performer.tsx b/ui/v2.5/src/components/Performers/PerformerDetails/Performer.tsx index ccc56531b..b4e2699c0 100644 --- a/ui/v2.5/src/components/Performers/PerformerDetails/Performer.tsx +++ b/ui/v2.5/src/components/Performers/PerformerDetails/Performer.tsx @@ -47,7 +47,7 @@ export const Performer: React.FC = () => { const activeImage = imagePreview === undefined ? performer.image_path ?? "" - : imagePreview ?? `${performer.image_path}?default=true`; + : imagePreview ?? (isNew ? "" : `${performer.image_path}&default=true`); const lightboxImages = useMemo( () => [{ paths: { thumbnail: activeImage, image: activeImage } }], [activeImage] diff --git a/ui/v2.5/src/components/Studios/StudioDetails/Studio.tsx b/ui/v2.5/src/components/Studios/StudioDetails/Studio.tsx index 048f199b3..67dc73454 100644 --- a/ui/v2.5/src/components/Studios/StudioDetails/Studio.tsx +++ b/ui/v2.5/src/components/Studios/StudioDetails/Studio.tsx @@ -231,7 +231,7 @@ export const Studio: React.FC = () => { function onClearImage() { setImage(null); setImagePreview( - studio.image_path ? `${studio.image_path}?default=true` : undefined + studio.image_path ? `${studio.image_path}&default=true` : undefined ); } diff --git a/ui/v2.5/src/components/Tags/TagDetails/Tag.tsx b/ui/v2.5/src/components/Tags/TagDetails/Tag.tsx index c1ec1d63f..e5dc7d14d 100644 --- a/ui/v2.5/src/components/Tags/TagDetails/Tag.tsx +++ b/ui/v2.5/src/components/Tags/TagDetails/Tag.tsx @@ -204,7 +204,7 @@ export const Tag: React.FC = () => { function onClearImage() { setImage(null); setImagePreview( - tag?.image_path ? `${tag.image_path}?default=true` : undefined + tag?.image_path ? `${tag.image_path}&default=true` : undefined ); } From 25311247ed8ae3255c8ed5de3c8ff30929272d6b Mon Sep 17 00:00:00 2001 From: julien0221 <68500525+julien0221@users.noreply.github.com> Date: Fri, 9 Apr 2021 06:05:11 +0100 Subject: [PATCH 16/66] added an url filter option in scenes (#1266) * added an url filter option in scenes * added url filter on gallery, movies, performers and studios * Add empty string filter to stringCriterionHandler * Add unit tests Co-authored-by: WithoutPants <53250216+WithoutPants@users.noreply.github.com> --- graphql/schema/types/filters.graphql | 10 +++ pkg/sqlite/filter.go | 4 + pkg/sqlite/filter_internal_test.go | 4 +- pkg/sqlite/gallery.go | 1 + pkg/sqlite/gallery_test.go | 56 ++++++++++++++ pkg/sqlite/movies.go | 39 +++++----- pkg/sqlite/movies_test.go | 65 ++++++++++++++++ pkg/sqlite/performer.go | 1 + pkg/sqlite/performer_test.go | 56 ++++++++++++++ pkg/sqlite/scene.go | 1 + pkg/sqlite/scene_test.go | 61 +++++++++++++++ pkg/sqlite/setup_test.go | 75 ++++++++++++++++--- pkg/sqlite/studio.go | 40 +++++----- pkg/sqlite/studio_test.go | 65 ++++++++++++++++ .../src/components/Changelog/versions/v070.md | 3 +- .../models/list-filter/criteria/criterion.ts | 5 +- .../src/models/list-filter/criteria/utils.ts | 1 + ui/v2.5/src/models/list-filter/filter.ts | 45 +++++++++++ 18 files changed, 480 insertions(+), 52 deletions(-) diff --git a/graphql/schema/types/filters.graphql b/graphql/schema/types/filters.graphql index 750cb6c89..ae51f1091 100644 --- a/graphql/schema/types/filters.graphql +++ b/graphql/schema/types/filters.graphql @@ -63,6 +63,8 @@ input PerformerFilterType { tags: MultiCriterionInput """Filter by StashID""" stash_id: String + """Filter by url""" + url: StringCriterionInput } input SceneMarkerFilterType { @@ -109,6 +111,8 @@ input SceneFilterType { performers: MultiCriterionInput """Filter by StashID""" stash_id: String + """Filter by url""" + url: StringCriterionInput } input MovieFilterType { @@ -116,6 +120,8 @@ input MovieFilterType { studios: MultiCriterionInput """Filter to only include movies missing this property""" is_missing: String + """Filter by url""" + url: StringCriterionInput } input StudioFilterType { @@ -125,6 +131,8 @@ input StudioFilterType { stash_id: String """Filter to only include studios missing this property""" is_missing: String + """Filter by url""" + url: StringCriterionInput } input GalleryFilterType { @@ -150,6 +158,8 @@ input GalleryFilterType { performers: MultiCriterionInput """Filter by number of images in this gallery""" image_count: IntCriterionInput + """Filter by url""" + url: StringCriterionInput } input TagFilterType { diff --git a/pkg/sqlite/filter.go b/pkg/sqlite/filter.go index bae383825..a781e70fc 100644 --- a/pkg/sqlite/filter.go +++ b/pkg/sqlite/filter.go @@ -321,6 +321,10 @@ func stringCriterionHandler(c *models.StringCriterionInput, column string) crite return } f.addWhere(fmt.Sprintf("(%s IS NULL OR %[1]s NOT regexp ?)", column), c.Value) + case models.CriterionModifierIsNull: + f.addWhere("(" + column + " IS NULL OR TRIM(" + column + ") = '')") + case models.CriterionModifierNotNull: + f.addWhere("(" + column + " IS NOT NULL AND TRIM(" + column + ") != '')") default: clause, count := getSimpleCriterionClause(modifier, "?") diff --git a/pkg/sqlite/filter_internal_test.go b/pkg/sqlite/filter_internal_test.go index 302aff0db..20ca571e1 100644 --- a/pkg/sqlite/filter_internal_test.go +++ b/pkg/sqlite/filter_internal_test.go @@ -594,7 +594,7 @@ func TestStringCriterionHandlerIsNull(t *testing.T) { }, column)) assert.Len(f.whereClauses, 1) - assert.Equal(fmt.Sprintf("%[1]s IS NULL", column), f.whereClauses[0].sql) + assert.Equal(fmt.Sprintf("(%[1]s IS NULL OR TRIM(%[1]s) = '')", column), f.whereClauses[0].sql) assert.Len(f.whereClauses[0].args, 0) } @@ -609,6 +609,6 @@ func TestStringCriterionHandlerNotNull(t *testing.T) { }, column)) assert.Len(f.whereClauses, 1) - assert.Equal(fmt.Sprintf("%[1]s IS NOT NULL", column), f.whereClauses[0].sql) + assert.Equal(fmt.Sprintf("(%[1]s IS NOT NULL AND TRIM(%[1]s) != '')", column), f.whereClauses[0].sql) assert.Len(f.whereClauses[0].args, 0) } diff --git a/pkg/sqlite/gallery.go b/pkg/sqlite/gallery.go index 2f2e8fef7..77ddabc24 100644 --- a/pkg/sqlite/gallery.go +++ b/pkg/sqlite/gallery.go @@ -198,6 +198,7 @@ func (qb *galleryQueryBuilder) Query(galleryFilter *models.GalleryFilterType, fi query.handleStringCriterionInput(galleryFilter.Path, "galleries.path") query.handleIntCriterionInput(galleryFilter.Rating, "galleries.rating") + query.handleStringCriterionInput(galleryFilter.URL, "galleries.url") qb.handleAverageResolutionFilter(&query, galleryFilter.AverageResolution) if Organized := galleryFilter.Organized; Organized != nil { diff --git a/pkg/sqlite/gallery_test.go b/pkg/sqlite/gallery_test.go index f34328ecd..a715d7d5c 100644 --- a/pkg/sqlite/gallery_test.go +++ b/pkg/sqlite/gallery_test.go @@ -193,6 +193,62 @@ func verifyGalleriesPath(t *testing.T, sqb models.GalleryReader, pathCriterion m } } +func TestGalleryQueryURL(t *testing.T) { + const sceneIdx = 1 + galleryURL := getGalleryStringValue(sceneIdx, urlField) + + urlCriterion := models.StringCriterionInput{ + Value: galleryURL, + Modifier: models.CriterionModifierEquals, + } + + filter := models.GalleryFilterType{ + URL: &urlCriterion, + } + + verifyFn := func(g *models.Gallery) { + t.Helper() + verifyNullString(t, g.URL, urlCriterion) + } + + verifyGalleryQuery(t, filter, verifyFn) + + urlCriterion.Modifier = models.CriterionModifierNotEquals + verifyGalleryQuery(t, filter, verifyFn) + + urlCriterion.Modifier = models.CriterionModifierMatchesRegex + urlCriterion.Value = "gallery_.*1_URL" + verifyGalleryQuery(t, filter, verifyFn) + + urlCriterion.Modifier = models.CriterionModifierNotMatchesRegex + verifyGalleryQuery(t, filter, verifyFn) + + urlCriterion.Modifier = models.CriterionModifierIsNull + urlCriterion.Value = "" + verifyGalleryQuery(t, filter, verifyFn) + + urlCriterion.Modifier = models.CriterionModifierNotNull + verifyGalleryQuery(t, filter, verifyFn) +} + +func verifyGalleryQuery(t *testing.T, filter models.GalleryFilterType, verifyFn func(s *models.Gallery)) { + withTxn(func(r models.Repository) error { + t.Helper() + sqb := r.Gallery() + + galleries := queryGallery(t, sqb, &filter, nil) + + // assume it should find at least one + assert.Greater(t, len(galleries), 0) + + for _, gallery := range galleries { + verifyFn(gallery) + } + + return nil + }) +} + func TestGalleryQueryRating(t *testing.T) { const rating = 3 ratingCriterion := models.IntCriterionInput{ diff --git a/pkg/sqlite/movies.go b/pkg/sqlite/movies.go index 567c8c1b7..cec607c5f 100644 --- a/pkg/sqlite/movies.go +++ b/pkg/sqlite/movies.go @@ -122,11 +122,10 @@ func (qb *movieQueryBuilder) Query(movieFilter *models.MovieFilterType, findFilt movieFilter = &models.MovieFilterType{} } - var whereClauses []string - var havingClauses []string - var args []interface{} - body := selectDistinctIDs("movies") - body += ` + query := qb.newQuery() + + query.body = selectDistinctIDs("movies") + query.body += ` left join movies_scenes as scenes_join on scenes_join.movie_id = movies.id left join scenes on scenes_join.scene_id = scenes.id left join studios as studio on studio.id = movies.studio_id @@ -135,41 +134,43 @@ func (qb *movieQueryBuilder) Query(movieFilter *models.MovieFilterType, findFilt if q := findFilter.Q; q != nil && *q != "" { searchColumns := []string{"movies.name"} clause, thisArgs := getSearchBinding(searchColumns, *q, false) - whereClauses = append(whereClauses, clause) - args = append(args, thisArgs...) + query.addWhere(clause) + query.addArg(thisArgs...) } if studiosFilter := movieFilter.Studios; studiosFilter != nil && len(studiosFilter.Value) > 0 { for _, studioID := range studiosFilter.Value { - args = append(args, studioID) + query.addArg(studioID) } whereClause, havingClause := getMultiCriterionClause("movies", "studio", "", "", "studio_id", studiosFilter) - whereClauses = appendClause(whereClauses, whereClause) - havingClauses = appendClause(havingClauses, havingClause) + query.addWhere(whereClause) + query.addHaving(havingClause) } if isMissingFilter := movieFilter.IsMissing; isMissingFilter != nil && *isMissingFilter != "" { switch *isMissingFilter { case "front_image": - body += `left join movies_images on movies_images.movie_id = movies.id + query.body += `left join movies_images on movies_images.movie_id = movies.id ` - whereClauses = appendClause(whereClauses, "movies_images.front_image IS NULL") + query.addWhere("movies_images.front_image IS NULL") case "back_image": - body += `left join movies_images on movies_images.movie_id = movies.id + query.body += `left join movies_images on movies_images.movie_id = movies.id ` - whereClauses = appendClause(whereClauses, "movies_images.back_image IS NULL") + query.addWhere("movies_images.back_image IS NULL") case "scenes": - body += `left join movies_scenes on movies_scenes.movie_id = movies.id + query.body += `left join movies_scenes on movies_scenes.movie_id = movies.id ` - whereClauses = appendClause(whereClauses, "movies_scenes.scene_id IS NULL") + query.addWhere("movies_scenes.scene_id IS NULL") default: - whereClauses = appendClause(whereClauses, "movies."+*isMissingFilter+" IS NULL") + query.addWhere("movies." + *isMissingFilter + " IS NULL") } } - sortAndPagination := qb.getMovieSort(findFilter) + getPagination(findFilter) - idsResult, countResult, err := qb.executeFindQuery(body, args, sortAndPagination, whereClauses, havingClauses) + query.handleStringCriterionInput(movieFilter.URL, "movies.url") + + query.sortAndPagination = qb.getMovieSort(findFilter) + getPagination(findFilter) + idsResult, countResult, err := query.executeFind() if err != nil { return nil, 0, err } diff --git a/pkg/sqlite/movies_test.go b/pkg/sqlite/movies_test.go index 235f0184c..5ba543798 100644 --- a/pkg/sqlite/movies_test.go +++ b/pkg/sqlite/movies_test.go @@ -119,6 +119,71 @@ func TestMovieQueryStudio(t *testing.T) { }) } +func TestMovieQueryURL(t *testing.T) { + const sceneIdx = 1 + movieURL := getMovieStringValue(sceneIdx, urlField) + + urlCriterion := models.StringCriterionInput{ + Value: movieURL, + Modifier: models.CriterionModifierEquals, + } + + filter := models.MovieFilterType{ + URL: &urlCriterion, + } + + verifyFn := func(n *models.Movie) { + t.Helper() + verifyNullString(t, n.URL, urlCriterion) + } + + verifyMovieQuery(t, filter, verifyFn) + + urlCriterion.Modifier = models.CriterionModifierNotEquals + verifyMovieQuery(t, filter, verifyFn) + + urlCriterion.Modifier = models.CriterionModifierMatchesRegex + urlCriterion.Value = "movie_.*1_URL" + verifyMovieQuery(t, filter, verifyFn) + + urlCriterion.Modifier = models.CriterionModifierNotMatchesRegex + verifyMovieQuery(t, filter, verifyFn) + + urlCriterion.Modifier = models.CriterionModifierIsNull + urlCriterion.Value = "" + verifyMovieQuery(t, filter, verifyFn) + + urlCriterion.Modifier = models.CriterionModifierNotNull + verifyMovieQuery(t, filter, verifyFn) +} + +func verifyMovieQuery(t *testing.T, filter models.MovieFilterType, verifyFn func(s *models.Movie)) { + withTxn(func(r models.Repository) error { + t.Helper() + sqb := r.Movie() + + movies := queryMovie(t, sqb, &filter, nil) + + // assume it should find at least one + assert.Greater(t, len(movies), 0) + + for _, m := range movies { + verifyFn(m) + } + + return nil + }) +} + +func queryMovie(t *testing.T, sqb models.MovieReader, movieFilter *models.MovieFilterType, findFilter *models.FindFilterType) []*models.Movie { + movies, _, err := sqb.Query(movieFilter, findFilter) + if err != nil { + t.Errorf("Error querying movie: %s", err.Error()) + } + + return movies +} + func TestMovieUpdateMovieImages(t *testing.T) { if err := withTxn(func(r models.Repository) error { mqb := r.Movie() diff --git a/pkg/sqlite/performer.go b/pkg/sqlite/performer.go index 342fe8c63..2daa22b89 100644 --- a/pkg/sqlite/performer.go +++ b/pkg/sqlite/performer.go @@ -254,6 +254,7 @@ func (qb *performerQueryBuilder) Query(performerFilter *models.PerformerFilterTy query.handleStringCriterionInput(performerFilter.CareerLength, tableName+".career_length") query.handleStringCriterionInput(performerFilter.Tattoos, tableName+".tattoos") query.handleStringCriterionInput(performerFilter.Piercings, tableName+".piercings") + query.handleStringCriterionInput(performerFilter.URL, tableName+".url") // TODO - need better handling of aliases query.handleStringCriterionInput(performerFilter.Aliases, tableName+".aliases") diff --git a/pkg/sqlite/performer_test.go b/pkg/sqlite/performer_test.go index c5c7dc6cd..bf9024017 100644 --- a/pkg/sqlite/performer_test.go +++ b/pkg/sqlite/performer_test.go @@ -268,6 +268,62 @@ func verifyPerformerCareerLength(t *testing.T, criterion models.StringCriterionI }) } +func TestPerformerQueryURL(t *testing.T) { + const sceneIdx = 1 + performerURL := getPerformerStringValue(sceneIdx, urlField) + + urlCriterion := models.StringCriterionInput{ + Value: performerURL, + Modifier: models.CriterionModifierEquals, + } + + filter := models.PerformerFilterType{ + URL: &urlCriterion, + } + + verifyFn := func(g *models.Performer) { + t.Helper() + verifyNullString(t, g.URL, urlCriterion) + } + + verifyPerformerQuery(t, filter, verifyFn) + + urlCriterion.Modifier = models.CriterionModifierNotEquals + verifyPerformerQuery(t, filter, verifyFn) + + urlCriterion.Modifier = models.CriterionModifierMatchesRegex + urlCriterion.Value = "performer_.*1_URL" + verifyPerformerQuery(t, filter, verifyFn) + + urlCriterion.Modifier = models.CriterionModifierNotMatchesRegex + verifyPerformerQuery(t, filter, verifyFn) + + urlCriterion.Modifier = models.CriterionModifierIsNull + urlCriterion.Value = "" + verifyPerformerQuery(t, filter, verifyFn) + + urlCriterion.Modifier = models.CriterionModifierNotNull + verifyPerformerQuery(t, filter, verifyFn) +} + +func verifyPerformerQuery(t *testing.T, filter models.PerformerFilterType, verifyFn func(s *models.Performer)) { + withTxn(func(r models.Repository) error { + t.Helper() + sqb := r.Performer() + + performers := queryPerformers(t, sqb, &filter, nil) + + // assume it should find at least one + assert.Greater(t, len(performers), 0) + + for _, p := range performers { + verifyFn(p) + } + + return nil + }) +} + func queryPerformers(t *testing.T, qb models.PerformerReader, performerFilter *models.PerformerFilterType, findFilter *models.FindFilterType) []*models.Performer { performers, _, err := qb.Query(performerFilter, findFilter) if err != nil { diff --git a/pkg/sqlite/scene.go b/pkg/sqlite/scene.go index 8dd954322..1cce0c344 100644 --- a/pkg/sqlite/scene.go +++ b/pkg/sqlite/scene.go @@ -345,6 +345,7 @@ func (qb *sceneQueryBuilder) makeFilter(sceneFilter *models.SceneFilterType) *fi query.handleCriterionFunc(resolutionCriterionHandler(sceneFilter.Resolution, "scenes.height", "scenes.width")) query.handleCriterionFunc(hasMarkersCriterionHandler(sceneFilter.HasMarkers)) query.handleCriterionFunc(sceneIsMissingCriterionHandler(qb, sceneFilter.IsMissing)) + query.handleCriterionFunc(stringCriterionHandler(sceneFilter.URL, "scenes.url")) query.handleCriterionFunc(sceneTagsCriterionHandler(qb, sceneFilter.Tags)) query.handleCriterionFunc(scenePerformersCriterionHandler(qb, sceneFilter.Performers)) diff --git a/pkg/sqlite/scene_test.go b/pkg/sqlite/scene_test.go index ac34403fb..5f30ce4ac 100644 --- a/pkg/sqlite/scene_test.go +++ b/pkg/sqlite/scene_test.go @@ -187,6 +187,44 @@ func TestSceneQueryPath(t *testing.T) { verifyScenesPath(t, pathCriterion) } +func TestSceneQueryURL(t *testing.T) { + const sceneIdx = 1 + scenePath := getSceneStringValue(sceneIdx, urlField) + + urlCriterion := models.StringCriterionInput{ + Value: scenePath, + Modifier: models.CriterionModifierEquals, + } + + filter := models.SceneFilterType{ + URL: &urlCriterion, + } + + verifyFn := func(s *models.Scene) { + t.Helper() + verifyNullString(t, s.URL, urlCriterion) + } + + verifySceneQuery(t, filter, verifyFn) + + urlCriterion.Modifier = models.CriterionModifierNotEquals + verifySceneQuery(t, filter, verifyFn) + + urlCriterion.Modifier = models.CriterionModifierMatchesRegex + urlCriterion.Value = "scene_.*1_URL" + verifySceneQuery(t, filter, verifyFn) + + urlCriterion.Modifier = models.CriterionModifierNotMatchesRegex + verifySceneQuery(t, filter, verifyFn) + + urlCriterion.Modifier = models.CriterionModifierIsNull + urlCriterion.Value = "" + verifySceneQuery(t, filter, verifyFn) + + urlCriterion.Modifier = models.CriterionModifierNotNull + verifySceneQuery(t, filter, verifyFn) +} + func TestSceneQueryPathOr(t *testing.T) { const scene1Idx = 1 const scene2Idx = 2 @@ -324,6 +362,24 @@ func TestSceneIllegalQuery(t *testing.T) { }) } +func verifySceneQuery(t *testing.T, filter models.SceneFilterType, verifyFn func(s *models.Scene)) { + withTxn(func(r models.Repository) error { + t.Helper() + sqb := r.Scene() + + scenes := queryScene(t, sqb, &filter, nil) + + // assume it should find at least one + assert.Greater(t, len(scenes), 0) + + for _, scene := range scenes { + verifyFn(scene) + } + + return nil + }) +} + func verifyScenesPath(t *testing.T, pathCriterion models.StringCriterionInput) { withTxn(func(r models.Repository) error { sqb := r.Scene() @@ -345,10 +401,15 @@ func verifyNullString(t *testing.T, value sql.NullString, criterion models.Strin t.Helper() assert := assert.New(t) if criterion.Modifier == models.CriterionModifierIsNull { + if value.Valid && value.String == "" { + // correct + return + } assert.False(value.Valid, "expect is null values to be null") } if criterion.Modifier == models.CriterionModifierNotNull { assert.True(value.Valid, "expect is null values to be null") + assert.Greater(len(value.String), 0) } if criterion.Modifier == models.CriterionModifierEquals { assert.Equal(criterion.Value, value.String) diff --git a/pkg/sqlite/setup_test.go b/pkg/sqlite/setup_test.go index 323bb113c..f178bac09 100644 --- a/pkg/sqlite/setup_test.go +++ b/pkg/sqlite/setup_test.go @@ -76,7 +76,8 @@ const ( movieIdxWithScene = iota movieIdxWithStudio // movies with dup names start from the end - movieIdxWithDupName + // create 10 more basic movies (can remove this if we add more indexes) + movieIdxWithDupName = movieIdxWithStudio + 10 moviesNameCase = movieIdxWithDupName moviesNameNoCase = 1 @@ -146,6 +147,7 @@ const ( pathField = "Path" checksumField = "Checksum" titleField = "Title" + urlField = "URL" zipPath = "zipPath.zip" ) @@ -407,8 +409,32 @@ func populateDB() error { return nil } +func getPrefixedStringValue(prefix string, index int, field string) string { + return fmt.Sprintf("%s_%04d_%s", prefix, index, field) +} + +func getPrefixedNullStringValue(prefix string, index int, field string) sql.NullString { + if index > 0 && index%5 == 0 { + return sql.NullString{} + } + if index > 0 && index%6 == 0 { + return sql.NullString{ + String: "", + Valid: true, + } + } + return sql.NullString{ + String: getPrefixedStringValue(prefix, index, field), + Valid: true, + } +} + func getSceneStringValue(index int, field string) string { - return fmt.Sprintf("scene_%04d_%s", index, field) + return getPrefixedStringValue("scene", index, field) +} + +func getSceneNullStringValue(index int, field string) sql.NullString { + return getPrefixedNullStringValue("scene", index, field) } func getRating(index int) sql.NullInt64 { @@ -455,6 +481,7 @@ func createScenes(sqb models.SceneReaderWriter, n int) error { Title: sql.NullString{String: getSceneStringValue(i, titleField), Valid: true}, Checksum: sql.NullString{String: getSceneStringValue(i, checksumField), Valid: true}, Details: sql.NullString{String: getSceneStringValue(i, "Details"), Valid: true}, + URL: getSceneNullStringValue(i, urlField), Rating: getRating(i), OCounter: getOCounter(i), Duration: getSceneDuration(i), @@ -511,13 +538,18 @@ func createImages(qb models.ImageReaderWriter, n int) error { } func getGalleryStringValue(index int, field string) string { - return "gallery_" + strconv.FormatInt(int64(index), 10) + "_" + field + return getPrefixedStringValue("gallery", index, field) +} + +func getGalleryNullStringValue(index int, field string) sql.NullString { + return getPrefixedNullStringValue("gallery", index, field) } func createGalleries(gqb models.GalleryReaderWriter, n int) error { for i := 0; i < n; i++ { gallery := models.Gallery{ Path: models.NullString(getGalleryStringValue(i, pathField)), + URL: getGalleryNullStringValue(i, urlField), Checksum: getGalleryStringValue(i, checksumField), } @@ -534,10 +566,14 @@ func createGalleries(gqb models.GalleryReaderWriter, n int) error { } func getMovieStringValue(index int, field string) string { - return "movie_" + strconv.FormatInt(int64(index), 10) + "_" + field + return getPrefixedStringValue("movie", index, field) } -//createMoviees creates n movies with plain Name and o movies with camel cased NaMe included +func getMovieNullStringValue(index int, field string) sql.NullString { + return getPrefixedNullStringValue("movie", index, field) +} + +// createMoviees creates n movies with plain Name and o movies with camel cased NaMe included func createMovies(mqb models.MovieReaderWriter, n int, o int) error { const namePlain = "Name" const nameNoCase = "NaMe" @@ -555,6 +591,7 @@ func createMovies(mqb models.MovieReaderWriter, n int, o int) error { name = getMovieStringValue(index, name) movie := models.Movie{ Name: sql.NullString{String: name, Valid: true}, + URL: getMovieNullStringValue(index, urlField), Checksum: utils.MD5FromString(name), } @@ -572,7 +609,11 @@ func createMovies(mqb models.MovieReaderWriter, n int, o int) error { } func getPerformerStringValue(index int, field string) string { - return "performer_" + strconv.FormatInt(int64(index), 10) + "_" + field + return getPrefixedStringValue("performer", index, field) +} + +func getPerformerNullStringValue(index int, field string) sql.NullString { + return getPrefixedNullStringValue("performer", index, field) } func getPerformerBoolValue(index int) bool { @@ -596,7 +637,7 @@ func getPerformerCareerLength(index int) *string { return &ret } -//createPerformers creates n performers with plain Name and o performers with camel cased NaMe included +// createPerformers creates n performers with plain Name and o performers with camel cased NaMe included func createPerformers(pqb models.PerformerReaderWriter, n int, o int) error { const namePlain = "Name" const nameNoCase = "NaMe" @@ -615,6 +656,7 @@ func createPerformers(pqb models.PerformerReaderWriter, n int, o int) error { performer := models.Performer{ Name: sql.NullString{String: getPerformerStringValue(index, name), Valid: true}, Checksum: getPerformerStringValue(i, checksumField), + URL: getPerformerNullStringValue(i, urlField), Favorite: sql.NullBool{Bool: getPerformerBoolValue(i), Valid: true}, Birthdate: models.SQLiteDate{ String: getPerformerBirthdate(i), @@ -718,7 +760,11 @@ func createTags(tqb models.TagReaderWriter, n int, o int) error { } func getStudioStringValue(index int, field string) string { - return "studio_" + strconv.FormatInt(int64(index), 10) + "_" + field + return getPrefixedStringValue("studio", index, field) +} + +func getStudioNullStringValue(index int, field string) sql.NullString { + return getPrefixedNullStringValue("studio", index, field) } func createStudio(sqb models.StudioReaderWriter, name string, parentID *int64) (*models.Studio, error) { @@ -731,6 +777,10 @@ func createStudio(sqb models.StudioReaderWriter, name string, parentID *int64) ( studio.ParentID = sql.NullInt64{Int64: *parentID, Valid: true} } + return createStudioFromModel(sqb, studio) +} + +func createStudioFromModel(sqb models.StudioReaderWriter, studio models.Studio) (*models.Studio, error) { created, err := sqb.Create(studio) if err != nil { @@ -740,7 +790,7 @@ func createStudio(sqb models.StudioReaderWriter, name string, parentID *int64) ( return created, nil } -//createStudios creates n studios with plain Name and o studios with camel cased NaMe included +// createStudios creates n studios with plain Name and o studios with camel cased NaMe included func createStudios(sqb models.StudioReaderWriter, n int, o int) error { const namePlain = "Name" const nameNoCase = "NaMe" @@ -756,7 +806,12 @@ func createStudios(sqb models.StudioReaderWriter, n int, o int) error { // studios [ i ] and [ n + o - i - 1 ] should have similar names with only the Name!=NaMe part different name = getStudioStringValue(index, name) - created, err := createStudio(sqb, name, nil) + studio := models.Studio{ + Name: sql.NullString{String: name, Valid: true}, + Checksum: utils.MD5FromString(name), + URL: getStudioNullStringValue(index, urlField), + } + created, err := createStudioFromModel(sqb, studio) if err != nil { return err diff --git a/pkg/sqlite/studio.go b/pkg/sqlite/studio.go index fa08017a1..c3a984c03 100644 --- a/pkg/sqlite/studio.go +++ b/pkg/sqlite/studio.go @@ -129,11 +129,10 @@ func (qb *studioQueryBuilder) Query(studioFilter *models.StudioFilterType, findF findFilter = &models.FindFilterType{} } - var whereClauses []string - var havingClauses []string - var args []interface{} - body := selectDistinctIDs("studios") - body += ` + query := qb.newQuery() + + query.body = selectDistinctIDs("studios") + query.body += ` left join scenes on studios.id = scenes.studio_id left join studio_stash_ids on studio_stash_ids.studio_id = studios.id ` @@ -142,44 +141,47 @@ func (qb *studioQueryBuilder) Query(studioFilter *models.StudioFilterType, findF searchColumns := []string{"studios.name"} clause, thisArgs := getSearchBinding(searchColumns, *q, false) - whereClauses = append(whereClauses, clause) - args = append(args, thisArgs...) + query.addWhere(clause) + query.addArg(thisArgs...) } if parentsFilter := studioFilter.Parents; parentsFilter != nil && len(parentsFilter.Value) > 0 { - body += ` + query.body += ` left join studios as parent_studio on parent_studio.id = studios.parent_id ` for _, studioID := range parentsFilter.Value { - args = append(args, studioID) + query.addArg(studioID) } whereClause, havingClause := getMultiCriterionClause("studios", "parent_studio", "", "", "parent_id", parentsFilter) - whereClauses = appendClause(whereClauses, whereClause) - havingClauses = appendClause(havingClauses, havingClause) + + query.addWhere(whereClause) + query.addHaving(havingClause) } if stashIDFilter := studioFilter.StashID; stashIDFilter != nil { - whereClauses = append(whereClauses, "studio_stash_ids.stash_id = ?") - args = append(args, stashIDFilter) + query.addWhere("studio_stash_ids.stash_id = ?") + query.addArg(stashIDFilter) } + query.handleStringCriterionInput(studioFilter.URL, "studios.url") + if isMissingFilter := studioFilter.IsMissing; isMissingFilter != nil && *isMissingFilter != "" { switch *isMissingFilter { case "image": - body += `left join studios_image on studios_image.studio_id = studios.id + query.body += `left join studios_image on studios_image.studio_id = studios.id ` - whereClauses = appendClause(whereClauses, "studios_image.studio_id IS NULL") + query.addWhere("studios_image.studio_id IS NULL") case "stash_id": - whereClauses = appendClause(whereClauses, "studio_stash_ids.studio_id IS NULL") + query.addWhere("studio_stash_ids.studio_id IS NULL") default: - whereClauses = appendClause(whereClauses, "studios."+*isMissingFilter+" IS NULL") + query.addWhere("studios." + *isMissingFilter + " IS NULL") } } - sortAndPagination := qb.getStudioSort(findFilter) + getPagination(findFilter) - idsResult, countResult, err := qb.executeFindQuery(body, args, sortAndPagination, whereClauses, havingClauses) + query.sortAndPagination = qb.getStudioSort(findFilter) + getPagination(findFilter) + idsResult, countResult, err := query.executeFind() if err != nil { return nil, 0, err } diff --git a/pkg/sqlite/studio_test.go b/pkg/sqlite/studio_test.go index 0dd3e4585..9325c720a 100644 --- a/pkg/sqlite/studio_test.go +++ b/pkg/sqlite/studio_test.go @@ -283,6 +283,71 @@ func TestStudioStashIDs(t *testing.T) { } } +func TestStudioQueryURL(t *testing.T) { + const sceneIdx = 1 + studioURL := getStudioStringValue(sceneIdx, urlField) + + urlCriterion := models.StringCriterionInput{ + Value: studioURL, + Modifier: models.CriterionModifierEquals, + } + + filter := models.StudioFilterType{ + URL: &urlCriterion, + } + + verifyFn := func(g *models.Studio) { + t.Helper() + verifyNullString(t, g.URL, urlCriterion) + } + + verifyStudioQuery(t, filter, verifyFn) + + urlCriterion.Modifier = models.CriterionModifierNotEquals + verifyStudioQuery(t, filter, verifyFn) + + urlCriterion.Modifier = models.CriterionModifierMatchesRegex + urlCriterion.Value = "studio_.*1_URL" + verifyStudioQuery(t, filter, verifyFn) + + urlCriterion.Modifier = models.CriterionModifierNotMatchesRegex + verifyStudioQuery(t, filter, verifyFn) + + urlCriterion.Modifier = models.CriterionModifierIsNull + urlCriterion.Value = "" + verifyStudioQuery(t, filter, verifyFn) + + urlCriterion.Modifier = models.CriterionModifierNotNull + verifyStudioQuery(t, filter, verifyFn) +} + +func verifyStudioQuery(t *testing.T, filter models.StudioFilterType, verifyFn func(s *models.Studio)) { + withTxn(func(r models.Repository) error { + t.Helper() + sqb := r.Studio() + + galleries := queryStudio(t, sqb, &filter, nil) + + // assume it should find at least one + assert.Greater(t, len(galleries), 0) + + for _, studio := range galleries { + verifyFn(studio) + } + + return nil + }) +} + +func queryStudio(t *testing.T, sqb models.StudioReader, studioFilter *models.StudioFilterType, findFilter *models.FindFilterType) []*models.Studio { + studios, _, err := sqb.Query(studioFilter, findFilter) + if err != nil { + t.Errorf("Error querying studio: %s", err.Error()) + } + + return studios +} + // TODO Create // TODO Update // TODO Destroy diff --git a/ui/v2.5/src/components/Changelog/versions/v070.md b/ui/v2.5/src/components/Changelog/versions/v070.md index e3452b69a..2ad53bfff 100644 --- a/ui/v2.5/src/components/Changelog/versions/v070.md +++ b/ui/v2.5/src/components/Changelog/versions/v070.md @@ -3,7 +3,8 @@ * Added scene queue. ### 🎨 Improvements -* Add HTTP endpoint for health checking at /healthz. +* Add URL filter criteria for scenes, galleries, movies, performers and studios. +* Add HTTP endpoint for health checking at `/healthz`. * Support `today` and `yesterday` for `parseDate` in scrapers. * Add random sorting option for galleries, studios, movies and tags. * Disable sounds on scene/marker wall previews by default. diff --git a/ui/v2.5/src/models/list-filter/criteria/criterion.ts b/ui/v2.5/src/models/list-filter/criteria/criterion.ts index 213ca8263..f18a22366 100644 --- a/ui/v2.5/src/models/list-filter/criteria/criterion.ts +++ b/ui/v2.5/src/models/list-filter/criteria/criterion.ts @@ -47,7 +47,8 @@ export type CriterionType = | "marker_count" | "image_count" | "gallery_count" - | "performer_count"; + | "performer_count" + | "url"; type Option = string | number | IOptionType; export type CriterionValue = string | number | ILabeledId[]; @@ -135,6 +136,8 @@ export abstract class Criterion { return "Gallery Count"; case "performer_count": return "Performer Count"; + case "url": + return "URL"; } } diff --git a/ui/v2.5/src/models/list-filter/criteria/utils.ts b/ui/v2.5/src/models/list-filter/criteria/utils.ts index 171b72e52..011574546 100644 --- a/ui/v2.5/src/models/list-filter/criteria/utils.ts +++ b/ui/v2.5/src/models/list-filter/criteria/utils.ts @@ -112,6 +112,7 @@ export function makeCriteria(type: CriterionType = "none") { case "tattoos": case "piercings": case "aliases": + case "url": return new StringCriterion(type, type); } } diff --git a/ui/v2.5/src/models/list-filter/filter.ts b/ui/v2.5/src/models/list-filter/filter.ts index 68306518d..5ede1dee1 100644 --- a/ui/v2.5/src/models/list-filter/filter.ts +++ b/ui/v2.5/src/models/list-filter/filter.ts @@ -151,6 +151,7 @@ export class ListFilterModel { new PerformersCriterionOption(), new StudiosCriterionOption(), new MoviesCriterionOption(), + ListFilterModel.createCriterionOption("url"), ]; break; case FilterMode.Images: @@ -210,6 +211,7 @@ export class ListFilterModel { new GenderCriterionOption(), new PerformerIsMissingCriterionOption(), new TagsCriterionOption(), + ListFilterModel.createCriterionOption("url"), ...numberCriteria .concat(stringCriteria) .map((c) => ListFilterModel.createCriterionOption(c)), @@ -225,6 +227,7 @@ export class ListFilterModel { new NoneCriterionOption(), new ParentStudiosCriterionOption(), new StudioIsMissingCriterionOption(), + ListFilterModel.createCriterionOption("url"), ]; break; case FilterMode.Movies: @@ -235,6 +238,7 @@ export class ListFilterModel { new NoneCriterionOption(), new StudiosCriterionOption(), new MovieIsMissingCriterionOption(), + ListFilterModel.createCriterionOption("url"), ]; break; case FilterMode.Galleries: @@ -257,6 +261,7 @@ export class ListFilterModel { new PerformerTagsCriterionOption(), new PerformersCriterionOption(), new StudiosCriterionOption(), + ListFilterModel.createCriterionOption("url"), ]; this.displayModeOptions = [ DisplayMode.Grid, @@ -582,6 +587,14 @@ export class ListFilterModel { }; break; } + case "url": { + const urlCrit = criterion as StringCriterion; + result.url = { + value: urlCrit.value, + modifier: urlCrit.modifier, + }; + break; + } // no default } }); @@ -690,6 +703,14 @@ export class ListFilterModel { }; break; } + case "url": { + const urlCrit = criterion as StringCriterion; + result.url = { + value: urlCrit.value, + modifier: urlCrit.modifier, + }; + break; + } // no default } }); @@ -868,6 +889,14 @@ export class ListFilterModel { }; break; } + case "url": { + const urlCrit = criterion as StringCriterion; + result.url = { + value: urlCrit.value, + modifier: urlCrit.modifier, + }; + break; + } case "movieIsMissing": result.is_missing = (criterion as IsMissingCriterion).value; // no default @@ -888,6 +917,14 @@ export class ListFilterModel { }; break; } + case "url": { + const urlCrit = criterion as StringCriterion; + result.url = { + value: urlCrit.value, + modifier: urlCrit.modifier, + }; + break; + } case "studioIsMissing": result.is_missing = (criterion as IsMissingCriterion).value; // no default @@ -1001,6 +1038,14 @@ export class ListFilterModel { }; break; } + case "url": { + const urlCrit = criterion as StringCriterion; + result.url = { + value: urlCrit.value, + modifier: urlCrit.modifier, + }; + break; + } // no default } }); From d042ec42ee0fb57df6956fd1701dd2c2444ae0ad Mon Sep 17 00:00:00 2001 From: julien0221 <68500525+julien0221@users.noreply.github.com> Date: Fri, 9 Apr 2021 06:27:48 +0100 Subject: [PATCH 17/66] Added Auto scroll user back to the top when page navigation is clicked (#1270) --- ui/v2.5/src/components/Changelog/versions/v070.md | 1 + ui/v2.5/src/hooks/ListHook.tsx | 1 + 2 files changed, 2 insertions(+) diff --git a/ui/v2.5/src/components/Changelog/versions/v070.md b/ui/v2.5/src/components/Changelog/versions/v070.md index 2ad53bfff..1ba1099ae 100644 --- a/ui/v2.5/src/components/Changelog/versions/v070.md +++ b/ui/v2.5/src/components/Changelog/versions/v070.md @@ -3,6 +3,7 @@ * Added scene queue. ### 🎨 Improvements +* Scroll to top when changing page number. * Add URL filter criteria for scenes, galleries, movies, performers and studios. * Add HTTP endpoint for health checking at `/healthz`. * Support `today` and `yesterday` for `parseDate` in scrapers. diff --git a/ui/v2.5/src/hooks/ListHook.tsx b/ui/v2.5/src/hooks/ListHook.tsx index 2e6579115..1365da170 100644 --- a/ui/v2.5/src/hooks/ListHook.tsx +++ b/ui/v2.5/src/hooks/ListHook.tsx @@ -534,6 +534,7 @@ const useList = ( const newFilter = _.cloneDeep(filter); newFilter.currentPage = page; updateQueryParams(newFilter); + window.scrollTo(0, 0); }; const renderFilter = !options.filterHook From 6a0c73b3a1d63492e32a1c2c2eedd0fbb2b4d386 Mon Sep 17 00:00:00 2001 From: peolic <66393006+peolic@users.noreply.github.com> Date: Fri, 9 Apr 2021 09:42:52 +0300 Subject: [PATCH 18/66] Remove external resource from Login page (#1275) --- ui/login/login.css | 2 +- ui/login/login.html | 5 ++--- ui/v2.5/src/components/Changelog/versions/v070.md | 1 + 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/ui/login/login.css b/ui/login/login.css index e0ed5f797..cddf4c8d3 100644 --- a/ui/login/login.css +++ b/ui/login/login.css @@ -9,7 +9,7 @@ html { body { background-color: #202b33; color: #f5f8fa; - font-family: -apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,"Helvetica Neue",Arial,"Noto Sans",sans-serif,"Apple Color Emoji","Segoe UI Emoji","Segoe UI Symbol","Noto Color Emoji"; + font-family: -apple-system,BlinkMacSystemFont,"Segoe UI","Helvetica Neue",Arial,"Noto Sans",sans-serif,"Apple Color Emoji","Segoe UI Emoji","Segoe UI Symbol","Noto Color Emoji"; -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale; margin: 0; diff --git a/ui/login/login.html b/ui/login/login.html index c8ad61256..ea552bba8 100644 --- a/ui/login/login.html +++ b/ui/login/login.html @@ -5,7 +5,6 @@ Login - @@ -26,9 +25,9 @@ - + - +
    diff --git a/ui/v2.5/src/components/Changelog/versions/v070.md b/ui/v2.5/src/components/Changelog/versions/v070.md index 1ba1099ae..35e591f1f 100644 --- a/ui/v2.5/src/components/Changelog/versions/v070.md +++ b/ui/v2.5/src/components/Changelog/versions/v070.md @@ -13,6 +13,7 @@ * Change performer text query to search by name and alias only. ### 🐛 Bug fixes +* Fix hang on Login page when not connected to internet. * Fix `Clear Image` button not updating image preview. * Fix processing some webp files. * Fix incorrect performer age calculation in UI. From a2582047ca69087a69774fb32509be2167a71b6a Mon Sep 17 00:00:00 2001 From: WithoutPants <53250216+WithoutPants@users.noreply.github.com> Date: Fri, 9 Apr 2021 18:46:00 +1000 Subject: [PATCH 19/66] Join count filter criteria (#1254) Co-authored-by: mrbrdo Co-authored-by: peolic <66393006+peolic@users.noreply.github.com> --- graphql/schema/types/filters.graphql | 24 ++- pkg/sqlite/filter.go | 20 ++ pkg/sqlite/gallery.go | 30 ++- pkg/sqlite/gallery_test.go | 82 ++++++++ pkg/sqlite/image.go | 30 ++- pkg/sqlite/image_test.go | 82 ++++++++ pkg/sqlite/performer.go | 10 + pkg/sqlite/performer_test.go | 182 ++++++++++++++++++ pkg/sqlite/query.go | 12 ++ pkg/sqlite/scene.go | 54 +++++- pkg/sqlite/scene_test.go | 82 ++++++++ pkg/sqlite/setup_test.go | 15 ++ pkg/sqlite/sql.go | 19 +- .../src/components/Changelog/versions/v070.md | 1 + .../models/list-filter/criteria/criterion.ts | 12 ++ .../src/models/list-filter/criteria/utils.ts | 19 +- ui/v2.5/src/models/list-filter/filter.ts | 98 ++++++++++ 17 files changed, 743 insertions(+), 29 deletions(-) diff --git a/graphql/schema/types/filters.graphql b/graphql/schema/types/filters.graphql index ae51f1091..db4c6c426 100644 --- a/graphql/schema/types/filters.graphql +++ b/graphql/schema/types/filters.graphql @@ -47,7 +47,7 @@ input PerformerFilterType { measurements: StringCriterionInput """Filter by fake tits value""" fake_tits: StringCriterionInput - """Filter by career length""" + """Filter by career length""" career_length: StringCriterionInput """Filter by tattoos""" tattoos: StringCriterionInput @@ -61,6 +61,14 @@ input PerformerFilterType { is_missing: String """Filter to only include performers with these tags""" tags: MultiCriterionInput + """Filter by tag count""" + tag_count: IntCriterionInput + """Filter by scene count""" + scene_count: IntCriterionInput + """Filter by image count""" + image_count: IntCriterionInput + """Filter by gallery count""" + gallery_count: IntCriterionInput """Filter by StashID""" stash_id: String """Filter by url""" @@ -82,7 +90,7 @@ input SceneFilterType { AND: SceneFilterType OR: SceneFilterType NOT: SceneFilterType - + """Filter by path""" path: StringCriterionInput """Filter by rating""" @@ -105,10 +113,14 @@ input SceneFilterType { movies: MultiCriterionInput """Filter to only include scenes with these tags""" tags: MultiCriterionInput + """Filter by tag count""" + tag_count: IntCriterionInput """Filter to only include scenes with performers with these tags""" performer_tags: MultiCriterionInput """Filter to only include scenes with these performers""" performers: MultiCriterionInput + """Filter by performer count""" + performer_count: IntCriterionInput """Filter by StashID""" stash_id: String """Filter by url""" @@ -152,10 +164,14 @@ input GalleryFilterType { studios: MultiCriterionInput """Filter to only include galleries with these tags""" tags: MultiCriterionInput + """Filter by tag count""" + tag_count: IntCriterionInput """Filter to only include galleries with performers with these tags""" performer_tags: MultiCriterionInput """Filter to only include galleries with these performers""" performers: MultiCriterionInput + """Filter by performer count""" + performer_count: IntCriterionInput """Filter by number of images in this gallery""" image_count: IntCriterionInput """Filter by url""" @@ -203,10 +219,14 @@ input ImageFilterType { studios: MultiCriterionInput """Filter to only include images with these tags""" tags: MultiCriterionInput + """Filter by tag count""" + tag_count: IntCriterionInput """Filter to only include images with performers with these tags""" performer_tags: MultiCriterionInput """Filter to only include images with these performers""" performers: MultiCriterionInput + """Filter by performer count""" + performer_count: IntCriterionInput """Filter to only include images with these galleries""" galleries: MultiCriterionInput } diff --git a/pkg/sqlite/filter.go b/pkg/sqlite/filter.go index a781e70fc..db3a5b8d0 100644 --- a/pkg/sqlite/filter.go +++ b/pkg/sqlite/filter.go @@ -405,3 +405,23 @@ func (m *multiCriterionHandlerBuilder) handler(criterion *models.MultiCriterionI } } } + +type countCriterionHandlerBuilder struct { + primaryTable string + joinTable string + primaryFK string +} + +func (m *countCriterionHandlerBuilder) handler(criterion *models.IntCriterionInput) criterionHandlerFunc { + return func(f *filterBuilder) { + if criterion != nil { + clause, count := getCountCriterionClause(m.primaryTable, m.joinTable, m.primaryFK, *criterion) + + if count == 1 { + f.addWhere(clause, criterion.Value) + } else { + f.addWhere(clause) + } + } + } +} diff --git a/pkg/sqlite/gallery.go b/pkg/sqlite/gallery.go index 77ddabc24..2fb59bcc0 100644 --- a/pkg/sqlite/gallery.go +++ b/pkg/sqlite/gallery.go @@ -239,6 +239,16 @@ func (qb *galleryQueryBuilder) Query(galleryFilter *models.GalleryFilterType, fi query.addHaving(havingClause) } + if tagCountFilter := galleryFilter.TagCount; tagCountFilter != nil { + clause, count := getCountCriterionClause(galleryTable, galleriesTagsTable, galleryIDColumn, *tagCountFilter) + + if count == 1 { + query.addArg(tagCountFilter.Value) + } + + query.addWhere(clause) + } + if performersFilter := galleryFilter.Performers; performersFilter != nil && len(performersFilter.Value) > 0 { for _, performerID := range performersFilter.Value { query.addArg(performerID) @@ -250,6 +260,16 @@ func (qb *galleryQueryBuilder) Query(galleryFilter *models.GalleryFilterType, fi query.addHaving(havingClause) } + if performerCountFilter := galleryFilter.PerformerCount; performerCountFilter != nil { + clause, count := getCountCriterionClause(galleryTable, performersGalleriesTable, galleryIDColumn, *performerCountFilter) + + if count == 1 { + query.addArg(performerCountFilter.Value) + } + + query.addWhere(clause) + } + if studiosFilter := galleryFilter.Studios; studiosFilter != nil && len(studiosFilter.Value) > 0 { for _, studioID := range studiosFilter.Value { query.addArg(studioID) @@ -382,7 +402,15 @@ func (qb *galleryQueryBuilder) getGallerySort(findFilter *models.FindFilterType) sort = findFilter.GetSort("path") direction = findFilter.GetDirection() } - return getSort(sort, direction, "galleries") + + switch sort { + case "tag_count": + return getCountSort(galleryTable, galleriesTagsTable, galleryIDColumn, direction) + case "performer_count": + return getCountSort(galleryTable, performersGalleriesTable, galleryIDColumn, direction) + default: + return getSort(sort, direction, "galleries") + } } func (qb *galleryQueryBuilder) queryGallery(query string, args []interface{}) (*models.Gallery, error) { diff --git a/pkg/sqlite/gallery_test.go b/pkg/sqlite/gallery_test.go index a715d7d5c..999224bdc 100644 --- a/pkg/sqlite/gallery_test.go +++ b/pkg/sqlite/gallery_test.go @@ -630,6 +630,88 @@ func TestGalleryQueryPerformerTags(t *testing.T) { }) } +func TestGalleryQueryTagCount(t *testing.T) { + const tagCount = 1 + tagCountCriterion := models.IntCriterionInput{ + Value: tagCount, + Modifier: models.CriterionModifierEquals, + } + + verifyGalleriesTagCount(t, tagCountCriterion) + + tagCountCriterion.Modifier = models.CriterionModifierNotEquals + verifyGalleriesTagCount(t, tagCountCriterion) + + tagCountCriterion.Modifier = models.CriterionModifierGreaterThan + verifyGalleriesTagCount(t, tagCountCriterion) + + tagCountCriterion.Modifier = models.CriterionModifierLessThan + verifyGalleriesTagCount(t, tagCountCriterion) +} + +func verifyGalleriesTagCount(t *testing.T, tagCountCriterion models.IntCriterionInput) { + withTxn(func(r models.Repository) error { + sqb := r.Gallery() + galleryFilter := models.GalleryFilterType{ + TagCount: &tagCountCriterion, + } + + galleries := queryGallery(t, sqb, &galleryFilter, nil) + assert.Greater(t, len(galleries), 0) + + for _, gallery := range galleries { + ids, err := sqb.GetTagIDs(gallery.ID) + if err != nil { + return err + } + verifyInt(t, len(ids), tagCountCriterion) + } + + return nil + }) +} + +func TestGalleryQueryPerformerCount(t *testing.T) { + const performerCount = 1 + performerCountCriterion := models.IntCriterionInput{ + Value: performerCount, + Modifier: models.CriterionModifierEquals, + } + + verifyGalleriesPerformerCount(t, performerCountCriterion) + + performerCountCriterion.Modifier = models.CriterionModifierNotEquals + verifyGalleriesPerformerCount(t, performerCountCriterion) + + performerCountCriterion.Modifier = models.CriterionModifierGreaterThan + verifyGalleriesPerformerCount(t, performerCountCriterion) + + performerCountCriterion.Modifier = models.CriterionModifierLessThan + verifyGalleriesPerformerCount(t, performerCountCriterion) +} + +func verifyGalleriesPerformerCount(t *testing.T, performerCountCriterion models.IntCriterionInput) { + withTxn(func(r models.Repository) error { + sqb := r.Gallery() + galleryFilter := models.GalleryFilterType{ + PerformerCount: &performerCountCriterion, + } + + galleries := queryGallery(t, sqb, &galleryFilter, nil) + assert.Greater(t, len(galleries), 0) + + for _, gallery := range galleries { + ids, err := sqb.GetPerformerIDs(gallery.ID) + if err != nil { + return err + } + verifyInt(t, len(ids), performerCountCriterion) + } + + return nil + }) +} + // TODO Count // TODO All // TODO Query diff --git a/pkg/sqlite/image.go b/pkg/sqlite/image.go index adb23004e..bf7815bb5 100644 --- a/pkg/sqlite/image.go +++ b/pkg/sqlite/image.go @@ -328,6 +328,16 @@ func (qb *imageQueryBuilder) Query(imageFilter *models.ImageFilterType, findFilt query.addHaving(havingClause) } + if tagCountFilter := imageFilter.TagCount; tagCountFilter != nil { + clause, count := getCountCriterionClause(imageTable, imagesTagsTable, imageIDColumn, *tagCountFilter) + + if count == 1 { + query.addArg(tagCountFilter.Value) + } + + query.addWhere(clause) + } + if galleriesFilter := imageFilter.Galleries; galleriesFilter != nil && len(galleriesFilter.Value) > 0 { for _, galleryID := range galleriesFilter.Value { query.addArg(galleryID) @@ -350,6 +360,16 @@ func (qb *imageQueryBuilder) Query(imageFilter *models.ImageFilterType, findFilt query.addHaving(havingClause) } + if performerCountFilter := imageFilter.PerformerCount; performerCountFilter != nil { + clause, count := getCountCriterionClause(imageTable, performersImagesTable, imageIDColumn, *performerCountFilter) + + if count == 1 { + query.addArg(performerCountFilter.Value) + } + + query.addWhere(clause) + } + if studiosFilter := imageFilter.Studios; studiosFilter != nil && len(studiosFilter.Value) > 0 { for _, studioID := range studiosFilter.Value { query.addArg(studioID) @@ -412,7 +432,15 @@ func (qb *imageQueryBuilder) getImageSort(findFilter *models.FindFilterType) str } sort := findFilter.GetSort("title") direction := findFilter.GetDirection() - return getSort(sort, direction, "images") + + switch sort { + case "tag_count": + return getCountSort(imageTable, imagesTagsTable, imageIDColumn, direction) + case "performer_count": + return getCountSort(imageTable, performersImagesTable, imageIDColumn, direction) + default: + return getSort(sort, direction, "images") + } } func (qb *imageQueryBuilder) queryImage(query string, args []interface{}) (*models.Image, error) { diff --git a/pkg/sqlite/image_test.go b/pkg/sqlite/image_test.go index 36077739b..153162420 100644 --- a/pkg/sqlite/image_test.go +++ b/pkg/sqlite/image_test.go @@ -683,6 +683,88 @@ func TestImageQueryPerformerTags(t *testing.T) { }) } +func TestImageQueryTagCount(t *testing.T) { + const tagCount = 1 + tagCountCriterion := models.IntCriterionInput{ + Value: tagCount, + Modifier: models.CriterionModifierEquals, + } + + verifyImagesTagCount(t, tagCountCriterion) + + tagCountCriterion.Modifier = models.CriterionModifierNotEquals + verifyImagesTagCount(t, tagCountCriterion) + + tagCountCriterion.Modifier = models.CriterionModifierGreaterThan + verifyImagesTagCount(t, tagCountCriterion) + + tagCountCriterion.Modifier = models.CriterionModifierLessThan + verifyImagesTagCount(t, tagCountCriterion) +} + +func verifyImagesTagCount(t *testing.T, tagCountCriterion models.IntCriterionInput) { + withTxn(func(r models.Repository) error { + sqb := r.Image() + imageFilter := models.ImageFilterType{ + TagCount: &tagCountCriterion, + } + + images := queryImages(t, sqb, &imageFilter, nil) + assert.Greater(t, len(images), 0) + + for _, image := range images { + ids, err := sqb.GetTagIDs(image.ID) + if err != nil { + return err + } + verifyInt(t, len(ids), tagCountCriterion) + } + + return nil + }) +} + +func TestImageQueryPerformerCount(t *testing.T) { + const performerCount = 1 + performerCountCriterion := models.IntCriterionInput{ + Value: performerCount, + Modifier: models.CriterionModifierEquals, + } + + verifyImagesPerformerCount(t, performerCountCriterion) + + performerCountCriterion.Modifier = models.CriterionModifierNotEquals + verifyImagesPerformerCount(t, performerCountCriterion) + + performerCountCriterion.Modifier = models.CriterionModifierGreaterThan + verifyImagesPerformerCount(t, performerCountCriterion) + + performerCountCriterion.Modifier = models.CriterionModifierLessThan + verifyImagesPerformerCount(t, performerCountCriterion) +} + +func verifyImagesPerformerCount(t *testing.T, performerCountCriterion models.IntCriterionInput) { + withTxn(func(r models.Repository) error { + sqb := r.Image() + imageFilter := models.ImageFilterType{ + PerformerCount: &performerCountCriterion, + } + + images := queryImages(t, sqb, &imageFilter, nil) + assert.Greater(t, len(images), 0) + + for _, image := range images { + ids, err := sqb.GetPerformerIDs(image.ID) + if err != nil { + return err + } + verifyInt(t, len(ids), performerCountCriterion) + } + + return nil + }) +} + func TestImageQuerySorting(t *testing.T) { withTxn(func(r models.Repository) error { sort := titleField diff --git a/pkg/sqlite/performer.go b/pkg/sqlite/performer.go index 2daa22b89..fd744f983 100644 --- a/pkg/sqlite/performer.go +++ b/pkg/sqlite/performer.go @@ -271,6 +271,11 @@ func (qb *performerQueryBuilder) Query(performerFilter *models.PerformerFilterTy query.addHaving(havingClause) } + query.handleCountCriterion(performerFilter.TagCount, performerTable, performersTagsTable, performerIDColumn) + query.handleCountCriterion(performerFilter.SceneCount, performerTable, performersScenesTable, performerIDColumn) + query.handleCountCriterion(performerFilter.ImageCount, performerTable, performersImagesTable, performerIDColumn) + query.handleCountCriterion(performerFilter.GalleryCount, performerTable, performersGalleriesTable, performerIDColumn) + query.sortAndPagination = qb.getPerformerSort(findFilter) + getPagination(findFilter) idsResult, countResult, err := query.executeFind() if err != nil { @@ -370,6 +375,11 @@ func (qb *performerQueryBuilder) getPerformerSort(findFilter *models.FindFilterT sort = findFilter.GetSort("name") direction = findFilter.GetDirection() } + + if sort == "tag_count" { + return getCountSort(performerTable, performersTagsTable, performerIDColumn, direction) + } + return getSort(sort, direction, "performers") } diff --git a/pkg/sqlite/performer_test.go b/pkg/sqlite/performer_test.go index bf9024017..8fb1a7df3 100644 --- a/pkg/sqlite/performer_test.go +++ b/pkg/sqlite/performer_test.go @@ -387,6 +387,188 @@ func TestPerformerQueryTags(t *testing.T) { }) } +func TestPerformerQueryTagCount(t *testing.T) { + const tagCount = 1 + tagCountCriterion := models.IntCriterionInput{ + Value: tagCount, + Modifier: models.CriterionModifierEquals, + } + + verifyPerformersTagCount(t, tagCountCriterion) + + tagCountCriterion.Modifier = models.CriterionModifierNotEquals + verifyPerformersTagCount(t, tagCountCriterion) + + tagCountCriterion.Modifier = models.CriterionModifierGreaterThan + verifyPerformersTagCount(t, tagCountCriterion) + + tagCountCriterion.Modifier = models.CriterionModifierLessThan + verifyPerformersTagCount(t, tagCountCriterion) +} + +func verifyPerformersTagCount(t *testing.T, tagCountCriterion models.IntCriterionInput) { + withTxn(func(r models.Repository) error { + sqb := r.Performer() + performerFilter := models.PerformerFilterType{ + TagCount: &tagCountCriterion, + } + + performers := queryPerformers(t, sqb, &performerFilter, nil) + assert.Greater(t, len(performers), 0) + + for _, performer := range performers { + ids, err := sqb.GetTagIDs(performer.ID) + if err != nil { + return err + } + verifyInt(t, len(ids), tagCountCriterion) + } + + return nil + }) +} + +func TestPerformerQuerySceneCount(t *testing.T) { + const sceneCount = 1 + sceneCountCriterion := models.IntCriterionInput{ + Value: sceneCount, + Modifier: models.CriterionModifierEquals, + } + + verifyPerformersSceneCount(t, sceneCountCriterion) + + sceneCountCriterion.Modifier = models.CriterionModifierNotEquals + verifyPerformersSceneCount(t, sceneCountCriterion) + + sceneCountCriterion.Modifier = models.CriterionModifierGreaterThan + verifyPerformersSceneCount(t, sceneCountCriterion) + + sceneCountCriterion.Modifier = models.CriterionModifierLessThan + verifyPerformersSceneCount(t, sceneCountCriterion) +} + +func verifyPerformersSceneCount(t *testing.T, sceneCountCriterion models.IntCriterionInput) { + withTxn(func(r models.Repository) error { + sqb := r.Performer() + performerFilter := models.PerformerFilterType{ + SceneCount: &sceneCountCriterion, + } + + performers := queryPerformers(t, sqb, &performerFilter, nil) + assert.Greater(t, len(performers), 0) + + for _, performer := range performers { + ids, err := r.Scene().FindByPerformerID(performer.ID) + if err != nil { + return err + } + verifyInt(t, len(ids), sceneCountCriterion) + } + + return nil + }) +} + +func TestPerformerQueryImageCount(t *testing.T) { + const imageCount = 1 + imageCountCriterion := models.IntCriterionInput{ + Value: imageCount, + Modifier: models.CriterionModifierEquals, + } + + verifyPerformersImageCount(t, imageCountCriterion) + + imageCountCriterion.Modifier = models.CriterionModifierNotEquals + verifyPerformersImageCount(t, imageCountCriterion) + + imageCountCriterion.Modifier = models.CriterionModifierGreaterThan + verifyPerformersImageCount(t, imageCountCriterion) + + imageCountCriterion.Modifier = models.CriterionModifierLessThan + verifyPerformersImageCount(t, imageCountCriterion) +} + +func verifyPerformersImageCount(t *testing.T, imageCountCriterion models.IntCriterionInput) { + withTxn(func(r models.Repository) error { + sqb := r.Performer() + performerFilter := models.PerformerFilterType{ + ImageCount: &imageCountCriterion, + } + + performers := queryPerformers(t, sqb, &performerFilter, nil) + assert.Greater(t, len(performers), 0) + + for _, performer := range performers { + pp := 0 + + _, count, err := r.Image().Query(&models.ImageFilterType{ + Performers: &models.MultiCriterionInput{ + Value: []string{strconv.Itoa(performer.ID)}, + Modifier: models.CriterionModifierIncludes, + }, + }, &models.FindFilterType{ + PerPage: &pp, + }) + if err != nil { + return err + } + verifyInt(t, count, imageCountCriterion) + } + + return nil + }) +} + +func TestPerformerQueryGalleryCount(t *testing.T) { + const galleryCount = 1 + galleryCountCriterion := models.IntCriterionInput{ + Value: galleryCount, + Modifier: models.CriterionModifierEquals, + } + + verifyPerformersGalleryCount(t, galleryCountCriterion) + + galleryCountCriterion.Modifier = models.CriterionModifierNotEquals + verifyPerformersGalleryCount(t, galleryCountCriterion) + + galleryCountCriterion.Modifier = models.CriterionModifierGreaterThan + verifyPerformersGalleryCount(t, galleryCountCriterion) + + galleryCountCriterion.Modifier = models.CriterionModifierLessThan + verifyPerformersGalleryCount(t, galleryCountCriterion) +} + +func verifyPerformersGalleryCount(t *testing.T, galleryCountCriterion models.IntCriterionInput) { + withTxn(func(r models.Repository) error { + sqb := r.Performer() + performerFilter := models.PerformerFilterType{ + GalleryCount: &galleryCountCriterion, + } + + performers := queryPerformers(t, sqb, &performerFilter, nil) + assert.Greater(t, len(performers), 0) + + for _, performer := range performers { + pp := 0 + + _, count, err := r.Gallery().Query(&models.GalleryFilterType{ + Performers: &models.MultiCriterionInput{ + Value: []string{strconv.Itoa(performer.ID)}, + Modifier: models.CriterionModifierIncludes, + }, + }, &models.FindFilterType{ + PerPage: &pp, + }) + if err != nil { + return err + } + verifyInt(t, count, galleryCountCriterion) + } + + return nil + }) +} + func TestPerformerStashIDs(t *testing.T) { if err := withTxn(func(r models.Repository) error { qb := r.Performer() diff --git a/pkg/sqlite/query.go b/pkg/sqlite/query.go index 82a17cf4f..cf4cf0b5f 100644 --- a/pkg/sqlite/query.go +++ b/pkg/sqlite/query.go @@ -151,3 +151,15 @@ func (qb *queryBuilder) handleStringCriterionInput(c *models.StringCriterionInpu } } } + +func (qb *queryBuilder) handleCountCriterion(countFilter *models.IntCriterionInput, primaryTable, joinTable, primaryFK string) { + if countFilter != nil { + clause, count := getCountCriterionClause(primaryTable, joinTable, primaryFK, *countFilter) + + if count == 1 { + qb.addArg(countFilter.Value) + } + + qb.addWhere(clause) + } +} diff --git a/pkg/sqlite/scene.go b/pkg/sqlite/scene.go index 1cce0c344..ffd2f01c5 100644 --- a/pkg/sqlite/scene.go +++ b/pkg/sqlite/scene.go @@ -286,7 +286,7 @@ func (qb *sceneQueryBuilder) Wall(q *string) ([]*models.Scene, error) { } func (qb *sceneQueryBuilder) All() ([]*models.Scene, error) { - return qb.queryScenes(selectAll(sceneTable)+qb.getSceneSort(nil), nil) + return qb.queryScenes(selectAll(sceneTable)+qb.getDefaultSceneSort(), nil) } func illegalFilterCombination(type1, type2 string) error { @@ -348,7 +348,9 @@ func (qb *sceneQueryBuilder) makeFilter(sceneFilter *models.SceneFilterType) *fi query.handleCriterionFunc(stringCriterionHandler(sceneFilter.URL, "scenes.url")) query.handleCriterionFunc(sceneTagsCriterionHandler(qb, sceneFilter.Tags)) + query.handleCriterionFunc(sceneTagCountCriterionHandler(qb, sceneFilter.TagCount)) query.handleCriterionFunc(scenePerformersCriterionHandler(qb, sceneFilter.Performers)) + query.handleCriterionFunc(scenePerformerCountCriterionHandler(qb, sceneFilter.PerformerCount)) query.handleCriterionFunc(sceneStudioCriterionHandler(qb, sceneFilter.Studios)) query.handleCriterionFunc(sceneMoviesCriterionHandler(qb, sceneFilter.Movies)) query.handleCriterionFunc(sceneStashIDsHandler(qb, sceneFilter.StashID)) @@ -384,7 +386,8 @@ func (qb *sceneQueryBuilder) Query(sceneFilter *models.SceneFilterType, findFilt query.addFilter(filter) - query.sortAndPagination = qb.getSceneSort(findFilter) + getPagination(findFilter) + qb.setSceneSort(&query, findFilter) + query.sortAndPagination += getPagination(findFilter) idsResult, countResult, err := query.executeFind() if err != nil { @@ -520,6 +523,7 @@ func (qb *sceneQueryBuilder) getMultiCriterionHandlerBuilder(foreignTable, joinT addJoinsFunc: addJoinsFunc, } } + func sceneTagsCriterionHandler(qb *sceneQueryBuilder, tags *models.MultiCriterionInput) criterionHandlerFunc { addJoinsFunc := func(f *filterBuilder) { qb.tagsRepository().join(f, "tags_join", "scenes.id") @@ -530,6 +534,16 @@ func sceneTagsCriterionHandler(qb *sceneQueryBuilder, tags *models.MultiCriterio return h.handler(tags) } +func sceneTagCountCriterionHandler(qb *sceneQueryBuilder, tagCount *models.IntCriterionInput) criterionHandlerFunc { + h := countCriterionHandlerBuilder{ + primaryTable: sceneTable, + joinTable: scenesTagsTable, + primaryFK: sceneIDColumn, + } + + return h.handler(tagCount) +} + func scenePerformersCriterionHandler(qb *sceneQueryBuilder, performers *models.MultiCriterionInput) criterionHandlerFunc { addJoinsFunc := func(f *filterBuilder) { qb.performersRepository().join(f, "performers_join", "scenes.id") @@ -540,6 +554,16 @@ func scenePerformersCriterionHandler(qb *sceneQueryBuilder, performers *models.M return h.handler(performers) } +func scenePerformerCountCriterionHandler(qb *sceneQueryBuilder, performerCount *models.IntCriterionInput) criterionHandlerFunc { + h := countCriterionHandlerBuilder{ + primaryTable: sceneTable, + joinTable: performersScenesTable, + primaryFK: sceneIDColumn, + } + + return h.handler(performerCount) +} + func sceneStudioCriterionHandler(qb *sceneQueryBuilder, studios *models.MultiCriterionInput) criterionHandlerFunc { addJoinsFunc := func(f *filterBuilder) { f.addJoin("studios", "studio", "studio.id = scenes.studio_id") @@ -586,8 +610,8 @@ func scenePerformerTagsCriterionHandler(qb *sceneQueryBuilder, performerTagsFilt f.addWhere("performer_tags_join.tag_id IN "+getInBinding(len(performerTagsFilter.Value)), args...) f.addHaving(fmt.Sprintf("count(distinct performer_tags_join.tag_id) IS %d", len(performerTagsFilter.Value))) } else if performerTagsFilter.Modifier == models.CriterionModifierExcludes { - f.addWhere(fmt.Sprintf(`not exists - (select performers_scenes.performer_id from performers_scenes + f.addWhere(fmt.Sprintf(`not exists + (select performers_scenes.performer_id from performers_scenes left join performers_tags on performers_tags.performer_id = performers_scenes.performer_id where performers_scenes.scene_id = scenes.id AND performers_tags.tag_id in %s)`, getInBinding(len(performerTagsFilter.Value))), args...) @@ -612,8 +636,8 @@ func handleScenePerformerTagsCriterion(query *queryBuilder, performerTagsFilter query.addWhere("performer_tags_join.tag_id IN " + getInBinding(len(performerTagsFilter.Value))) query.addHaving(fmt.Sprintf("count(distinct performer_tags_join.tag_id) IS %d", len(performerTagsFilter.Value))) } else if performerTagsFilter.Modifier == models.CriterionModifierExcludes { - query.addWhere(fmt.Sprintf(`not exists - (select performers_scenes.performer_id from performers_scenes + query.addWhere(fmt.Sprintf(`not exists + (select performers_scenes.performer_id from performers_scenes left join performers_tags on performers_tags.performer_id = performers_scenes.performer_id where performers_scenes.scene_id = scenes.id AND performers_tags.tag_id in %s)`, getInBinding(len(performerTagsFilter.Value)))) @@ -621,13 +645,25 @@ func handleScenePerformerTagsCriterion(query *queryBuilder, performerTagsFilter } } -func (qb *sceneQueryBuilder) getSceneSort(findFilter *models.FindFilterType) string { +func (qb *sceneQueryBuilder) getDefaultSceneSort() string { + return " ORDER BY scenes.path, scenes.date ASC " +} + +func (qb *sceneQueryBuilder) setSceneSort(query *queryBuilder, findFilter *models.FindFilterType) { if findFilter == nil { - return " ORDER BY scenes.path, scenes.date ASC " + query.sortAndPagination += qb.getDefaultSceneSort() + return } sort := findFilter.GetSort("title") direction := findFilter.GetDirection() - return getSort(sort, direction, "scenes") + switch sort { + case "tag_count": + query.sortAndPagination += getCountSort(sceneTable, scenesTagsTable, sceneIDColumn, direction) + case "performer_count": + query.sortAndPagination += getCountSort(sceneTable, performersScenesTable, sceneIDColumn, direction) + default: + query.sortAndPagination += getSort(sort, direction, "scenes") + } } func (qb *sceneQueryBuilder) queryScene(query string, args []interface{}) (*models.Scene, error) { diff --git a/pkg/sqlite/scene_test.go b/pkg/sqlite/scene_test.go index 5f30ce4ac..cb4927523 100644 --- a/pkg/sqlite/scene_test.go +++ b/pkg/sqlite/scene_test.go @@ -1214,6 +1214,88 @@ func TestSceneQueryPagination(t *testing.T) { }) } +func TestSceneQueryTagCount(t *testing.T) { + const tagCount = 1 + tagCountCriterion := models.IntCriterionInput{ + Value: tagCount, + Modifier: models.CriterionModifierEquals, + } + + verifyScenesTagCount(t, tagCountCriterion) + + tagCountCriterion.Modifier = models.CriterionModifierNotEquals + verifyScenesTagCount(t, tagCountCriterion) + + tagCountCriterion.Modifier = models.CriterionModifierGreaterThan + verifyScenesTagCount(t, tagCountCriterion) + + tagCountCriterion.Modifier = models.CriterionModifierLessThan + verifyScenesTagCount(t, tagCountCriterion) +} + +func verifyScenesTagCount(t *testing.T, tagCountCriterion models.IntCriterionInput) { + withTxn(func(r models.Repository) error { + sqb := r.Scene() + sceneFilter := models.SceneFilterType{ + TagCount: &tagCountCriterion, + } + + scenes := queryScene(t, sqb, &sceneFilter, nil) + assert.Greater(t, len(scenes), 0) + + for _, scene := range scenes { + ids, err := sqb.GetTagIDs(scene.ID) + if err != nil { + return err + } + verifyInt(t, len(ids), tagCountCriterion) + } + + return nil + }) +} + +func TestSceneQueryPerformerCount(t *testing.T) { + const performerCount = 1 + performerCountCriterion := models.IntCriterionInput{ + Value: performerCount, + Modifier: models.CriterionModifierEquals, + } + + verifyScenesPerformerCount(t, performerCountCriterion) + + performerCountCriterion.Modifier = models.CriterionModifierNotEquals + verifyScenesPerformerCount(t, performerCountCriterion) + + performerCountCriterion.Modifier = models.CriterionModifierGreaterThan + verifyScenesPerformerCount(t, performerCountCriterion) + + performerCountCriterion.Modifier = models.CriterionModifierLessThan + verifyScenesPerformerCount(t, performerCountCriterion) +} + +func verifyScenesPerformerCount(t *testing.T, performerCountCriterion models.IntCriterionInput) { + withTxn(func(r models.Repository) error { + sqb := r.Scene() + sceneFilter := models.SceneFilterType{ + PerformerCount: &performerCountCriterion, + } + + scenes := queryScene(t, sqb, &sceneFilter, nil) + assert.Greater(t, len(scenes), 0) + + for _, scene := range scenes { + ids, err := sqb.GetPerformerIDs(scene.ID) + if err != nil { + return err + } + verifyInt(t, len(ids), performerCountCriterion) + } + + return nil + }) +} + func TestSceneCountByTagID(t *testing.T) { withTxn(func(r models.Repository) error { sqb := r.Scene() diff --git a/pkg/sqlite/setup_test.go b/pkg/sqlite/setup_test.go index f178bac09..de5930fd4 100644 --- a/pkg/sqlite/setup_test.go +++ b/pkg/sqlite/setup_test.go @@ -24,6 +24,8 @@ const ( sceneIdxWithMovie = iota sceneIdxWithGallery sceneIdxWithPerformer + sceneIdx1WithPerformer + sceneIdx2WithPerformer sceneIdxWithTwoPerformers sceneIdxWithTag sceneIdxWithTwoTags @@ -40,6 +42,8 @@ const ( const ( imageIdxWithGallery = iota imageIdxWithPerformer + imageIdx1WithPerformer + imageIdx2WithPerformer imageIdxWithTwoPerformers imageIdxWithTag imageIdxWithTwoTags @@ -55,12 +59,15 @@ const ( performerIdxWithScene = iota performerIdx1WithScene performerIdx2WithScene + performerIdxWithTwoScenes performerIdxWithImage + performerIdxWithTwoImages performerIdx1WithImage performerIdx2WithImage performerIdxWithTag performerIdxWithTwoTags performerIdxWithGallery + performerIdxWithTwoGalleries performerIdx1WithGallery performerIdx2WithGallery // new indexes above @@ -87,6 +94,8 @@ const ( galleryIdxWithScene = iota galleryIdxWithImage galleryIdxWithPerformer + galleryIdx1WithPerformer + galleryIdx2WithPerformer galleryIdxWithTwoPerformers galleryIdxWithTag galleryIdxWithTwoTags @@ -185,6 +194,8 @@ var ( {sceneIdxWithTwoPerformers, performerIdx2WithScene}, {sceneIdxWithPerformerTag, performerIdxWithTag}, {sceneIdxWithPerformerTwoTags, performerIdxWithTwoTags}, + {sceneIdx1WithPerformer, performerIdxWithTwoScenes}, + {sceneIdx2WithPerformer, performerIdxWithTwoScenes}, } sceneGalleryLinks = [][2]int{ @@ -218,6 +229,8 @@ var ( {imageIdxWithTwoPerformers, performerIdx2WithImage}, {imageIdxWithPerformerTag, performerIdxWithTag}, {imageIdxWithPerformerTwoTags, performerIdxWithTwoTags}, + {imageIdx1WithPerformer, performerIdxWithTwoImages}, + {imageIdx2WithPerformer, performerIdxWithTwoImages}, } ) @@ -228,6 +241,8 @@ var ( {galleryIdxWithTwoPerformers, performerIdx2WithGallery}, {galleryIdxWithPerformerTag, performerIdxWithTag}, {galleryIdxWithPerformerTwoTags, performerIdxWithTwoTags}, + {galleryIdx1WithPerformer, performerIdxWithTwoGalleries}, + {galleryIdx2WithPerformer, performerIdxWithTwoGalleries}, } galleryTagLinks = [][2]int{ diff --git a/pkg/sqlite/sql.go b/pkg/sqlite/sql.go index 71b46fe80..bbd8fde13 100644 --- a/pkg/sqlite/sql.go +++ b/pkg/sqlite/sql.go @@ -2,6 +2,7 @@ package sqlite import ( "database/sql" + "fmt" "math/rand" "strconv" "strings" @@ -44,10 +45,15 @@ func getPaginationSQL(page int, perPage int) string { return " LIMIT " + strconv.Itoa(perPage) + " OFFSET " + strconv.Itoa(page) + " " } -func getSort(sort string, direction string, tableName string) string { +func getSortDirection(direction string) string { if direction != "ASC" && direction != "DESC" { - direction = "ASC" + return "ASC" + } else { + return direction } +} +func getSort(sort string, direction string, tableName string) string { + direction = getSortDirection(direction) const randomSeedPrefix = "random_" @@ -96,6 +102,10 @@ func getRandomSort(tableName string, direction string, seed float64) string { return " ORDER BY " + "(substr(" + colName + " * " + randomSortString + ", length(" + colName + ") + 2))" + " " + direction } +func getCountSort(primaryTable, joinTable, primaryFK, direction string) string { + return fmt.Sprintf(" ORDER BY (SELECT COUNT(*) FROM %s WHERE %s = %s.id) %s", joinTable, primaryFK, primaryTable, getSortDirection(direction)) +} + func getSearchBinding(columns []string, q string, not bool) (string, []interface{}) { var likeClauses []string var args []interface{} @@ -213,6 +223,11 @@ func getMultiCriterionClause(primaryTable, foreignTable, joinTable, primaryFK, f return whereClause, havingClause } +func getCountCriterionClause(primaryTable, joinTable, primaryFK string, criterion models.IntCriterionInput) (string, int) { + lhs := fmt.Sprintf("(SELECT COUNT(*) FROM %s s WHERE s.%s = %s.id)", joinTable, primaryFK, primaryTable) + return getIntCriterionWhereClause(lhs, criterion) +} + func ensureTx(tx *sqlx.Tx) { if tx == nil { panic("must use a transaction") diff --git a/ui/v2.5/src/components/Changelog/versions/v070.md b/ui/v2.5/src/components/Changelog/versions/v070.md index 35e591f1f..92eccf82a 100644 --- a/ui/v2.5/src/components/Changelog/versions/v070.md +++ b/ui/v2.5/src/components/Changelog/versions/v070.md @@ -3,6 +3,7 @@ * Added scene queue. ### 🎨 Improvements +* Add various `count` filter criteria and sort options. * Scroll to top when changing page number. * Add URL filter criteria for scenes, galleries, movies, performers and studios. * Add HTTP endpoint for health checking at `/healthz`. diff --git a/ui/v2.5/src/models/list-filter/criteria/criterion.ts b/ui/v2.5/src/models/list-filter/criteria/criterion.ts index f18a22366..ee351a3c5 100644 --- a/ui/v2.5/src/models/list-filter/criteria/criterion.ts +++ b/ui/v2.5/src/models/list-filter/criteria/criterion.ts @@ -25,6 +25,7 @@ export type CriterionType = | "tags" | "sceneTags" | "performerTags" + | "tag_count" | "performers" | "studios" | "movies" @@ -90,6 +91,8 @@ export abstract class Criterion { return "Scene Tags"; case "performerTags": return "Performer Tags"; + case "tag_count": + return "Tag Count"; case "performers": return "Performers"; case "studios": @@ -358,6 +361,15 @@ export class NumberCriterion extends Criterion { } } +export class MandatoryNumberCriterion extends NumberCriterion { + public modifierOptions = [ + Criterion.getModifierOption(CriterionModifier.Equals), + Criterion.getModifierOption(CriterionModifier.NotEquals), + Criterion.getModifierOption(CriterionModifier.GreaterThan), + Criterion.getModifierOption(CriterionModifier.LessThan), + ]; +} + export class DurationCriterion extends Criterion { public type: CriterionType; public parameterName: string; diff --git a/ui/v2.5/src/models/list-filter/criteria/utils.ts b/ui/v2.5/src/models/list-filter/criteria/utils.ts index 011574546..81d37e750 100644 --- a/ui/v2.5/src/models/list-filter/criteria/utils.ts +++ b/ui/v2.5/src/models/list-filter/criteria/utils.ts @@ -1,12 +1,11 @@ /* eslint-disable consistent-return, default-case */ -import { CriterionModifier } from "src/core/generated-graphql"; import { - Criterion, CriterionType, StringCriterion, NumberCriterion, DurationCriterion, MandatoryStringCriterion, + MandatoryNumberCriterion, } from "./criterion"; import { OrganizedCriterion } from "./organized"; import { FavoriteCriterion } from "./favorite"; @@ -46,7 +45,8 @@ export function makeCriteria(type: CriterionType = "none") { case "image_count": case "gallery_count": case "performer_count": - return new NumberCriterion(type, type); + case "tag_count": + return new MandatoryNumberCriterion(type, type); case "resolution": return new ResolutionCriterion(); case "average_resolution": @@ -89,17 +89,8 @@ export function makeCriteria(type: CriterionType = "none") { return new GalleriesCriterion(); case "birth_year": return new NumberCriterion(type, type); - case "age": { - const ret = new NumberCriterion(type, type); - // null/not null doesn't make sense for these criteria - ret.modifierOptions = [ - Criterion.getModifierOption(CriterionModifier.Equals), - Criterion.getModifierOption(CriterionModifier.NotEquals), - Criterion.getModifierOption(CriterionModifier.GreaterThan), - Criterion.getModifierOption(CriterionModifier.LessThan), - ]; - return ret; - } + case "age": + return new MandatoryNumberCriterion(type, type); case "gender": return new GenderCriterion(); case "ethnicity": diff --git a/ui/v2.5/src/models/list-filter/filter.ts b/ui/v2.5/src/models/list-filter/filter.ts index 5ede1dee1..58116771b 100644 --- a/ui/v2.5/src/models/list-filter/filter.ts +++ b/ui/v2.5/src/models/list-filter/filter.ts @@ -128,6 +128,8 @@ export class ListFilterModel { "duration", "framerate", "bitrate", + "tag_count", + "performer_count", "random", ]; this.displayModeOptions = [ @@ -147,8 +149,10 @@ export class ListFilterModel { new HasMarkersCriterionOption(), new SceneIsMissingCriterionOption(), new TagsCriterionOption(), + ListFilterModel.createCriterionOption("tag_count"), new PerformerTagsCriterionOption(), new PerformersCriterionOption(), + ListFilterModel.createCriterionOption("performer_count"), new StudiosCriterionOption(), new MoviesCriterionOption(), ListFilterModel.createCriterionOption("url"), @@ -163,6 +167,8 @@ export class ListFilterModel { "o_counter", "filesize", "file_mod_time", + "tag_count", + "performer_count", "random", ]; this.displayModeOptions = [DisplayMode.Grid, DisplayMode.Wall]; @@ -175,8 +181,10 @@ export class ListFilterModel { new ResolutionCriterionOption(), new ImageIsMissingCriterionOption(), new TagsCriterionOption(), + ListFilterModel.createCriterionOption("tag_count"), new PerformerTagsCriterionOption(), new PerformersCriterionOption(), + ListFilterModel.createCriterionOption("performer_count"), new StudiosCriterionOption(), ]; break; @@ -187,6 +195,7 @@ export class ListFilterModel { "height", "birthdate", "scenes_count", + "tag_count", "random", ]; this.displayModeOptions = [DisplayMode.Grid, DisplayMode.List]; @@ -212,6 +221,10 @@ export class ListFilterModel { new PerformerIsMissingCriterionOption(), new TagsCriterionOption(), ListFilterModel.createCriterionOption("url"), + ListFilterModel.createCriterionOption("tag_count"), + ListFilterModel.createCriterionOption("scene_count"), + ListFilterModel.createCriterionOption("image_count"), + ListFilterModel.createCriterionOption("gallery_count"), ...numberCriteria .concat(stringCriteria) .map((c) => ListFilterModel.createCriterionOption(c)), @@ -247,6 +260,8 @@ export class ListFilterModel { "path", "file_mod_time", "images_count", + "tag_count", + "performer_count", "random", ]; this.displayModeOptions = [DisplayMode.Grid, DisplayMode.List]; @@ -258,8 +273,10 @@ export class ListFilterModel { new AverageResolutionCriterionOption(), new GalleryIsMissingCriterionOption(), new TagsCriterionOption(), + ListFilterModel.createCriterionOption("tag_count"), new PerformerTagsCriterionOption(), new PerformersCriterionOption(), + ListFilterModel.createCriterionOption("performer_count"), new StudiosCriterionOption(), ListFilterModel.createCriterionOption("url"), ]; @@ -563,6 +580,14 @@ export class ListFilterModel { }; break; } + case "tag_count": { + const tagCountCrit = criterion as NumberCriterion; + result.tag_count = { + value: tagCountCrit.value, + modifier: tagCountCrit.modifier, + }; + break; + } case "performers": { const perfCrit = criterion as PerformersCriterion; result.performers = { @@ -571,6 +596,14 @@ export class ListFilterModel { }; break; } + case "performer_count": { + const performerCountCrit = criterion as NumberCriterion; + result.performer_count = { + value: performerCountCrit.value, + modifier: performerCountCrit.modifier, + }; + break; + } case "studios": { const studCrit = criterion as StudiosCriterion; result.studios = { @@ -711,9 +744,42 @@ export class ListFilterModel { }; break; } + case "tag_count": { + const tagCountCrit = criterion as NumberCriterion; + result.tag_count = { + value: tagCountCrit.value, + modifier: tagCountCrit.modifier, + }; + break; + } + case "scene_count": { + const countCrit = criterion as NumberCriterion; + result.scene_count = { + value: countCrit.value, + modifier: countCrit.modifier, + }; + break; + } + case "image_count": { + const countCrit = criterion as NumberCriterion; + result.image_count = { + value: countCrit.value, + modifier: countCrit.modifier, + }; + break; + } + case "gallery_count": { + const countCrit = criterion as NumberCriterion; + result.gallery_count = { + value: countCrit.value, + modifier: countCrit.modifier, + }; + break; + } // no default } }); + return result; } @@ -839,6 +905,14 @@ export class ListFilterModel { }; break; } + case "tag_count": { + const tagCountCrit = criterion as NumberCriterion; + result.tag_count = { + value: tagCountCrit.value, + modifier: tagCountCrit.modifier, + }; + break; + } case "performerTags": { const performerTagsCrit = criterion as TagsCriterion; result.performer_tags = { @@ -855,6 +929,14 @@ export class ListFilterModel { }; break; } + case "performer_count": { + const countCrit = criterion as NumberCriterion; + result.performer_count = { + value: countCrit.value, + modifier: countCrit.modifier, + }; + break; + } case "studios": { const studCrit = criterion as StudiosCriterion; result.studios = { @@ -1014,6 +1096,14 @@ export class ListFilterModel { }; break; } + case "tag_count": { + const tagCountCrit = criterion as NumberCriterion; + result.tag_count = { + value: tagCountCrit.value, + modifier: tagCountCrit.modifier, + }; + break; + } case "performerTags": { const performerTagsCrit = criterion as TagsCriterion; result.performer_tags = { @@ -1030,6 +1120,14 @@ export class ListFilterModel { }; break; } + case "performer_count": { + const countCrit = criterion as NumberCriterion; + result.performer_count = { + value: countCrit.value, + modifier: countCrit.modifier, + }; + break; + } case "studios": { const studCrit = criterion as StudiosCriterion; result.studios = { From c38660d209d134b7b848721244ce31276f856990 Mon Sep 17 00:00:00 2001 From: InfiniteTF Date: Mon, 12 Apr 2021 01:04:40 +0200 Subject: [PATCH 20/66] Add phash generation and dupe checking (#1158) --- go.mod | 1 + go.sum | 4 + graphql/documents/data/scene-slim.graphql | 2 + graphql/documents/data/scene.graphql | 1 + graphql/documents/queries/scene.graphql | 6 + graphql/schema/schema.graphql | 3 + graphql/schema/types/metadata.graphql | 3 + graphql/schema/types/scene.graphql | 2 + pkg/api/resolver_model_scene.go | 10 + pkg/api/resolver_query_find_scene.go | 15 + pkg/api/urlbuilders/scene.go | 4 + pkg/database/database.go | 2 +- pkg/database/migrations/20_phash.up.sql | 1 + pkg/manager/generator_phash.go | 121 ++++ pkg/manager/jsonschema/scene.go | 1 + pkg/manager/manager_tasks.go | 25 +- pkg/manager/task_generate_phash.go | 62 ++ pkg/manager/task_scan.go | 11 + pkg/models/mocks/SceneReaderWriter.go | 24 + pkg/models/model_scene.go | 2 + pkg/models/scene.go | 1 + pkg/scene/export.go | 4 + pkg/scene/export_test.go | 4 + pkg/scene/import.go | 5 + .../stashbox/graphql/generated_client.go | 327 ++++----- .../stashbox/graphql/generated_models.go | 345 ++++++++-- pkg/scraper/stashbox/stash_box.go | 18 +- pkg/sqlite/scene.go | 64 ++ pkg/utils/phash.go | 57 ++ .../src/components/Changelog/versions/v070.md | 1 + ui/v2.5/src/components/Help/Manual.tsx | 6 + ui/v2.5/src/components/List/styles.scss | 8 + .../SceneDetails/SceneFileInfoPanel.tsx | 14 + .../components/Scenes/SceneGenerateDialog.tsx | 8 + ui/v2.5/src/components/Settings/Settings.tsx | 7 + .../Settings/SettingsDuplicatePanel.tsx | 270 ++++++++ .../SettingsTasksPanel/GenerateButton.tsx | 8 + .../SettingsTasksPanel/SettingsTasksPanel.tsx | 10 + ui/v2.5/src/components/Settings/styles.scss | 53 ++ .../components/Tagger/StashSearchResult.tsx | 16 +- ui/v2.5/src/docs/en/Deduplication.md | 9 + .../corona10/goimagehash/.gitignore | 14 + .../corona10/goimagehash/AUTHORS.md | 5 + .../corona10/goimagehash/CODEOWNERS | 1 + .../corona10/goimagehash/Gopkg.lock | 17 + .../corona10/goimagehash/Gopkg.toml | 34 + .../github.com/corona10/goimagehash/LICENSE | 25 + .../github.com/corona10/goimagehash/README.md | 93 +++ vendor/github.com/corona10/goimagehash/doc.go | 5 + .../corona10/goimagehash/etcs/doc.go | 5 + .../corona10/goimagehash/etcs/utils.go | 61 ++ vendor/github.com/corona10/goimagehash/go.mod | 3 + vendor/github.com/corona10/goimagehash/go.sum | 2 + .../corona10/goimagehash/hashcompute.go | 183 ++++++ .../corona10/goimagehash/imagehash.go | 294 +++++++++ .../corona10/goimagehash/imagehash18.go | 13 + .../corona10/goimagehash/imagehash19.go | 9 + .../corona10/goimagehash/transforms/dct.go | 75 +++ .../corona10/goimagehash/transforms/doc.go | 5 + .../corona10/goimagehash/transforms/pixels.go | 39 ++ vendor/github.com/nfnt/resize/.travis.yml | 7 + vendor/github.com/nfnt/resize/LICENSE | 13 + vendor/github.com/nfnt/resize/README.md | 151 +++++ vendor/github.com/nfnt/resize/converter.go | 438 +++++++++++++ vendor/github.com/nfnt/resize/filters.go | 143 ++++ vendor/github.com/nfnt/resize/nearest.go | 318 +++++++++ vendor/github.com/nfnt/resize/resize.go | 620 ++++++++++++++++++ vendor/github.com/nfnt/resize/thumbnail.go | 55 ++ vendor/github.com/nfnt/resize/ycc.go | 387 +++++++++++ vendor/modules.txt | 6 + 70 files changed, 4342 insertions(+), 214 deletions(-) create mode 100644 pkg/database/migrations/20_phash.up.sql create mode 100644 pkg/manager/generator_phash.go create mode 100644 pkg/manager/task_generate_phash.go create mode 100644 pkg/utils/phash.go create mode 100644 ui/v2.5/src/components/Settings/SettingsDuplicatePanel.tsx create mode 100644 ui/v2.5/src/docs/en/Deduplication.md create mode 100644 vendor/github.com/corona10/goimagehash/.gitignore create mode 100644 vendor/github.com/corona10/goimagehash/AUTHORS.md create mode 100644 vendor/github.com/corona10/goimagehash/CODEOWNERS create mode 100644 vendor/github.com/corona10/goimagehash/Gopkg.lock create mode 100644 vendor/github.com/corona10/goimagehash/Gopkg.toml create mode 100644 vendor/github.com/corona10/goimagehash/LICENSE create mode 100644 vendor/github.com/corona10/goimagehash/README.md create mode 100644 vendor/github.com/corona10/goimagehash/doc.go create mode 100644 vendor/github.com/corona10/goimagehash/etcs/doc.go create mode 100644 vendor/github.com/corona10/goimagehash/etcs/utils.go create mode 100644 vendor/github.com/corona10/goimagehash/go.mod create mode 100644 vendor/github.com/corona10/goimagehash/go.sum create mode 100644 vendor/github.com/corona10/goimagehash/hashcompute.go create mode 100644 vendor/github.com/corona10/goimagehash/imagehash.go create mode 100644 vendor/github.com/corona10/goimagehash/imagehash18.go create mode 100644 vendor/github.com/corona10/goimagehash/imagehash19.go create mode 100644 vendor/github.com/corona10/goimagehash/transforms/dct.go create mode 100644 vendor/github.com/corona10/goimagehash/transforms/doc.go create mode 100644 vendor/github.com/corona10/goimagehash/transforms/pixels.go create mode 100644 vendor/github.com/nfnt/resize/.travis.yml create mode 100644 vendor/github.com/nfnt/resize/LICENSE create mode 100644 vendor/github.com/nfnt/resize/README.md create mode 100644 vendor/github.com/nfnt/resize/converter.go create mode 100644 vendor/github.com/nfnt/resize/filters.go create mode 100644 vendor/github.com/nfnt/resize/nearest.go create mode 100644 vendor/github.com/nfnt/resize/resize.go create mode 100644 vendor/github.com/nfnt/resize/thumbnail.go create mode 100644 vendor/github.com/nfnt/resize/ycc.go diff --git a/go.mod b/go.mod index c4d9865bf..d99b84bf4 100644 --- a/go.mod +++ b/go.mod @@ -6,6 +6,7 @@ require ( github.com/antchfx/htmlquery v1.2.3 github.com/chromedp/cdproto v0.0.0-20200608134039-8a80cdaf865c github.com/chromedp/chromedp v0.5.3 + github.com/corona10/goimagehash v1.0.3 github.com/dgrijalva/jwt-go v3.2.0+incompatible github.com/disintegration/imaging v1.6.0 github.com/fvbommel/sortorder v1.0.2 diff --git a/go.sum b/go.sum index d08261d70..d81e4867b 100644 --- a/go.sum +++ b/go.sum @@ -83,6 +83,8 @@ github.com/coreos/go-semver v0.3.0/go.mod h1:nnelYz7RCh+5ahJtPPxZlU+153eP4D4r3Ee github.com/coreos/go-systemd v0.0.0-20181012123002-c6f51f82210d/go.mod h1:F5haX7vjVVG0kc13fIWeqUViNPyEJxv/OmvnBo0Yme4= github.com/coreos/go-systemd v0.0.0-20190321100706-95778dfbb74e/go.mod h1:F5haX7vjVVG0kc13fIWeqUViNPyEJxv/OmvnBo0Yme4= github.com/coreos/pkg v0.0.0-20180928190104-399ea9e2e55f/go.mod h1:E3G3o1h8I7cfcXa63jLwjI0eiQQMgzzUDFVpN/nH/eA= +github.com/corona10/goimagehash v1.0.3 h1:NZM518aKLmoNluluhfHGxT3LGOnrojrxhGn63DR/CZA= +github.com/corona10/goimagehash v1.0.3/go.mod h1:VkvE0mLn84L4aF8vCb6mafVajEb6QYMHl2ZJLn0mOGI= github.com/cpuguy83/go-md2man/v2 v2.0.0-20190314233015-f79a8a8ca69d/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsrgA7czyZG/E6dU= github.com/cpuguy83/go-md2man/v2 v2.0.0 h1:EoUDS0afbrsXAZ9YQ9jdu/mZ2sXgT1/2yyNng4PGlyM= github.com/cpuguy83/go-md2man/v2 v2.0.0/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsrgA7czyZG/E6dU= @@ -573,6 +575,8 @@ github.com/natefinch/pie v0.0.0-20170715172608-9a0d72014007 h1:Ohgj9L0EYOgXxkDp+ github.com/natefinch/pie v0.0.0-20170715172608-9a0d72014007/go.mod h1:wKCOWMb6iNlvKiOToY2cNuaovSXvIiv1zDi9QDR7aGQ= github.com/neelance/astrewrite v0.0.0-20160511093645-99348263ae86/go.mod h1:kHJEU3ofeGjhHklVoIGuVj85JJwZ6kWPaJwCIxgnFmo= github.com/neelance/sourcemap v0.0.0-20151028013722-8c68805598ab/go.mod h1:Qr6/a/Q4r9LP1IltGz7tA7iOK1WonHEYhu1HRBA7ZiM= +github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646 h1:zYyBkD/k9seD2A7fsi6Oo2LfFZAehjjQMERAvZLEDnQ= +github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646/go.mod h1:jpp1/29i3P1S/RLdc7JQKbRpFeM1dOBd8T9ki5s+AY8= github.com/nicksnyder/go-i18n v1.10.0/go.mod h1:HrK7VCrbOvQoUAQ7Vpy7i87N7JZZZ7R2xBGjv0j365Q= github.com/oklog/ulid v1.3.1/go.mod h1:CirwcVhetQ6Lv90oh/F+FBtV6XMibvdAFo93nm5qn4U= github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= diff --git a/graphql/documents/data/scene-slim.graphql b/graphql/documents/data/scene-slim.graphql index 473012d55..f427eb904 100644 --- a/graphql/documents/data/scene-slim.graphql +++ b/graphql/documents/data/scene-slim.graphql @@ -10,6 +10,7 @@ fragment SlimSceneData on Scene { o_counter organized path + phash file { size @@ -29,6 +30,7 @@ fragment SlimSceneData on Scene { webp vtt chapters_vtt + sprite } scene_markers { diff --git a/graphql/documents/data/scene.graphql b/graphql/documents/data/scene.graphql index 1f8061e06..491983b4f 100644 --- a/graphql/documents/data/scene.graphql +++ b/graphql/documents/data/scene.graphql @@ -10,6 +10,7 @@ fragment SceneData on Scene { o_counter organized path + phash file { size diff --git a/graphql/documents/queries/scene.graphql b/graphql/documents/queries/scene.graphql index 87bb3fd7d..daeabbaaf 100644 --- a/graphql/documents/queries/scene.graphql +++ b/graphql/documents/queries/scene.graphql @@ -16,6 +16,12 @@ query FindScenesByPathRegex($filter: FindFilterType) { } } +query FindDuplicateScenes($distance: Int) { + findDuplicateScenes(distance: $distance) { + ...SlimSceneData + } +} + query FindScene($id: ID!, $checksum: String) { findScene(id: $id, checksum: $checksum) { ...SceneData diff --git a/graphql/schema/schema.graphql b/graphql/schema/schema.graphql index 29a1e5681..048aa37d4 100644 --- a/graphql/schema/schema.graphql +++ b/graphql/schema/schema.graphql @@ -9,6 +9,9 @@ type Query { findScenesByPathRegex(filter: FindFilterType): FindScenesResultType! + """ Returns any groups of scenes that are perceptual duplicates within the queried distance """ + findDuplicateScenes(distance: Int): [[Scene!]!]! + """Return valid stream paths""" sceneStreams(id: ID): [SceneStreamEndpoint!]! diff --git a/graphql/schema/types/metadata.graphql b/graphql/schema/types/metadata.graphql index f83f3ad78..600d8f9c8 100644 --- a/graphql/schema/types/metadata.graphql +++ b/graphql/schema/types/metadata.graphql @@ -7,6 +7,7 @@ input GenerateMetadataInput { previewOptions: GeneratePreviewOptionsInput markers: Boolean! transcodes: Boolean! + phashes: Boolean! """scene ids to generate for""" sceneIDs: [ID!] @@ -42,6 +43,8 @@ input ScanMetadataInput { scanGenerateImagePreviews: Boolean """Generate sprites during scan""" scanGenerateSprites: Boolean + """Generate phashes during scan""" + scanGeneratePhashes: Boolean } input CleanMetadataInput { diff --git a/graphql/schema/types/scene.graphql b/graphql/schema/types/scene.graphql index f37c0bfd9..84d2fdf79 100644 --- a/graphql/schema/types/scene.graphql +++ b/graphql/schema/types/scene.graphql @@ -16,6 +16,7 @@ type ScenePathsType { webp: String # Resolver vtt: String # Resolver chapters_vtt: String # Resolver + sprite: String # Resolver } type SceneMovie { @@ -35,6 +36,7 @@ type Scene { organized: Boolean! o_counter: Int path: String! + phash: String file: SceneFileType! # Resolver paths: ScenePathsType! # Resolver diff --git a/pkg/api/resolver_model_scene.go b/pkg/api/resolver_model_scene.go index 960c561ff..dcec7fa86 100644 --- a/pkg/api/resolver_model_scene.go +++ b/pkg/api/resolver_model_scene.go @@ -83,6 +83,7 @@ func (r *sceneResolver) Paths(ctx context.Context, obj *models.Scene) (*models.S streamPath := builder.GetStreamURL() webpPath := builder.GetStreamPreviewImageURL() vttPath := builder.GetSpriteVTTURL() + spritePath := builder.GetSpriteURL() chaptersVttPath := builder.GetChaptersVTTURL() return &models.ScenePathsType{ Screenshot: &screenshotPath, @@ -91,6 +92,7 @@ func (r *sceneResolver) Paths(ctx context.Context, obj *models.Scene) (*models.S Webp: &webpPath, Vtt: &vttPath, ChaptersVtt: &chaptersVttPath, + Sprite: &spritePath, }, nil } @@ -200,3 +202,11 @@ func (r *sceneResolver) StashIds(ctx context.Context, obj *models.Scene) (ret [] return ret, nil } + +func (r *sceneResolver) Phash(ctx context.Context, obj *models.Scene) (*string, error) { + if obj.Phash.Valid { + hexval := utils.PhashToString(obj.Phash.Int64) + return &hexval, nil + } + return nil, nil +} diff --git a/pkg/api/resolver_query_find_scene.go b/pkg/api/resolver_query_find_scene.go index 44a111646..be250e101 100644 --- a/pkg/api/resolver_query_find_scene.go +++ b/pkg/api/resolver_query_find_scene.go @@ -151,3 +151,18 @@ func (r *queryResolver) ParseSceneFilenames(ctx context.Context, filter *models. return ret, nil } + +func (r *queryResolver) FindDuplicateScenes(ctx context.Context, distance *int) (ret [][]*models.Scene, err error) { + dist := 0 + if distance != nil { + dist = *distance + } + if err := r.withReadTxn(ctx, func(repo models.ReaderRepository) error { + ret, err = repo.Scene().FindDuplicates(dist) + return err + }); err != nil { + return nil, err + } + + return ret, nil +} diff --git a/pkg/api/urlbuilders/scene.go b/pkg/api/urlbuilders/scene.go index 57c50f3cf..5d7af407c 100644 --- a/pkg/api/urlbuilders/scene.go +++ b/pkg/api/urlbuilders/scene.go @@ -33,6 +33,10 @@ func (b SceneURLBuilder) GetSpriteVTTURL() string { return b.BaseURL + "/scene/" + b.SceneID + "_thumbs.vtt" } +func (b SceneURLBuilder) GetSpriteURL() string { + return b.BaseURL + "/scene/" + b.SceneID + "_sprite.jpg" +} + func (b SceneURLBuilder) GetScreenshotURL(updateTime time.Time) string { return b.BaseURL + "/scene/" + b.SceneID + "/screenshot?" + strconv.FormatInt(updateTime.Unix(), 10) } diff --git a/pkg/database/database.go b/pkg/database/database.go index e4099b073..8082e0978 100644 --- a/pkg/database/database.go +++ b/pkg/database/database.go @@ -23,7 +23,7 @@ import ( var DB *sqlx.DB var WriteMu *sync.Mutex var dbPath string -var appSchemaVersion uint = 19 +var appSchemaVersion uint = 20 var databaseSchemaVersion uint const sqlite3Driver = "sqlite3ex" diff --git a/pkg/database/migrations/20_phash.up.sql b/pkg/database/migrations/20_phash.up.sql new file mode 100644 index 000000000..c1c889956 --- /dev/null +++ b/pkg/database/migrations/20_phash.up.sql @@ -0,0 +1 @@ +ALTER TABLE `scenes` ADD COLUMN `phash` blob; diff --git a/pkg/manager/generator_phash.go b/pkg/manager/generator_phash.go new file mode 100644 index 000000000..4e711560b --- /dev/null +++ b/pkg/manager/generator_phash.go @@ -0,0 +1,121 @@ +package manager + +import ( + "fmt" + "image" + "image/color" + "math" + "os" + "sort" + + "github.com/corona10/goimagehash" + "github.com/disintegration/imaging" + "github.com/fvbommel/sortorder" + + "github.com/stashapp/stash/pkg/ffmpeg" + "github.com/stashapp/stash/pkg/logger" + "github.com/stashapp/stash/pkg/utils" +) + +type PhashGenerator struct { + Info *GeneratorInfo + + VideoChecksum string + Columns int + Rows int +} + +func NewPhashGenerator(videoFile ffmpeg.VideoFile, checksum string) (*PhashGenerator, error) { + exists, err := utils.FileExists(videoFile.Path) + if !exists { + return nil, err + } + + generator, err := newGeneratorInfo(videoFile) + if err != nil { + return nil, err + } + + return &PhashGenerator{ + Info: generator, + VideoChecksum: checksum, + Columns: 5, + Rows: 5, + }, nil +} + +func (g *PhashGenerator) Generate() (*uint64, error) { + encoder := ffmpeg.NewEncoder(instance.FFMPEGPath) + + sprite, err := g.generateSprite(&encoder) + if err != nil { + return nil, err + } + + hash, err := goimagehash.PerceptionHash(sprite) + if err != nil { + return nil, err + } + hashValue := hash.GetHash() + return &hashValue, nil +} + +func (g *PhashGenerator) generateSprite(encoder *ffmpeg.Encoder) (image.Image, error) { + logger.Infof("[generator] generating phash sprite for %s", g.Info.VideoFile.Path) + + // Generate sprite image offset by 5% on each end to avoid intro/outros + chunkCount := g.Columns * g.Rows + offset := 0.05 * g.Info.VideoFile.Duration + stepSize := (0.9 * g.Info.VideoFile.Duration) / float64(chunkCount) + for i := 0; i < chunkCount; i++ { + time := offset + (float64(i) * stepSize) + num := fmt.Sprintf("%.3d", i) + filename := "phash_" + g.VideoChecksum + "_" + num + ".bmp" + + options := ffmpeg.ScreenshotOptions{ + OutputPath: instance.Paths.Generated.GetTmpPath(filename), + Time: time, + Width: 160, + } + if err := encoder.Screenshot(g.Info.VideoFile, options); err != nil { + return nil, err + } + } + + // Combine all of the thumbnails into a sprite image + pattern := fmt.Sprintf("phash_%s_.+\\.bmp$", g.VideoChecksum) + imagePaths, err := utils.MatchEntries(instance.Paths.Generated.Tmp, pattern) + if err != nil { + return nil, err + } + sort.Sort(sortorder.Natural(imagePaths)) + var images []image.Image + for _, imagePath := range imagePaths { + img, err := imaging.Open(imagePath) + if err != nil { + return nil, err + } + images = append(images, img) + } + + if len(images) == 0 { + return nil, fmt.Errorf("images slice is empty, failed to generate phash sprite for %s", g.Info.VideoFile.Path) + } + width := images[0].Bounds().Size().X + height := images[0].Bounds().Size().Y + canvasWidth := width * g.Columns + canvasHeight := height * g.Rows + montage := imaging.New(canvasWidth, canvasHeight, color.NRGBA{}) + for index := 0; index < len(images); index++ { + x := width * (index % g.Columns) + y := height * int(math.Floor(float64(index)/float64(g.Rows))) + img := images[index] + montage = imaging.Paste(montage, img, image.Pt(x, y)) + } + + for _, imagePath := range imagePaths { + os.Remove(imagePath) + } + + return montage, nil +} diff --git a/pkg/manager/jsonschema/scene.go b/pkg/manager/jsonschema/scene.go index 79c466be6..540447757 100644 --- a/pkg/manager/jsonschema/scene.go +++ b/pkg/manager/jsonschema/scene.go @@ -39,6 +39,7 @@ type Scene struct { Title string `json:"title,omitempty"` Checksum string `json:"checksum,omitempty"` OSHash string `json:"oshash,omitempty"` + Phash string `json:"phash,omitempty"` Studio string `json:"studio,omitempty"` URL string `json:"url,omitempty"` Date string `json:"date,omitempty"` diff --git a/pkg/manager/manager_tasks.go b/pkg/manager/manager_tasks.go index 28e42022b..ff8116bda 100644 --- a/pkg/manager/manager_tasks.go +++ b/pkg/manager/manager_tasks.go @@ -222,6 +222,7 @@ func (s *singleton) Scan(input models.ScanMetadataInput) { GeneratePreview: utils.IsTrue(input.ScanGeneratePreviews), GenerateImagePreview: utils.IsTrue(input.ScanGenerateImagePreviews), GenerateSprite: utils.IsTrue(input.ScanGenerateSprites), + GeneratePhash: utils.IsTrue(input.ScanGeneratePhashes), } go task.Start(&wg) @@ -427,7 +428,7 @@ func (s *singleton) Generate(input models.GenerateMetadataInput) { logger.Infof("Taking too long to count content. Skipping...") logger.Infof("Generating content") } else { - logger.Infof("Generating %d sprites %d previews %d image previews %d markers %d transcodes", totalsNeeded.sprites, totalsNeeded.previews, totalsNeeded.imagePreviews, totalsNeeded.markers, totalsNeeded.transcodes) + logger.Infof("Generating %d sprites %d previews %d image previews %d markers %d transcodes %d phashes", totalsNeeded.sprites, totalsNeeded.previews, totalsNeeded.imagePreviews, totalsNeeded.markers, totalsNeeded.transcodes, totalsNeeded.phashes) } fileNamingAlgo := config.GetVideoFileNamingAlgorithm() @@ -501,6 +502,16 @@ func (s *singleton) Generate(input models.GenerateMetadataInput) { } go task.Start(&wg) } + + if input.Phashes { + task := GeneratePhashTask{ + Scene: *scene, + fileNamingAlgorithm: fileNamingAlgo, + txnManager: s.TxnManager, + } + wg.Add() + go task.Start(&wg) + } } wg.Wait() @@ -992,6 +1003,7 @@ type totalsGenerate struct { imagePreviews int64 markers int64 transcodes int64 + phashes int64 } func (s *singleton) neededGenerate(scenes []*models.Scene, input models.GenerateMetadataInput) *totalsGenerate { @@ -1065,6 +1077,17 @@ func (s *singleton) neededGenerate(scenes []*models.Scene, input models.Generate totals.transcodes++ } } + + if input.Phashes { + task := GeneratePhashTask{ + Scene: *scene, + fileNamingAlgorithm: fileNamingAlgo, + } + + if task.shouldGenerate() { + totals.phashes++ + } + } } //check for timeout select { diff --git a/pkg/manager/task_generate_phash.go b/pkg/manager/task_generate_phash.go new file mode 100644 index 000000000..f8ef6d6be --- /dev/null +++ b/pkg/manager/task_generate_phash.go @@ -0,0 +1,62 @@ +package manager + +import ( + "github.com/remeh/sizedwaitgroup" + + "context" + "database/sql" + + "github.com/stashapp/stash/pkg/ffmpeg" + "github.com/stashapp/stash/pkg/logger" + "github.com/stashapp/stash/pkg/models" +) + +type GeneratePhashTask struct { + Scene models.Scene + fileNamingAlgorithm models.HashAlgorithm + txnManager models.TransactionManager +} + +func (t *GeneratePhashTask) Start(wg *sizedwaitgroup.SizedWaitGroup) { + defer wg.Done() + + if !t.shouldGenerate() { + return + } + + videoFile, err := ffmpeg.NewVideoFile(instance.FFProbePath, t.Scene.Path, false) + if err != nil { + logger.Errorf("error reading video file: %s", err.Error()) + return + } + + sceneHash := t.Scene.GetHash(t.fileNamingAlgorithm) + generator, err := NewPhashGenerator(*videoFile, sceneHash) + + if err != nil { + logger.Errorf("error creating phash generator: %s", err.Error()) + return + } + hash, err := generator.Generate() + if err != nil { + logger.Errorf("error generating phash: %s", err.Error()) + return + } + + if err := t.txnManager.WithTxn(context.TODO(), func(r models.Repository) error { + qb := r.Scene() + hashValue := sql.NullInt64{Int64: int64(*hash), Valid: true} + scenePartial := models.ScenePartial{ + ID: t.Scene.ID, + Phash: &hashValue, + } + _, err := qb.Update(scenePartial) + return err + }); err != nil { + logger.Error(err.Error()) + } +} + +func (t *GeneratePhashTask) shouldGenerate() bool { + return !t.Scene.Phash.Valid +} diff --git a/pkg/manager/task_scan.go b/pkg/manager/task_scan.go index d35432f4a..d9ce9581c 100644 --- a/pkg/manager/task_scan.go +++ b/pkg/manager/task_scan.go @@ -31,6 +31,7 @@ type ScanTask struct { calculateMD5 bool fileNamingAlgorithm models.HashAlgorithm GenerateSprite bool + GeneratePhash bool GeneratePreview bool GenerateImagePreview bool zipGallery *models.Gallery @@ -55,6 +56,16 @@ func (t *ScanTask) Start(wg *sizedwaitgroup.SizedWaitGroup) { go taskSprite.Start(&iwg) } + if t.GeneratePhash { + iwg.Add() + taskPhash := GeneratePhashTask{ + Scene: *s, + fileNamingAlgorithm: t.fileNamingAlgorithm, + txnManager: t.TxnManager, + } + go taskPhash.Start(&iwg) + } + if t.GeneratePreview { iwg.Add() diff --git a/pkg/models/mocks/SceneReaderWriter.go b/pkg/models/mocks/SceneReaderWriter.go index 0e5295759..b5c3af191 100644 --- a/pkg/models/mocks/SceneReaderWriter.go +++ b/pkg/models/mocks/SceneReaderWriter.go @@ -438,6 +438,30 @@ func (_m *SceneReaderWriter) FindMany(ids []int) ([]*models.Scene, error) { return r0, r1 } +// FindDuplicates provides a mock function with given fields: distance +func (_m *SceneReaderWriter) FindDuplicates(distance int) ([][]*models.Scene, error) { + ret := _m.Called(distance) + + var r0 [][]*models.Scene + if rf, ok := ret.Get(0).(func(int) [][]*models.Scene); ok { + r0 = rf(distance) + + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).([][]*models.Scene) + } + } + + var r1 error + if rf, ok := ret.Get(1).(func(int) error); ok { + r1 = rf(distance) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + // GetCover provides a mock function with given fields: sceneID func (_m *SceneReaderWriter) GetCover(sceneID int) ([]byte, error) { ret := _m.Called(sceneID) diff --git a/pkg/models/model_scene.go b/pkg/models/model_scene.go index 40bcd43e9..514ef8cbf 100644 --- a/pkg/models/model_scene.go +++ b/pkg/models/model_scene.go @@ -29,6 +29,7 @@ type Scene struct { Bitrate sql.NullInt64 `db:"bitrate" json:"bitrate"` StudioID sql.NullInt64 `db:"studio_id,omitempty" json:"studio_id"` FileModTime NullSQLiteTimestamp `db:"file_mod_time" json:"file_mod_time"` + Phash sql.NullInt64 `db:"phash,omitempty" json:"phash"` CreatedAt SQLiteTimestamp `db:"created_at" json:"created_at"` UpdatedAt SQLiteTimestamp `db:"updated_at" json:"updated_at"` } @@ -58,6 +59,7 @@ type ScenePartial struct { StudioID *sql.NullInt64 `db:"studio_id,omitempty" json:"studio_id"` MovieID *sql.NullInt64 `db:"movie_id,omitempty" json:"movie_id"` FileModTime *NullSQLiteTimestamp `db:"file_mod_time" json:"file_mod_time"` + Phash *sql.NullInt64 `db:"phash,omitempty" json:"phash"` CreatedAt *SQLiteTimestamp `db:"created_at" json:"created_at"` UpdatedAt *SQLiteTimestamp `db:"updated_at" json:"updated_at"` } diff --git a/pkg/models/scene.go b/pkg/models/scene.go index ef4485717..8e77b2497 100644 --- a/pkg/models/scene.go +++ b/pkg/models/scene.go @@ -8,6 +8,7 @@ type SceneReader interface { FindByPath(path string) (*Scene, error) FindByPerformerID(performerID int) ([]*Scene, error) FindByGalleryID(performerID int) ([]*Scene, error) + FindDuplicates(distance int) ([][]*Scene, error) CountByPerformerID(performerID int) (int, error) // FindByStudioID(studioID int) ([]*Scene, error) FindByMovieID(movieID int) ([]*Scene, error) diff --git a/pkg/scene/export.go b/pkg/scene/export.go index 9fcd6d096..5f723cdf5 100644 --- a/pkg/scene/export.go +++ b/pkg/scene/export.go @@ -27,6 +27,10 @@ func ToBasicJSON(reader models.SceneReader, scene *models.Scene) (*jsonschema.Sc newSceneJSON.OSHash = scene.OSHash.String } + if scene.Phash.Valid { + newSceneJSON.Phash = utils.PhashToString(scene.Phash.Int64) + } + if scene.Title.Valid { newSceneJSON.Title = scene.Title.String } diff --git a/pkg/scene/export_test.go b/pkg/scene/export_test.go index 2d30d9672..dc3164f13 100644 --- a/pkg/scene/export_test.go +++ b/pkg/scene/export_test.go @@ -7,6 +7,7 @@ import ( "github.com/stashapp/stash/pkg/manager/jsonschema" "github.com/stashapp/stash/pkg/models" "github.com/stashapp/stash/pkg/models/mocks" + "github.com/stashapp/stash/pkg/utils" "github.com/stretchr/testify/assert" "testing" @@ -43,6 +44,7 @@ const ( checksum = "checksum" oshash = "oshash" title = "title" + phash = -3846826108889195 date = "2001-01-01" rating = 5 ocounter = 2 @@ -112,6 +114,7 @@ func createFullScene(id int) models.Scene { Height: models.NullInt64(height), OCounter: ocounter, OSHash: models.NullString(oshash), + Phash: models.NullInt64(phash), Rating: models.NullInt64(rating), Organized: organized, Size: models.NullString(size), @@ -147,6 +150,7 @@ func createFullJSONScene(image string) *jsonschema.Scene { Details: details, OCounter: ocounter, OSHash: oshash, + Phash: utils.PhashToString(phash), Rating: rating, Organized: organized, URL: url, diff --git a/pkg/scene/import.go b/pkg/scene/import.go index eee87c8a8..a1cad8808 100644 --- a/pkg/scene/import.go +++ b/pkg/scene/import.go @@ -73,6 +73,11 @@ func (i *Importer) sceneJSONToScene(sceneJSON jsonschema.Scene) models.Scene { Path: i.Path, } + if sceneJSON.Phash != "" { + hash, err := strconv.ParseUint(sceneJSON.Phash, 16, 64) + newScene.Phash = sql.NullInt64{Int64: int64(hash), Valid: err == nil} + } + if sceneJSON.Title != "" { newScene.Title = sql.NullString{String: sceneJSON.Title, Valid: true} } diff --git a/pkg/scraper/stashbox/graphql/generated_client.go b/pkg/scraper/stashbox/graphql/generated_client.go index 0cd062fc1..aaae56b8d 100644 --- a/pkg/scraper/stashbox/graphql/generated_client.go +++ b/pkg/scraper/stashbox/graphql/generated_client.go @@ -18,56 +18,67 @@ func NewClient(cli *http.Client, baseURL string, options ...client.HTTPRequestOp } type Query struct { - FindPerformer *Performer "json:\"findPerformer\" graphql:\"findPerformer\"" - QueryPerformers QueryPerformersResultType "json:\"queryPerformers\" graphql:\"queryPerformers\"" - FindStudio *Studio "json:\"findStudio\" graphql:\"findStudio\"" - QueryStudios QueryStudiosResultType "json:\"queryStudios\" graphql:\"queryStudios\"" - FindTag *Tag "json:\"findTag\" graphql:\"findTag\"" - QueryTags QueryTagsResultType "json:\"queryTags\" graphql:\"queryTags\"" - FindScene *Scene "json:\"findScene\" graphql:\"findScene\"" - FindSceneByFingerprint []*Scene "json:\"findSceneByFingerprint\" graphql:\"findSceneByFingerprint\"" - FindScenesByFingerprints []*Scene "json:\"findScenesByFingerprints\" graphql:\"findScenesByFingerprints\"" - QueryScenes QueryScenesResultType "json:\"queryScenes\" graphql:\"queryScenes\"" - FindEdit *Edit "json:\"findEdit\" graphql:\"findEdit\"" - QueryEdits QueryEditsResultType "json:\"queryEdits\" graphql:\"queryEdits\"" - FindUser *User "json:\"findUser\" graphql:\"findUser\"" - QueryUsers QueryUsersResultType "json:\"queryUsers\" graphql:\"queryUsers\"" - Me *User "json:\"me\" graphql:\"me\"" - SearchPerformer []*Performer "json:\"searchPerformer\" graphql:\"searchPerformer\"" - SearchScene []*Scene "json:\"searchScene\" graphql:\"searchScene\"" - Version Version "json:\"version\" graphql:\"version\"" + FindPerformer *Performer "json:\"findPerformer\" graphql:\"findPerformer\"" + QueryPerformers QueryPerformersResultType "json:\"queryPerformers\" graphql:\"queryPerformers\"" + FindStudio *Studio "json:\"findStudio\" graphql:\"findStudio\"" + QueryStudios QueryStudiosResultType "json:\"queryStudios\" graphql:\"queryStudios\"" + FindTag *Tag "json:\"findTag\" graphql:\"findTag\"" + QueryTags QueryTagsResultType "json:\"queryTags\" graphql:\"queryTags\"" + FindTagCategory *TagCategory "json:\"findTagCategory\" graphql:\"findTagCategory\"" + QueryTagCategories QueryTagCategoriesResultType "json:\"queryTagCategories\" graphql:\"queryTagCategories\"" + FindScene *Scene "json:\"findScene\" graphql:\"findScene\"" + FindSceneByFingerprint []*Scene "json:\"findSceneByFingerprint\" graphql:\"findSceneByFingerprint\"" + FindScenesByFingerprints []*Scene "json:\"findScenesByFingerprints\" graphql:\"findScenesByFingerprints\"" + QueryScenes QueryScenesResultType "json:\"queryScenes\" graphql:\"queryScenes\"" + FindEdit *Edit "json:\"findEdit\" graphql:\"findEdit\"" + QueryEdits QueryEditsResultType "json:\"queryEdits\" graphql:\"queryEdits\"" + FindUser *User "json:\"findUser\" graphql:\"findUser\"" + QueryUsers QueryUsersResultType "json:\"queryUsers\" graphql:\"queryUsers\"" + Me *User "json:\"me\" graphql:\"me\"" + SearchPerformer []*Performer "json:\"searchPerformer\" graphql:\"searchPerformer\"" + SearchScene []*Scene "json:\"searchScene\" graphql:\"searchScene\"" + Version Version "json:\"version\" graphql:\"version\"" } type Mutation struct { - SceneCreate *Scene "json:\"sceneCreate\" graphql:\"sceneCreate\"" - SceneUpdate *Scene "json:\"sceneUpdate\" graphql:\"sceneUpdate\"" - SceneDestroy bool "json:\"sceneDestroy\" graphql:\"sceneDestroy\"" - PerformerCreate *Performer "json:\"performerCreate\" graphql:\"performerCreate\"" - PerformerUpdate *Performer "json:\"performerUpdate\" graphql:\"performerUpdate\"" - PerformerDestroy bool "json:\"performerDestroy\" graphql:\"performerDestroy\"" - StudioCreate *Studio "json:\"studioCreate\" graphql:\"studioCreate\"" - StudioUpdate *Studio "json:\"studioUpdate\" graphql:\"studioUpdate\"" - StudioDestroy bool "json:\"studioDestroy\" graphql:\"studioDestroy\"" - TagCreate *Tag "json:\"tagCreate\" graphql:\"tagCreate\"" - TagUpdate *Tag "json:\"tagUpdate\" graphql:\"tagUpdate\"" - TagDestroy bool "json:\"tagDestroy\" graphql:\"tagDestroy\"" - UserCreate *User "json:\"userCreate\" graphql:\"userCreate\"" - UserUpdate *User "json:\"userUpdate\" graphql:\"userUpdate\"" - UserDestroy bool "json:\"userDestroy\" graphql:\"userDestroy\"" - ImageCreate *Image "json:\"imageCreate\" graphql:\"imageCreate\"" - ImageUpdate *Image "json:\"imageUpdate\" graphql:\"imageUpdate\"" - ImageDestroy bool "json:\"imageDestroy\" graphql:\"imageDestroy\"" - RegenerateAPIKey string "json:\"regenerateAPIKey\" graphql:\"regenerateAPIKey\"" - ChangePassword bool "json:\"changePassword\" graphql:\"changePassword\"" - SceneEdit Edit "json:\"sceneEdit\" graphql:\"sceneEdit\"" - PerformerEdit Edit "json:\"performerEdit\" graphql:\"performerEdit\"" - StudioEdit Edit "json:\"studioEdit\" graphql:\"studioEdit\"" - TagEdit Edit "json:\"tagEdit\" graphql:\"tagEdit\"" - EditVote Edit "json:\"editVote\" graphql:\"editVote\"" - EditComment Edit "json:\"editComment\" graphql:\"editComment\"" - ApplyEdit Edit "json:\"applyEdit\" graphql:\"applyEdit\"" - CancelEdit Edit "json:\"cancelEdit\" graphql:\"cancelEdit\"" - SubmitFingerprint bool "json:\"submitFingerprint\" graphql:\"submitFingerprint\"" + SceneCreate *Scene "json:\"sceneCreate\" graphql:\"sceneCreate\"" + SceneUpdate *Scene "json:\"sceneUpdate\" graphql:\"sceneUpdate\"" + SceneDestroy bool "json:\"sceneDestroy\" graphql:\"sceneDestroy\"" + PerformerCreate *Performer "json:\"performerCreate\" graphql:\"performerCreate\"" + PerformerUpdate *Performer "json:\"performerUpdate\" graphql:\"performerUpdate\"" + PerformerDestroy bool "json:\"performerDestroy\" graphql:\"performerDestroy\"" + StudioCreate *Studio "json:\"studioCreate\" graphql:\"studioCreate\"" + StudioUpdate *Studio "json:\"studioUpdate\" graphql:\"studioUpdate\"" + StudioDestroy bool "json:\"studioDestroy\" graphql:\"studioDestroy\"" + TagCreate *Tag "json:\"tagCreate\" graphql:\"tagCreate\"" + TagUpdate *Tag "json:\"tagUpdate\" graphql:\"tagUpdate\"" + TagDestroy bool "json:\"tagDestroy\" graphql:\"tagDestroy\"" + UserCreate *User "json:\"userCreate\" graphql:\"userCreate\"" + UserUpdate *User "json:\"userUpdate\" graphql:\"userUpdate\"" + UserDestroy bool "json:\"userDestroy\" graphql:\"userDestroy\"" + ImageCreate *Image "json:\"imageCreate\" graphql:\"imageCreate\"" + ImageDestroy bool "json:\"imageDestroy\" graphql:\"imageDestroy\"" + NewUser *string "json:\"newUser\" graphql:\"newUser\"" + ActivateNewUser *User "json:\"activateNewUser\" graphql:\"activateNewUser\"" + GenerateInviteCode string "json:\"generateInviteCode\" graphql:\"generateInviteCode\"" + RescindInviteCode bool "json:\"rescindInviteCode\" graphql:\"rescindInviteCode\"" + GrantInvite int "json:\"grantInvite\" graphql:\"grantInvite\"" + RevokeInvite int "json:\"revokeInvite\" graphql:\"revokeInvite\"" + TagCategoryCreate *TagCategory "json:\"tagCategoryCreate\" graphql:\"tagCategoryCreate\"" + TagCategoryUpdate *TagCategory "json:\"tagCategoryUpdate\" graphql:\"tagCategoryUpdate\"" + TagCategoryDestroy bool "json:\"tagCategoryDestroy\" graphql:\"tagCategoryDestroy\"" + RegenerateAPIKey string "json:\"regenerateAPIKey\" graphql:\"regenerateAPIKey\"" + ResetPassword bool "json:\"resetPassword\" graphql:\"resetPassword\"" + ChangePassword bool "json:\"changePassword\" graphql:\"changePassword\"" + SceneEdit Edit "json:\"sceneEdit\" graphql:\"sceneEdit\"" + PerformerEdit Edit "json:\"performerEdit\" graphql:\"performerEdit\"" + StudioEdit Edit "json:\"studioEdit\" graphql:\"studioEdit\"" + TagEdit Edit "json:\"tagEdit\" graphql:\"tagEdit\"" + EditVote Edit "json:\"editVote\" graphql:\"editVote\"" + EditComment Edit "json:\"editComment\" graphql:\"editComment\"" + ApplyEdit Edit "json:\"applyEdit\" graphql:\"applyEdit\"" + CancelEdit Edit "json:\"cancelEdit\" graphql:\"cancelEdit\"" + SubmitFingerprint bool "json:\"submitFingerprint\" graphql:\"submitFingerprint\"" } type URLFragment struct { URL string "json:\"url\" graphql:\"url\"" @@ -76,8 +87,8 @@ type URLFragment struct { type ImageFragment struct { ID string "json:\"id\" graphql:\"id\"" URL string "json:\"url\" graphql:\"url\"" - Width *int "json:\"width\" graphql:\"width\"" - Height *int "json:\"height\" graphql:\"height\"" + Width int "json:\"width\" graphql:\"width\"" + Height int "json:\"height\" graphql:\"height\"" } type StudioFragment struct { Name string "json:\"name\" graphql:\"name\"" @@ -189,9 +200,21 @@ fragment SceneFragment on Scene { ... FingerprintFragment } } -fragment URLFragment on URL { +fragment ImageFragment on Image { + id url - type + width + height +} +fragment StudioFragment on Studio { + name + id + urls { + ... URLFragment + } + images { + ... ImageFragment + } } fragment PerformerAppearanceFragment on PerformerAppearance { as @@ -199,6 +222,24 @@ fragment PerformerAppearanceFragment on PerformerAppearance { ... PerformerFragment } } +fragment FuzzyDateFragment on FuzzyDate { + date + accuracy +} +fragment MeasurementsFragment on Measurements { + band_size + cup_size + waist + hip +} +fragment URLFragment on URL { + url + type +} +fragment TagFragment on Tag { + name + id +} fragment PerformerFragment on Performer { id name @@ -232,45 +273,15 @@ fragment PerformerFragment on Performer { ... BodyModificationFragment } } -fragment FuzzyDateFragment on FuzzyDate { - date - accuracy +fragment BodyModificationFragment on BodyModification { + location + description } fragment FingerprintFragment on Fingerprint { algorithm hash duration } -fragment ImageFragment on Image { - id - url - width - height -} -fragment StudioFragment on Studio { - name - id - urls { - ... URLFragment - } - images { - ... ImageFragment - } -} -fragment TagFragment on Tag { - name - id -} -fragment MeasurementsFragment on Measurements { - band_size - cup_size - waist - hip -} -fragment BodyModificationFragment on BodyModification { - location - description -} ` func (c *Client) FindSceneByFingerprint(ctx context.Context, fingerprint FingerprintQueryInput, httpRequestOptions ...client.HTTPRequestOption) (*FindSceneByFingerprint, error) { @@ -291,11 +302,29 @@ const FindScenesByFingerprintsQuery = `query FindScenesByFingerprints ($fingerpr ... SceneFragment } } -fragment PerformerAppearanceFragment on PerformerAppearance { - as - performer { - ... PerformerFragment +fragment BodyModificationFragment on BodyModification { + location + description +} +fragment ImageFragment on Image { + id + url + width + height +} +fragment StudioFragment on Studio { + name + id + urls { + ... URLFragment } + images { + ... ImageFragment + } +} +fragment TagFragment on Tag { + name + id } fragment PerformerFragment on Performer { id @@ -336,11 +365,6 @@ fragment MeasurementsFragment on Measurements { waist hip } -fragment FingerprintFragment on Fingerprint { - algorithm - hash - duration -} fragment SceneFragment on Scene { id title @@ -370,33 +394,20 @@ fragment URLFragment on URL { url type } -fragment ImageFragment on Image { - id - url - width - height -} -fragment TagFragment on Tag { - name - id -} -fragment StudioFragment on Studio { - name - id - urls { - ... URLFragment - } - images { - ... ImageFragment +fragment PerformerAppearanceFragment on PerformerAppearance { + as + performer { + ... PerformerFragment } } fragment FuzzyDateFragment on FuzzyDate { date accuracy } -fragment BodyModificationFragment on BodyModification { - location - description +fragment FingerprintFragment on Fingerprint { + algorithm + hash + duration } ` @@ -418,16 +429,50 @@ const SearchSceneQuery = `query SearchScene ($term: String!) { ... SceneFragment } } +fragment PerformerAppearanceFragment on PerformerAppearance { + as + performer { + ... PerformerFragment + } +} +fragment BodyModificationFragment on BodyModification { + location + description +} +fragment FingerprintFragment on Fingerprint { + algorithm + hash + duration +} +fragment SceneFragment on Scene { + id + title + details + duration + date + urls { + ... URLFragment + } + images { + ... ImageFragment + } + studio { + ... StudioFragment + } + tags { + ... TagFragment + } + performers { + ... PerformerAppearanceFragment + } + fingerprints { + ... FingerprintFragment + } +} fragment URLFragment on URL { url type } -fragment ImageFragment on Image { - id - url - width - height -} fragment TagFragment on Tag { name id @@ -475,30 +520,11 @@ fragment MeasurementsFragment on Measurements { waist hip } -fragment SceneFragment on Scene { +fragment ImageFragment on Image { id - title - details - duration - date - urls { - ... URLFragment - } - images { - ... ImageFragment - } - studio { - ... StudioFragment - } - tags { - ... TagFragment - } - performers { - ... PerformerAppearanceFragment - } - fingerprints { - ... FingerprintFragment - } + url + width + height } fragment StudioFragment on Studio { name @@ -510,21 +536,6 @@ fragment StudioFragment on Studio { ... ImageFragment } } -fragment PerformerAppearanceFragment on PerformerAppearance { - as - performer { - ... PerformerFragment - } -} -fragment BodyModificationFragment on BodyModification { - location - description -} -fragment FingerprintFragment on Fingerprint { - algorithm - hash - duration -} ` func (c *Client) SearchScene(ctx context.Context, term string, httpRequestOptions ...client.HTTPRequestOption) (*SearchScene, error) { diff --git a/pkg/scraper/stashbox/graphql/generated_models.go b/pkg/scraper/stashbox/graphql/generated_models.go index a8715092b..9fa66170f 100644 --- a/pkg/scraper/stashbox/graphql/generated_models.go +++ b/pkg/scraper/stashbox/graphql/generated_models.go @@ -7,6 +7,8 @@ import ( "io" "strconv" "time" + + "github.com/99designs/gqlgen/graphql" ) type EditDetails interface { @@ -17,6 +19,13 @@ type EditTarget interface { IsEditTarget() } +type ActivateNewUserInput struct { + Name string `json:"name"` + Email string `json:"email"` + ActivationKey string `json:"activation_key"` + Password string `json:"password"` +} + type ApplyEditInput struct { ID string `json:"id"` } @@ -58,11 +67,15 @@ type Edit struct { Target EditTarget `json:"target"` TargetType TargetTypeEnum `json:"target_type"` // Objects to merge with the target. Only applicable to merges - MergeSources []EditTarget `json:"merge_sources"` - Operation OperationEnum `json:"operation"` - Details EditDetails `json:"details"` - Comments []*EditComment `json:"comments"` - Votes []*VoteComment `json:"votes"` + MergeSources []EditTarget `json:"merge_sources"` + Operation OperationEnum `json:"operation"` + Details EditDetails `json:"details"` + // Previous state of fields being modified - null if operation is create or delete. + OldDetails EditDetails `json:"old_details"` + // Entity specific options + Options *PerformerEditOptions `json:"options"` + Comments []*EditComment `json:"comments"` + Votes []*VoteComment `json:"votes"` // = Accepted - Rejected VoteCount int `json:"vote_count"` Status VoteStatusEnum `json:"status"` @@ -115,11 +128,6 @@ type EditVoteInput struct { Type VoteTypeEnum `json:"type"` } -type EthnicityCriterionInput struct { - Value *EthnicityEnum `json:"value"` - Modifier CriterionModifier `json:"modifier"` -} - type EyeColorCriterionInput struct { Value *EyeColorEnum `json:"value"` Modifier CriterionModifier `json:"modifier"` @@ -157,6 +165,11 @@ type FuzzyDateInput struct { Accuracy DateAccuracyEnum `json:"accuracy"` } +type GrantInviteInput struct { + UserID string `json:"user_id"` + Amount int `json:"amount"` +} + type HairColorCriterionInput struct { Value *HairColorEnum `json:"value"` Modifier CriterionModifier `json:"modifier"` @@ -170,12 +183,13 @@ type IDCriterionInput struct { type Image struct { ID string `json:"id"` URL string `json:"url"` - Width *int `json:"width"` - Height *int `json:"height"` + Width int `json:"width"` + Height int `json:"height"` } type ImageCreateInput struct { - URL string `json:"url"` + URL *string `json:"url"` + File *graphql.Upload `json:"file"` } type ImageDestroyInput struct { @@ -183,8 +197,8 @@ type ImageDestroyInput struct { } type ImageUpdateInput struct { - ID string `json:"id"` - URL string `json:"url"` + ID string `json:"id"` + URL *string `json:"url"` } type IntCriterionInput struct { @@ -211,6 +225,11 @@ type MultiIDCriterionInput struct { Modifier CriterionModifier `json:"modifier"` } +type NewUserInput struct { + Email string `json:"email"` + InviteKey *string `json:"invite_key"` +} + type Performer struct { ID string `json:"id"` Name string `json:"name"` @@ -234,6 +253,8 @@ type Performer struct { Piercings []*BodyModification `json:"piercings"` Images []*Image `json:"images"` Deleted bool `json:"deleted"` + Edits []*Edit `json:"edits"` + SceneCount int `json:"scene_count"` } func (Performer) IsEditTarget() {} @@ -276,21 +297,25 @@ type PerformerDestroyInput struct { } type PerformerEdit struct { - Name *string `json:"name"` - Disambiguation *string `json:"disambiguation"` - AddedAliases []string `json:"added_aliases"` - RemovedAliases []string `json:"removed_aliases"` - Gender *GenderEnum `json:"gender"` - AddedUrls []*URL `json:"added_urls"` - RemovedUrls []*URL `json:"removed_urls"` - Birthdate *FuzzyDate `json:"birthdate"` - Ethnicity *EthnicityEnum `json:"ethnicity"` - Country *string `json:"country"` - EyeColor *EyeColorEnum `json:"eye_color"` - HairColor *HairColorEnum `json:"hair_color"` + Name *string `json:"name"` + Disambiguation *string `json:"disambiguation"` + AddedAliases []string `json:"added_aliases"` + RemovedAliases []string `json:"removed_aliases"` + Gender *GenderEnum `json:"gender"` + AddedUrls []*URL `json:"added_urls"` + RemovedUrls []*URL `json:"removed_urls"` + Birthdate *string `json:"birthdate"` + BirthdateAccuracy *string `json:"birthdate_accuracy"` + Ethnicity *EthnicityEnum `json:"ethnicity"` + Country *string `json:"country"` + EyeColor *EyeColorEnum `json:"eye_color"` + HairColor *HairColorEnum `json:"hair_color"` // Height in cm Height *int `json:"height"` - Measurements *Measurements `json:"measurements"` + CupSize *string `json:"cup_size"` + BandSize *int `json:"band_size"` + WaistSize *int `json:"waist_size"` + HipSize *int `json:"hip_size"` BreastType *BreastTypeEnum `json:"breast_type"` CareerStartYear *int `json:"career_start_year"` CareerEndYear *int `json:"career_end_year"` @@ -329,6 +354,22 @@ type PerformerEditInput struct { Edit *EditInput `json:"edit"` // Not required for destroy type Details *PerformerEditDetailsInput `json:"details"` + // Controls aliases modification for merges and name modifications + Options *PerformerEditOptionsInput `json:"options"` +} + +type PerformerEditOptions struct { + // Set performer alias on scenes without alias to old name if name is changed + SetModifyAliases bool `json:"set_modify_aliases"` + // Set performer alias on scenes attached to merge sources to old name + SetMergeAliases bool `json:"set_merge_aliases"` +} + +type PerformerEditOptionsInput struct { + // Set performer alias on scenes without alias to old name if name is changed + SetModifyAliases *bool `json:"set_modify_aliases"` + // Set performer alias on scenes attached to merge sources to old name + SetMergeAliases *bool `json:"set_merge_aliases"` } type PerformerFilterType struct { @@ -339,13 +380,13 @@ type PerformerFilterType struct { // Search aliases only - assumes like query unless quoted Alias *string `json:"alias"` Disambiguation *StringCriterionInput `json:"disambiguation"` - Gender *GenderEnum `json:"gender"` + Gender *GenderFilterEnum `json:"gender"` // Filter to search urls - assumes like query unless quoted URL *string `json:"url"` Birthdate *DateCriterionInput `json:"birthdate"` BirthYear *IntCriterionInput `json:"birth_year"` Age *IntCriterionInput `json:"age"` - Ethnicity *EthnicityCriterionInput `json:"ethnicity"` + Ethnicity *EthnicityFilterEnum `json:"ethnicity"` Country *StringCriterionInput `json:"country"` EyeColor *EyeColorCriterionInput `json:"eye_color"` HairColor *HairColorCriterionInput `json:"hair_color"` @@ -410,6 +451,11 @@ type QueryStudiosResultType struct { Studios []*Studio `json:"studios"` } +type QueryTagCategoriesResultType struct { + Count int `json:"count"` + TagCategories []*TagCategory `json:"tag_categories"` +} + type QueryTagsResultType struct { Count int `json:"count"` Tags []*Tag `json:"tags"` @@ -420,6 +466,15 @@ type QueryUsersResultType struct { Users []*User `json:"users"` } +type ResetPasswordInput struct { + Email string `json:"email"` +} + +type RevokeInviteInput struct { + UserID string `json:"user_id"` + Amount int `json:"amount"` +} + type RoleCriterionInput struct { Value []RoleEnum `json:"value"` Modifier CriterionModifier `json:"modifier"` @@ -515,6 +570,8 @@ type SceneFilterType struct { Date *DateCriterionInput `json:"date"` // Filter to only include scenes with this studio Studios *MultiIDCriterionInput `json:"studios"` + // Filter to only include scenes with this studio as primary or parent + ParentStudio *string `json:"parentStudio"` // Filter to only include scenes with these tags Tags *MultiIDCriterionInput `json:"tags"` // Filter to only include scenes with these performers @@ -598,9 +655,12 @@ type StudioEditInput struct { type StudioFilterType struct { // Filter to search name - assumes like query unless quoted Name *string `json:"name"` + // Filter to search studio and parent studio name - assumes like query unless quoted + Names *string `json:"names"` // Filter to search url - assumes like query unless quoted - URL *string `json:"url"` - Parent *IDCriterionInput `json:"parent"` + URL *string `json:"url"` + Parent *IDCriterionInput `json:"parent"` + HasParent *bool `json:"has_parent"` } type StudioUpdateInput struct { @@ -613,20 +673,46 @@ type StudioUpdateInput struct { } type Tag struct { - ID string `json:"id"` - Name string `json:"name"` - Description *string `json:"description"` - Aliases []string `json:"aliases"` - Deleted bool `json:"deleted"` - Edits []*Edit `json:"edits"` + ID string `json:"id"` + Name string `json:"name"` + Description *string `json:"description"` + Aliases []string `json:"aliases"` + Deleted bool `json:"deleted"` + Edits []*Edit `json:"edits"` + Category *TagCategory `json:"category"` } func (Tag) IsEditTarget() {} +type TagCategory struct { + ID string `json:"id"` + Name string `json:"name"` + Group TagGroupEnum `json:"group"` + Description *string `json:"description"` +} + +type TagCategoryCreateInput struct { + Name string `json:"name"` + Group TagGroupEnum `json:"group"` + Description *string `json:"description"` +} + +type TagCategoryDestroyInput struct { + ID string `json:"id"` +} + +type TagCategoryUpdateInput struct { + ID string `json:"id"` + Name *string `json:"name"` + Group *TagGroupEnum `json:"group"` + Description *string `json:"description"` +} + type TagCreateInput struct { Name string `json:"name"` Description *string `json:"description"` Aliases []string `json:"aliases"` + CategoryID *string `json:"category_id"` } type TagDestroyInput struct { @@ -638,6 +724,7 @@ type TagEdit struct { Description *string `json:"description"` AddedAliases []string `json:"added_aliases"` RemovedAliases []string `json:"removed_aliases"` + CategoryID *string `json:"category_id"` } func (TagEdit) IsEditDetails() {} @@ -646,6 +733,7 @@ type TagEditDetailsInput struct { Name *string `json:"name"` Description *string `json:"description"` Aliases []string `json:"aliases"` + CategoryID *string `json:"category_id"` } type TagEditInput struct { @@ -661,6 +749,8 @@ type TagFilterType struct { Names *string `json:"names"` // Filter to search name - assumes like query unless quoted Name *string `json:"name"` + // Filter to category ID + CategoryID *string `json:"category_id"` } type TagUpdateInput struct { @@ -668,6 +758,7 @@ type TagUpdateInput struct { Name *string `json:"name"` Description *string `json:"description"` Aliases []string `json:"aliases"` + CategoryID *string `json:"category_id"` } type URL struct { @@ -695,21 +786,26 @@ type User struct { // Votes on unsuccessful edits UnsuccessfulVotes int `json:"unsuccessful_votes"` // Calls to the API from this user over a configurable time period - APICalls int `json:"api_calls"` + APICalls int `json:"api_calls"` + InvitedBy *User `json:"invited_by"` + InviteTokens *int `json:"invite_tokens"` + ActiveInviteCodes []string `json:"active_invite_codes"` } type UserChangePasswordInput struct { // Password in plain text - ExistingPassword string `json:"existing_password"` - NewPassword string `json:"new_password"` + ExistingPassword *string `json:"existing_password"` + NewPassword string `json:"new_password"` + ResetKey *string `json:"reset_key"` } type UserCreateInput struct { Name string `json:"name"` // Password in plain text - Password string `json:"password"` - Roles []RoleEnum `json:"roles"` - Email string `json:"email"` + Password string `json:"password"` + Roles []RoleEnum `json:"roles"` + Email string `json:"email"` + InvitedByID *string `json:"invited_by_id"` } type UserDestroyInput struct { @@ -735,6 +831,8 @@ type UserFilterType struct { UnsuccessfulVotes *IntCriterionInput `json:"unsuccessful_votes"` // Filter by number of API calls APICalls *IntCriterionInput `json:"api_calls"` + // Filter by user that invited + InvitedBy *string `json:"invited_by"` } type UserUpdateInput struct { @@ -960,6 +1058,61 @@ func (e EthnicityEnum) MarshalGQL(w io.Writer) { fmt.Fprint(w, strconv.Quote(e.String())) } +type EthnicityFilterEnum string + +const ( + EthnicityFilterEnumUnknown EthnicityFilterEnum = "UNKNOWN" + EthnicityFilterEnumCaucasian EthnicityFilterEnum = "CAUCASIAN" + EthnicityFilterEnumBlack EthnicityFilterEnum = "BLACK" + EthnicityFilterEnumAsian EthnicityFilterEnum = "ASIAN" + EthnicityFilterEnumIndian EthnicityFilterEnum = "INDIAN" + EthnicityFilterEnumLatin EthnicityFilterEnum = "LATIN" + EthnicityFilterEnumMiddleEastern EthnicityFilterEnum = "MIDDLE_EASTERN" + EthnicityFilterEnumMixed EthnicityFilterEnum = "MIXED" + EthnicityFilterEnumOther EthnicityFilterEnum = "OTHER" +) + +var AllEthnicityFilterEnum = []EthnicityFilterEnum{ + EthnicityFilterEnumUnknown, + EthnicityFilterEnumCaucasian, + EthnicityFilterEnumBlack, + EthnicityFilterEnumAsian, + EthnicityFilterEnumIndian, + EthnicityFilterEnumLatin, + EthnicityFilterEnumMiddleEastern, + EthnicityFilterEnumMixed, + EthnicityFilterEnumOther, +} + +func (e EthnicityFilterEnum) IsValid() bool { + switch e { + case EthnicityFilterEnumUnknown, EthnicityFilterEnumCaucasian, EthnicityFilterEnumBlack, EthnicityFilterEnumAsian, EthnicityFilterEnumIndian, EthnicityFilterEnumLatin, EthnicityFilterEnumMiddleEastern, EthnicityFilterEnumMixed, EthnicityFilterEnumOther: + return true + } + return false +} + +func (e EthnicityFilterEnum) String() string { + return string(e) +} + +func (e *EthnicityFilterEnum) UnmarshalGQL(v interface{}) error { + str, ok := v.(string) + if !ok { + return fmt.Errorf("enums must be strings") + } + + *e = EthnicityFilterEnum(str) + if !e.IsValid() { + return fmt.Errorf("%s is not a valid EthnicityFilterEnum", str) + } + return nil +} + +func (e EthnicityFilterEnum) MarshalGQL(w io.Writer) { + fmt.Fprint(w, strconv.Quote(e.String())) +} + type EyeColorEnum string const ( @@ -1014,16 +1167,18 @@ type FingerprintAlgorithm string const ( FingerprintAlgorithmMd5 FingerprintAlgorithm = "MD5" FingerprintAlgorithmOshash FingerprintAlgorithm = "OSHASH" + FingerprintAlgorithmPhash FingerprintAlgorithm = "PHASH" ) var AllFingerprintAlgorithm = []FingerprintAlgorithm{ FingerprintAlgorithmMd5, FingerprintAlgorithmOshash, + FingerprintAlgorithmPhash, } func (e FingerprintAlgorithm) IsValid() bool { switch e { - case FingerprintAlgorithmMd5, FingerprintAlgorithmOshash: + case FingerprintAlgorithmMd5, FingerprintAlgorithmOshash, FingerprintAlgorithmPhash: return true } return false @@ -1097,6 +1252,55 @@ func (e GenderEnum) MarshalGQL(w io.Writer) { fmt.Fprint(w, strconv.Quote(e.String())) } +type GenderFilterEnum string + +const ( + GenderFilterEnumUnknown GenderFilterEnum = "UNKNOWN" + GenderFilterEnumMale GenderFilterEnum = "MALE" + GenderFilterEnumFemale GenderFilterEnum = "FEMALE" + GenderFilterEnumTransgenderMale GenderFilterEnum = "TRANSGENDER_MALE" + GenderFilterEnumTransgenderFemale GenderFilterEnum = "TRANSGENDER_FEMALE" + GenderFilterEnumIntersex GenderFilterEnum = "INTERSEX" +) + +var AllGenderFilterEnum = []GenderFilterEnum{ + GenderFilterEnumUnknown, + GenderFilterEnumMale, + GenderFilterEnumFemale, + GenderFilterEnumTransgenderMale, + GenderFilterEnumTransgenderFemale, + GenderFilterEnumIntersex, +} + +func (e GenderFilterEnum) IsValid() bool { + switch e { + case GenderFilterEnumUnknown, GenderFilterEnumMale, GenderFilterEnumFemale, GenderFilterEnumTransgenderMale, GenderFilterEnumTransgenderFemale, GenderFilterEnumIntersex: + return true + } + return false +} + +func (e GenderFilterEnum) String() string { + return string(e) +} + +func (e *GenderFilterEnum) UnmarshalGQL(v interface{}) error { + str, ok := v.(string) + if !ok { + return fmt.Errorf("enums must be strings") + } + + *e = GenderFilterEnum(str) + if !e.IsValid() { + return fmt.Errorf("%s is not a valid GenderFilterEnum", str) + } + return nil +} + +func (e GenderFilterEnum) MarshalGQL(w io.Writer) { + fmt.Fprint(w, strconv.Quote(e.String())) +} + type HairColorEnum string const ( @@ -1205,6 +1409,10 @@ const ( RoleEnumEdit RoleEnum = "EDIT" RoleEnumModify RoleEnum = "MODIFY" RoleEnumAdmin RoleEnum = "ADMIN" + // May generate invites without tokens + RoleEnumInvite RoleEnum = "INVITE" + // May grant and rescind invite tokens and resind invite keys + RoleEnumManageInvites RoleEnum = "MANAGE_INVITES" ) var AllRoleEnum = []RoleEnum{ @@ -1213,11 +1421,13 @@ var AllRoleEnum = []RoleEnum{ RoleEnumEdit, RoleEnumModify, RoleEnumAdmin, + RoleEnumInvite, + RoleEnumManageInvites, } func (e RoleEnum) IsValid() bool { switch e { - case RoleEnumRead, RoleEnumVote, RoleEnumEdit, RoleEnumModify, RoleEnumAdmin: + case RoleEnumRead, RoleEnumVote, RoleEnumEdit, RoleEnumModify, RoleEnumAdmin, RoleEnumInvite, RoleEnumManageInvites: return true } return false @@ -1285,6 +1495,49 @@ func (e SortDirectionEnum) MarshalGQL(w io.Writer) { fmt.Fprint(w, strconv.Quote(e.String())) } +type TagGroupEnum string + +const ( + TagGroupEnumPeople TagGroupEnum = "PEOPLE" + TagGroupEnumScene TagGroupEnum = "SCENE" + TagGroupEnumAction TagGroupEnum = "ACTION" +) + +var AllTagGroupEnum = []TagGroupEnum{ + TagGroupEnumPeople, + TagGroupEnumScene, + TagGroupEnumAction, +} + +func (e TagGroupEnum) IsValid() bool { + switch e { + case TagGroupEnumPeople, TagGroupEnumScene, TagGroupEnumAction: + return true + } + return false +} + +func (e TagGroupEnum) String() string { + return string(e) +} + +func (e *TagGroupEnum) UnmarshalGQL(v interface{}) error { + str, ok := v.(string) + if !ok { + return fmt.Errorf("enums must be strings") + } + + *e = TagGroupEnum(str) + if !e.IsValid() { + return fmt.Errorf("%s is not a valid TagGroupEnum", str) + } + return nil +} + +func (e TagGroupEnum) MarshalGQL(w io.Writer) { + fmt.Fprint(w, strconv.Quote(e.String())) +} + type TargetTypeEnum string const ( diff --git a/pkg/scraper/stashbox/stash_box.go b/pkg/scraper/stashbox/stash_box.go index 1dac41422..20a0fc95a 100644 --- a/pkg/scraper/stashbox/stash_box.go +++ b/pkg/scraper/stashbox/stash_box.go @@ -66,7 +66,7 @@ func (c Client) QueryStashBoxScene(queryStr string) ([]*models.ScrapedScene, err } // FindStashBoxScenesByFingerprints queries stash-box for scenes using every -// scene's MD5 checksum and/or oshash. +// scene's MD5/OSHASH checksum, or PHash func (c Client) FindStashBoxScenesByFingerprints(sceneIDs []string) ([]*models.ScrapedScene, error) { ids, err := utils.StringSliceToIntSlice(sceneIDs) if err != nil { @@ -95,6 +95,10 @@ func (c Client) FindStashBoxScenesByFingerprints(sceneIDs []string) ([]*models.S if scene.OSHash.Valid { fingerprints = append(fingerprints, scene.OSHash.String) } + + if scene.Phash.Valid { + fingerprints = append(fingerprints, utils.PhashToString(scene.Phash.Int64)) + } } return nil @@ -189,6 +193,18 @@ func (c Client) SubmitStashBoxFingerprints(sceneIDs []string, endpoint string) ( Fingerprint: &fingerprint, }) } + + if scene.Phash.Valid && scene.Duration.Valid { + fingerprint := graphql.FingerprintInput{ + Hash: utils.PhashToString(scene.Phash.Int64), + Algorithm: graphql.FingerprintAlgorithmPhash, + Duration: int(scene.Duration.Float64), + } + fingerprints = append(fingerprints, graphql.FingerprintSubmission{ + SceneID: sceneStashID, + Fingerprint: &fingerprint, + }) + } } } diff --git a/pkg/sqlite/scene.go b/pkg/sqlite/scene.go index ffd2f01c5..a727c7bfd 100644 --- a/pkg/sqlite/scene.go +++ b/pkg/sqlite/scene.go @@ -4,9 +4,11 @@ import ( "database/sql" "fmt" "strconv" + "strings" "github.com/jmoiron/sqlx" "github.com/stashapp/stash/pkg/models" + "github.com/stashapp/stash/pkg/utils" ) const sceneTable = "scenes" @@ -61,6 +63,20 @@ SELECT id FROM scenes WHERE scenes.oshash is null ` +var findExactDuplicateQuery = ` +SELECT GROUP_CONCAT(id) as ids +FROM scenes +WHERE phash IS NOT NULL +GROUP BY phash +HAVING COUNT(*) > 1; +` + +var findAllPhashesQuery = ` +SELECT id, phash +FROM scenes +WHERE phash IS NOT NULL +` + type sceneQueryBuilder struct { repository } @@ -824,3 +840,51 @@ func (qb *sceneQueryBuilder) GetStashIDs(sceneID int) ([]*models.StashID, error) func (qb *sceneQueryBuilder) UpdateStashIDs(sceneID int, stashIDs []models.StashID) error { return qb.stashIDRepository().replace(sceneID, stashIDs) } + +func (qb *sceneQueryBuilder) FindDuplicates(distance int) ([][]*models.Scene, error) { + var dupeIds [][]int + if distance == 0 { + var ids []string + if err := qb.tx.Select(&ids, findExactDuplicateQuery); err != nil { + return nil, err + } + + for _, id := range ids { + strIds := strings.Split(id, ",") + var sceneIds []int + for _, strId := range strIds { + if intId, err := strconv.Atoi(strId); err == nil { + sceneIds = append(sceneIds, intId) + } + } + dupeIds = append(dupeIds, sceneIds) + } + } else { + var hashes []*utils.Phash + + if err := qb.queryFunc(findAllPhashesQuery, nil, func(rows *sqlx.Rows) error { + phash := utils.Phash{ + Bucket: -1, + } + if err := rows.StructScan(&phash); err != nil { + return err + } + + hashes = append(hashes, &phash) + return nil + }); err != nil { + return nil, err + } + + dupeIds = utils.FindDuplicates(hashes, distance) + } + + var duplicates [][]*models.Scene + for _, sceneIds := range dupeIds { + if scenes, err := qb.FindMany(sceneIds); err == nil { + duplicates = append(duplicates, scenes) + } + } + + return duplicates, nil +} diff --git a/pkg/utils/phash.go b/pkg/utils/phash.go new file mode 100644 index 000000000..f5e1f2cd9 --- /dev/null +++ b/pkg/utils/phash.go @@ -0,0 +1,57 @@ +package utils + +import ( + "strconv" + + "github.com/corona10/goimagehash" +) + +type Phash struct { + SceneID int `db:"id"` + Hash int64 `db:"phash"` + Neighbors []int + Bucket int +} + +func FindDuplicates(hashes []*Phash, distance int) [][]int { + for i, scene := range hashes { + sceneHash := goimagehash.NewImageHash(uint64(scene.Hash), goimagehash.PHash) + for j, neighbor := range hashes { + if i != j { + neighborHash := goimagehash.NewImageHash(uint64(neighbor.Hash), goimagehash.PHash) + neighborDistance, _ := sceneHash.Distance(neighborHash) + if neighborDistance <= distance { + scene.Neighbors = append(scene.Neighbors, j) + } + } + } + } + + var buckets [][]int + for _, scene := range hashes { + if len(scene.Neighbors) > 0 && scene.Bucket == -1 { + bucket := len(buckets) + scenes := []int{scene.SceneID} + scene.Bucket = bucket + findNeighbors(bucket, scene.Neighbors, hashes, &scenes) + buckets = append(buckets, scenes) + } + } + + return buckets +} + +func findNeighbors(bucket int, neighbors []int, hashes []*Phash, scenes *[]int) { + for _, id := range neighbors { + hash := hashes[id] + if hash.Bucket == -1 { + hash.Bucket = bucket + *scenes = append(*scenes, hash.SceneID) + findNeighbors(bucket, hash.Neighbors, hashes, scenes) + } + } +} + +func PhashToString(phash int64) string { + return strconv.FormatUint(uint64(phash), 16) +} diff --git a/ui/v2.5/src/components/Changelog/versions/v070.md b/ui/v2.5/src/components/Changelog/versions/v070.md index 92eccf82a..9dbfdf5ab 100644 --- a/ui/v2.5/src/components/Changelog/versions/v070.md +++ b/ui/v2.5/src/components/Changelog/versions/v070.md @@ -1,4 +1,5 @@ ### ✨ New Features +* Added [perceptual dupe checker](/settings?tab=duplicates). * Support access to system without logging in via API key. * Added scene queue. diff --git a/ui/v2.5/src/components/Help/Manual.tsx b/ui/v2.5/src/components/Help/Manual.tsx index c53405f61..89ed6637b 100644 --- a/ui/v2.5/src/components/Help/Manual.tsx +++ b/ui/v2.5/src/components/Help/Manual.tsx @@ -14,6 +14,7 @@ import Contributing from "src/docs/en/Contributing.md"; import SceneFilenameParser from "src/docs/en/SceneFilenameParser.md"; import KeyboardShortcuts from "src/docs/en/KeyboardShortcuts.md"; import Help from "src/docs/en/Help.md"; +import Deduplication from "src/docs/en/Deduplication.md"; import { MarkdownPage } from "../Shared/MarkdownPage"; interface IManualProps { @@ -86,6 +87,11 @@ export const Manual: React.FC = ({ title: "Scene Tagger", content: Tagger, }, + { + key: "Deduplication.md", + title: "Dupe Checker", + content: Deduplication, + }, { key: "KeyboardShortcuts.md", title: "Keyboard Shortcuts", diff --git a/ui/v2.5/src/components/List/styles.scss b/ui/v2.5/src/components/List/styles.scss index 8ea230bc4..6cdea51aa 100644 --- a/ui/v2.5/src/components/List/styles.scss +++ b/ui/v2.5/src/components/List/styles.scss @@ -6,6 +6,14 @@ padding-left: 15px; padding-right: 15px; transition: none; + + &:first-child { + border-left: none; + } + + &:last-child { + border-right: none; + } } } diff --git a/ui/v2.5/src/components/Scenes/SceneDetails/SceneFileInfoPanel.tsx b/ui/v2.5/src/components/Scenes/SceneDetails/SceneFileInfoPanel.tsx index 6a41e67ed..343cd77a8 100644 --- a/ui/v2.5/src/components/Scenes/SceneDetails/SceneFileInfoPanel.tsx +++ b/ui/v2.5/src/components/Scenes/SceneDetails/SceneFileInfoPanel.tsx @@ -219,10 +219,24 @@ export const SceneFileInfoPanel: React.FC = ( ); } + function renderPhash() { + if (props.scene.phash) { + return ( +
    + + PHash + + +
    + ); + } + } + return (
    {renderOSHash()} {renderChecksum()} + {renderPhash()} {renderPath()} {renderStream()} {renderFileSize()} diff --git a/ui/v2.5/src/components/Scenes/SceneGenerateDialog.tsx b/ui/v2.5/src/components/Scenes/SceneGenerateDialog.tsx index abf950e9e..72a1601ee 100644 --- a/ui/v2.5/src/components/Scenes/SceneGenerateDialog.tsx +++ b/ui/v2.5/src/components/Scenes/SceneGenerateDialog.tsx @@ -19,6 +19,7 @@ export const SceneGenerateDialog: React.FC = ( const { data, error, loading } = useConfiguration(); const [sprites, setSprites] = useState(true); + const [phashes, setPhashes] = useState(true); const [previews, setPreviews] = useState(true); const [markers, setMarkers] = useState(true); const [transcodes, setTranscodes] = useState(false); @@ -60,6 +61,7 @@ export const SceneGenerateDialog: React.FC = ( try { await mutateMetadataGenerate({ sprites, + phashes, previews, imagePreviews: previews && imagePreviews, markers, @@ -243,6 +245,12 @@ export const SceneGenerateDialog: React.FC = ( label="Transcodes (MP4 conversions of unsupported video formats)" onChange={() => setTranscodes(!transcodes)} /> + setPhashes(!phashes)} + />
    { const location = useLocation(); @@ -45,6 +46,9 @@ export const Settings: React.FC = () => { Logs + + Dupe Checker + About @@ -71,6 +75,9 @@ export const Settings: React.FC = () => { + + + diff --git a/ui/v2.5/src/components/Settings/SettingsDuplicatePanel.tsx b/ui/v2.5/src/components/Settings/SettingsDuplicatePanel.tsx new file mode 100644 index 000000000..53916033a --- /dev/null +++ b/ui/v2.5/src/components/Settings/SettingsDuplicatePanel.tsx @@ -0,0 +1,270 @@ +import React, { useState } from "react"; +import { Button, Col, Form, Row, Table } from "react-bootstrap"; +import { Link, useHistory } from "react-router-dom"; +import { FormattedNumber } from "react-intl"; +import querystring from "query-string"; + +import * as GQL from "src/core/generated-graphql"; +import { + LoadingIndicator, + ErrorMessage, + HoverPopover, +} from "src/components/Shared"; +import { Pagination } from "src/components/List/Pagination"; +import { TextUtils } from "src/utils"; +import { DeleteScenesDialog } from "src/components/Scenes/DeleteScenesDialog"; + +const CLASSNAME = "DuplicateChecker"; + +export const SettingsDuplicatePanel: React.FC = () => { + const history = useHistory(); + const { page, size, distance } = querystring.parse(history.location.search); + const currentPage = Number.parseInt( + Array.isArray(page) ? page[0] : page ?? "1", + 10 + ); + const pageSize = Number.parseInt( + Array.isArray(size) ? size[0] : size ?? "20", + 10 + ); + const hashDistance = Number.parseInt( + Array.isArray(distance) ? distance[0] : distance ?? "0", + 10 + ); + const [isMultiDelete, setIsMultiDelete] = useState(false); + const [checkedScenes, setCheckedScenes] = useState>( + {} + ); + const { data, loading, refetch } = GQL.useFindDuplicateScenesQuery({ + fetchPolicy: "no-cache", + variables: { distance: hashDistance }, + }); + const [deletingScene, setDeletingScene] = useState< + GQL.SlimSceneDataFragment[] | null + >(null); + + if (loading) return ; + if (!data) return ; + + const scenes = data?.findDuplicateScenes ?? []; + const filteredScenes = scenes.slice( + (currentPage - 1) * pageSize, + currentPage * pageSize + ); + const checkCount = Object.keys(checkedScenes).filter( + (id) => checkedScenes[id] + ).length; + + const setQuery = (q: Record) => { + history.push({ + search: querystring.stringify({ + ...querystring.parse(history.location.search), + ...q, + }), + }); + }; + + function onDeleteDialogClosed(deleted: boolean) { + setDeletingScene(null); + if (deleted) { + refetch(); + if (isMultiDelete) setCheckedScenes({}); + } + } + + const handleCheck = (checked: boolean, sceneID: string) => { + setCheckedScenes({ ...checkedScenes, [sceneID]: checked }); + }; + + const handleDeleteChecked = () => { + setDeletingScene(scenes.flat().filter((s) => checkedScenes[s.id])); + setIsMultiDelete(true); + }; + + const handleDeleteScene = (scene: GQL.SlimSceneDataFragment) => { + setDeletingScene([scene]); + setIsMultiDelete(false); + }; + + const renderFilesize = (filesize: string | null | undefined) => { + const { size: parsedSize, unit } = TextUtils.fileSize( + Number.parseInt(filesize ?? "0", 10) + ); + return ( + + ); + }; + + return ( +
    + {deletingScene && ( + + )} +

    Duplicate Scenes

    + + + Search Accuracy + + + setQuery({ + distance: + e.currentTarget.value === "0" + ? undefined + : e.currentTarget.value, + page: undefined, + }) + } + defaultValue={distance ?? 0} + className="ml-4" + > + + + + + + + + + Levels below “Exact” can take longer to calculate. False + positives might also be returned on lower accuracy levels. + + +
    +
    + {scenes.length} sets of duplicates found. +
    + {checkCount > 0 && ( + + )} + + setQuery({ page: newPage === 1 ? undefined : newPage }) + } + /> + + setQuery({ + size: + e.currentTarget.value === "20" + ? undefined + : e.currentTarget.value, + }) + } + > + + + + + + +
    + + + + + + + + + + + + + + + + + + + + + + + + + + + {filteredScenes.map((group) => + group.map((scene, i) => ( + + + + + + + + + + + + )) + )} + +
    TitleDurationFilesizeResolutionBitrateCodecDelete
    + + handleCheck(e.currentTarget.checked, scene.id) + } + /> + + + } + placement="right" + > + + + + + {scene.title ?? TextUtils.fileNameFromPath(scene.path)} + + + {scene.file.duration && + TextUtils.secondsToTimestamp(scene.file.duration)} + {renderFilesize(scene.file.size)}{`${scene.file.width}x${scene.file.height}`} + +  mbps + {scene.file.video_codec} + +
    + {scenes.length === 0 && ( +

    + No duplicates found. Make sure the phash task has been run. +

    + )} +
    + ); +}; diff --git a/ui/v2.5/src/components/Settings/SettingsTasksPanel/GenerateButton.tsx b/ui/v2.5/src/components/Settings/SettingsTasksPanel/GenerateButton.tsx index 77ced445b..69d8f46cd 100644 --- a/ui/v2.5/src/components/Settings/SettingsTasksPanel/GenerateButton.tsx +++ b/ui/v2.5/src/components/Settings/SettingsTasksPanel/GenerateButton.tsx @@ -6,6 +6,7 @@ import { useToast } from "src/hooks"; export const GenerateButton: React.FC = () => { const Toast = useToast(); const [sprites, setSprites] = useState(true); + const [phashes, setPhashes] = useState(true); const [previews, setPreviews] = useState(true); const [markers, setMarkers] = useState(true); const [transcodes, setTranscodes] = useState(false); @@ -15,6 +16,7 @@ export const GenerateButton: React.FC = () => { try { await mutateMetadataGenerate({ sprites, + phashes, previews, imagePreviews: previews && imagePreviews, markers, @@ -64,6 +66,12 @@ export const GenerateButton: React.FC = () => { label="Transcodes (MP4 conversions of unsupported video formats)" onChange={() => setTranscodes(!transcodes)} /> + setPhashes(!phashes)} + />
    {getDurationStatus(scene, stashScene.file?.duration)} - {getFingerprintStatus( - scene, - stashScene.checksum ?? stashScene.oshash ?? undefined - )} + {getFingerprintStatus(scene, stashScene)}
    diff --git a/ui/v2.5/src/docs/en/Deduplication.md b/ui/v2.5/src/docs/en/Deduplication.md new file mode 100644 index 000000000..e44d535e6 --- /dev/null +++ b/ui/v2.5/src/docs/en/Deduplication.md @@ -0,0 +1,9 @@ +# Dupe Checker + +[The dupe checker](/settings?tab=duplicates) searches your collection for scenes that are perceptually similar. This means that the files don't need to be identical, and will be identified even with different bitrates, resolutions, and intros/outros. + +To achieve this stash needs to generate what's called a phash, or perceptual hash. Similar to sprite generation stash will generate a set of 25 images from fixed points in the scene. These images will be stitched together, and then hashed using the phash algorithm. The phash can then be used to find scenes that are the same or similar to others in the database. Phash generation can be run during scan, or as a separate task. Note that generation can take a while due to the work involved with extracting screenshots. + +The dupe checker can be run with four different levels of accuracy. `Exact` looks for scenes that have exactly the same phash. This is a fast and accurate operation that should not yield any false positives except in very rare cases. The other accuracy levels look for duplicate files within a set distance of each other. This means the scenes don't have exactly the same phash, but are very similar. `High` and `Medium` should still yield very good results with few or no false positives. `Low` is likely to produce some false positives, but might still be useful for finding dupes. + +Note that to generate a phash stash requires an uncorrupted file. If any errors are encountered during sprite generation the phash will not be generated. This is to prevent false positives. diff --git a/vendor/github.com/corona10/goimagehash/.gitignore b/vendor/github.com/corona10/goimagehash/.gitignore new file mode 100644 index 000000000..a1338d685 --- /dev/null +++ b/vendor/github.com/corona10/goimagehash/.gitignore @@ -0,0 +1,14 @@ +# Binaries for programs and plugins +*.exe +*.dll +*.so +*.dylib + +# Test binary, build with `go test -c` +*.test + +# Output of the go coverage tool, specifically when used with LiteIDE +*.out + +# Project-local glide cache, RE: https://github.com/Masterminds/glide/issues/736 +.glide/ diff --git a/vendor/github.com/corona10/goimagehash/AUTHORS.md b/vendor/github.com/corona10/goimagehash/AUTHORS.md new file mode 100644 index 000000000..832d2331f --- /dev/null +++ b/vendor/github.com/corona10/goimagehash/AUTHORS.md @@ -0,0 +1,5 @@ +## AUTHORS +- [Dominik Honnef](https://github.com/dominikh) dominik@honnef.co +- [Dong-hee Na](https://github.com/corona10/) donghee.na92@gmail.com +- [Gustavo Brunoro](https://github.com/brunoro/) git@hitnail.net +- [Alex Higashino](https://github.com/TokyoWolFrog/) TokyoWolFrog@mayxyou.com \ No newline at end of file diff --git a/vendor/github.com/corona10/goimagehash/CODEOWNERS b/vendor/github.com/corona10/goimagehash/CODEOWNERS new file mode 100644 index 000000000..4da23f706 --- /dev/null +++ b/vendor/github.com/corona10/goimagehash/CODEOWNERS @@ -0,0 +1 @@ +*.go @corona10 diff --git a/vendor/github.com/corona10/goimagehash/Gopkg.lock b/vendor/github.com/corona10/goimagehash/Gopkg.lock new file mode 100644 index 000000000..c51994eb6 --- /dev/null +++ b/vendor/github.com/corona10/goimagehash/Gopkg.lock @@ -0,0 +1,17 @@ +# This file is autogenerated, do not edit; changes may be undone by the next 'dep ensure'. + + +[[projects]] + branch = "master" + digest = "1:34534b73e925d20cc72cf202f8b482fdcbe3a1b113e19375f31aadabd0f0f97d" + name = "github.com/nfnt/resize" + packages = ["."] + pruneopts = "UT" + revision = "83c6a9932646f83e3267f353373d47347b6036b2" + +[solve-meta] + analyzer-name = "dep" + analyzer-version = 1 + input-imports = ["github.com/nfnt/resize"] + solver-name = "gps-cdcl" + solver-version = 1 diff --git a/vendor/github.com/corona10/goimagehash/Gopkg.toml b/vendor/github.com/corona10/goimagehash/Gopkg.toml new file mode 100644 index 000000000..7d0d6d5e7 --- /dev/null +++ b/vendor/github.com/corona10/goimagehash/Gopkg.toml @@ -0,0 +1,34 @@ +# Gopkg.toml example +# +# Refer to https://golang.github.io/dep/docs/Gopkg.toml.html +# for detailed Gopkg.toml documentation. +# +# required = ["github.com/user/thing/cmd/thing"] +# ignored = ["github.com/user/project/pkgX", "bitbucket.org/user/project/pkgA/pkgY"] +# +# [[constraint]] +# name = "github.com/user/project" +# version = "1.0.0" +# +# [[constraint]] +# name = "github.com/user/project2" +# branch = "dev" +# source = "github.com/myfork/project2" +# +# [[override]] +# name = "github.com/x/y" +# version = "2.4.0" +# +# [prune] +# non-go = false +# go-tests = true +# unused-packages = true + + +[[constraint]] + branch = "master" + name = "github.com/nfnt/resize" + +[prune] + go-tests = true + unused-packages = true diff --git a/vendor/github.com/corona10/goimagehash/LICENSE b/vendor/github.com/corona10/goimagehash/LICENSE new file mode 100644 index 000000000..37b5a9609 --- /dev/null +++ b/vendor/github.com/corona10/goimagehash/LICENSE @@ -0,0 +1,25 @@ +BSD 2-Clause License + +Copyright (c) 2017, Dong-hee Na +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + +* Redistributions of source code must retain the above copyright notice, this + list of conditions and the following disclaimer. + +* Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE +FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, +OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/vendor/github.com/corona10/goimagehash/README.md b/vendor/github.com/corona10/goimagehash/README.md new file mode 100644 index 000000000..07bab1435 --- /dev/null +++ b/vendor/github.com/corona10/goimagehash/README.md @@ -0,0 +1,93 @@ +![GitHub Action](https://github.com/corona10/goimagehash/workflows/goimagehash%20workflow/badge.svg) +[![GoDoc](https://godoc.org/github.com/corona10/goimagehash?status.svg)](https://godoc.org/github.com/corona10/goimagehash) +[![Go Report Card](https://goreportcard.com/badge/github.com/corona10/goimagehash)](https://goreportcard.com/report/github.com/corona10/goimagehash) + +# goimagehash +> Inspired by [imagehash](https://github.com/JohannesBuchner/imagehash) + +A image hashing library written in Go. ImageHash supports: +* [Average hashing](http://www.hackerfactor.com/blog/index.php?/archives/432-Looks-Like-It.html) +* [Difference hashing](http://www.hackerfactor.com/blog/index.php?/archives/529-Kind-of-Like-That.html) +* [Perception hashing](http://www.hackerfactor.com/blog/index.php?/archives/432-Looks-Like-It.html) +* [Wavelet hashing](https://fullstackml.com/wavelet-image-hash-in-python-3504fdd282b5) [TODO] + +## Installation +``` +go get github.com/corona10/goimagehash +``` +## Special thanks to +* [Haeun Kim](https://github.com/haeungun/) + +## Usage + +``` Go +func main() { + file1, _ := os.Open("sample1.jpg") + file2, _ := os.Open("sample2.jpg") + defer file1.Close() + defer file2.Close() + + img1, _ := jpeg.Decode(file1) + img2, _ := jpeg.Decode(file2) + hash1, _ := goimagehash.AverageHash(img1) + hash2, _ := goimagehash.AverageHash(img2) + distance, _ := hash1.Distance(hash2) + fmt.Printf("Distance between images: %v\n", distance) + + hash1, _ = goimagehash.DifferenceHash(img1) + hash2, _ = goimagehash.DifferenceHash(img2) + distance, _ = hash1.Distance(hash2) + fmt.Printf("Distance between images: %v\n", distance) + width, height := 8, 8 + hash3, _ = goimagehash.ExtAverageHash(img1, width, height) + hash4, _ = goimagehash.ExtAverageHash(img2, width, height) + distance, _ = hash3.Distance(hash4) + fmt.Printf("Distance between images: %v\n", distance) + fmt.Printf("hash3 bit size: %v\n", hash3.Bits()) + fmt.Printf("hash4 bit size: %v\n", hash4.Bits()) + + var b bytes.Buffer + foo := bufio.NewWriter(&b) + _ = hash4.Dump(foo) + foo.Flush() + bar := bufio.NewReader(&b) + hash5, _ := goimagehash.LoadExtImageHash(bar) +} +``` + +## Release Note +### v1.0.3 +- Add workflow for GithubAction +- Fix typo on the GoDoc for LoadImageHash + +### v1.0.2 +- go.mod is now used for install goimagehash + +### v1.0.1 +- Perception/ExtPerception hash creation times are reduced + +### v1.0.0 +**IMPORTANT** +goimagehash v1.0.0 does not have compatible with the before version for future features + +- More flexible extended hash APIs are provided ([ExtAverageHash](https://godoc.org/github.com/corona10/goimagehash#ExtAverageHash), [ExtPerceptionHash](https://godoc.org/github.com/corona10/goimagehash#ExtPerceptionHash), [ExtDifferenceHash](https://godoc.org/github.com/corona10/goimagehash#ExtDifferenceHash)) +- New serialization APIs are provided([ImageHash.Dump](https://godoc.org/github.com/corona10/goimagehash#ImageHash.Dump), [ExtImageHash.Dump](https://godoc.org/github.com/corona10/goimagehash#ExtImageHash.Dump)) +- [ExtImageHashFromString](https://godoc.org/github.com/corona10/goimagehash#ExtImageHashFromString), [ImageHashFromString](https://godoc.org/github.com/corona10/goimagehash#ImageHashFromString) is deprecated and will be removed +- New deserialization APIs are provided([LoadImageHash](https://godoc.org/github.com/corona10/goimagehash#LoadImageHash), [LoadExtImageHash](https://godoc.org/github.com/corona10/goimagehash#LoadExtImageHash)) +- Bits APIs are provided to measure actual bit size of hash + +### v0.3.0 +- Support DifferenceHashExtend. +- Support AverageHashExtend. +- Support PerceptionHashExtend by @TokyoWolFrog. + +### v0.2.0 +- Perception Hash is updated. +- Fix a critical bug of finding median value. + +### v0.1.0 +- Support Average hashing +- Support Difference hashing +- Support Perception hashing +- Use bits.OnesCount64 for computing Hamming distance by @dominikh +- Support hex serialization methods to ImageHash by @brunoro diff --git a/vendor/github.com/corona10/goimagehash/doc.go b/vendor/github.com/corona10/goimagehash/doc.go new file mode 100644 index 000000000..39e655de0 --- /dev/null +++ b/vendor/github.com/corona10/goimagehash/doc.go @@ -0,0 +1,5 @@ +// Copyright 2017 The goimagehash Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +package goimagehash diff --git a/vendor/github.com/corona10/goimagehash/etcs/doc.go b/vendor/github.com/corona10/goimagehash/etcs/doc.go new file mode 100644 index 000000000..0c1485dbc --- /dev/null +++ b/vendor/github.com/corona10/goimagehash/etcs/doc.go @@ -0,0 +1,5 @@ +// Copyright 2017 The goimagehash Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +package etcs diff --git a/vendor/github.com/corona10/goimagehash/etcs/utils.go b/vendor/github.com/corona10/goimagehash/etcs/utils.go new file mode 100644 index 000000000..795c75fa6 --- /dev/null +++ b/vendor/github.com/corona10/goimagehash/etcs/utils.go @@ -0,0 +1,61 @@ +// Copyright 2017 The goimagehash Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +package etcs + +// MeanOfPixels function returns a mean of pixels. +func MeanOfPixels(pixels []float64) float64 { + m := 0.0 + lens := len(pixels) + if lens == 0 { + return 0 + } + + for _, p := range pixels { + m += p + } + + return m / float64(lens) +} + +// MedianOfPixels function returns a median value of pixels. +// It uses quick selection algorithm. +func MedianOfPixels(pixels []float64) float64 { + tmp := make([]float64, len(pixels)) + copy(tmp, pixels) + l := len(tmp) + pos := l / 2 + v := quickSelectMedian(tmp, 0, l-1, pos) + return v +} + +func quickSelectMedian(sequence []float64, low int, hi int, k int) float64 { + if low == hi { + return sequence[k] + } + + for low < hi { + pivot := low/2 + hi/2 + pivotValue := sequence[pivot] + storeIdx := low + sequence[pivot], sequence[hi] = sequence[hi], sequence[pivot] + for i := low; i < hi; i++ { + if sequence[i] < pivotValue { + sequence[storeIdx], sequence[i] = sequence[i], sequence[storeIdx] + storeIdx++ + } + } + sequence[hi], sequence[storeIdx] = sequence[storeIdx], sequence[hi] + if k <= storeIdx { + hi = storeIdx + } else { + low = storeIdx + 1 + } + } + + if len(sequence)%2 == 0 { + return sequence[k-1]/2 + sequence[k]/2 + } + return sequence[k] +} diff --git a/vendor/github.com/corona10/goimagehash/go.mod b/vendor/github.com/corona10/goimagehash/go.mod new file mode 100644 index 000000000..681fdc2bf --- /dev/null +++ b/vendor/github.com/corona10/goimagehash/go.mod @@ -0,0 +1,3 @@ +module github.com/corona10/goimagehash + +require github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646 diff --git a/vendor/github.com/corona10/goimagehash/go.sum b/vendor/github.com/corona10/goimagehash/go.sum new file mode 100644 index 000000000..96adbed66 --- /dev/null +++ b/vendor/github.com/corona10/goimagehash/go.sum @@ -0,0 +1,2 @@ +github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646 h1:zYyBkD/k9seD2A7fsi6Oo2LfFZAehjjQMERAvZLEDnQ= +github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646/go.mod h1:jpp1/29i3P1S/RLdc7JQKbRpFeM1dOBd8T9ki5s+AY8= diff --git a/vendor/github.com/corona10/goimagehash/hashcompute.go b/vendor/github.com/corona10/goimagehash/hashcompute.go new file mode 100644 index 000000000..9b1fcbfe3 --- /dev/null +++ b/vendor/github.com/corona10/goimagehash/hashcompute.go @@ -0,0 +1,183 @@ +// Copyright 2017 The goimagehash Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +package goimagehash + +import ( + "errors" + "image" + + "github.com/corona10/goimagehash/etcs" + "github.com/corona10/goimagehash/transforms" + "github.com/nfnt/resize" +) + +// AverageHash fuction returns a hash computation of average hash. +// Implementation follows +// http://www.hackerfactor.com/blog/index.php?/archives/432-Looks-Like-It.html +func AverageHash(img image.Image) (*ImageHash, error) { + if img == nil { + return nil, errors.New("Image object can not be nil") + } + + // Create 64bits hash. + ahash := NewImageHash(0, AHash) + resized := resize.Resize(8, 8, img, resize.Bilinear) + pixels := transforms.Rgb2Gray(resized) + flattens := transforms.FlattenPixels(pixels, 8, 8) + avg := etcs.MeanOfPixels(flattens) + + for idx, p := range flattens { + if p > avg { + ahash.leftShiftSet(len(flattens) - idx - 1) + } + } + + return ahash, nil +} + +// DifferenceHash function returns a hash computation of difference hash. +// Implementation follows +// http://www.hackerfactor.com/blog/?/archives/529-Kind-of-Like-That.html +func DifferenceHash(img image.Image) (*ImageHash, error) { + if img == nil { + return nil, errors.New("Image object can not be nil") + } + + dhash := NewImageHash(0, DHash) + resized := resize.Resize(9, 8, img, resize.Bilinear) + pixels := transforms.Rgb2Gray(resized) + idx := 0 + for i := 0; i < len(pixels); i++ { + for j := 0; j < len(pixels[i])-1; j++ { + if pixels[i][j] < pixels[i][j+1] { + dhash.leftShiftSet(64 - idx - 1) + } + idx++ + } + } + + return dhash, nil +} + +// PerceptionHash function returns a hash computation of phash. +// Implementation follows +// http://www.hackerfactor.com/blog/index.php?/archives/432-Looks-Like-It.html +func PerceptionHash(img image.Image) (*ImageHash, error) { + if img == nil { + return nil, errors.New("Image object can not be nil") + } + + phash := NewImageHash(0, PHash) + resized := resize.Resize(64, 64, img, resize.Bilinear) + pixels := transforms.Rgb2Gray(resized) + dct := transforms.DCT2D(pixels, 64, 64) + flattens := transforms.FlattenPixels(dct, 8, 8) + median := etcs.MedianOfPixels(flattens) + + for idx, p := range flattens { + if p > median { + phash.leftShiftSet(len(flattens) - idx - 1) + } + } + return phash, nil +} + +// ExtPerceptionHash function returns phash of which the size can be set larger than uint64 +// Some variable name refer to https://github.com/JohannesBuchner/imagehash/blob/master/imagehash/__init__.py +// Support 64bits phash (width=8, height=8) and 256bits phash (width=16, height=16) +// Important: width * height should be the power of 2 +func ExtPerceptionHash(img image.Image, width, height int) (*ExtImageHash, error) { + imgSize := width * height + if img == nil { + return nil, errors.New("Image object can not be nil") + } + if imgSize <= 0 || imgSize&(imgSize-1) != 0 { + return nil, errors.New("width * height should be power of 2") + } + var phash []uint64 + resized := resize.Resize(uint(imgSize), uint(imgSize), img, resize.Bilinear) + pixels := transforms.Rgb2Gray(resized) + dct := transforms.DCT2D(pixels, imgSize, imgSize) + flattens := transforms.FlattenPixels(dct, width, height) + median := etcs.MedianOfPixels(flattens) + + lenOfUnit := 64 + if imgSize%lenOfUnit == 0 { + phash = make([]uint64, imgSize/lenOfUnit) + } else { + phash = make([]uint64, imgSize/lenOfUnit+1) + } + for idx, p := range flattens { + indexOfArray := idx / lenOfUnit + indexOfBit := lenOfUnit - idx%lenOfUnit - 1 + if p > median { + phash[indexOfArray] |= 1 << uint(indexOfBit) + } + } + return NewExtImageHash(phash, PHash, imgSize), nil +} + +// ExtAverageHash function returns ahash of which the size can be set larger than uint64 +// Support 64bits ahash (width=8, height=8) and 256bits ahash (width=16, height=16) +func ExtAverageHash(img image.Image, width, height int) (*ExtImageHash, error) { + if img == nil { + return nil, errors.New("Image object can not be nil") + } + var ahash []uint64 + imgSize := width * height + + resized := resize.Resize(uint(width), uint(height), img, resize.Bilinear) + pixels := transforms.Rgb2Gray(resized) + flattens := transforms.FlattenPixels(pixels, width, height) + avg := etcs.MeanOfPixels(flattens) + + lenOfUnit := 64 + if imgSize%lenOfUnit == 0 { + ahash = make([]uint64, imgSize/lenOfUnit) + } else { + ahash = make([]uint64, imgSize/lenOfUnit+1) + } + for idx, p := range flattens { + indexOfArray := idx / lenOfUnit + indexOfBit := lenOfUnit - idx%lenOfUnit - 1 + if p > avg { + ahash[indexOfArray] |= 1 << uint(indexOfBit) + } + } + return NewExtImageHash(ahash, AHash, imgSize), nil +} + +// ExtDifferenceHash function returns dhash of which the size can be set larger than uint64 +// Support 64bits dhash (width=8, height=8) and 256bits dhash (width=16, height=16) +func ExtDifferenceHash(img image.Image, width, height int) (*ExtImageHash, error) { + if img == nil { + return nil, errors.New("Image object can not be nil") + } + + var dhash []uint64 + imgSize := width * height + + resized := resize.Resize(uint(width)+1, uint(height), img, resize.Bilinear) + pixels := transforms.Rgb2Gray(resized) + + lenOfUnit := 64 + if imgSize%lenOfUnit == 0 { + dhash = make([]uint64, imgSize/lenOfUnit) + } else { + dhash = make([]uint64, imgSize/lenOfUnit+1) + } + idx := 0 + for i := 0; i < len(pixels); i++ { + for j := 0; j < len(pixels[i])-1; j++ { + indexOfArray := idx / lenOfUnit + indexOfBit := lenOfUnit - idx%lenOfUnit - 1 + if pixels[i][j] < pixels[i][j+1] { + dhash[indexOfArray] |= 1 << uint(indexOfBit) + } + idx++ + } + } + return NewExtImageHash(dhash, DHash, imgSize), nil +} diff --git a/vendor/github.com/corona10/goimagehash/imagehash.go b/vendor/github.com/corona10/goimagehash/imagehash.go new file mode 100644 index 000000000..9cc384a4e --- /dev/null +++ b/vendor/github.com/corona10/goimagehash/imagehash.go @@ -0,0 +1,294 @@ +// Copyright 2017 The goimagehash Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +package goimagehash + +import ( + "encoding/binary" + "encoding/gob" + "encoding/hex" + "errors" + "fmt" + "io" +) + +// Kind describes the kinds of hash. +type Kind int + +// ImageHash is a struct of hash computation. +type ImageHash struct { + hash uint64 + kind Kind +} + +// ExtImageHash is a struct of big hash computation. +type ExtImageHash struct { + hash []uint64 + kind Kind + bits int +} + +const ( + // Unknown is a enum value of the unknown hash. + Unknown Kind = iota + // AHash is a enum value of the average hash. + AHash + //PHash is a enum value of the perceptual hash. + PHash + // DHash is a enum value of the difference hash. + DHash + // WHash is a enum value of the wavelet hash. + WHash +) + +// NewImageHash function creates a new image hash. +func NewImageHash(hash uint64, kind Kind) *ImageHash { + return &ImageHash{hash: hash, kind: kind} +} + +// Bits method returns an actual hash bit size +func (h *ImageHash) Bits() int { + return 64 +} + +// Distance method returns a distance between two hashes. +func (h *ImageHash) Distance(other *ImageHash) (int, error) { + if h.GetKind() != other.GetKind() { + return -1, errors.New("Image hashes's kind should be identical") + } + + lhash := h.GetHash() + rhash := other.GetHash() + + hamming := lhash ^ rhash + return popcnt(hamming), nil +} + +// GetHash method returns a 64bits hash value. +func (h *ImageHash) GetHash() uint64 { + return h.hash +} + +// GetKind method returns a kind of image hash. +func (h *ImageHash) GetKind() Kind { + return h.kind +} + +func (h *ImageHash) leftShiftSet(idx int) { + h.hash |= 1 << uint(idx) +} + +const strFmt = "%1s:%016x" + +// Dump method writes a binary serialization into w io.Writer. +func (h *ImageHash) Dump(w io.Writer) error { + type D struct { + Hash uint64 + Kind Kind + } + enc := gob.NewEncoder(w) + err := enc.Encode(D{Hash: h.hash, Kind: h.kind}) + if err != nil { + return err + } + return nil +} + +// LoadImageHash method loads a ImageHash from io.Reader. +func LoadImageHash(b io.Reader) (*ImageHash, error) { + type E struct { + Hash uint64 + Kind Kind + } + var e E + dec := gob.NewDecoder(b) + err := dec.Decode(&e) + if err != nil { + return nil, err + } + return &ImageHash{hash: e.Hash, kind: e.Kind}, nil +} + +// ImageHashFromString returns an image hash from a hex representation +// +// Deprecated: Use goimagehash.LoadImageHash instead. +func ImageHashFromString(s string) (*ImageHash, error) { + var kindStr string + var hash uint64 + _, err := fmt.Sscanf(s, strFmt, &kindStr, &hash) + if err != nil { + return nil, errors.New("Couldn't parse string " + s) + } + + kind := Unknown + switch kindStr { + case "a": + kind = AHash + case "p": + kind = PHash + case "d": + kind = DHash + case "w": + kind = WHash + } + return NewImageHash(hash, kind), nil +} + +// ToString returns a hex representation of the hash +func (h *ImageHash) ToString() string { + kindStr := "" + switch h.kind { + case AHash: + kindStr = "a" + case PHash: + kindStr = "p" + case DHash: + kindStr = "d" + case WHash: + kindStr = "w" + } + return fmt.Sprintf(strFmt, kindStr, h.hash) +} + +// NewExtImageHash function creates a new big hash +func NewExtImageHash(hash []uint64, kind Kind, bits int) *ExtImageHash { + return &ExtImageHash{hash: hash, kind: kind, bits: bits} +} + +// Bits method returns an actual hash bit size +func (h *ExtImageHash) Bits() int { + return h.bits +} + +// Distance method returns a distance between two big hashes +func (h *ExtImageHash) Distance(other *ExtImageHash) (int, error) { + if h.GetKind() != other.GetKind() { + return -1, errors.New("Extended Image hashes's kind should be identical") + } + + if h.Bits() != other.Bits() { + msg := fmt.Sprintf("Extended image hash should has an identical bit size but got %v vs %v", h.Bits(), other.Bits()) + return -1, errors.New(msg) + } + + lHash := h.GetHash() + rHash := other.GetHash() + if len(lHash) != len(rHash) { + return -1, errors.New("Extended Image hashes's size should be identical") + } + + distance := 0 + for idx, lh := range lHash { + rh := rHash[idx] + hamming := lh ^ rh + distance += popcnt(hamming) + } + return distance, nil +} + +// GetHash method returns a big hash value +func (h *ExtImageHash) GetHash() []uint64 { + return h.hash +} + +// GetKind method returns a kind of big hash +func (h *ExtImageHash) GetKind() Kind { + return h.kind +} + +// Dump method writes a binary serialization into w io.Writer. +func (h *ExtImageHash) Dump(w io.Writer) error { + type D struct { + Hash []uint64 + Kind Kind + Bits int + } + enc := gob.NewEncoder(w) + err := enc.Encode(D{Hash: h.hash, Kind: h.kind, Bits: h.bits}) + if err != nil { + return err + } + return nil +} + +// LoadExtImageHash method loads a ExtImageHash from io.Reader. +func LoadExtImageHash(b io.Reader) (*ExtImageHash, error) { + type E struct { + Hash []uint64 + Kind Kind + Bits int + } + var e E + dec := gob.NewDecoder(b) + err := dec.Decode(&e) + if err != nil { + return nil, err + } + return &ExtImageHash{hash: e.Hash, kind: e.Kind, bits: e.Bits}, nil +} + +const extStrFmt = "%1s:%s" + +// ExtImageHashFromString returns a big hash from a hex representation +// +// Deprecated: Use goimagehash.LoadExtImageHash instead. +func ExtImageHashFromString(s string) (*ExtImageHash, error) { + var kindStr string + var hashStr string + _, err := fmt.Sscanf(s, extStrFmt, &kindStr, &hashStr) + if err != nil { + return nil, errors.New("Couldn't parse string " + s) + } + + hexBytes, err := hex.DecodeString(hashStr) + if err != nil { + return nil, err + } + + var hash []uint64 + lenOfByte := 8 + for i := 0; i < len(hexBytes)/lenOfByte; i++ { + startIndex := i * lenOfByte + endIndex := startIndex + lenOfByte + hashUint64 := binary.BigEndian.Uint64(hexBytes[startIndex:endIndex]) + hash = append(hash, hashUint64) + } + + kind := Unknown + switch kindStr { + case "a": + kind = AHash + case "p": + kind = PHash + case "d": + kind = DHash + case "w": + kind = WHash + } + return NewExtImageHash(hash, kind, len(hash)*64), nil +} + +// ToString returns a hex representation of big hash +func (h *ExtImageHash) ToString() string { + var hexBytes []byte + for _, hash := range h.hash { + hashBytes := make([]byte, 8) + binary.BigEndian.PutUint64(hashBytes, hash) + hexBytes = append(hexBytes, hashBytes...) + } + hexStr := hex.EncodeToString(hexBytes) + + kindStr := "" + switch h.kind { + case AHash: + kindStr = "a" + case PHash: + kindStr = "p" + case DHash: + kindStr = "d" + case WHash: + kindStr = "w" + } + return fmt.Sprintf(extStrFmt, kindStr, hexStr) +} diff --git a/vendor/github.com/corona10/goimagehash/imagehash18.go b/vendor/github.com/corona10/goimagehash/imagehash18.go new file mode 100644 index 000000000..e8d3fd62a --- /dev/null +++ b/vendor/github.com/corona10/goimagehash/imagehash18.go @@ -0,0 +1,13 @@ +// +build !go1.9 + +package goimagehash + +func popcnt(x uint64) int { + diff := 0 + for x != 0 { + diff += int(x & 1) + x >>= 1 + } + + return diff +} diff --git a/vendor/github.com/corona10/goimagehash/imagehash19.go b/vendor/github.com/corona10/goimagehash/imagehash19.go new file mode 100644 index 000000000..c1d47be36 --- /dev/null +++ b/vendor/github.com/corona10/goimagehash/imagehash19.go @@ -0,0 +1,9 @@ +// +build go1.9 + +package goimagehash + +import ( + "math/bits" +) + +func popcnt(x uint64) int { return bits.OnesCount64(x) } diff --git a/vendor/github.com/corona10/goimagehash/transforms/dct.go b/vendor/github.com/corona10/goimagehash/transforms/dct.go new file mode 100644 index 000000000..b0976a3bc --- /dev/null +++ b/vendor/github.com/corona10/goimagehash/transforms/dct.go @@ -0,0 +1,75 @@ +// Copyright 2017 The goimagehash Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +package transforms + +import ( + "math" + "sync" +) + +// DCT1D function returns result of DCT-II. +// DCT type II, unscaled. Algorithm by Byeong Gi Lee, 1984. +func DCT1D(input []float64) []float64 { + temp := make([]float64, len(input)) + forwardTransform(input, temp, len(input)) + return input +} + +func forwardTransform(input, temp []float64, Len int) { + if Len == 1 { + return + } + + halfLen := Len / 2 + for i := 0; i < halfLen; i++ { + x, y := input[i], input[Len-1-i] + temp[i] = x + y + temp[i+halfLen] = (x - y) / (math.Cos((float64(i)+0.5)*math.Pi/float64(Len)) * 2) + } + forwardTransform(temp, input, halfLen) + forwardTransform(temp[halfLen:], input, halfLen) + for i := 0; i < halfLen-1; i++ { + input[i*2+0] = temp[i] + input[i*2+1] = temp[i+halfLen] + temp[i+halfLen+1] + } + input[Len-2], input[Len-1] = temp[halfLen-1], temp[Len-1] +} + +// DCT2D function returns a result of DCT2D by using the seperable property. +func DCT2D(input [][]float64, w int, h int) [][]float64 { + output := make([][]float64, h) + for i := range output { + output[i] = make([]float64, w) + } + + wg := new(sync.WaitGroup) + for i := 0; i < h; i++ { + wg.Add(1) + go func(i int) { + cols := DCT1D(input[i]) + output[i] = cols + wg.Done() + }(i) + } + + wg.Wait() + for i := 0; i < w; i++ { + wg.Add(1) + in := make([]float64, h) + go func(i int) { + for j := 0; j < h; j++ { + in[j] = output[j][i] + } + rows := DCT1D(in) + for j := 0; j < len(rows); j++ { + output[j][i] = rows[j] + } + wg.Done() + }(i) + } + + wg.Wait() + return output +} diff --git a/vendor/github.com/corona10/goimagehash/transforms/doc.go b/vendor/github.com/corona10/goimagehash/transforms/doc.go new file mode 100644 index 000000000..01bd0e839 --- /dev/null +++ b/vendor/github.com/corona10/goimagehash/transforms/doc.go @@ -0,0 +1,5 @@ +// Copyright 2017 The goimagehash Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +package transforms diff --git a/vendor/github.com/corona10/goimagehash/transforms/pixels.go b/vendor/github.com/corona10/goimagehash/transforms/pixels.go new file mode 100644 index 000000000..378e1b559 --- /dev/null +++ b/vendor/github.com/corona10/goimagehash/transforms/pixels.go @@ -0,0 +1,39 @@ +// Copyright 2017 The goimagehash Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +package transforms + +import ( + "image" +) + +// Rgb2Gray function converts RGB to a gray scale array. +func Rgb2Gray(colorImg image.Image) [][]float64 { + bounds := colorImg.Bounds() + w, h := bounds.Max.X-bounds.Min.X, bounds.Max.Y-bounds.Min.Y + pixels := make([][]float64, h) + + for i := range pixels { + pixels[i] = make([]float64, w) + for j := range pixels[i] { + color := colorImg.At(j, i) + r, g, b, _ := color.RGBA() + lum := 0.299*float64(r/257) + 0.587*float64(g/257) + 0.114*float64(b/256) + pixels[i][j] = lum + } + } + + return pixels +} + +// FlattenPixels function flattens 2d array into 1d array. +func FlattenPixels(pixels [][]float64, x int, y int) []float64 { + flattens := make([]float64, x*y) + for i := 0; i < y; i++ { + for j := 0; j < x; j++ { + flattens[y*i+j] = pixels[i][j] + } + } + return flattens +} diff --git a/vendor/github.com/nfnt/resize/.travis.yml b/vendor/github.com/nfnt/resize/.travis.yml new file mode 100644 index 000000000..5ff08e7e4 --- /dev/null +++ b/vendor/github.com/nfnt/resize/.travis.yml @@ -0,0 +1,7 @@ +language: go + +go: + - "1.x" + - "1.1" + - "1.4" + - "1.10" diff --git a/vendor/github.com/nfnt/resize/LICENSE b/vendor/github.com/nfnt/resize/LICENSE new file mode 100644 index 000000000..7836cad5f --- /dev/null +++ b/vendor/github.com/nfnt/resize/LICENSE @@ -0,0 +1,13 @@ +Copyright (c) 2012, Jan Schlicht + +Permission to use, copy, modify, and/or distribute this software for any purpose +with or without fee is hereby granted, provided that the above copyright notice +and this permission notice appear in all copies. + +THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH +REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND +FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, +INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS +OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER +TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF +THIS SOFTWARE. diff --git a/vendor/github.com/nfnt/resize/README.md b/vendor/github.com/nfnt/resize/README.md new file mode 100644 index 000000000..372777d2e --- /dev/null +++ b/vendor/github.com/nfnt/resize/README.md @@ -0,0 +1,151 @@ +# This package is no longer being updated! Please look for alternatives if that bothers you. + +Resize +====== + +Image resizing for the [Go programming language](http://golang.org) with common interpolation methods. + +[![Build Status](https://travis-ci.org/nfnt/resize.svg)](https://travis-ci.org/nfnt/resize) + +Installation +------------ + +```bash +$ go get github.com/nfnt/resize +``` + +It's that easy! + +Usage +----- + +This package needs at least Go 1.1. Import package with + +```go +import "github.com/nfnt/resize" +``` + +The resize package provides 2 functions: + +* `resize.Resize` creates a scaled image with new dimensions (`width`, `height`) using the interpolation function `interp`. + If either `width` or `height` is set to 0, it will be set to an aspect ratio preserving value. +* `resize.Thumbnail` downscales an image preserving its aspect ratio to the maximum dimensions (`maxWidth`, `maxHeight`). + It will return the original image if original sizes are smaller than the provided dimensions. + +```go +resize.Resize(width, height uint, img image.Image, interp resize.InterpolationFunction) image.Image +resize.Thumbnail(maxWidth, maxHeight uint, img image.Image, interp resize.InterpolationFunction) image.Image +``` + +The provided interpolation functions are (from fast to slow execution time) + +- `NearestNeighbor`: [Nearest-neighbor interpolation](http://en.wikipedia.org/wiki/Nearest-neighbor_interpolation) +- `Bilinear`: [Bilinear interpolation](http://en.wikipedia.org/wiki/Bilinear_interpolation) +- `Bicubic`: [Bicubic interpolation](http://en.wikipedia.org/wiki/Bicubic_interpolation) +- `MitchellNetravali`: [Mitchell-Netravali interpolation](http://dl.acm.org/citation.cfm?id=378514) +- `Lanczos2`: [Lanczos resampling](http://en.wikipedia.org/wiki/Lanczos_resampling) with a=2 +- `Lanczos3`: [Lanczos resampling](http://en.wikipedia.org/wiki/Lanczos_resampling) with a=3 + +Which of these methods gives the best results depends on your use case. + +Sample usage: + +```go +package main + +import ( + "github.com/nfnt/resize" + "image/jpeg" + "log" + "os" +) + +func main() { + // open "test.jpg" + file, err := os.Open("test.jpg") + if err != nil { + log.Fatal(err) + } + + // decode jpeg into image.Image + img, err := jpeg.Decode(file) + if err != nil { + log.Fatal(err) + } + file.Close() + + // resize to width 1000 using Lanczos resampling + // and preserve aspect ratio + m := resize.Resize(1000, 0, img, resize.Lanczos3) + + out, err := os.Create("test_resized.jpg") + if err != nil { + log.Fatal(err) + } + defer out.Close() + + // write new image to file + jpeg.Encode(out, m, nil) +} +``` + +Caveats +------- + +* Optimized access routines are used for `image.RGBA`, `image.NRGBA`, `image.RGBA64`, `image.NRGBA64`, `image.YCbCr`, `image.Gray`, and `image.Gray16` types. All other image types are accessed in a generic way that will result in slow processing speed. +* JPEG images are stored in `image.YCbCr`. This image format stores data in a way that will decrease processing speed. A resize may be up to 2 times slower than with `image.RGBA`. + + +Downsizing Samples +------- + +Downsizing is not as simple as it might look like. Images have to be filtered before they are scaled down, otherwise aliasing might occur. +Filtering is highly subjective: Applying too much will blur the whole image, too little will make aliasing become apparent. +Resize tries to provide sane defaults that should suffice in most cases. + +### Artificial sample + +Original image +![Rings](http://nfnt.github.com/img/rings_lg_orig.png) + + + + + + + + + + + + + + +

    Nearest-Neighbor

    Bilinear

    Bicubic

    Mitchell-Netravali

    Lanczos2

    Lanczos3
    + +### Real-Life sample + +Original image +![Original](http://nfnt.github.com/img/IMG_3694_720.jpg) + + + + + + + + + + + + + + +

    Nearest-Neighbor

    Bilinear

    Bicubic

    Mitchell-Netravali

    Lanczos2

    Lanczos3
    + + +License +------- + +Copyright (c) 2012 Jan Schlicht +Resize is released under a MIT style license. diff --git a/vendor/github.com/nfnt/resize/converter.go b/vendor/github.com/nfnt/resize/converter.go new file mode 100644 index 000000000..f9c520d09 --- /dev/null +++ b/vendor/github.com/nfnt/resize/converter.go @@ -0,0 +1,438 @@ +/* +Copyright (c) 2012, Jan Schlicht + +Permission to use, copy, modify, and/or distribute this software for any purpose +with or without fee is hereby granted, provided that the above copyright notice +and this permission notice appear in all copies. + +THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH +REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND +FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, +INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS +OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER +TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF +THIS SOFTWARE. +*/ + +package resize + +import "image" + +// Keep value in [0,255] range. +func clampUint8(in int32) uint8 { + // casting a negative int to an uint will result in an overflown + // large uint. this behavior will be exploited here and in other functions + // to achieve a higher performance. + if uint32(in) < 256 { + return uint8(in) + } + if in > 255 { + return 255 + } + return 0 +} + +// Keep value in [0,65535] range. +func clampUint16(in int64) uint16 { + if uint64(in) < 65536 { + return uint16(in) + } + if in > 65535 { + return 65535 + } + return 0 +} + +func resizeGeneric(in image.Image, out *image.RGBA64, scale float64, coeffs []int32, offset []int, filterLength int) { + newBounds := out.Bounds() + maxX := in.Bounds().Dx() - 1 + + for x := newBounds.Min.X; x < newBounds.Max.X; x++ { + for y := newBounds.Min.Y; y < newBounds.Max.Y; y++ { + var rgba [4]int64 + var sum int64 + start := offset[y] + ci := y * filterLength + for i := 0; i < filterLength; i++ { + coeff := coeffs[ci+i] + if coeff != 0 { + xi := start + i + switch { + case xi < 0: + xi = 0 + case xi >= maxX: + xi = maxX + } + + r, g, b, a := in.At(xi+in.Bounds().Min.X, x+in.Bounds().Min.Y).RGBA() + + rgba[0] += int64(coeff) * int64(r) + rgba[1] += int64(coeff) * int64(g) + rgba[2] += int64(coeff) * int64(b) + rgba[3] += int64(coeff) * int64(a) + sum += int64(coeff) + } + } + + offset := (y-newBounds.Min.Y)*out.Stride + (x-newBounds.Min.X)*8 + + value := clampUint16(rgba[0] / sum) + out.Pix[offset+0] = uint8(value >> 8) + out.Pix[offset+1] = uint8(value) + value = clampUint16(rgba[1] / sum) + out.Pix[offset+2] = uint8(value >> 8) + out.Pix[offset+3] = uint8(value) + value = clampUint16(rgba[2] / sum) + out.Pix[offset+4] = uint8(value >> 8) + out.Pix[offset+5] = uint8(value) + value = clampUint16(rgba[3] / sum) + out.Pix[offset+6] = uint8(value >> 8) + out.Pix[offset+7] = uint8(value) + } + } +} + +func resizeRGBA(in *image.RGBA, out *image.RGBA, scale float64, coeffs []int16, offset []int, filterLength int) { + newBounds := out.Bounds() + maxX := in.Bounds().Dx() - 1 + + for x := newBounds.Min.X; x < newBounds.Max.X; x++ { + row := in.Pix[x*in.Stride:] + for y := newBounds.Min.Y; y < newBounds.Max.Y; y++ { + var rgba [4]int32 + var sum int32 + start := offset[y] + ci := y * filterLength + for i := 0; i < filterLength; i++ { + coeff := coeffs[ci+i] + if coeff != 0 { + xi := start + i + switch { + case uint(xi) < uint(maxX): + xi *= 4 + case xi >= maxX: + xi = 4 * maxX + default: + xi = 0 + } + + rgba[0] += int32(coeff) * int32(row[xi+0]) + rgba[1] += int32(coeff) * int32(row[xi+1]) + rgba[2] += int32(coeff) * int32(row[xi+2]) + rgba[3] += int32(coeff) * int32(row[xi+3]) + sum += int32(coeff) + } + } + + xo := (y-newBounds.Min.Y)*out.Stride + (x-newBounds.Min.X)*4 + + out.Pix[xo+0] = clampUint8(rgba[0] / sum) + out.Pix[xo+1] = clampUint8(rgba[1] / sum) + out.Pix[xo+2] = clampUint8(rgba[2] / sum) + out.Pix[xo+3] = clampUint8(rgba[3] / sum) + } + } +} + +func resizeNRGBA(in *image.NRGBA, out *image.RGBA, scale float64, coeffs []int16, offset []int, filterLength int) { + newBounds := out.Bounds() + maxX := in.Bounds().Dx() - 1 + + for x := newBounds.Min.X; x < newBounds.Max.X; x++ { + row := in.Pix[x*in.Stride:] + for y := newBounds.Min.Y; y < newBounds.Max.Y; y++ { + var rgba [4]int32 + var sum int32 + start := offset[y] + ci := y * filterLength + for i := 0; i < filterLength; i++ { + coeff := coeffs[ci+i] + if coeff != 0 { + xi := start + i + switch { + case uint(xi) < uint(maxX): + xi *= 4 + case xi >= maxX: + xi = 4 * maxX + default: + xi = 0 + } + + // Forward alpha-premultiplication + a := int32(row[xi+3]) + r := int32(row[xi+0]) * a + r /= 0xff + g := int32(row[xi+1]) * a + g /= 0xff + b := int32(row[xi+2]) * a + b /= 0xff + + rgba[0] += int32(coeff) * r + rgba[1] += int32(coeff) * g + rgba[2] += int32(coeff) * b + rgba[3] += int32(coeff) * a + sum += int32(coeff) + } + } + + xo := (y-newBounds.Min.Y)*out.Stride + (x-newBounds.Min.X)*4 + + out.Pix[xo+0] = clampUint8(rgba[0] / sum) + out.Pix[xo+1] = clampUint8(rgba[1] / sum) + out.Pix[xo+2] = clampUint8(rgba[2] / sum) + out.Pix[xo+3] = clampUint8(rgba[3] / sum) + } + } +} + +func resizeRGBA64(in *image.RGBA64, out *image.RGBA64, scale float64, coeffs []int32, offset []int, filterLength int) { + newBounds := out.Bounds() + maxX := in.Bounds().Dx() - 1 + + for x := newBounds.Min.X; x < newBounds.Max.X; x++ { + row := in.Pix[x*in.Stride:] + for y := newBounds.Min.Y; y < newBounds.Max.Y; y++ { + var rgba [4]int64 + var sum int64 + start := offset[y] + ci := y * filterLength + for i := 0; i < filterLength; i++ { + coeff := coeffs[ci+i] + if coeff != 0 { + xi := start + i + switch { + case uint(xi) < uint(maxX): + xi *= 8 + case xi >= maxX: + xi = 8 * maxX + default: + xi = 0 + } + + rgba[0] += int64(coeff) * (int64(row[xi+0])<<8 | int64(row[xi+1])) + rgba[1] += int64(coeff) * (int64(row[xi+2])<<8 | int64(row[xi+3])) + rgba[2] += int64(coeff) * (int64(row[xi+4])<<8 | int64(row[xi+5])) + rgba[3] += int64(coeff) * (int64(row[xi+6])<<8 | int64(row[xi+7])) + sum += int64(coeff) + } + } + + xo := (y-newBounds.Min.Y)*out.Stride + (x-newBounds.Min.X)*8 + + value := clampUint16(rgba[0] / sum) + out.Pix[xo+0] = uint8(value >> 8) + out.Pix[xo+1] = uint8(value) + value = clampUint16(rgba[1] / sum) + out.Pix[xo+2] = uint8(value >> 8) + out.Pix[xo+3] = uint8(value) + value = clampUint16(rgba[2] / sum) + out.Pix[xo+4] = uint8(value >> 8) + out.Pix[xo+5] = uint8(value) + value = clampUint16(rgba[3] / sum) + out.Pix[xo+6] = uint8(value >> 8) + out.Pix[xo+7] = uint8(value) + } + } +} + +func resizeNRGBA64(in *image.NRGBA64, out *image.RGBA64, scale float64, coeffs []int32, offset []int, filterLength int) { + newBounds := out.Bounds() + maxX := in.Bounds().Dx() - 1 + + for x := newBounds.Min.X; x < newBounds.Max.X; x++ { + row := in.Pix[x*in.Stride:] + for y := newBounds.Min.Y; y < newBounds.Max.Y; y++ { + var rgba [4]int64 + var sum int64 + start := offset[y] + ci := y * filterLength + for i := 0; i < filterLength; i++ { + coeff := coeffs[ci+i] + if coeff != 0 { + xi := start + i + switch { + case uint(xi) < uint(maxX): + xi *= 8 + case xi >= maxX: + xi = 8 * maxX + default: + xi = 0 + } + + // Forward alpha-premultiplication + a := int64(uint16(row[xi+6])<<8 | uint16(row[xi+7])) + r := int64(uint16(row[xi+0])<<8|uint16(row[xi+1])) * a + r /= 0xffff + g := int64(uint16(row[xi+2])<<8|uint16(row[xi+3])) * a + g /= 0xffff + b := int64(uint16(row[xi+4])<<8|uint16(row[xi+5])) * a + b /= 0xffff + + rgba[0] += int64(coeff) * r + rgba[1] += int64(coeff) * g + rgba[2] += int64(coeff) * b + rgba[3] += int64(coeff) * a + sum += int64(coeff) + } + } + + xo := (y-newBounds.Min.Y)*out.Stride + (x-newBounds.Min.X)*8 + + value := clampUint16(rgba[0] / sum) + out.Pix[xo+0] = uint8(value >> 8) + out.Pix[xo+1] = uint8(value) + value = clampUint16(rgba[1] / sum) + out.Pix[xo+2] = uint8(value >> 8) + out.Pix[xo+3] = uint8(value) + value = clampUint16(rgba[2] / sum) + out.Pix[xo+4] = uint8(value >> 8) + out.Pix[xo+5] = uint8(value) + value = clampUint16(rgba[3] / sum) + out.Pix[xo+6] = uint8(value >> 8) + out.Pix[xo+7] = uint8(value) + } + } +} + +func resizeGray(in *image.Gray, out *image.Gray, scale float64, coeffs []int16, offset []int, filterLength int) { + newBounds := out.Bounds() + maxX := in.Bounds().Dx() - 1 + + for x := newBounds.Min.X; x < newBounds.Max.X; x++ { + row := in.Pix[(x-newBounds.Min.X)*in.Stride:] + for y := newBounds.Min.Y; y < newBounds.Max.Y; y++ { + var gray int32 + var sum int32 + start := offset[y] + ci := y * filterLength + for i := 0; i < filterLength; i++ { + coeff := coeffs[ci+i] + if coeff != 0 { + xi := start + i + switch { + case xi < 0: + xi = 0 + case xi >= maxX: + xi = maxX + } + gray += int32(coeff) * int32(row[xi]) + sum += int32(coeff) + } + } + + offset := (y-newBounds.Min.Y)*out.Stride + (x - newBounds.Min.X) + out.Pix[offset] = clampUint8(gray / sum) + } + } +} + +func resizeGray16(in *image.Gray16, out *image.Gray16, scale float64, coeffs []int32, offset []int, filterLength int) { + newBounds := out.Bounds() + maxX := in.Bounds().Dx() - 1 + + for x := newBounds.Min.X; x < newBounds.Max.X; x++ { + row := in.Pix[x*in.Stride:] + for y := newBounds.Min.Y; y < newBounds.Max.Y; y++ { + var gray int64 + var sum int64 + start := offset[y] + ci := y * filterLength + for i := 0; i < filterLength; i++ { + coeff := coeffs[ci+i] + if coeff != 0 { + xi := start + i + switch { + case uint(xi) < uint(maxX): + xi *= 2 + case xi >= maxX: + xi = 2 * maxX + default: + xi = 0 + } + gray += int64(coeff) * int64(uint16(row[xi+0])<<8|uint16(row[xi+1])) + sum += int64(coeff) + } + } + + offset := (y-newBounds.Min.Y)*out.Stride + (x-newBounds.Min.X)*2 + value := clampUint16(gray / sum) + out.Pix[offset+0] = uint8(value >> 8) + out.Pix[offset+1] = uint8(value) + } + } +} + +func resizeYCbCr(in *ycc, out *ycc, scale float64, coeffs []int16, offset []int, filterLength int) { + newBounds := out.Bounds() + maxX := in.Bounds().Dx() - 1 + + for x := newBounds.Min.X; x < newBounds.Max.X; x++ { + row := in.Pix[x*in.Stride:] + for y := newBounds.Min.Y; y < newBounds.Max.Y; y++ { + var p [3]int32 + var sum int32 + start := offset[y] + ci := y * filterLength + for i := 0; i < filterLength; i++ { + coeff := coeffs[ci+i] + if coeff != 0 { + xi := start + i + switch { + case uint(xi) < uint(maxX): + xi *= 3 + case xi >= maxX: + xi = 3 * maxX + default: + xi = 0 + } + p[0] += int32(coeff) * int32(row[xi+0]) + p[1] += int32(coeff) * int32(row[xi+1]) + p[2] += int32(coeff) * int32(row[xi+2]) + sum += int32(coeff) + } + } + + xo := (y-newBounds.Min.Y)*out.Stride + (x-newBounds.Min.X)*3 + out.Pix[xo+0] = clampUint8(p[0] / sum) + out.Pix[xo+1] = clampUint8(p[1] / sum) + out.Pix[xo+2] = clampUint8(p[2] / sum) + } + } +} + +func nearestYCbCr(in *ycc, out *ycc, scale float64, coeffs []bool, offset []int, filterLength int) { + newBounds := out.Bounds() + maxX := in.Bounds().Dx() - 1 + + for x := newBounds.Min.X; x < newBounds.Max.X; x++ { + row := in.Pix[x*in.Stride:] + for y := newBounds.Min.Y; y < newBounds.Max.Y; y++ { + var p [3]float32 + var sum float32 + start := offset[y] + ci := y * filterLength + for i := 0; i < filterLength; i++ { + if coeffs[ci+i] { + xi := start + i + switch { + case uint(xi) < uint(maxX): + xi *= 3 + case xi >= maxX: + xi = 3 * maxX + default: + xi = 0 + } + p[0] += float32(row[xi+0]) + p[1] += float32(row[xi+1]) + p[2] += float32(row[xi+2]) + sum++ + } + } + + xo := (y-newBounds.Min.Y)*out.Stride + (x-newBounds.Min.X)*3 + out.Pix[xo+0] = floatToUint8(p[0] / sum) + out.Pix[xo+1] = floatToUint8(p[1] / sum) + out.Pix[xo+2] = floatToUint8(p[2] / sum) + } + } +} diff --git a/vendor/github.com/nfnt/resize/filters.go b/vendor/github.com/nfnt/resize/filters.go new file mode 100644 index 000000000..4ce04e389 --- /dev/null +++ b/vendor/github.com/nfnt/resize/filters.go @@ -0,0 +1,143 @@ +/* +Copyright (c) 2012, Jan Schlicht + +Permission to use, copy, modify, and/or distribute this software for any purpose +with or without fee is hereby granted, provided that the above copyright notice +and this permission notice appear in all copies. + +THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH +REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND +FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, +INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS +OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER +TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF +THIS SOFTWARE. +*/ + +package resize + +import ( + "math" +) + +func nearest(in float64) float64 { + if in >= -0.5 && in < 0.5 { + return 1 + } + return 0 +} + +func linear(in float64) float64 { + in = math.Abs(in) + if in <= 1 { + return 1 - in + } + return 0 +} + +func cubic(in float64) float64 { + in = math.Abs(in) + if in <= 1 { + return in*in*(1.5*in-2.5) + 1.0 + } + if in <= 2 { + return in*(in*(2.5-0.5*in)-4.0) + 2.0 + } + return 0 +} + +func mitchellnetravali(in float64) float64 { + in = math.Abs(in) + if in <= 1 { + return (7.0*in*in*in - 12.0*in*in + 5.33333333333) * 0.16666666666 + } + if in <= 2 { + return (-2.33333333333*in*in*in + 12.0*in*in - 20.0*in + 10.6666666667) * 0.16666666666 + } + return 0 +} + +func sinc(x float64) float64 { + x = math.Abs(x) * math.Pi + if x >= 1.220703e-4 { + return math.Sin(x) / x + } + return 1 +} + +func lanczos2(in float64) float64 { + if in > -2 && in < 2 { + return sinc(in) * sinc(in*0.5) + } + return 0 +} + +func lanczos3(in float64) float64 { + if in > -3 && in < 3 { + return sinc(in) * sinc(in*0.3333333333333333) + } + return 0 +} + +// range [-256,256] +func createWeights8(dy, filterLength int, blur, scale float64, kernel func(float64) float64) ([]int16, []int, int) { + filterLength = filterLength * int(math.Max(math.Ceil(blur*scale), 1)) + filterFactor := math.Min(1./(blur*scale), 1) + + coeffs := make([]int16, dy*filterLength) + start := make([]int, dy) + for y := 0; y < dy; y++ { + interpX := scale*(float64(y)+0.5) - 0.5 + start[y] = int(interpX) - filterLength/2 + 1 + interpX -= float64(start[y]) + for i := 0; i < filterLength; i++ { + in := (interpX - float64(i)) * filterFactor + coeffs[y*filterLength+i] = int16(kernel(in) * 256) + } + } + + return coeffs, start, filterLength +} + +// range [-65536,65536] +func createWeights16(dy, filterLength int, blur, scale float64, kernel func(float64) float64) ([]int32, []int, int) { + filterLength = filterLength * int(math.Max(math.Ceil(blur*scale), 1)) + filterFactor := math.Min(1./(blur*scale), 1) + + coeffs := make([]int32, dy*filterLength) + start := make([]int, dy) + for y := 0; y < dy; y++ { + interpX := scale*(float64(y)+0.5) - 0.5 + start[y] = int(interpX) - filterLength/2 + 1 + interpX -= float64(start[y]) + for i := 0; i < filterLength; i++ { + in := (interpX - float64(i)) * filterFactor + coeffs[y*filterLength+i] = int32(kernel(in) * 65536) + } + } + + return coeffs, start, filterLength +} + +func createWeightsNearest(dy, filterLength int, blur, scale float64) ([]bool, []int, int) { + filterLength = filterLength * int(math.Max(math.Ceil(blur*scale), 1)) + filterFactor := math.Min(1./(blur*scale), 1) + + coeffs := make([]bool, dy*filterLength) + start := make([]int, dy) + for y := 0; y < dy; y++ { + interpX := scale*(float64(y)+0.5) - 0.5 + start[y] = int(interpX) - filterLength/2 + 1 + interpX -= float64(start[y]) + for i := 0; i < filterLength; i++ { + in := (interpX - float64(i)) * filterFactor + if in >= -0.5 && in < 0.5 { + coeffs[y*filterLength+i] = true + } else { + coeffs[y*filterLength+i] = false + } + } + } + + return coeffs, start, filterLength +} diff --git a/vendor/github.com/nfnt/resize/nearest.go b/vendor/github.com/nfnt/resize/nearest.go new file mode 100644 index 000000000..888039d85 --- /dev/null +++ b/vendor/github.com/nfnt/resize/nearest.go @@ -0,0 +1,318 @@ +/* +Copyright (c) 2014, Charlie Vieth + +Permission to use, copy, modify, and/or distribute this software for any purpose +with or without fee is hereby granted, provided that the above copyright notice +and this permission notice appear in all copies. + +THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH +REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND +FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, +INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS +OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER +TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF +THIS SOFTWARE. +*/ + +package resize + +import "image" + +func floatToUint8(x float32) uint8 { + // Nearest-neighbor values are always + // positive no need to check lower-bound. + if x > 0xfe { + return 0xff + } + return uint8(x) +} + +func floatToUint16(x float32) uint16 { + if x > 0xfffe { + return 0xffff + } + return uint16(x) +} + +func nearestGeneric(in image.Image, out *image.RGBA64, scale float64, coeffs []bool, offset []int, filterLength int) { + newBounds := out.Bounds() + maxX := in.Bounds().Dx() - 1 + + for x := newBounds.Min.X; x < newBounds.Max.X; x++ { + for y := newBounds.Min.Y; y < newBounds.Max.Y; y++ { + var rgba [4]float32 + var sum float32 + start := offset[y] + ci := y * filterLength + for i := 0; i < filterLength; i++ { + if coeffs[ci+i] { + xi := start + i + switch { + case xi < 0: + xi = 0 + case xi >= maxX: + xi = maxX + } + r, g, b, a := in.At(xi+in.Bounds().Min.X, x+in.Bounds().Min.Y).RGBA() + rgba[0] += float32(r) + rgba[1] += float32(g) + rgba[2] += float32(b) + rgba[3] += float32(a) + sum++ + } + } + + offset := (y-newBounds.Min.Y)*out.Stride + (x-newBounds.Min.X)*8 + value := floatToUint16(rgba[0] / sum) + out.Pix[offset+0] = uint8(value >> 8) + out.Pix[offset+1] = uint8(value) + value = floatToUint16(rgba[1] / sum) + out.Pix[offset+2] = uint8(value >> 8) + out.Pix[offset+3] = uint8(value) + value = floatToUint16(rgba[2] / sum) + out.Pix[offset+4] = uint8(value >> 8) + out.Pix[offset+5] = uint8(value) + value = floatToUint16(rgba[3] / sum) + out.Pix[offset+6] = uint8(value >> 8) + out.Pix[offset+7] = uint8(value) + } + } +} + +func nearestRGBA(in *image.RGBA, out *image.RGBA, scale float64, coeffs []bool, offset []int, filterLength int) { + newBounds := out.Bounds() + maxX := in.Bounds().Dx() - 1 + + for x := newBounds.Min.X; x < newBounds.Max.X; x++ { + row := in.Pix[x*in.Stride:] + for y := newBounds.Min.Y; y < newBounds.Max.Y; y++ { + var rgba [4]float32 + var sum float32 + start := offset[y] + ci := y * filterLength + for i := 0; i < filterLength; i++ { + if coeffs[ci+i] { + xi := start + i + switch { + case uint(xi) < uint(maxX): + xi *= 4 + case xi >= maxX: + xi = 4 * maxX + default: + xi = 0 + } + rgba[0] += float32(row[xi+0]) + rgba[1] += float32(row[xi+1]) + rgba[2] += float32(row[xi+2]) + rgba[3] += float32(row[xi+3]) + sum++ + } + } + + xo := (y-newBounds.Min.Y)*out.Stride + (x-newBounds.Min.X)*4 + out.Pix[xo+0] = floatToUint8(rgba[0] / sum) + out.Pix[xo+1] = floatToUint8(rgba[1] / sum) + out.Pix[xo+2] = floatToUint8(rgba[2] / sum) + out.Pix[xo+3] = floatToUint8(rgba[3] / sum) + } + } +} + +func nearestNRGBA(in *image.NRGBA, out *image.NRGBA, scale float64, coeffs []bool, offset []int, filterLength int) { + newBounds := out.Bounds() + maxX := in.Bounds().Dx() - 1 + + for x := newBounds.Min.X; x < newBounds.Max.X; x++ { + row := in.Pix[x*in.Stride:] + for y := newBounds.Min.Y; y < newBounds.Max.Y; y++ { + var rgba [4]float32 + var sum float32 + start := offset[y] + ci := y * filterLength + for i := 0; i < filterLength; i++ { + if coeffs[ci+i] { + xi := start + i + switch { + case uint(xi) < uint(maxX): + xi *= 4 + case xi >= maxX: + xi = 4 * maxX + default: + xi = 0 + } + rgba[0] += float32(row[xi+0]) + rgba[1] += float32(row[xi+1]) + rgba[2] += float32(row[xi+2]) + rgba[3] += float32(row[xi+3]) + sum++ + } + } + + xo := (y-newBounds.Min.Y)*out.Stride + (x-newBounds.Min.X)*4 + out.Pix[xo+0] = floatToUint8(rgba[0] / sum) + out.Pix[xo+1] = floatToUint8(rgba[1] / sum) + out.Pix[xo+2] = floatToUint8(rgba[2] / sum) + out.Pix[xo+3] = floatToUint8(rgba[3] / sum) + } + } +} + +func nearestRGBA64(in *image.RGBA64, out *image.RGBA64, scale float64, coeffs []bool, offset []int, filterLength int) { + newBounds := out.Bounds() + maxX := in.Bounds().Dx() - 1 + + for x := newBounds.Min.X; x < newBounds.Max.X; x++ { + row := in.Pix[x*in.Stride:] + for y := newBounds.Min.Y; y < newBounds.Max.Y; y++ { + var rgba [4]float32 + var sum float32 + start := offset[y] + ci := y * filterLength + for i := 0; i < filterLength; i++ { + if coeffs[ci+i] { + xi := start + i + switch { + case uint(xi) < uint(maxX): + xi *= 8 + case xi >= maxX: + xi = 8 * maxX + default: + xi = 0 + } + rgba[0] += float32(uint16(row[xi+0])<<8 | uint16(row[xi+1])) + rgba[1] += float32(uint16(row[xi+2])<<8 | uint16(row[xi+3])) + rgba[2] += float32(uint16(row[xi+4])<<8 | uint16(row[xi+5])) + rgba[3] += float32(uint16(row[xi+6])<<8 | uint16(row[xi+7])) + sum++ + } + } + + xo := (y-newBounds.Min.Y)*out.Stride + (x-newBounds.Min.X)*8 + value := floatToUint16(rgba[0] / sum) + out.Pix[xo+0] = uint8(value >> 8) + out.Pix[xo+1] = uint8(value) + value = floatToUint16(rgba[1] / sum) + out.Pix[xo+2] = uint8(value >> 8) + out.Pix[xo+3] = uint8(value) + value = floatToUint16(rgba[2] / sum) + out.Pix[xo+4] = uint8(value >> 8) + out.Pix[xo+5] = uint8(value) + value = floatToUint16(rgba[3] / sum) + out.Pix[xo+6] = uint8(value >> 8) + out.Pix[xo+7] = uint8(value) + } + } +} + +func nearestNRGBA64(in *image.NRGBA64, out *image.NRGBA64, scale float64, coeffs []bool, offset []int, filterLength int) { + newBounds := out.Bounds() + maxX := in.Bounds().Dx() - 1 + + for x := newBounds.Min.X; x < newBounds.Max.X; x++ { + row := in.Pix[x*in.Stride:] + for y := newBounds.Min.Y; y < newBounds.Max.Y; y++ { + var rgba [4]float32 + var sum float32 + start := offset[y] + ci := y * filterLength + for i := 0; i < filterLength; i++ { + if coeffs[ci+i] { + xi := start + i + switch { + case uint(xi) < uint(maxX): + xi *= 8 + case xi >= maxX: + xi = 8 * maxX + default: + xi = 0 + } + rgba[0] += float32(uint16(row[xi+0])<<8 | uint16(row[xi+1])) + rgba[1] += float32(uint16(row[xi+2])<<8 | uint16(row[xi+3])) + rgba[2] += float32(uint16(row[xi+4])<<8 | uint16(row[xi+5])) + rgba[3] += float32(uint16(row[xi+6])<<8 | uint16(row[xi+7])) + sum++ + } + } + + xo := (y-newBounds.Min.Y)*out.Stride + (x-newBounds.Min.X)*8 + value := floatToUint16(rgba[0] / sum) + out.Pix[xo+0] = uint8(value >> 8) + out.Pix[xo+1] = uint8(value) + value = floatToUint16(rgba[1] / sum) + out.Pix[xo+2] = uint8(value >> 8) + out.Pix[xo+3] = uint8(value) + value = floatToUint16(rgba[2] / sum) + out.Pix[xo+4] = uint8(value >> 8) + out.Pix[xo+5] = uint8(value) + value = floatToUint16(rgba[3] / sum) + out.Pix[xo+6] = uint8(value >> 8) + out.Pix[xo+7] = uint8(value) + } + } +} + +func nearestGray(in *image.Gray, out *image.Gray, scale float64, coeffs []bool, offset []int, filterLength int) { + newBounds := out.Bounds() + maxX := in.Bounds().Dx() - 1 + + for x := newBounds.Min.X; x < newBounds.Max.X; x++ { + row := in.Pix[x*in.Stride:] + for y := newBounds.Min.Y; y < newBounds.Max.Y; y++ { + var gray float32 + var sum float32 + start := offset[y] + ci := y * filterLength + for i := 0; i < filterLength; i++ { + if coeffs[ci+i] { + xi := start + i + switch { + case xi < 0: + xi = 0 + case xi >= maxX: + xi = maxX + } + gray += float32(row[xi]) + sum++ + } + } + + offset := (y-newBounds.Min.Y)*out.Stride + (x - newBounds.Min.X) + out.Pix[offset] = floatToUint8(gray / sum) + } + } +} + +func nearestGray16(in *image.Gray16, out *image.Gray16, scale float64, coeffs []bool, offset []int, filterLength int) { + newBounds := out.Bounds() + maxX := in.Bounds().Dx() - 1 + + for x := newBounds.Min.X; x < newBounds.Max.X; x++ { + row := in.Pix[x*in.Stride:] + for y := newBounds.Min.Y; y < newBounds.Max.Y; y++ { + var gray float32 + var sum float32 + start := offset[y] + ci := y * filterLength + for i := 0; i < filterLength; i++ { + if coeffs[ci+i] { + xi := start + i + switch { + case uint(xi) < uint(maxX): + xi *= 2 + case xi >= maxX: + xi = 2 * maxX + default: + xi = 0 + } + gray += float32(uint16(row[xi+0])<<8 | uint16(row[xi+1])) + sum++ + } + } + + offset := (y-newBounds.Min.Y)*out.Stride + (x-newBounds.Min.X)*2 + value := floatToUint16(gray / sum) + out.Pix[offset+0] = uint8(value >> 8) + out.Pix[offset+1] = uint8(value) + } + } +} diff --git a/vendor/github.com/nfnt/resize/resize.go b/vendor/github.com/nfnt/resize/resize.go new file mode 100644 index 000000000..0d7fbf69a --- /dev/null +++ b/vendor/github.com/nfnt/resize/resize.go @@ -0,0 +1,620 @@ +/* +Copyright (c) 2012, Jan Schlicht + +Permission to use, copy, modify, and/or distribute this software for any purpose +with or without fee is hereby granted, provided that the above copyright notice +and this permission notice appear in all copies. + +THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH +REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND +FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, +INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS +OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER +TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF +THIS SOFTWARE. +*/ + +// Package resize implements various image resizing methods. +// +// The package works with the Image interface described in the image package. +// Various interpolation methods are provided and multiple processors may be +// utilized in the computations. +// +// Example: +// imgResized := resize.Resize(1000, 0, imgOld, resize.MitchellNetravali) +package resize + +import ( + "image" + "runtime" + "sync" +) + +// An InterpolationFunction provides the parameters that describe an +// interpolation kernel. It returns the number of samples to take +// and the kernel function to use for sampling. +type InterpolationFunction int + +// InterpolationFunction constants +const ( + // Nearest-neighbor interpolation + NearestNeighbor InterpolationFunction = iota + // Bilinear interpolation + Bilinear + // Bicubic interpolation (with cubic hermite spline) + Bicubic + // Mitchell-Netravali interpolation + MitchellNetravali + // Lanczos interpolation (a=2) + Lanczos2 + // Lanczos interpolation (a=3) + Lanczos3 +) + +// kernal, returns an InterpolationFunctions taps and kernel. +func (i InterpolationFunction) kernel() (int, func(float64) float64) { + switch i { + case Bilinear: + return 2, linear + case Bicubic: + return 4, cubic + case MitchellNetravali: + return 4, mitchellnetravali + case Lanczos2: + return 4, lanczos2 + case Lanczos3: + return 6, lanczos3 + default: + // Default to NearestNeighbor. + return 2, nearest + } +} + +// values <1 will sharpen the image +var blur = 1.0 + +// Resize scales an image to new width and height using the interpolation function interp. +// A new image with the given dimensions will be returned. +// If one of the parameters width or height is set to 0, its size will be calculated so that +// the aspect ratio is that of the originating image. +// The resizing algorithm uses channels for parallel computation. +// If the input image has width or height of 0, it is returned unchanged. +func Resize(width, height uint, img image.Image, interp InterpolationFunction) image.Image { + scaleX, scaleY := calcFactors(width, height, float64(img.Bounds().Dx()), float64(img.Bounds().Dy())) + if width == 0 { + width = uint(0.7 + float64(img.Bounds().Dx())/scaleX) + } + if height == 0 { + height = uint(0.7 + float64(img.Bounds().Dy())/scaleY) + } + + // Trivial case: return input image + if int(width) == img.Bounds().Dx() && int(height) == img.Bounds().Dy() { + return img + } + + // Input image has no pixels + if img.Bounds().Dx() <= 0 || img.Bounds().Dy() <= 0 { + return img + } + + if interp == NearestNeighbor { + return resizeNearest(width, height, scaleX, scaleY, img, interp) + } + + taps, kernel := interp.kernel() + cpus := runtime.GOMAXPROCS(0) + wg := sync.WaitGroup{} + + // Generic access to image.Image is slow in tight loops. + // The optimal access has to be determined from the concrete image type. + switch input := img.(type) { + case *image.RGBA: + // 8-bit precision + temp := image.NewRGBA(image.Rect(0, 0, input.Bounds().Dy(), int(width))) + result := image.NewRGBA(image.Rect(0, 0, int(width), int(height))) + + // horizontal filter, results in transposed temporary image + coeffs, offset, filterLength := createWeights8(temp.Bounds().Dy(), taps, blur, scaleX, kernel) + wg.Add(cpus) + for i := 0; i < cpus; i++ { + slice := makeSlice(temp, i, cpus).(*image.RGBA) + go func() { + defer wg.Done() + resizeRGBA(input, slice, scaleX, coeffs, offset, filterLength) + }() + } + wg.Wait() + + // horizontal filter on transposed image, result is not transposed + coeffs, offset, filterLength = createWeights8(result.Bounds().Dy(), taps, blur, scaleY, kernel) + wg.Add(cpus) + for i := 0; i < cpus; i++ { + slice := makeSlice(result, i, cpus).(*image.RGBA) + go func() { + defer wg.Done() + resizeRGBA(temp, slice, scaleY, coeffs, offset, filterLength) + }() + } + wg.Wait() + return result + case *image.NRGBA: + // 8-bit precision + temp := image.NewRGBA(image.Rect(0, 0, input.Bounds().Dy(), int(width))) + result := image.NewRGBA(image.Rect(0, 0, int(width), int(height))) + + // horizontal filter, results in transposed temporary image + coeffs, offset, filterLength := createWeights8(temp.Bounds().Dy(), taps, blur, scaleX, kernel) + wg.Add(cpus) + for i := 0; i < cpus; i++ { + slice := makeSlice(temp, i, cpus).(*image.RGBA) + go func() { + defer wg.Done() + resizeNRGBA(input, slice, scaleX, coeffs, offset, filterLength) + }() + } + wg.Wait() + + // horizontal filter on transposed image, result is not transposed + coeffs, offset, filterLength = createWeights8(result.Bounds().Dy(), taps, blur, scaleY, kernel) + wg.Add(cpus) + for i := 0; i < cpus; i++ { + slice := makeSlice(result, i, cpus).(*image.RGBA) + go func() { + defer wg.Done() + resizeRGBA(temp, slice, scaleY, coeffs, offset, filterLength) + }() + } + wg.Wait() + return result + + case *image.YCbCr: + // 8-bit precision + // accessing the YCbCr arrays in a tight loop is slow. + // converting the image to ycc increases performance by 2x. + temp := newYCC(image.Rect(0, 0, input.Bounds().Dy(), int(width)), input.SubsampleRatio) + result := newYCC(image.Rect(0, 0, int(width), int(height)), image.YCbCrSubsampleRatio444) + + coeffs, offset, filterLength := createWeights8(temp.Bounds().Dy(), taps, blur, scaleX, kernel) + in := imageYCbCrToYCC(input) + wg.Add(cpus) + for i := 0; i < cpus; i++ { + slice := makeSlice(temp, i, cpus).(*ycc) + go func() { + defer wg.Done() + resizeYCbCr(in, slice, scaleX, coeffs, offset, filterLength) + }() + } + wg.Wait() + + coeffs, offset, filterLength = createWeights8(result.Bounds().Dy(), taps, blur, scaleY, kernel) + wg.Add(cpus) + for i := 0; i < cpus; i++ { + slice := makeSlice(result, i, cpus).(*ycc) + go func() { + defer wg.Done() + resizeYCbCr(temp, slice, scaleY, coeffs, offset, filterLength) + }() + } + wg.Wait() + return result.YCbCr() + case *image.RGBA64: + // 16-bit precision + temp := image.NewRGBA64(image.Rect(0, 0, input.Bounds().Dy(), int(width))) + result := image.NewRGBA64(image.Rect(0, 0, int(width), int(height))) + + // horizontal filter, results in transposed temporary image + coeffs, offset, filterLength := createWeights16(temp.Bounds().Dy(), taps, blur, scaleX, kernel) + wg.Add(cpus) + for i := 0; i < cpus; i++ { + slice := makeSlice(temp, i, cpus).(*image.RGBA64) + go func() { + defer wg.Done() + resizeRGBA64(input, slice, scaleX, coeffs, offset, filterLength) + }() + } + wg.Wait() + + // horizontal filter on transposed image, result is not transposed + coeffs, offset, filterLength = createWeights16(result.Bounds().Dy(), taps, blur, scaleY, kernel) + wg.Add(cpus) + for i := 0; i < cpus; i++ { + slice := makeSlice(result, i, cpus).(*image.RGBA64) + go func() { + defer wg.Done() + resizeRGBA64(temp, slice, scaleY, coeffs, offset, filterLength) + }() + } + wg.Wait() + return result + case *image.NRGBA64: + // 16-bit precision + temp := image.NewRGBA64(image.Rect(0, 0, input.Bounds().Dy(), int(width))) + result := image.NewRGBA64(image.Rect(0, 0, int(width), int(height))) + + // horizontal filter, results in transposed temporary image + coeffs, offset, filterLength := createWeights16(temp.Bounds().Dy(), taps, blur, scaleX, kernel) + wg.Add(cpus) + for i := 0; i < cpus; i++ { + slice := makeSlice(temp, i, cpus).(*image.RGBA64) + go func() { + defer wg.Done() + resizeNRGBA64(input, slice, scaleX, coeffs, offset, filterLength) + }() + } + wg.Wait() + + // horizontal filter on transposed image, result is not transposed + coeffs, offset, filterLength = createWeights16(result.Bounds().Dy(), taps, blur, scaleY, kernel) + wg.Add(cpus) + for i := 0; i < cpus; i++ { + slice := makeSlice(result, i, cpus).(*image.RGBA64) + go func() { + defer wg.Done() + resizeRGBA64(temp, slice, scaleY, coeffs, offset, filterLength) + }() + } + wg.Wait() + return result + case *image.Gray: + // 8-bit precision + temp := image.NewGray(image.Rect(0, 0, input.Bounds().Dy(), int(width))) + result := image.NewGray(image.Rect(0, 0, int(width), int(height))) + + // horizontal filter, results in transposed temporary image + coeffs, offset, filterLength := createWeights8(temp.Bounds().Dy(), taps, blur, scaleX, kernel) + wg.Add(cpus) + for i := 0; i < cpus; i++ { + slice := makeSlice(temp, i, cpus).(*image.Gray) + go func() { + defer wg.Done() + resizeGray(input, slice, scaleX, coeffs, offset, filterLength) + }() + } + wg.Wait() + + // horizontal filter on transposed image, result is not transposed + coeffs, offset, filterLength = createWeights8(result.Bounds().Dy(), taps, blur, scaleY, kernel) + wg.Add(cpus) + for i := 0; i < cpus; i++ { + slice := makeSlice(result, i, cpus).(*image.Gray) + go func() { + defer wg.Done() + resizeGray(temp, slice, scaleY, coeffs, offset, filterLength) + }() + } + wg.Wait() + return result + case *image.Gray16: + // 16-bit precision + temp := image.NewGray16(image.Rect(0, 0, input.Bounds().Dy(), int(width))) + result := image.NewGray16(image.Rect(0, 0, int(width), int(height))) + + // horizontal filter, results in transposed temporary image + coeffs, offset, filterLength := createWeights16(temp.Bounds().Dy(), taps, blur, scaleX, kernel) + wg.Add(cpus) + for i := 0; i < cpus; i++ { + slice := makeSlice(temp, i, cpus).(*image.Gray16) + go func() { + defer wg.Done() + resizeGray16(input, slice, scaleX, coeffs, offset, filterLength) + }() + } + wg.Wait() + + // horizontal filter on transposed image, result is not transposed + coeffs, offset, filterLength = createWeights16(result.Bounds().Dy(), taps, blur, scaleY, kernel) + wg.Add(cpus) + for i := 0; i < cpus; i++ { + slice := makeSlice(result, i, cpus).(*image.Gray16) + go func() { + defer wg.Done() + resizeGray16(temp, slice, scaleY, coeffs, offset, filterLength) + }() + } + wg.Wait() + return result + default: + // 16-bit precision + temp := image.NewRGBA64(image.Rect(0, 0, img.Bounds().Dy(), int(width))) + result := image.NewRGBA64(image.Rect(0, 0, int(width), int(height))) + + // horizontal filter, results in transposed temporary image + coeffs, offset, filterLength := createWeights16(temp.Bounds().Dy(), taps, blur, scaleX, kernel) + wg.Add(cpus) + for i := 0; i < cpus; i++ { + slice := makeSlice(temp, i, cpus).(*image.RGBA64) + go func() { + defer wg.Done() + resizeGeneric(img, slice, scaleX, coeffs, offset, filterLength) + }() + } + wg.Wait() + + // horizontal filter on transposed image, result is not transposed + coeffs, offset, filterLength = createWeights16(result.Bounds().Dy(), taps, blur, scaleY, kernel) + wg.Add(cpus) + for i := 0; i < cpus; i++ { + slice := makeSlice(result, i, cpus).(*image.RGBA64) + go func() { + defer wg.Done() + resizeRGBA64(temp, slice, scaleY, coeffs, offset, filterLength) + }() + } + wg.Wait() + return result + } +} + +func resizeNearest(width, height uint, scaleX, scaleY float64, img image.Image, interp InterpolationFunction) image.Image { + taps, _ := interp.kernel() + cpus := runtime.GOMAXPROCS(0) + wg := sync.WaitGroup{} + + switch input := img.(type) { + case *image.RGBA: + // 8-bit precision + temp := image.NewRGBA(image.Rect(0, 0, input.Bounds().Dy(), int(width))) + result := image.NewRGBA(image.Rect(0, 0, int(width), int(height))) + + // horizontal filter, results in transposed temporary image + coeffs, offset, filterLength := createWeightsNearest(temp.Bounds().Dy(), taps, blur, scaleX) + wg.Add(cpus) + for i := 0; i < cpus; i++ { + slice := makeSlice(temp, i, cpus).(*image.RGBA) + go func() { + defer wg.Done() + nearestRGBA(input, slice, scaleX, coeffs, offset, filterLength) + }() + } + wg.Wait() + + // horizontal filter on transposed image, result is not transposed + coeffs, offset, filterLength = createWeightsNearest(result.Bounds().Dy(), taps, blur, scaleY) + wg.Add(cpus) + for i := 0; i < cpus; i++ { + slice := makeSlice(result, i, cpus).(*image.RGBA) + go func() { + defer wg.Done() + nearestRGBA(temp, slice, scaleY, coeffs, offset, filterLength) + }() + } + wg.Wait() + return result + case *image.NRGBA: + // 8-bit precision + temp := image.NewNRGBA(image.Rect(0, 0, input.Bounds().Dy(), int(width))) + result := image.NewNRGBA(image.Rect(0, 0, int(width), int(height))) + + // horizontal filter, results in transposed temporary image + coeffs, offset, filterLength := createWeightsNearest(temp.Bounds().Dy(), taps, blur, scaleX) + wg.Add(cpus) + for i := 0; i < cpus; i++ { + slice := makeSlice(temp, i, cpus).(*image.NRGBA) + go func() { + defer wg.Done() + nearestNRGBA(input, slice, scaleX, coeffs, offset, filterLength) + }() + } + wg.Wait() + + // horizontal filter on transposed image, result is not transposed + coeffs, offset, filterLength = createWeightsNearest(result.Bounds().Dy(), taps, blur, scaleY) + wg.Add(cpus) + for i := 0; i < cpus; i++ { + slice := makeSlice(result, i, cpus).(*image.NRGBA) + go func() { + defer wg.Done() + nearestNRGBA(temp, slice, scaleY, coeffs, offset, filterLength) + }() + } + wg.Wait() + return result + case *image.YCbCr: + // 8-bit precision + // accessing the YCbCr arrays in a tight loop is slow. + // converting the image to ycc increases performance by 2x. + temp := newYCC(image.Rect(0, 0, input.Bounds().Dy(), int(width)), input.SubsampleRatio) + result := newYCC(image.Rect(0, 0, int(width), int(height)), image.YCbCrSubsampleRatio444) + + coeffs, offset, filterLength := createWeightsNearest(temp.Bounds().Dy(), taps, blur, scaleX) + in := imageYCbCrToYCC(input) + wg.Add(cpus) + for i := 0; i < cpus; i++ { + slice := makeSlice(temp, i, cpus).(*ycc) + go func() { + defer wg.Done() + nearestYCbCr(in, slice, scaleX, coeffs, offset, filterLength) + }() + } + wg.Wait() + + coeffs, offset, filterLength = createWeightsNearest(result.Bounds().Dy(), taps, blur, scaleY) + wg.Add(cpus) + for i := 0; i < cpus; i++ { + slice := makeSlice(result, i, cpus).(*ycc) + go func() { + defer wg.Done() + nearestYCbCr(temp, slice, scaleY, coeffs, offset, filterLength) + }() + } + wg.Wait() + return result.YCbCr() + case *image.RGBA64: + // 16-bit precision + temp := image.NewRGBA64(image.Rect(0, 0, input.Bounds().Dy(), int(width))) + result := image.NewRGBA64(image.Rect(0, 0, int(width), int(height))) + + // horizontal filter, results in transposed temporary image + coeffs, offset, filterLength := createWeightsNearest(temp.Bounds().Dy(), taps, blur, scaleX) + wg.Add(cpus) + for i := 0; i < cpus; i++ { + slice := makeSlice(temp, i, cpus).(*image.RGBA64) + go func() { + defer wg.Done() + nearestRGBA64(input, slice, scaleX, coeffs, offset, filterLength) + }() + } + wg.Wait() + + // horizontal filter on transposed image, result is not transposed + coeffs, offset, filterLength = createWeightsNearest(result.Bounds().Dy(), taps, blur, scaleY) + wg.Add(cpus) + for i := 0; i < cpus; i++ { + slice := makeSlice(result, i, cpus).(*image.RGBA64) + go func() { + defer wg.Done() + nearestRGBA64(temp, slice, scaleY, coeffs, offset, filterLength) + }() + } + wg.Wait() + return result + case *image.NRGBA64: + // 16-bit precision + temp := image.NewNRGBA64(image.Rect(0, 0, input.Bounds().Dy(), int(width))) + result := image.NewNRGBA64(image.Rect(0, 0, int(width), int(height))) + + // horizontal filter, results in transposed temporary image + coeffs, offset, filterLength := createWeightsNearest(temp.Bounds().Dy(), taps, blur, scaleX) + wg.Add(cpus) + for i := 0; i < cpus; i++ { + slice := makeSlice(temp, i, cpus).(*image.NRGBA64) + go func() { + defer wg.Done() + nearestNRGBA64(input, slice, scaleX, coeffs, offset, filterLength) + }() + } + wg.Wait() + + // horizontal filter on transposed image, result is not transposed + coeffs, offset, filterLength = createWeightsNearest(result.Bounds().Dy(), taps, blur, scaleY) + wg.Add(cpus) + for i := 0; i < cpus; i++ { + slice := makeSlice(result, i, cpus).(*image.NRGBA64) + go func() { + defer wg.Done() + nearestNRGBA64(temp, slice, scaleY, coeffs, offset, filterLength) + }() + } + wg.Wait() + return result + case *image.Gray: + // 8-bit precision + temp := image.NewGray(image.Rect(0, 0, input.Bounds().Dy(), int(width))) + result := image.NewGray(image.Rect(0, 0, int(width), int(height))) + + // horizontal filter, results in transposed temporary image + coeffs, offset, filterLength := createWeightsNearest(temp.Bounds().Dy(), taps, blur, scaleX) + wg.Add(cpus) + for i := 0; i < cpus; i++ { + slice := makeSlice(temp, i, cpus).(*image.Gray) + go func() { + defer wg.Done() + nearestGray(input, slice, scaleX, coeffs, offset, filterLength) + }() + } + wg.Wait() + + // horizontal filter on transposed image, result is not transposed + coeffs, offset, filterLength = createWeightsNearest(result.Bounds().Dy(), taps, blur, scaleY) + wg.Add(cpus) + for i := 0; i < cpus; i++ { + slice := makeSlice(result, i, cpus).(*image.Gray) + go func() { + defer wg.Done() + nearestGray(temp, slice, scaleY, coeffs, offset, filterLength) + }() + } + wg.Wait() + return result + case *image.Gray16: + // 16-bit precision + temp := image.NewGray16(image.Rect(0, 0, input.Bounds().Dy(), int(width))) + result := image.NewGray16(image.Rect(0, 0, int(width), int(height))) + + // horizontal filter, results in transposed temporary image + coeffs, offset, filterLength := createWeightsNearest(temp.Bounds().Dy(), taps, blur, scaleX) + wg.Add(cpus) + for i := 0; i < cpus; i++ { + slice := makeSlice(temp, i, cpus).(*image.Gray16) + go func() { + defer wg.Done() + nearestGray16(input, slice, scaleX, coeffs, offset, filterLength) + }() + } + wg.Wait() + + // horizontal filter on transposed image, result is not transposed + coeffs, offset, filterLength = createWeightsNearest(result.Bounds().Dy(), taps, blur, scaleY) + wg.Add(cpus) + for i := 0; i < cpus; i++ { + slice := makeSlice(result, i, cpus).(*image.Gray16) + go func() { + defer wg.Done() + nearestGray16(temp, slice, scaleY, coeffs, offset, filterLength) + }() + } + wg.Wait() + return result + default: + // 16-bit precision + temp := image.NewRGBA64(image.Rect(0, 0, img.Bounds().Dy(), int(width))) + result := image.NewRGBA64(image.Rect(0, 0, int(width), int(height))) + + // horizontal filter, results in transposed temporary image + coeffs, offset, filterLength := createWeightsNearest(temp.Bounds().Dy(), taps, blur, scaleX) + wg.Add(cpus) + for i := 0; i < cpus; i++ { + slice := makeSlice(temp, i, cpus).(*image.RGBA64) + go func() { + defer wg.Done() + nearestGeneric(img, slice, scaleX, coeffs, offset, filterLength) + }() + } + wg.Wait() + + // horizontal filter on transposed image, result is not transposed + coeffs, offset, filterLength = createWeightsNearest(result.Bounds().Dy(), taps, blur, scaleY) + wg.Add(cpus) + for i := 0; i < cpus; i++ { + slice := makeSlice(result, i, cpus).(*image.RGBA64) + go func() { + defer wg.Done() + nearestRGBA64(temp, slice, scaleY, coeffs, offset, filterLength) + }() + } + wg.Wait() + return result + } + +} + +// Calculates scaling factors using old and new image dimensions. +func calcFactors(width, height uint, oldWidth, oldHeight float64) (scaleX, scaleY float64) { + if width == 0 { + if height == 0 { + scaleX = 1.0 + scaleY = 1.0 + } else { + scaleY = oldHeight / float64(height) + scaleX = scaleY + } + } else { + scaleX = oldWidth / float64(width) + if height == 0 { + scaleY = scaleX + } else { + scaleY = oldHeight / float64(height) + } + } + return +} + +type imageWithSubImage interface { + image.Image + SubImage(image.Rectangle) image.Image +} + +func makeSlice(img imageWithSubImage, i, n int) image.Image { + return img.SubImage(image.Rect(img.Bounds().Min.X, img.Bounds().Min.Y+i*img.Bounds().Dy()/n, img.Bounds().Max.X, img.Bounds().Min.Y+(i+1)*img.Bounds().Dy()/n)) +} diff --git a/vendor/github.com/nfnt/resize/thumbnail.go b/vendor/github.com/nfnt/resize/thumbnail.go new file mode 100644 index 000000000..9efc246be --- /dev/null +++ b/vendor/github.com/nfnt/resize/thumbnail.go @@ -0,0 +1,55 @@ +/* +Copyright (c) 2012, Jan Schlicht + +Permission to use, copy, modify, and/or distribute this software for any purpose +with or without fee is hereby granted, provided that the above copyright notice +and this permission notice appear in all copies. + +THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH +REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND +FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, +INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS +OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER +TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF +THIS SOFTWARE. +*/ + +package resize + +import ( + "image" +) + +// Thumbnail will downscale provided image to max width and height preserving +// original aspect ratio and using the interpolation function interp. +// It will return original image, without processing it, if original sizes +// are already smaller than provided constraints. +func Thumbnail(maxWidth, maxHeight uint, img image.Image, interp InterpolationFunction) image.Image { + origBounds := img.Bounds() + origWidth := uint(origBounds.Dx()) + origHeight := uint(origBounds.Dy()) + newWidth, newHeight := origWidth, origHeight + + // Return original image if it have same or smaller size as constraints + if maxWidth >= origWidth && maxHeight >= origHeight { + return img + } + + // Preserve aspect ratio + if origWidth > maxWidth { + newHeight = uint(origHeight * maxWidth / origWidth) + if newHeight < 1 { + newHeight = 1 + } + newWidth = maxWidth + } + + if newHeight > maxHeight { + newWidth = uint(newWidth * maxHeight / newHeight) + if newWidth < 1 { + newWidth = 1 + } + newHeight = maxHeight + } + return Resize(newWidth, newHeight, img, interp) +} diff --git a/vendor/github.com/nfnt/resize/ycc.go b/vendor/github.com/nfnt/resize/ycc.go new file mode 100644 index 000000000..143e4d06a --- /dev/null +++ b/vendor/github.com/nfnt/resize/ycc.go @@ -0,0 +1,387 @@ +/* +Copyright (c) 2014, Charlie Vieth + +Permission to use, copy, modify, and/or distribute this software for any purpose +with or without fee is hereby granted, provided that the above copyright notice +and this permission notice appear in all copies. + +THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH +REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND +FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, +INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS +OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER +TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF +THIS SOFTWARE. +*/ + +package resize + +import ( + "image" + "image/color" +) + +// ycc is an in memory YCbCr image. The Y, Cb and Cr samples are held in a +// single slice to increase resizing performance. +type ycc struct { + // Pix holds the image's pixels, in Y, Cb, Cr order. The pixel at + // (x, y) starts at Pix[(y-Rect.Min.Y)*Stride + (x-Rect.Min.X)*3]. + Pix []uint8 + // Stride is the Pix stride (in bytes) between vertically adjacent pixels. + Stride int + // Rect is the image's bounds. + Rect image.Rectangle + // SubsampleRatio is the subsample ratio of the original YCbCr image. + SubsampleRatio image.YCbCrSubsampleRatio +} + +// PixOffset returns the index of the first element of Pix that corresponds to +// the pixel at (x, y). +func (p *ycc) PixOffset(x, y int) int { + return (y-p.Rect.Min.Y)*p.Stride + (x-p.Rect.Min.X)*3 +} + +func (p *ycc) Bounds() image.Rectangle { + return p.Rect +} + +func (p *ycc) ColorModel() color.Model { + return color.YCbCrModel +} + +func (p *ycc) At(x, y int) color.Color { + if !(image.Point{x, y}.In(p.Rect)) { + return color.YCbCr{} + } + i := p.PixOffset(x, y) + return color.YCbCr{ + p.Pix[i+0], + p.Pix[i+1], + p.Pix[i+2], + } +} + +func (p *ycc) Opaque() bool { + return true +} + +// SubImage returns an image representing the portion of the image p visible +// through r. The returned value shares pixels with the original image. +func (p *ycc) SubImage(r image.Rectangle) image.Image { + r = r.Intersect(p.Rect) + if r.Empty() { + return &ycc{SubsampleRatio: p.SubsampleRatio} + } + i := p.PixOffset(r.Min.X, r.Min.Y) + return &ycc{ + Pix: p.Pix[i:], + Stride: p.Stride, + Rect: r, + SubsampleRatio: p.SubsampleRatio, + } +} + +// newYCC returns a new ycc with the given bounds and subsample ratio. +func newYCC(r image.Rectangle, s image.YCbCrSubsampleRatio) *ycc { + w, h := r.Dx(), r.Dy() + buf := make([]uint8, 3*w*h) + return &ycc{Pix: buf, Stride: 3 * w, Rect: r, SubsampleRatio: s} +} + +// Copy of image.YCbCrSubsampleRatio constants - this allows us to support +// older versions of Go where these constants are not defined (i.e. Go 1.4) +const ( + ycbcrSubsampleRatio444 image.YCbCrSubsampleRatio = iota + ycbcrSubsampleRatio422 + ycbcrSubsampleRatio420 + ycbcrSubsampleRatio440 + ycbcrSubsampleRatio411 + ycbcrSubsampleRatio410 +) + +// YCbCr converts ycc to a YCbCr image with the same subsample ratio +// as the YCbCr image that ycc was generated from. +func (p *ycc) YCbCr() *image.YCbCr { + ycbcr := image.NewYCbCr(p.Rect, p.SubsampleRatio) + switch ycbcr.SubsampleRatio { + case ycbcrSubsampleRatio422: + return p.ycbcr422(ycbcr) + case ycbcrSubsampleRatio420: + return p.ycbcr420(ycbcr) + case ycbcrSubsampleRatio440: + return p.ycbcr440(ycbcr) + case ycbcrSubsampleRatio444: + return p.ycbcr444(ycbcr) + case ycbcrSubsampleRatio411: + return p.ycbcr411(ycbcr) + case ycbcrSubsampleRatio410: + return p.ycbcr410(ycbcr) + } + return ycbcr +} + +// imageYCbCrToYCC converts a YCbCr image to a ycc image for resizing. +func imageYCbCrToYCC(in *image.YCbCr) *ycc { + w, h := in.Rect.Dx(), in.Rect.Dy() + p := ycc{ + Pix: make([]uint8, 3*w*h), + Stride: 3 * w, + Rect: image.Rect(0, 0, w, h), + SubsampleRatio: in.SubsampleRatio, + } + switch in.SubsampleRatio { + case ycbcrSubsampleRatio422: + return convertToYCC422(in, &p) + case ycbcrSubsampleRatio420: + return convertToYCC420(in, &p) + case ycbcrSubsampleRatio440: + return convertToYCC440(in, &p) + case ycbcrSubsampleRatio444: + return convertToYCC444(in, &p) + case ycbcrSubsampleRatio411: + return convertToYCC411(in, &p) + case ycbcrSubsampleRatio410: + return convertToYCC410(in, &p) + } + return &p +} + +func (p *ycc) ycbcr422(ycbcr *image.YCbCr) *image.YCbCr { + var off int + Pix := p.Pix + Y := ycbcr.Y + Cb := ycbcr.Cb + Cr := ycbcr.Cr + for y := 0; y < ycbcr.Rect.Max.Y-ycbcr.Rect.Min.Y; y++ { + yy := y * ycbcr.YStride + cy := y * ycbcr.CStride + for x := 0; x < ycbcr.Rect.Max.X-ycbcr.Rect.Min.X; x++ { + ci := cy + x/2 + Y[yy+x] = Pix[off+0] + Cb[ci] = Pix[off+1] + Cr[ci] = Pix[off+2] + off += 3 + } + } + return ycbcr +} + +func (p *ycc) ycbcr420(ycbcr *image.YCbCr) *image.YCbCr { + var off int + Pix := p.Pix + Y := ycbcr.Y + Cb := ycbcr.Cb + Cr := ycbcr.Cr + for y := 0; y < ycbcr.Rect.Max.Y-ycbcr.Rect.Min.Y; y++ { + yy := y * ycbcr.YStride + cy := (y / 2) * ycbcr.CStride + for x := 0; x < ycbcr.Rect.Max.X-ycbcr.Rect.Min.X; x++ { + ci := cy + x/2 + Y[yy+x] = Pix[off+0] + Cb[ci] = Pix[off+1] + Cr[ci] = Pix[off+2] + off += 3 + } + } + return ycbcr +} + +func (p *ycc) ycbcr440(ycbcr *image.YCbCr) *image.YCbCr { + var off int + Pix := p.Pix + Y := ycbcr.Y + Cb := ycbcr.Cb + Cr := ycbcr.Cr + for y := 0; y < ycbcr.Rect.Max.Y-ycbcr.Rect.Min.Y; y++ { + yy := y * ycbcr.YStride + cy := (y / 2) * ycbcr.CStride + for x := 0; x < ycbcr.Rect.Max.X-ycbcr.Rect.Min.X; x++ { + ci := cy + x + Y[yy+x] = Pix[off+0] + Cb[ci] = Pix[off+1] + Cr[ci] = Pix[off+2] + off += 3 + } + } + return ycbcr +} + +func (p *ycc) ycbcr444(ycbcr *image.YCbCr) *image.YCbCr { + var off int + Pix := p.Pix + Y := ycbcr.Y + Cb := ycbcr.Cb + Cr := ycbcr.Cr + for y := 0; y < ycbcr.Rect.Max.Y-ycbcr.Rect.Min.Y; y++ { + yy := y * ycbcr.YStride + cy := y * ycbcr.CStride + for x := 0; x < ycbcr.Rect.Max.X-ycbcr.Rect.Min.X; x++ { + ci := cy + x + Y[yy+x] = Pix[off+0] + Cb[ci] = Pix[off+1] + Cr[ci] = Pix[off+2] + off += 3 + } + } + return ycbcr +} + +func (p *ycc) ycbcr411(ycbcr *image.YCbCr) *image.YCbCr { + var off int + Pix := p.Pix + Y := ycbcr.Y + Cb := ycbcr.Cb + Cr := ycbcr.Cr + for y := 0; y < ycbcr.Rect.Max.Y-ycbcr.Rect.Min.Y; y++ { + yy := y * ycbcr.YStride + cy := y * ycbcr.CStride + for x := 0; x < ycbcr.Rect.Max.X-ycbcr.Rect.Min.X; x++ { + ci := cy + x/4 + Y[yy+x] = Pix[off+0] + Cb[ci] = Pix[off+1] + Cr[ci] = Pix[off+2] + off += 3 + } + } + return ycbcr +} + +func (p *ycc) ycbcr410(ycbcr *image.YCbCr) *image.YCbCr { + var off int + Pix := p.Pix + Y := ycbcr.Y + Cb := ycbcr.Cb + Cr := ycbcr.Cr + for y := 0; y < ycbcr.Rect.Max.Y-ycbcr.Rect.Min.Y; y++ { + yy := y * ycbcr.YStride + cy := (y / 2) * ycbcr.CStride + for x := 0; x < ycbcr.Rect.Max.X-ycbcr.Rect.Min.X; x++ { + ci := cy + x/4 + Y[yy+x] = Pix[off+0] + Cb[ci] = Pix[off+1] + Cr[ci] = Pix[off+2] + off += 3 + } + } + return ycbcr +} + +func convertToYCC422(in *image.YCbCr, p *ycc) *ycc { + var off int + Pix := p.Pix + Y := in.Y + Cb := in.Cb + Cr := in.Cr + for y := 0; y < in.Rect.Max.Y-in.Rect.Min.Y; y++ { + yy := y * in.YStride + cy := y * in.CStride + for x := 0; x < in.Rect.Max.X-in.Rect.Min.X; x++ { + ci := cy + x/2 + Pix[off+0] = Y[yy+x] + Pix[off+1] = Cb[ci] + Pix[off+2] = Cr[ci] + off += 3 + } + } + return p +} + +func convertToYCC420(in *image.YCbCr, p *ycc) *ycc { + var off int + Pix := p.Pix + Y := in.Y + Cb := in.Cb + Cr := in.Cr + for y := 0; y < in.Rect.Max.Y-in.Rect.Min.Y; y++ { + yy := y * in.YStride + cy := (y / 2) * in.CStride + for x := 0; x < in.Rect.Max.X-in.Rect.Min.X; x++ { + ci := cy + x/2 + Pix[off+0] = Y[yy+x] + Pix[off+1] = Cb[ci] + Pix[off+2] = Cr[ci] + off += 3 + } + } + return p +} + +func convertToYCC440(in *image.YCbCr, p *ycc) *ycc { + var off int + Pix := p.Pix + Y := in.Y + Cb := in.Cb + Cr := in.Cr + for y := 0; y < in.Rect.Max.Y-in.Rect.Min.Y; y++ { + yy := y * in.YStride + cy := (y / 2) * in.CStride + for x := 0; x < in.Rect.Max.X-in.Rect.Min.X; x++ { + ci := cy + x + Pix[off+0] = Y[yy+x] + Pix[off+1] = Cb[ci] + Pix[off+2] = Cr[ci] + off += 3 + } + } + return p +} + +func convertToYCC444(in *image.YCbCr, p *ycc) *ycc { + var off int + Pix := p.Pix + Y := in.Y + Cb := in.Cb + Cr := in.Cr + for y := 0; y < in.Rect.Max.Y-in.Rect.Min.Y; y++ { + yy := y * in.YStride + cy := y * in.CStride + for x := 0; x < in.Rect.Max.X-in.Rect.Min.X; x++ { + ci := cy + x + Pix[off+0] = Y[yy+x] + Pix[off+1] = Cb[ci] + Pix[off+2] = Cr[ci] + off += 3 + } + } + return p +} + +func convertToYCC411(in *image.YCbCr, p *ycc) *ycc { + var off int + Pix := p.Pix + Y := in.Y + Cb := in.Cb + Cr := in.Cr + for y := 0; y < in.Rect.Max.Y-in.Rect.Min.Y; y++ { + yy := y * in.YStride + cy := y * in.CStride + for x := 0; x < in.Rect.Max.X-in.Rect.Min.X; x++ { + ci := cy + x/4 + Pix[off+0] = Y[yy+x] + Pix[off+1] = Cb[ci] + Pix[off+2] = Cr[ci] + off += 3 + } + } + return p +} + +func convertToYCC410(in *image.YCbCr, p *ycc) *ycc { + var off int + Pix := p.Pix + Y := in.Y + Cb := in.Cb + Cr := in.Cr + for y := 0; y < in.Rect.Max.Y-in.Rect.Min.Y; y++ { + yy := y * in.YStride + cy := (y / 2) * in.CStride + for x := 0; x < in.Rect.Max.X-in.Rect.Min.X; x++ { + ci := cy + x/4 + Pix[off+0] = Y[yy+x] + Pix[off+1] = Cb[ci] + Pix[off+2] = Cr[ci] + off += 3 + } + } + return p +} diff --git a/vendor/modules.txt b/vendor/modules.txt index b9ea5e2e0..27c01e28e 100644 --- a/vendor/modules.txt +++ b/vendor/modules.txt @@ -90,6 +90,10 @@ github.com/chromedp/cdproto/webauthn github.com/chromedp/chromedp github.com/chromedp/chromedp/device github.com/chromedp/chromedp/kb +# github.com/corona10/goimagehash v1.0.3 +github.com/corona10/goimagehash +github.com/corona10/goimagehash/etcs +github.com/corona10/goimagehash/transforms # github.com/cpuguy83/go-md2man/v2 v2.0.0 github.com/cpuguy83/go-md2man/v2/md2man # github.com/davecgh/go-spew v1.1.1 @@ -228,6 +232,8 @@ github.com/modern-go/concurrent github.com/modern-go/reflect2 # github.com/natefinch/pie v0.0.0-20170715172608-9a0d72014007 github.com/natefinch/pie +# github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646 +github.com/nfnt/resize # github.com/pelletier/go-toml v1.2.0 github.com/pelletier/go-toml # github.com/pkg/errors v0.9.1 From f6ffda7504083620103756af69b024b3a0f6b1e4 Mon Sep 17 00:00:00 2001 From: WithoutPants <53250216+WithoutPants@users.noreply.github.com> Date: Mon, 12 Apr 2021 09:31:33 +1000 Subject: [PATCH 21/66] Setup and migration UI refactor (#1190) * Make config instance-based * Remove config dependency in paths * Refactor config init * Allow startup without database * Get system status at UI initialise * Add setup wizard * Cache and Metadata optional. Database mandatory * Handle metadata not set during full import/export * Add links * Remove config check middleware * Stash not mandatory * Panic on missing mandatory config fields * Redirect setup to main page if setup not required * Add migration UI * Remove unused stuff * Move UI initialisation into App * Don't create metadata paths on RefreshConfig * Add folder selector for generated in setup * Env variable to set and create config file. Make docker images use a fixed config file. * Set config file during setup --- docker/build/x86_64/Dockerfile | 2 + docker/ci/x86_64/Dockerfile | 2 + docker/develop/x86_64/Dockerfile | 3 + docker/production/x86_64/Dockerfile | 3 + graphql/documents/mutations/config.graphql | 8 + .../queries/settings/metadata.graphql | 9 + graphql/schema/schema.graphql | 5 +- graphql/schema/types/config.graphql | 10 + graphql/schema/types/metadata.graphql | 17 + main.go | 8 - pkg/api/migrate.go | 96 ---- pkg/api/resolver_mutation_configure.go | 120 +++-- pkg/api/resolver_mutation_metadata.go | 14 +- pkg/api/resolver_mutation_plugin.go | 1 + pkg/api/resolver_mutation_scene.go | 8 +- pkg/api/resolver_mutation_stash_box.go | 2 +- pkg/api/resolver_query_configuration.go | 2 + pkg/api/resolver_query_metadata.go | 4 + pkg/api/resolver_query_scene.go | 2 +- pkg/api/resolver_query_scraper.go | 2 +- pkg/api/routes_scene.go | 18 +- pkg/api/server.go | 134 +---- pkg/api/session.go | 8 +- pkg/database/database.go | 35 +- pkg/manager/apikey.go | 4 +- pkg/manager/checksum.go | 2 + pkg/manager/config/config.go | 235 +++++---- pkg/manager/config/init.go | 104 ++++ pkg/manager/manager.go | 272 +++++++---- pkg/manager/manager_tasks.go | 46 +- pkg/manager/paths/paths.go | 16 +- pkg/manager/paths/paths_generated.go | 17 +- pkg/manager/scene.go | 4 +- pkg/manager/task_clean.go | 9 +- pkg/manager/task_export.go | 2 +- pkg/manager/task_import.go | 2 +- pkg/manager/task_scan.go | 8 +- pkg/manager/task_transcode.go | 2 +- pkg/scraper/image.go | 5 +- pkg/scraper/scrapers.go | 25 +- pkg/scraper/url.go | 16 +- pkg/scraper/xpath_test.go | 20 +- pkg/sqlite/transaction.go | 8 + ui/setup/index.html | 38 -- ui/setup/migrate.html | 37 -- ui/setup/milligram.min.css | 11 - ui/v2.5/src/App.tsx | 91 +++- .../src/components/Changelog/versions/v070.md | 1 + .../SettingsTasksPanel/SettingsTasksPanel.tsx | 18 +- ui/v2.5/src/components/Setup/Migrate.tsx | 158 ++++++ ui/v2.5/src/components/Setup/Setup.tsx | 460 ++++++++++++++++++ ui/v2.5/src/core/StashService.ts | 25 + 52 files changed, 1467 insertions(+), 682 deletions(-) delete mode 100644 pkg/api/migrate.go create mode 100644 pkg/manager/config/init.go delete mode 100644 ui/setup/index.html delete mode 100644 ui/setup/migrate.html delete mode 100755 ui/setup/milligram.min.css create mode 100644 ui/v2.5/src/components/Setup/Migrate.tsx create mode 100644 ui/v2.5/src/components/Setup/Setup.tsx diff --git a/docker/build/x86_64/Dockerfile b/docker/build/x86_64/Dockerfile index 6eabf1105..3a2d8a198 100644 --- a/docker/build/x86_64/Dockerfile +++ b/docker/build/x86_64/Dockerfile @@ -53,6 +53,8 @@ FROM ubuntu:20.04 as app RUN apt-get update && apt-get -y install ca-certificates COPY --from=compiler /stash/stash /ffmpeg/ffmpeg /ffmpeg/ffprobe /usr/bin/ +ENV STASH_CONFIG_FILE=/root/.stash/config.yml + EXPOSE 9999 CMD ["stash"] diff --git a/docker/ci/x86_64/Dockerfile b/docker/ci/x86_64/Dockerfile index 833037c36..1db050163 100644 --- a/docker/ci/x86_64/Dockerfile +++ b/docker/ci/x86_64/Dockerfile @@ -12,6 +12,8 @@ FROM ubuntu:20.04 as app run apt update && apt install -y python3 python3 python-is-python3 python3-requests ffmpeg && rm -rf /var/lib/apt/lists/* COPY --from=prep /stash /usr/bin/ +ENV STASH_CONFIG_FILE=/root/.stash/config.yml + EXPOSE 9999 CMD ["stash"] diff --git a/docker/develop/x86_64/Dockerfile b/docker/develop/x86_64/Dockerfile index cca25aafd..c2efef3a1 100644 --- a/docker/develop/x86_64/Dockerfile +++ b/docker/develop/x86_64/Dockerfile @@ -20,5 +20,8 @@ RUN curl --http1.1 -o /ffmpeg.tar.xz https://johnvansickle.com/ffmpeg/releases/f FROM ubuntu:20.04 as app RUN apt-get update && apt-get -y install ca-certificates COPY --from=prep /stash /ffmpeg/ffmpeg /ffmpeg/ffprobe /usr/bin/ + +ENV STASH_CONFIG_FILE=/root/.stash/config.yml + EXPOSE 9999 CMD ["stash"] diff --git a/docker/production/x86_64/Dockerfile b/docker/production/x86_64/Dockerfile index bc152161c..95a2516ed 100644 --- a/docker/production/x86_64/Dockerfile +++ b/docker/production/x86_64/Dockerfile @@ -20,5 +20,8 @@ RUN curl --http1.1 -o /ffmpeg.tar.xz https://johnvansickle.com/ffmpeg/releases/f FROM ubuntu:20.04 as app RUN apt-get update && apt-get -y install ca-certificates COPY --from=prep /stash /ffmpeg/ffmpeg /ffmpeg/ffprobe /usr/bin/ + +ENV STASH_CONFIG_FILE=/root/.stash/config.yml + EXPOSE 9999 CMD ["stash"] diff --git a/graphql/documents/mutations/config.graphql b/graphql/documents/mutations/config.graphql index 3b2dc6cb2..25bc7f01d 100644 --- a/graphql/documents/mutations/config.graphql +++ b/graphql/documents/mutations/config.graphql @@ -1,3 +1,11 @@ +mutation Setup($input: SetupInput!) { + setup(input: $input) +} + +mutation Migrate($input: MigrateInput!) { + migrate(input: $input) +} + mutation ConfigureGeneral($input: ConfigGeneralInput!) { configureGeneral(input: $input) { ...ConfigGeneralData diff --git a/graphql/documents/queries/settings/metadata.graphql b/graphql/documents/queries/settings/metadata.graphql index 376f8e4a0..048d287e0 100644 --- a/graphql/documents/queries/settings/metadata.graphql +++ b/graphql/documents/queries/settings/metadata.graphql @@ -5,3 +5,12 @@ query JobStatus { message } } + +query SystemStatus { + systemStatus { + databaseSchema + databasePath + appSchema + status + } +} diff --git a/graphql/schema/schema.graphql b/graphql/schema/schema.graphql index 048aa37d4..08e8834be 100644 --- a/graphql/schema/schema.graphql +++ b/graphql/schema/schema.graphql @@ -106,7 +106,7 @@ type Query { directory(path: String): Directory! # Metadata - + systemStatus: SystemStatus! jobStatus: MetadataUpdateStatus! # Get everything @@ -126,6 +126,9 @@ type Query { } type Mutation { + setup(input: SetupInput!): Boolean! + migrate(input: MigrateInput!): Boolean! + sceneUpdate(input: SceneUpdateInput!): Scene bulkSceneUpdate(input: BulkSceneUpdateInput!): [Scene!] sceneDestroy(input: SceneDestroyInput!): Boolean! diff --git a/graphql/schema/types/config.graphql b/graphql/schema/types/config.graphql index df1c3415e..9cf463125 100644 --- a/graphql/schema/types/config.graphql +++ b/graphql/schema/types/config.graphql @@ -1,3 +1,13 @@ +input SetupInput { + """Empty to indicate $HOME/.stash/config.yml default""" + configLocation: String! + stashes: [StashConfigInput!]! + """Empty to indicate default""" + databaseFile: String! + """Empty to indicate default""" + generatedLocation: String! +} + enum StreamingResolutionEnum { "240p", LOW "480p", STANDARD diff --git a/graphql/schema/types/metadata.graphql b/graphql/schema/types/metadata.graphql index 600d8f9c8..ac8185982 100644 --- a/graphql/schema/types/metadata.graphql +++ b/graphql/schema/types/metadata.graphql @@ -106,3 +106,20 @@ input ImportObjectsInput { input BackupDatabaseInput { download: Boolean } + +enum SystemStatusEnum { + SETUP + NEEDS_MIGRATION + OK +} + +type SystemStatus { + databaseSchema: Int + databasePath: String + appSchema: Int! + status: SystemStatusEnum! +} + +input MigrateInput { + backupPath: String! +} diff --git a/main.go b/main.go index e54d5cad7..bc2a83c6f 100644 --- a/main.go +++ b/main.go @@ -3,9 +3,7 @@ package main import ( "github.com/stashapp/stash/pkg/api" - "github.com/stashapp/stash/pkg/database" "github.com/stashapp/stash/pkg/manager" - "github.com/stashapp/stash/pkg/manager/config" _ "github.com/golang-migrate/migrate/v4/database/sqlite3" _ "github.com/golang-migrate/migrate/v4/source/file" @@ -13,12 +11,6 @@ import ( func main() { manager.Initialize() - - // perform the post-migration for new databases - if database.Initialize(config.GetDatabasePath()) { - manager.GetInstance().PostMigrate() - } - api.Start() blockForever() } diff --git a/pkg/api/migrate.go b/pkg/api/migrate.go deleted file mode 100644 index 4a7bcddf8..000000000 --- a/pkg/api/migrate.go +++ /dev/null @@ -1,96 +0,0 @@ -package api - -import ( - "fmt" - "html/template" - "net/http" - "os" - - "github.com/stashapp/stash/pkg/database" - "github.com/stashapp/stash/pkg/logger" - "github.com/stashapp/stash/pkg/manager" -) - -type migrateData struct { - ExistingVersion uint - MigrateVersion uint - BackupPath string -} - -func getMigrateData() migrateData { - return migrateData{ - ExistingVersion: database.Version(), - MigrateVersion: database.AppSchemaVersion(), - BackupPath: database.DatabaseBackupPath(), - } -} - -func getMigrateHandler(w http.ResponseWriter, r *http.Request) { - if !database.NeedsMigration() { - http.Redirect(w, r, "/", 301) - return - } - - data, _ := setupUIBox.Find("migrate.html") - templ, err := template.New("Migrate").Parse(string(data)) - if err != nil { - http.Error(w, fmt.Sprintf("error: %s", err), 500) - return - } - - err = templ.Execute(w, getMigrateData()) - if err != nil { - http.Error(w, fmt.Sprintf("error: %s", err), 500) - } -} - -func doMigrateHandler(w http.ResponseWriter, r *http.Request) { - err := r.ParseForm() - if err != nil { - http.Error(w, fmt.Sprintf("error: %s", err), 500) - } - - formBackupPath := r.Form.Get("backuppath") - - // always backup so that we can roll back to the previous version if - // migration fails - backupPath := formBackupPath - if formBackupPath == "" { - backupPath = database.DatabaseBackupPath() - } - - // perform database backup - if err = database.Backup(database.DB, backupPath); err != nil { - http.Error(w, fmt.Sprintf("error backing up database: %s", err), 500) - return - } - - err = database.RunMigrations() - if err != nil { - errStr := fmt.Sprintf("error performing migration: %s", err) - - // roll back to the backed up version - restoreErr := database.RestoreFromBackup(backupPath) - if restoreErr != nil { - errStr = fmt.Sprintf("ERROR: unable to restore database from backup after migration failure: %s\n%s", restoreErr.Error(), errStr) - } else { - errStr = "An error occurred migrating the database to the latest schema version. The backup database file was automatically renamed to restore the database.\n" + errStr - } - - http.Error(w, errStr, 500) - return - } - - // perform post-migration operations - manager.GetInstance().PostMigrate() - - // if no backup path was provided, then delete the created backup - if formBackupPath == "" { - err = os.Remove(backupPath) - if err != nil { - logger.Warnf("error removing unwanted database backup (%s): %s", backupPath, err.Error()) - } - } - - http.Redirect(w, r, "/", 301) -} diff --git a/pkg/api/resolver_mutation_configure.go b/pkg/api/resolver_mutation_configure.go index 40b71c63f..e5cd71c9e 100644 --- a/pkg/api/resolver_mutation_configure.go +++ b/pkg/api/resolver_mutation_configure.go @@ -13,7 +13,18 @@ import ( "github.com/stashapp/stash/pkg/utils" ) +func (r *mutationResolver) Setup(ctx context.Context, input models.SetupInput) (bool, error) { + err := manager.GetInstance().Setup(input) + return err == nil, err +} + +func (r *mutationResolver) Migrate(ctx context.Context, input models.MigrateInput) (bool, error) { + err := manager.GetInstance().Migrate(input) + return err == nil, err +} + func (r *mutationResolver) ConfigureGeneral(ctx context.Context, input models.ConfigGeneralInput) (*models.ConfigGeneralResult, error) { + c := config.GetInstance() if len(input.Stashes) > 0 { for _, s := range input.Stashes { exists, err := utils.DirExists(s.Path) @@ -21,7 +32,7 @@ func (r *mutationResolver) ConfigureGeneral(ctx context.Context, input models.Co return makeConfigGeneralResult(), err } } - config.Set(config.Stash, input.Stashes) + c.Set(config.Stash, input.Stashes) } if input.DatabasePath != nil { @@ -29,138 +40,140 @@ func (r *mutationResolver) ConfigureGeneral(ctx context.Context, input models.Co if ext != ".db" && ext != ".sqlite" && ext != ".sqlite3" { return makeConfigGeneralResult(), fmt.Errorf("invalid database path, use extension db, sqlite, or sqlite3") } - config.Set(config.Database, input.DatabasePath) + c.Set(config.Database, input.DatabasePath) } if input.GeneratedPath != nil { if err := utils.EnsureDir(*input.GeneratedPath); err != nil { return makeConfigGeneralResult(), err } - config.Set(config.Generated, input.GeneratedPath) + c.Set(config.Generated, input.GeneratedPath) } if input.CachePath != nil { - if err := utils.EnsureDir(*input.CachePath); err != nil { - return makeConfigGeneralResult(), err + if *input.CachePath != "" { + if err := utils.EnsureDir(*input.CachePath); err != nil { + return makeConfigGeneralResult(), err + } } - config.Set(config.Cache, input.CachePath) + c.Set(config.Cache, input.CachePath) } if !input.CalculateMd5 && input.VideoFileNamingAlgorithm == models.HashAlgorithmMd5 { return makeConfigGeneralResult(), errors.New("calculateMD5 must be true if using MD5") } - if input.VideoFileNamingAlgorithm != config.GetVideoFileNamingAlgorithm() { + if input.VideoFileNamingAlgorithm != c.GetVideoFileNamingAlgorithm() { // validate changing VideoFileNamingAlgorithm if err := manager.ValidateVideoFileNamingAlgorithm(r.txnManager, input.VideoFileNamingAlgorithm); err != nil { return makeConfigGeneralResult(), err } - config.Set(config.VideoFileNamingAlgorithm, input.VideoFileNamingAlgorithm) + c.Set(config.VideoFileNamingAlgorithm, input.VideoFileNamingAlgorithm) } - config.Set(config.CalculateMD5, input.CalculateMd5) + c.Set(config.CalculateMD5, input.CalculateMd5) if input.ParallelTasks != nil { - config.Set(config.ParallelTasks, *input.ParallelTasks) + c.Set(config.ParallelTasks, *input.ParallelTasks) } if input.PreviewSegments != nil { - config.Set(config.PreviewSegments, *input.PreviewSegments) + c.Set(config.PreviewSegments, *input.PreviewSegments) } if input.PreviewSegmentDuration != nil { - config.Set(config.PreviewSegmentDuration, *input.PreviewSegmentDuration) + c.Set(config.PreviewSegmentDuration, *input.PreviewSegmentDuration) } if input.PreviewExcludeStart != nil { - config.Set(config.PreviewExcludeStart, *input.PreviewExcludeStart) + c.Set(config.PreviewExcludeStart, *input.PreviewExcludeStart) } if input.PreviewExcludeEnd != nil { - config.Set(config.PreviewExcludeEnd, *input.PreviewExcludeEnd) + c.Set(config.PreviewExcludeEnd, *input.PreviewExcludeEnd) } if input.PreviewPreset != nil { - config.Set(config.PreviewPreset, input.PreviewPreset.String()) + c.Set(config.PreviewPreset, input.PreviewPreset.String()) } if input.MaxTranscodeSize != nil { - config.Set(config.MaxTranscodeSize, input.MaxTranscodeSize.String()) + c.Set(config.MaxTranscodeSize, input.MaxTranscodeSize.String()) } if input.MaxStreamingTranscodeSize != nil { - config.Set(config.MaxStreamingTranscodeSize, input.MaxStreamingTranscodeSize.String()) + c.Set(config.MaxStreamingTranscodeSize, input.MaxStreamingTranscodeSize.String()) } if input.Username != nil { - config.Set(config.Username, input.Username) + c.Set(config.Username, input.Username) } if input.Password != nil { // bit of a hack - check if the passed in password is the same as the stored hash // and only set if they are different - currentPWHash := config.GetPasswordHash() + currentPWHash := c.GetPasswordHash() if *input.Password != currentPWHash { - config.SetPassword(*input.Password) + c.SetPassword(*input.Password) } } if input.MaxSessionAge != nil { - config.Set(config.MaxSessionAge, *input.MaxSessionAge) + c.Set(config.MaxSessionAge, *input.MaxSessionAge) } if input.LogFile != nil { - config.Set(config.LogFile, input.LogFile) + c.Set(config.LogFile, input.LogFile) } - config.Set(config.LogOut, input.LogOut) - config.Set(config.LogAccess, input.LogAccess) + c.Set(config.LogOut, input.LogOut) + c.Set(config.LogAccess, input.LogAccess) - if input.LogLevel != config.GetLogLevel() { - config.Set(config.LogLevel, input.LogLevel) + if input.LogLevel != c.GetLogLevel() { + c.Set(config.LogLevel, input.LogLevel) logger.SetLogLevel(input.LogLevel) } if input.Excludes != nil { - config.Set(config.Exclude, input.Excludes) + c.Set(config.Exclude, input.Excludes) } if input.ImageExcludes != nil { - config.Set(config.ImageExclude, input.ImageExcludes) + c.Set(config.ImageExclude, input.ImageExcludes) } if input.VideoExtensions != nil { - config.Set(config.VideoExtensions, input.VideoExtensions) + c.Set(config.VideoExtensions, input.VideoExtensions) } if input.ImageExtensions != nil { - config.Set(config.ImageExtensions, input.ImageExtensions) + c.Set(config.ImageExtensions, input.ImageExtensions) } if input.GalleryExtensions != nil { - config.Set(config.GalleryExtensions, input.GalleryExtensions) + c.Set(config.GalleryExtensions, input.GalleryExtensions) } - config.Set(config.CreateGalleriesFromFolders, input.CreateGalleriesFromFolders) + c.Set(config.CreateGalleriesFromFolders, input.CreateGalleriesFromFolders) refreshScraperCache := false if input.ScraperUserAgent != nil { - config.Set(config.ScraperUserAgent, input.ScraperUserAgent) + c.Set(config.ScraperUserAgent, input.ScraperUserAgent) refreshScraperCache = true } if input.ScraperCDPPath != nil { - config.Set(config.ScraperCDPPath, input.ScraperCDPPath) + c.Set(config.ScraperCDPPath, input.ScraperCDPPath) refreshScraperCache = true } - config.Set(config.ScraperCertCheck, input.ScraperCertCheck) + c.Set(config.ScraperCertCheck, input.ScraperCertCheck) if input.StashBoxes != nil { - if err := config.ValidateStashBoxes(input.StashBoxes); err != nil { + if err := c.ValidateStashBoxes(input.StashBoxes); err != nil { return nil, err } - config.Set(config.StashBoxes, input.StashBoxes) + c.Set(config.StashBoxes, input.StashBoxes) } - if err := config.Write(); err != nil { + if err := c.Write(); err != nil { return makeConfigGeneralResult(), err } @@ -173,36 +186,37 @@ func (r *mutationResolver) ConfigureGeneral(ctx context.Context, input models.Co } func (r *mutationResolver) ConfigureInterface(ctx context.Context, input models.ConfigInterfaceInput) (*models.ConfigInterfaceResult, error) { + c := config.GetInstance() if input.MenuItems != nil { - config.Set(config.MenuItems, input.MenuItems) + c.Set(config.MenuItems, input.MenuItems) } if input.SoundOnPreview != nil { - config.Set(config.SoundOnPreview, *input.SoundOnPreview) + c.Set(config.SoundOnPreview, *input.SoundOnPreview) } if input.WallShowTitle != nil { - config.Set(config.WallShowTitle, *input.WallShowTitle) + c.Set(config.WallShowTitle, *input.WallShowTitle) } if input.WallPlayback != nil { - config.Set(config.WallPlayback, *input.WallPlayback) + c.Set(config.WallPlayback, *input.WallPlayback) } if input.MaximumLoopDuration != nil { - config.Set(config.MaximumLoopDuration, *input.MaximumLoopDuration) + c.Set(config.MaximumLoopDuration, *input.MaximumLoopDuration) } if input.AutostartVideo != nil { - config.Set(config.AutostartVideo, *input.AutostartVideo) + c.Set(config.AutostartVideo, *input.AutostartVideo) } if input.ShowStudioAsText != nil { - config.Set(config.ShowStudioAsText, *input.ShowStudioAsText) + c.Set(config.ShowStudioAsText, *input.ShowStudioAsText) } if input.Language != nil { - config.Set(config.Language, *input.Language) + c.Set(config.Language, *input.Language) } css := "" @@ -211,13 +225,13 @@ func (r *mutationResolver) ConfigureInterface(ctx context.Context, input models. css = *input.CSS } - config.SetCSS(css) + c.SetCSS(css) if input.CSSEnabled != nil { - config.Set(config.CSSEnabled, *input.CSSEnabled) + c.Set(config.CSSEnabled, *input.CSSEnabled) } - if err := config.Write(); err != nil { + if err := c.Write(); err != nil { return makeConfigInterfaceResult(), err } @@ -225,9 +239,11 @@ func (r *mutationResolver) ConfigureInterface(ctx context.Context, input models. } func (r *mutationResolver) GenerateAPIKey(ctx context.Context, input models.GenerateAPIKeyInput) (string, error) { + c := config.GetInstance() + var newAPIKey string if input.Clear == nil || !*input.Clear { - username := config.GetUsername() + username := c.GetUsername() if username != "" { var err error newAPIKey, err = manager.GenerateAPIKey(username) @@ -237,8 +253,8 @@ func (r *mutationResolver) GenerateAPIKey(ctx context.Context, input models.Gene } } - config.Set(config.ApiKey, newAPIKey) - if err := config.Write(); err != nil { + c.Set(config.ApiKey, newAPIKey) + if err := c.Write(); err != nil { return newAPIKey, err } diff --git a/pkg/api/resolver_mutation_metadata.go b/pkg/api/resolver_mutation_metadata.go index 19ce4f279..82f678c5c 100644 --- a/pkg/api/resolver_mutation_metadata.go +++ b/pkg/api/resolver_mutation_metadata.go @@ -20,12 +20,15 @@ func (r *mutationResolver) MetadataScan(ctx context.Context, input models.ScanMe } func (r *mutationResolver) MetadataImport(ctx context.Context) (string, error) { - manager.GetInstance().Import() + if err := manager.GetInstance().Import(); err != nil { + return "", err + } + return "todo", nil } func (r *mutationResolver) ImportObjects(ctx context.Context, input models.ImportObjectsInput) (string, error) { - t, err := manager.CreateImportTask(config.GetVideoFileNamingAlgorithm(), input) + t, err := manager.CreateImportTask(config.GetInstance().GetVideoFileNamingAlgorithm(), input) if err != nil { return "", err } @@ -39,12 +42,15 @@ func (r *mutationResolver) ImportObjects(ctx context.Context, input models.Impor } func (r *mutationResolver) MetadataExport(ctx context.Context) (string, error) { - manager.GetInstance().Export() + if err := manager.GetInstance().Export(); err != nil { + return "", err + } + return "todo", nil } func (r *mutationResolver) ExportObjects(ctx context.Context, input models.ExportObjectsInput) (*string, error) { - t := manager.CreateExportTask(config.GetVideoFileNamingAlgorithm(), input) + t := manager.CreateExportTask(config.GetInstance().GetVideoFileNamingAlgorithm(), input) wg, err := manager.GetInstance().RunSingleTask(t) if err != nil { return nil, err diff --git a/pkg/api/resolver_mutation_plugin.go b/pkg/api/resolver_mutation_plugin.go index c5a83e8fc..3e65366c2 100644 --- a/pkg/api/resolver_mutation_plugin.go +++ b/pkg/api/resolver_mutation_plugin.go @@ -23,6 +23,7 @@ func (r *mutationResolver) RunPluginTask(ctx context.Context, pluginID string, t } } + config := config.GetInstance() serverConnection := common.StashServerConnection{ Scheme: "http", Port: config.GetPort(), diff --git a/pkg/api/resolver_mutation_scene.go b/pkg/api/resolver_mutation_scene.go index ba73aecb1..2a187bf1b 100644 --- a/pkg/api/resolver_mutation_scene.go +++ b/pkg/api/resolver_mutation_scene.go @@ -139,7 +139,7 @@ func (r *mutationResolver) sceneUpdate(input models.SceneUpdateInput, translator // only update the cover image if provided and everything else was successful if coverImageData != nil { - err = manager.SetSceneScreenshot(scene.GetHash(config.GetVideoFileNamingAlgorithm()), coverImageData) + err = manager.SetSceneScreenshot(scene.GetHash(config.GetInstance().GetVideoFileNamingAlgorithm()), coverImageData) if err != nil { return nil, err } @@ -384,7 +384,7 @@ func (r *mutationResolver) SceneDestroy(ctx context.Context, input models.SceneD // if delete generated is true, then delete the generated files // for the scene if input.DeleteGenerated != nil && *input.DeleteGenerated { - manager.DeleteGeneratedSceneFiles(scene, config.GetVideoFileNamingAlgorithm()) + manager.DeleteGeneratedSceneFiles(scene, config.GetInstance().GetVideoFileNamingAlgorithm()) } // if delete file is true, then delete the file as well @@ -426,7 +426,7 @@ func (r *mutationResolver) ScenesDestroy(ctx context.Context, input models.Scene f() } - fileNamingAlgo := config.GetVideoFileNamingAlgorithm() + fileNamingAlgo := config.GetInstance().GetVideoFileNamingAlgorithm() for _, scene := range scenes { // if delete generated is true, then delete the generated files // for the scene @@ -586,7 +586,7 @@ func (r *mutationResolver) changeMarker(ctx context.Context, changeType int, cha // remove the marker preview if the timestamp was changed if scene != nil && existingMarker != nil && existingMarker.Seconds != changedMarker.Seconds { seconds := int(existingMarker.Seconds) - manager.DeleteSceneMarkerFiles(scene, seconds, config.GetVideoFileNamingAlgorithm()) + manager.DeleteSceneMarkerFiles(scene, seconds, config.GetInstance().GetVideoFileNamingAlgorithm()) } return sceneMarker, nil diff --git a/pkg/api/resolver_mutation_stash_box.go b/pkg/api/resolver_mutation_stash_box.go index 303b66dae..7cb7134ad 100644 --- a/pkg/api/resolver_mutation_stash_box.go +++ b/pkg/api/resolver_mutation_stash_box.go @@ -10,7 +10,7 @@ import ( ) func (r *mutationResolver) SubmitStashBoxFingerprints(ctx context.Context, input models.StashBoxFingerprintSubmissionInput) (bool, error) { - boxes := config.GetStashBoxes() + boxes := config.GetInstance().GetStashBoxes() if input.StashBoxIndex < 0 || input.StashBoxIndex >= len(boxes) { return false, fmt.Errorf("invalid stash_box_index %d", input.StashBoxIndex) diff --git a/pkg/api/resolver_query_configuration.go b/pkg/api/resolver_query_configuration.go index cc8de2a3e..c11d8dc0c 100644 --- a/pkg/api/resolver_query_configuration.go +++ b/pkg/api/resolver_query_configuration.go @@ -34,6 +34,7 @@ func makeConfigResult() *models.ConfigResult { } func makeConfigGeneralResult() *models.ConfigGeneralResult { + config := config.GetInstance() logFile := config.GetLogFile() maxTranscodeSize := config.GetMaxTranscodeSize() @@ -81,6 +82,7 @@ func makeConfigGeneralResult() *models.ConfigGeneralResult { } func makeConfigInterfaceResult() *models.ConfigInterfaceResult { + config := config.GetInstance() menuItems := config.GetMenuItems() soundOnPreview := config.GetSoundOnPreview() wallShowTitle := config.GetWallShowTitle() diff --git a/pkg/api/resolver_query_metadata.go b/pkg/api/resolver_query_metadata.go index 862d91eae..cb12d96e7 100644 --- a/pkg/api/resolver_query_metadata.go +++ b/pkg/api/resolver_query_metadata.go @@ -17,3 +17,7 @@ func (r *queryResolver) JobStatus(ctx context.Context) (*models.MetadataUpdateSt return &ret, nil } + +func (r *queryResolver) SystemStatus(ctx context.Context) (*models.SystemStatus, error) { + return manager.GetInstance().GetSystemStatus(), nil +} diff --git a/pkg/api/resolver_query_scene.go b/pkg/api/resolver_query_scene.go index 64110e70d..236913689 100644 --- a/pkg/api/resolver_query_scene.go +++ b/pkg/api/resolver_query_scene.go @@ -30,5 +30,5 @@ func (r *queryResolver) SceneStreams(ctx context.Context, id *string) ([]*models baseURL, _ := ctx.Value(BaseURLCtxKey).(string) builder := urlbuilders.NewSceneURLBuilder(baseURL, scene.ID) - return manager.GetSceneStreamPaths(scene, builder.GetStreamURL(), config.GetMaxStreamingTranscodeSize()) + return manager.GetSceneStreamPaths(scene, builder.GetStreamURL(), config.GetInstance().GetMaxStreamingTranscodeSize()) } diff --git a/pkg/api/resolver_query_scraper.go b/pkg/api/resolver_query_scraper.go index 7a197a025..0daf80154 100644 --- a/pkg/api/resolver_query_scraper.go +++ b/pkg/api/resolver_query_scraper.go @@ -89,7 +89,7 @@ func (r *queryResolver) ScrapeMovieURL(ctx context.Context, url string) (*models } func (r *queryResolver) QueryStashBoxScene(ctx context.Context, input models.StashBoxQueryInput) ([]*models.ScrapedScene, error) { - boxes := config.GetStashBoxes() + boxes := config.GetInstance().GetStashBoxes() if input.StashBoxIndex < 0 || input.StashBoxIndex >= len(boxes) { return nil, fmt.Errorf("invalid stash_box_index %d", input.StashBoxIndex) diff --git a/pkg/api/routes_scene.go b/pkg/api/routes_scene.go index 328983be9..c83876e8a 100644 --- a/pkg/api/routes_scene.go +++ b/pkg/api/routes_scene.go @@ -69,7 +69,7 @@ func getSceneFileContainer(scene *models.Scene) ffmpeg.Container { func (rs sceneRoutes) StreamDirect(w http.ResponseWriter, r *http.Request) { scene := r.Context().Value(sceneKey).(*models.Scene) - fileNamingAlgo := config.GetVideoFileNamingAlgorithm() + fileNamingAlgo := config.GetInstance().GetVideoFileNamingAlgorithm() filepath := manager.GetInstance().Paths.Scene.GetStreamPath(scene.Path, scene.GetHash(fileNamingAlgo)) manager.RegisterStream(filepath, &w) @@ -158,7 +158,7 @@ func (rs sceneRoutes) streamTranscode(w http.ResponseWriter, r *http.Request, vi options := ffmpeg.GetTranscodeStreamOptions(*videoFile, videoCodec, audioCodec) options.StartTime = startTime - options.MaxTranscodeSize = config.GetMaxStreamingTranscodeSize() + options.MaxTranscodeSize = config.GetInstance().GetMaxStreamingTranscodeSize() if requestedSize != "" { options.MaxTranscodeSize = models.StreamingResolutionEnum(requestedSize) } @@ -178,7 +178,7 @@ func (rs sceneRoutes) streamTranscode(w http.ResponseWriter, r *http.Request, vi func (rs sceneRoutes) Screenshot(w http.ResponseWriter, r *http.Request) { scene := r.Context().Value(sceneKey).(*models.Scene) - filepath := manager.GetInstance().Paths.Scene.GetScreenshotPath(scene.GetHash(config.GetVideoFileNamingAlgorithm())) + filepath := manager.GetInstance().Paths.Scene.GetScreenshotPath(scene.GetHash(config.GetInstance().GetVideoFileNamingAlgorithm())) // fall back to the scene image blob if the file isn't present screenshotExists, _ := utils.FileExists(filepath) @@ -196,13 +196,13 @@ func (rs sceneRoutes) Screenshot(w http.ResponseWriter, r *http.Request) { func (rs sceneRoutes) Preview(w http.ResponseWriter, r *http.Request) { scene := r.Context().Value(sceneKey).(*models.Scene) - filepath := manager.GetInstance().Paths.Scene.GetStreamPreviewPath(scene.GetHash(config.GetVideoFileNamingAlgorithm())) + filepath := manager.GetInstance().Paths.Scene.GetStreamPreviewPath(scene.GetHash(config.GetInstance().GetVideoFileNamingAlgorithm())) utils.ServeFileNoCache(w, r, filepath) } func (rs sceneRoutes) Webp(w http.ResponseWriter, r *http.Request) { scene := r.Context().Value(sceneKey).(*models.Scene) - filepath := manager.GetInstance().Paths.Scene.GetStreamPreviewImagePath(scene.GetHash(config.GetVideoFileNamingAlgorithm())) + filepath := manager.GetInstance().Paths.Scene.GetStreamPreviewImagePath(scene.GetHash(config.GetInstance().GetVideoFileNamingAlgorithm())) http.ServeFile(w, r, filepath) } @@ -267,14 +267,14 @@ func (rs sceneRoutes) ChapterVtt(w http.ResponseWriter, r *http.Request) { func (rs sceneRoutes) VttThumbs(w http.ResponseWriter, r *http.Request) { scene := r.Context().Value(sceneKey).(*models.Scene) w.Header().Set("Content-Type", "text/vtt") - filepath := manager.GetInstance().Paths.Scene.GetSpriteVttFilePath(scene.GetHash(config.GetVideoFileNamingAlgorithm())) + filepath := manager.GetInstance().Paths.Scene.GetSpriteVttFilePath(scene.GetHash(config.GetInstance().GetVideoFileNamingAlgorithm())) http.ServeFile(w, r, filepath) } func (rs sceneRoutes) VttSprite(w http.ResponseWriter, r *http.Request) { scene := r.Context().Value(sceneKey).(*models.Scene) w.Header().Set("Content-Type", "image/jpeg") - filepath := manager.GetInstance().Paths.Scene.GetSpriteImageFilePath(scene.GetHash(config.GetVideoFileNamingAlgorithm())) + filepath := manager.GetInstance().Paths.Scene.GetSpriteImageFilePath(scene.GetHash(config.GetInstance().GetVideoFileNamingAlgorithm())) http.ServeFile(w, r, filepath) } @@ -291,7 +291,7 @@ func (rs sceneRoutes) SceneMarkerStream(w http.ResponseWriter, r *http.Request) http.Error(w, http.StatusText(500), 500) return } - filepath := manager.GetInstance().Paths.SceneMarkers.GetStreamPath(scene.GetHash(config.GetVideoFileNamingAlgorithm()), int(sceneMarker.Seconds)) + filepath := manager.GetInstance().Paths.SceneMarkers.GetStreamPath(scene.GetHash(config.GetInstance().GetVideoFileNamingAlgorithm()), int(sceneMarker.Seconds)) http.ServeFile(w, r, filepath) } @@ -308,7 +308,7 @@ func (rs sceneRoutes) SceneMarkerPreview(w http.ResponseWriter, r *http.Request) http.Error(w, http.StatusText(500), 500) return } - filepath := manager.GetInstance().Paths.SceneMarkers.GetStreamPreviewImagePath(scene.GetHash(config.GetVideoFileNamingAlgorithm()), int(sceneMarker.Seconds)) + filepath := manager.GetInstance().Paths.SceneMarkers.GetStreamPreviewImagePath(scene.GetHash(config.GetInstance().GetVideoFileNamingAlgorithm()), int(sceneMarker.Seconds)) // If the image doesn't exist, send the placeholder exists, _ := utils.FileExists(filepath) diff --git a/pkg/api/server.go b/pkg/api/server.go index e4e3a1fea..26dd34fbe 100644 --- a/pkg/api/server.go +++ b/pkg/api/server.go @@ -8,9 +8,7 @@ import ( "io/ioutil" "net/http" "net/url" - "os" "path" - "path/filepath" "runtime/debug" "strconv" "strings" @@ -22,7 +20,6 @@ import ( "github.com/gobuffalo/packr/v2" "github.com/gorilla/websocket" "github.com/rs/cors" - "github.com/stashapp/stash/pkg/database" "github.com/stashapp/stash/pkg/logger" "github.com/stashapp/stash/pkg/manager" "github.com/stashapp/stash/pkg/manager/config" @@ -38,7 +35,6 @@ var githash string var uiBox *packr.Box //var legacyUiBox *packr.Box -var setupUIBox *packr.Box var loginUIBox *packr.Box const ApiKeyHeader = "ApiKey" @@ -50,6 +46,7 @@ func allowUnauthenticated(r *http.Request) bool { func authenticateHandler() func(http.Handler) http.Handler { return func(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + c := config.GetInstance() ctx := r.Context() // translate api key into current user, if present @@ -61,13 +58,13 @@ func authenticateHandler() func(http.Handler) http.Handler { // match against configured API and set userID to the // configured username. In future, we'll want to // get the username from the key. - if config.GetAPIKey() != apiKey { + if c.GetAPIKey() != apiKey { w.Header().Add("WWW-Authenticate", `FormBased`) w.WriteHeader(http.StatusUnauthorized) return } - userID = config.GetUsername() + userID = c.GetUsername() } else { // handle session userID, err = getSessionUserID(w, r) @@ -80,7 +77,7 @@ func authenticateHandler() func(http.Handler) http.Handler { } // handle redirect if no user and user is required - if userID == "" && config.HasCredentials() && !allowUnauthenticated(r) { + if userID == "" && c.HasCredentials() && !allowUnauthenticated(r) { // if we don't have a userID, then redirect // if graphql was requested, we just return a forbidden error if r.URL.Path == "/graphql" { @@ -109,14 +106,11 @@ func authenticateHandler() func(http.Handler) http.Handler { } } -const setupEndPoint = "/setup" -const migrateEndPoint = "/migrate" const loginEndPoint = "/login" func Start() { uiBox = packr.New("UI Box", "../../ui/v2.5/build") //legacyUiBox = packr.New("UI Box", "../../ui/v1/dist/stash-frontend") - setupUIBox = packr.New("Setup UI Box", "../../ui/setup") loginUIBox = packr.New("Login UI Box", "../../ui/login") initSessionStore() @@ -128,15 +122,14 @@ func Start() { r.Use(authenticateHandler()) r.Use(middleware.Recoverer) - if config.GetLogAccess() { + c := config.GetInstance() + if c.GetLogAccess() { r.Use(middleware.Logger) } r.Use(middleware.DefaultCompress) r.Use(middleware.StripSlashes) r.Use(cors.AllowAll().Handler) r.Use(BaseURLMiddleware) - r.Use(ConfigCheckMiddleware) - r.Use(DatabaseCheckMiddleware) recoverFunc := handler.RecoverFunc(func(ctx context.Context, err interface{}) error { logger.Error(err) @@ -150,7 +143,7 @@ func Start() { return true }, }) - maxUploadSize := handler.UploadMaxSize(config.GetMaxUploadSize()) + maxUploadSize := handler.UploadMaxSize(c.GetMaxUploadSize()) websocketKeepAliveDuration := handler.WebsocketKeepAliveDuration(10 * time.Second) txnManager := manager.GetInstance().TxnManager @@ -191,12 +184,12 @@ func Start() { r.HandleFunc("/css", func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "text/css") - if !config.GetCSSEnabled() { + if !c.GetCSSEnabled() { return } // search for custom.css in current directory, then $HOME/.stash - fn := config.GetCSSPath() + fn := c.GetCSSPath() exists, _ := utils.FileExists(fn) if !exists { return @@ -205,21 +198,6 @@ func Start() { http.ServeFile(w, r, fn) }) - // Serve the migration UI - r.Get("/migrate", getMigrateHandler) - r.Post("/migrate", doMigrateHandler) - - // Serve the setup UI - r.HandleFunc("/setup*", func(w http.ResponseWriter, r *http.Request) { - ext := path.Ext(r.URL.Path) - if ext == ".html" || ext == "" { - data, _ := setupUIBox.Find("index.html") - _, _ = w.Write(data) - } else { - r.URL.Path = strings.Replace(r.URL.Path, "/setup", "", 1) - http.FileServer(setupUIBox).ServeHTTP(w, r) - } - }) r.HandleFunc("/login*", func(w http.ResponseWriter, r *http.Request) { ext := path.Ext(r.URL.Path) if ext == ".html" || ext == "" { @@ -230,62 +208,9 @@ func Start() { http.FileServer(loginUIBox).ServeHTTP(w, r) } }) - r.Post("/init", func(w http.ResponseWriter, r *http.Request) { - err := r.ParseForm() - if err != nil { - http.Error(w, fmt.Sprintf("error: %s", err), 500) - } - stash := filepath.Clean(r.Form.Get("stash")) - generated := filepath.Clean(r.Form.Get("generated")) - metadata := filepath.Clean(r.Form.Get("metadata")) - cache := filepath.Clean(r.Form.Get("cache")) - //downloads := filepath.Clean(r.Form.Get("downloads")) // TODO - downloads := filepath.Join(metadata, "downloads") - - exists, _ := utils.DirExists(stash) - if !exists || stash == "." { - http.Error(w, fmt.Sprintf("the stash path either doesn't exist, or is not a directory <%s>. Go back and try again.", stash), 500) - return - } - - exists, _ = utils.DirExists(generated) - if !exists || generated == "." { - http.Error(w, fmt.Sprintf("the generated path either doesn't exist, or is not a directory <%s>. Go back and try again.", generated), 500) - return - } - - exists, _ = utils.DirExists(metadata) - if !exists || metadata == "." { - http.Error(w, fmt.Sprintf("the metadata path either doesn't exist, or is not a directory <%s> Go back and try again.", metadata), 500) - return - } - - exists, _ = utils.DirExists(cache) - if !exists || cache == "." { - http.Error(w, fmt.Sprintf("the cache path either doesn't exist, or is not a directory <%s> Go back and try again.", cache), 500) - return - } - - _ = os.Mkdir(downloads, 0755) - - // #536 - set stash as slice of strings - config.Set(config.Stash, []string{stash}) - config.Set(config.Generated, generated) - config.Set(config.Metadata, metadata) - config.Set(config.Cache, cache) - config.Set(config.Downloads, downloads) - if err := config.Write(); err != nil { - http.Error(w, fmt.Sprintf("there was an error saving the config file: %s", err), 500) - return - } - - manager.GetInstance().RefreshConfig() - - http.Redirect(w, r, "/", 301) - }) // Serve static folders - customServedFolders := config.GetCustomServedFolders() + customServedFolders := c.GetCustomServedFolders() if customServedFolders != nil { r.HandleFunc("/custom/*", func(w http.ResponseWriter, r *http.Request) { r.URL.Path = strings.Replace(r.URL.Path, "/custom", "", 1) @@ -316,13 +241,13 @@ func Start() { } }) - displayHost := config.GetHost() + displayHost := c.GetHost() if displayHost == "0.0.0.0" { displayHost = "localhost" } - displayAddress := displayHost + ":" + strconv.Itoa(config.GetPort()) + displayAddress := displayHost + ":" + strconv.Itoa(c.GetPort()) - address := config.GetHost() + ":" + strconv.Itoa(config.GetPort()) + address := c.GetHost() + ":" + strconv.Itoa(c.GetPort()) if tlsConfig := makeTLSConfig(); tlsConfig != nil { httpsServer := &http.Server{ Addr: address, @@ -417,7 +342,7 @@ func BaseURLMiddleware(next http.Handler) http.Handler { } baseURL := scheme + "://" + r.Host - externalHost := config.GetExternalHost() + externalHost := config.GetInstance().GetExternalHost() if externalHost != "" { baseURL = externalHost } @@ -428,34 +353,3 @@ func BaseURLMiddleware(next http.Handler) http.Handler { } return http.HandlerFunc(fn) } - -func ConfigCheckMiddleware(next http.Handler) http.Handler { - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - ext := path.Ext(r.URL.Path) - shouldRedirect := ext == "" && r.Method == "GET" - if !config.IsValid() && shouldRedirect { - // #539 - don't redirect if loading login page - if !strings.HasPrefix(r.URL.Path, setupEndPoint) && !strings.HasPrefix(r.URL.Path, loginEndPoint) { - http.Redirect(w, r, setupEndPoint, http.StatusFound) - return - } - } - next.ServeHTTP(w, r) - }) -} - -func DatabaseCheckMiddleware(next http.Handler) http.Handler { - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - ext := path.Ext(r.URL.Path) - shouldRedirect := ext == "" && r.Method == "GET" - if shouldRedirect && database.NeedsMigration() { - // #451 - don't redirect if loading login page - // #539 - or setup page - if !strings.HasPrefix(r.URL.Path, migrateEndPoint) && !strings.HasPrefix(r.URL.Path, loginEndPoint) && !strings.HasPrefix(r.URL.Path, setupEndPoint) { - http.Redirect(w, r, migrateEndPoint, http.StatusFound) - return - } - } - next.ServeHTTP(w, r) - }) -} diff --git a/pkg/api/session.go b/pkg/api/session.go index 8be4876bd..a81d37c9e 100644 --- a/pkg/api/session.go +++ b/pkg/api/session.go @@ -19,7 +19,7 @@ const userIDKey = "userID" const returnURLParam = "returnURL" -var sessionStore = sessions.NewCookieStore(config.GetSessionStoreKey()) +var sessionStore = sessions.NewCookieStore(config.GetInstance().GetSessionStoreKey()) type loginTemplateData struct { URL string @@ -27,7 +27,7 @@ type loginTemplateData struct { } func initSessionStore() { - sessionStore.MaxAge(config.GetMaxSessionAge()) + sessionStore.MaxAge(config.GetInstance().GetMaxSessionAge()) } func redirectToLogin(w http.ResponseWriter, returnURL string, loginError string) { @@ -45,7 +45,7 @@ func redirectToLogin(w http.ResponseWriter, returnURL string, loginError string) } func getLoginHandler(w http.ResponseWriter, r *http.Request) { - if !config.HasCredentials() { + if !config.GetInstance().HasCredentials() { http.Redirect(w, r, "/", http.StatusFound) return } @@ -66,7 +66,7 @@ func handleLogin(w http.ResponseWriter, r *http.Request) { password := r.FormValue("password") // authenticate the user - if !config.ValidateCredentials(username, password) { + if !config.GetInstance().ValidateCredentials(username, password) { // redirect back to the login page with an error redirectToLogin(w, url, "Username or password is invalid") return diff --git a/pkg/database/database.go b/pkg/database/database.go index 8082e0978..95bcb9081 100644 --- a/pkg/database/database.go +++ b/pkg/database/database.go @@ -26,8 +26,27 @@ var dbPath string var appSchemaVersion uint = 20 var databaseSchemaVersion uint +var ( + // ErrMigrationNeeded indicates that a database migration is needed + // before the database can be initialized + ErrMigrationNeeded = errors.New("database migration required") + + // ErrDatabaseNotInitialized indicates that the database is not + // initialized, usually due to an incomplete configuration. + ErrDatabaseNotInitialized = errors.New("database not initialized") +) + const sqlite3Driver = "sqlite3ex" +// Ready returns an error if the database is not ready to begin transactions. +func Ready() error { + if DB == nil { + return ErrDatabaseNotInitialized + } + + return nil +} + func init() { // register custom driver with regexp function registerCustomDriver() @@ -37,20 +56,20 @@ func init() { // performs a full migration to the latest schema version. Otherwise, any // necessary migrations must be run separately using RunMigrations. // Returns true if the database is new. -func Initialize(databasePath string) bool { +func Initialize(databasePath string) error { dbPath = databasePath if err := getDatabaseSchemaVersion(); err != nil { - panic(err) + return fmt.Errorf("error getting database schema version: %s", err.Error()) } if databaseSchemaVersion == 0 { // new database, just run the migrations if err := RunMigrations(); err != nil { - panic(err) + return fmt.Errorf("error running initial schema migrations: %s", err.Error()) } // RunMigrations calls Initialise. Just return - return true + return nil } else { if databaseSchemaVersion > appSchemaVersion { panic(fmt.Sprintf("Database schema version %d is incompatible with required schema version %d", databaseSchemaVersion, appSchemaVersion)) @@ -59,7 +78,7 @@ func Initialize(databasePath string) bool { // if migration is needed, then don't open the connection if NeedsMigration() { logger.Warnf("Database schema version %d does not match required schema version %d.", databaseSchemaVersion, appSchemaVersion) - return false + return nil } } @@ -67,7 +86,7 @@ func Initialize(databasePath string) bool { DB = open(databasePath, disableForeignKeys) WriteMu = &sync.Mutex{} - return false + return nil } func open(databasePath string, disableForeignKeys bool) *sqlx.DB { @@ -150,6 +169,10 @@ func AppSchemaVersion() uint { return appSchemaVersion } +func DatabasePath() string { + return dbPath +} + func DatabaseBackupPath() string { return fmt.Sprintf("%s.%d.%s", dbPath, databaseSchemaVersion, time.Now().Format("20060102_150405")) } diff --git a/pkg/manager/apikey.go b/pkg/manager/apikey.go index e6423d362..a01a9b221 100644 --- a/pkg/manager/apikey.go +++ b/pkg/manager/apikey.go @@ -28,7 +28,7 @@ func GenerateAPIKey(userID string) (string, error) { token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) - ss, err := token.SignedString(config.GetJWTSignKey()) + ss, err := token.SignedString(config.GetInstance().GetJWTSignKey()) if err != nil { return "", err } @@ -40,7 +40,7 @@ func GenerateAPIKey(userID string) (string, error) { func GetUserIDFromAPIKey(apiKey string) (string, error) { claims := &APIKeyClaims{} token, err := jwt.ParseWithClaims(apiKey, claims, func(t *jwt.Token) (interface{}, error) { - return config.GetJWTSignKey(), nil + return config.GetInstance().GetJWTSignKey(), nil }) if err != nil { diff --git a/pkg/manager/checksum.go b/pkg/manager/checksum.go index 244549dab..a545008b6 100644 --- a/pkg/manager/checksum.go +++ b/pkg/manager/checksum.go @@ -31,9 +31,11 @@ func setInitialMD5Config(txnManager models.TransactionManager) { defaultAlgorithm = models.HashAlgorithmMd5 } + // TODO - this should use the config instance viper.SetDefault(config.VideoFileNamingAlgorithm, defaultAlgorithm) viper.SetDefault(config.CalculateMD5, usingMD5) + config := config.GetInstance() if err := config.Write(); err != nil { logger.Errorf("Error while writing configuration file: %s", err.Error()) } diff --git a/pkg/manager/config/config.go b/pkg/manager/config/config.go index 46fd0c2c5..1ce880b1a 100644 --- a/pkg/manager/config/config.go +++ b/pkg/manager/config/config.go @@ -1,7 +1,9 @@ package config import ( + "fmt" "runtime" + "strings" "golang.org/x/crypto/bcrypt" @@ -126,33 +128,64 @@ const LogAccess = "logAccess" // File upload options const MaxUploadSize = "max_upload_size" -func Set(key string, value interface{}) { +type MissingConfigError struct { + missingFields []string +} + +func (e MissingConfigError) Error() string { + return fmt.Sprintf("missing the following mandatory settings: %s", strings.Join(e.missingFields, ", ")) +} + +type Instance struct{} + +var instance *Instance + +func GetInstance() *Instance { + if instance == nil { + instance = &Instance{} + } + return instance +} + +func (i *Instance) SetConfigFile(fn string) { + viper.SetConfigFile(fn) +} + +func (i *Instance) Set(key string, value interface{}) { viper.Set(key, value) } -func SetPassword(value string) { +func (i *Instance) SetPassword(value string) { // if blank, don't bother hashing; we want it to be blank if value == "" { - Set(Password, "") + i.Set(Password, "") } else { - Set(Password, hashPassword(value)) + i.Set(Password, hashPassword(value)) } } -func Write() error { +func (i *Instance) Write() error { return viper.WriteConfig() } -func GetConfigPath() string { - configFileUsed := viper.ConfigFileUsed() - return filepath.Dir(configFileUsed) -} - -func GetConfigFilePath() string { +// GetConfigFile returns the full path to the used configuration file. +func (i *Instance) GetConfigFile() string { return viper.ConfigFileUsed() } -func GetStashPaths() []*models.StashConfig { +// GetConfigPath returns the path of the directory containing the used +// configuration file. +func (i *Instance) GetConfigPath() string { + return filepath.Dir(i.GetConfigFile()) +} + +// GetDefaultDatabaseFilePath returns the default database filename, +// which is located in the same directory as the config file. +func (i *Instance) GetDefaultDatabaseFilePath() string { + return filepath.Join(i.GetConfigPath(), "stash-go.sqlite") +} + +func (i *Instance) GetStashPaths() []*models.StashConfig { var ret []*models.StashConfig if err := viper.UnmarshalKey(Stash, &ret); err != nil || len(ret) == 0 { // fallback to legacy format @@ -169,47 +202,51 @@ func GetStashPaths() []*models.StashConfig { return ret } -func GetCachePath() string { +func (i *Instance) GetConfigFilePath() string { + return viper.ConfigFileUsed() +} + +func (i *Instance) GetCachePath() string { return viper.GetString(Cache) } -func GetGeneratedPath() string { +func (i *Instance) GetGeneratedPath() string { return viper.GetString(Generated) } -func GetMetadataPath() string { +func (i *Instance) GetMetadataPath() string { return viper.GetString(Metadata) } -func GetDatabasePath() string { +func (i *Instance) GetDatabasePath() string { return viper.GetString(Database) } -func GetJWTSignKey() []byte { +func (i *Instance) GetJWTSignKey() []byte { return []byte(viper.GetString(JWTSignKey)) } -func GetSessionStoreKey() []byte { +func (i *Instance) GetSessionStoreKey() []byte { return []byte(viper.GetString(SessionStoreKey)) } -func GetDefaultScrapersPath() string { +func (i *Instance) GetDefaultScrapersPath() string { // default to the same directory as the config file - fn := filepath.Join(GetConfigPath(), "scrapers") + fn := filepath.Join(i.GetConfigPath(), "scrapers") return fn } -func GetExcludes() []string { +func (i *Instance) GetExcludes() []string { return viper.GetStringSlice(Exclude) } -func GetImageExcludes() []string { +func (i *Instance) GetImageExcludes() []string { return viper.GetStringSlice(ImageExclude) } -func GetVideoExtensions() []string { +func (i *Instance) GetVideoExtensions() []string { ret := viper.GetStringSlice(VideoExtensions) if ret == nil { ret = defaultVideoExtensions @@ -217,7 +254,7 @@ func GetVideoExtensions() []string { return ret } -func GetImageExtensions() []string { +func (i *Instance) GetImageExtensions() []string { ret := viper.GetStringSlice(ImageExtensions) if ret == nil { ret = defaultImageExtensions @@ -225,7 +262,7 @@ func GetImageExtensions() []string { return ret } -func GetGalleryExtensions() []string { +func (i *Instance) GetGalleryExtensions() []string { ret := viper.GetStringSlice(GalleryExtensions) if ret == nil { ret = defaultGalleryExtensions @@ -233,11 +270,11 @@ func GetGalleryExtensions() []string { return ret } -func GetCreateGalleriesFromFolders() bool { +func (i *Instance) GetCreateGalleriesFromFolders() bool { return viper.GetBool(CreateGalleriesFromFolders) } -func GetLanguage() string { +func (i *Instance) GetLanguage() string { ret := viper.GetString(Language) // default to English @@ -250,13 +287,13 @@ func GetLanguage() string { // IsCalculateMD5 returns true if MD5 checksums should be generated for // scene video files. -func IsCalculateMD5() bool { +func (i *Instance) IsCalculateMD5() bool { return viper.GetBool(CalculateMD5) } // GetVideoFileNamingAlgorithm returns what hash algorithm should be used for // naming generated scene video files. -func GetVideoFileNamingAlgorithm() models.HashAlgorithm { +func (i *Instance) GetVideoFileNamingAlgorithm() models.HashAlgorithm { ret := viper.GetString(VideoFileNamingAlgorithm) // default to oshash @@ -267,23 +304,23 @@ func GetVideoFileNamingAlgorithm() models.HashAlgorithm { return models.HashAlgorithm(ret) } -func GetScrapersPath() string { +func (i *Instance) GetScrapersPath() string { return viper.GetString(ScrapersPath) } -func GetScraperUserAgent() string { +func (i *Instance) GetScraperUserAgent() string { return viper.GetString(ScraperUserAgent) } // GetScraperCDPPath gets the path to the Chrome executable or remote address // to an instance of Chrome. -func GetScraperCDPPath() string { +func (i *Instance) GetScraperCDPPath() string { return viper.GetString(ScraperCDPPath) } // GetScraperCertCheck returns true if the scraper should check for insecure // certificates when fetching an image or a page. -func GetScraperCertCheck() bool { +func (i *Instance) GetScraperCertCheck() bool { ret := true if viper.IsSet(ScraperCertCheck) { ret = viper.GetBool(ScraperCertCheck) @@ -292,48 +329,48 @@ func GetScraperCertCheck() bool { return ret } -func GetStashBoxes() []*models.StashBox { +func (i *Instance) GetStashBoxes() []*models.StashBox { var boxes []*models.StashBox viper.UnmarshalKey(StashBoxes, &boxes) return boxes } -func GetDefaultPluginsPath() string { +func (i *Instance) GetDefaultPluginsPath() string { // default to the same directory as the config file - fn := filepath.Join(GetConfigPath(), "plugins") + fn := filepath.Join(i.GetConfigPath(), "plugins") return fn } -func GetPluginsPath() string { +func (i *Instance) GetPluginsPath() string { return viper.GetString(PluginsPath) } -func GetHost() string { +func (i *Instance) GetHost() string { return viper.GetString(Host) } -func GetPort() int { +func (i *Instance) GetPort() int { return viper.GetInt(Port) } -func GetExternalHost() string { +func (i *Instance) GetExternalHost() string { return viper.GetString(ExternalHost) } // GetPreviewSegmentDuration returns the duration of a single segment in a // scene preview file, in seconds. -func GetPreviewSegmentDuration() float64 { +func (i *Instance) GetPreviewSegmentDuration() float64 { return viper.GetFloat64(PreviewSegmentDuration) } // GetParallelTasks returns the number of parallel tasks that should be started // by scan or generate task. -func GetParallelTasks() int { +func (i *Instance) GetParallelTasks() int { return viper.GetInt(ParallelTasks) } -func GetParallelTasksWithAutoDetection() int { +func (i *Instance) GetParallelTasksWithAutoDetection() int { parallelTasks := viper.GetInt(ParallelTasks) if parallelTasks <= 0 { parallelTasks = (runtime.NumCPU() / 4) + 1 @@ -342,7 +379,7 @@ func GetParallelTasksWithAutoDetection() int { } // GetPreviewSegments returns the amount of segments in a scene preview file. -func GetPreviewSegments() int { +func (i *Instance) GetPreviewSegments() int { return viper.GetInt(PreviewSegments) } @@ -352,7 +389,7 @@ func GetPreviewSegments() int { // of seconds to exclude from the start of the video before it is included // in the preview. If the value is suffixed with a '%' character (for example // '2%'), then it is interpreted as a proportion of the total video duration. -func GetPreviewExcludeStart() string { +func (i *Instance) GetPreviewExcludeStart() string { return viper.GetString(PreviewExcludeStart) } @@ -361,13 +398,13 @@ func GetPreviewExcludeStart() string { // is interpreted as the amount of seconds to exclude from the end of the video // when generating previews. If the value is suffixed with a '%' character, // then it is interpreted as a proportion of the total video duration. -func GetPreviewExcludeEnd() string { +func (i *Instance) GetPreviewExcludeEnd() string { return viper.GetString(PreviewExcludeEnd) } // GetPreviewPreset returns the preset when generating previews. Defaults to // Slow. -func GetPreviewPreset() models.PreviewPreset { +func (i *Instance) GetPreviewPreset() models.PreviewPreset { ret := viper.GetString(PreviewPreset) // default to slow @@ -378,7 +415,7 @@ func GetPreviewPreset() models.PreviewPreset { return models.PreviewPreset(ret) } -func GetMaxTranscodeSize() models.StreamingResolutionEnum { +func (i *Instance) GetMaxTranscodeSize() models.StreamingResolutionEnum { ret := viper.GetString(MaxTranscodeSize) // default to original @@ -389,7 +426,7 @@ func GetMaxTranscodeSize() models.StreamingResolutionEnum { return models.StreamingResolutionEnum(ret) } -func GetMaxStreamingTranscodeSize() models.StreamingResolutionEnum { +func (i *Instance) GetMaxStreamingTranscodeSize() models.StreamingResolutionEnum { ret := viper.GetString(MaxStreamingTranscodeSize) // default to original @@ -400,33 +437,33 @@ func GetMaxStreamingTranscodeSize() models.StreamingResolutionEnum { return models.StreamingResolutionEnum(ret) } -func GetAPIKey() string { +func (i *Instance) GetAPIKey() string { return viper.GetString(ApiKey) } -func GetUsername() string { +func (i *Instance) GetUsername() string { return viper.GetString(Username) } -func GetPasswordHash() string { +func (i *Instance) GetPasswordHash() string { return viper.GetString(Password) } -func GetCredentials() (string, string) { - if HasCredentials() { +func (i *Instance) GetCredentials() (string, string) { + if i.HasCredentials() { return viper.GetString(Username), viper.GetString(Password) } return "", "" } -func HasCredentials() bool { +func (i *Instance) HasCredentials() bool { if !viper.IsSet(Username) || !viper.IsSet(Password) { return false } - username := GetUsername() - pwHash := GetPasswordHash() + username := i.GetUsername() + pwHash := i.GetPasswordHash() return username != "" && pwHash != "" } @@ -437,20 +474,20 @@ func hashPassword(password string) string { return string(hash) } -func ValidateCredentials(username string, password string) bool { - if !HasCredentials() { +func (i *Instance) ValidateCredentials(username string, password string) bool { + if !i.HasCredentials() { // don't need to authenticate if no credentials saved return true } - authUser, authPWHash := GetCredentials() + authUser, authPWHash := i.GetCredentials() err := bcrypt.CompareHashAndPassword([]byte(authPWHash), []byte(password)) return username == authUser && err == nil } -func ValidateStashBoxes(boxes []*models.StashBoxInput) error { +func (i *Instance) ValidateStashBoxes(boxes []*models.StashBoxInput) error { isMulti := len(boxes) > 1 re, err := regexp.Compile("^http.*graphql$") @@ -474,56 +511,56 @@ func ValidateStashBoxes(boxes []*models.StashBoxInput) error { // GetMaxSessionAge gets the maximum age for session cookies, in seconds. // Session cookie expiry times are refreshed every request. -func GetMaxSessionAge() int { +func (i *Instance) GetMaxSessionAge() int { viper.SetDefault(MaxSessionAge, DefaultMaxSessionAge) return viper.GetInt(MaxSessionAge) } // GetCustomServedFolders gets the map of custom paths to their applicable // filesystem locations -func GetCustomServedFolders() URLMap { +func (i *Instance) GetCustomServedFolders() URLMap { return viper.GetStringMapString(CustomServedFolders) } // Interface options -func GetMenuItems() []string { +func (i *Instance) GetMenuItems() []string { if viper.IsSet(MenuItems) { return viper.GetStringSlice(MenuItems) } return defaultMenuItems } -func GetSoundOnPreview() bool { +func (i *Instance) GetSoundOnPreview() bool { viper.SetDefault(SoundOnPreview, false) return viper.GetBool(SoundOnPreview) } -func GetWallShowTitle() bool { +func (i *Instance) GetWallShowTitle() bool { viper.SetDefault(WallShowTitle, true) return viper.GetBool(WallShowTitle) } -func GetWallPlayback() string { +func (i *Instance) GetWallPlayback() string { viper.SetDefault(WallPlayback, "video") return viper.GetString(WallPlayback) } -func GetMaximumLoopDuration() int { +func (i *Instance) GetMaximumLoopDuration() int { viper.SetDefault(MaximumLoopDuration, 0) return viper.GetInt(MaximumLoopDuration) } -func GetAutostartVideo() bool { +func (i *Instance) GetAutostartVideo() bool { viper.SetDefault(AutostartVideo, false) return viper.GetBool(AutostartVideo) } -func GetShowStudioAsText() bool { +func (i *Instance) GetShowStudioAsText() bool { viper.SetDefault(ShowStudioAsText, false) return viper.GetBool(ShowStudioAsText) } -func GetCSSPath() string { +func (i *Instance) GetCSSPath() string { // use custom.css in the same directory as the config file configFileUsed := viper.ConfigFileUsed() configDir := filepath.Dir(configFileUsed) @@ -533,8 +570,8 @@ func GetCSSPath() string { return fn } -func GetCSS() string { - fn := GetCSSPath() +func (i *Instance) GetCSS() string { + fn := i.GetCSSPath() exists, _ := utils.FileExists(fn) if !exists { @@ -550,28 +587,28 @@ func GetCSS() string { return string(buf) } -func SetCSS(css string) { - fn := GetCSSPath() +func (i *Instance) SetCSS(css string) { + fn := i.GetCSSPath() buf := []byte(css) ioutil.WriteFile(fn, buf, 0777) } -func GetCSSEnabled() bool { +func (i *Instance) GetCSSEnabled() bool { return viper.GetBool(CSSEnabled) } // GetLogFile returns the filename of the file to output logs to. // An empty string means that file logging will be disabled. -func GetLogFile() string { +func (i *Instance) GetLogFile() string { return viper.GetString(LogFile) } // GetLogOut returns true if logging should be output to the terminal // in addition to writing to a log file. Logging will be output to the // terminal if file logging is disabled. Defaults to true. -func GetLogOut() bool { +func (i *Instance) GetLogOut() bool { ret := true if viper.IsSet(LogOut) { ret = viper.GetBool(LogOut) @@ -582,7 +619,7 @@ func GetLogOut() bool { // GetLogLevel returns the lowest log level to write to the log. // Should be one of "Debug", "Info", "Warning", "Error" -func GetLogLevel() string { +func (i *Instance) GetLogLevel() string { const defaultValue = "Info" value := viper.GetString(LogLevel) @@ -595,7 +632,7 @@ func GetLogLevel() string { // GetLogAccess returns true if http requests should be logged to the terminal. // HTTP requests are not logged to the log file. Defaults to true. -func GetLogAccess() bool { +func (i *Instance) GetLogAccess() bool { ret := true if viper.IsSet(LogAccess) { ret = viper.GetBool(LogAccess) @@ -605,7 +642,7 @@ func GetLogAccess() bool { } // Max allowed graphql upload size in megabytes -func GetMaxUploadSize() int64 { +func (i *Instance) GetMaxUploadSize() int64 { ret := int64(1024) if viper.IsSet(MaxUploadSize) { ret = viper.GetInt64(MaxUploadSize) @@ -613,11 +650,27 @@ func GetMaxUploadSize() int64 { return ret << 20 } -func IsValid() bool { - setPaths := viper.IsSet(Stash) && viper.IsSet(Cache) && viper.IsSet(Generated) && viper.IsSet(Metadata) +func (i *Instance) Validate() error { + mandatoryPaths := []string{ + Database, + Generated, + } - // TODO: check valid paths - return setPaths + var missingFields []string + + for _, p := range mandatoryPaths { + if !viper.IsSet(p) || viper.GetString(p) == "" { + missingFields = append(missingFields, p) + } + } + + if len(missingFields) > 0 { + return MissingConfigError{ + missingFields: missingFields, + } + } + + return nil } func setDefaultValues() { @@ -629,21 +682,19 @@ func setDefaultValues() { } // SetInitialConfig fills in missing required config fields -func SetInitialConfig() error { +func (i *Instance) SetInitialConfig() { // generate some api keys const apiKeyLength = 32 - if string(GetJWTSignKey()) == "" { + if string(i.GetJWTSignKey()) == "" { signKey := utils.GenerateRandomKey(apiKeyLength) - Set(JWTSignKey, signKey) + i.Set(JWTSignKey, signKey) } - if string(GetSessionStoreKey()) == "" { + if string(i.GetSessionStoreKey()) == "" { sessionStoreKey := utils.GenerateRandomKey(apiKeyLength) - Set(SessionStoreKey, sessionStoreKey) + i.Set(SessionStoreKey, sessionStoreKey) } setDefaultValues() - - return Write() } diff --git a/pkg/manager/config/init.go b/pkg/manager/config/init.go new file mode 100644 index 000000000..932641ff8 --- /dev/null +++ b/pkg/manager/config/init.go @@ -0,0 +1,104 @@ +package config + +import ( + "net" + "os" + "sync" + + "github.com/spf13/pflag" + "github.com/spf13/viper" + + "github.com/stashapp/stash/pkg/logger" + "github.com/stashapp/stash/pkg/utils" +) + +var once sync.Once + +type flagStruct struct { + configFilePath string +} + +func Initialize() (*Instance, error) { + var err error + once.Do(func() { + instance = &Instance{} + + flags := initFlags() + err = initConfig(flags) + initEnvs() + }) + return instance, err +} + +func initConfig(flags flagStruct) error { + // The config file is called config. Leave off the file extension. + viper.SetConfigName("config") + + if flagConfigFileExists, _ := utils.FileExists(flags.configFilePath); flagConfigFileExists { + viper.SetConfigFile(flags.configFilePath) + } + viper.AddConfigPath(".") // Look for config in the working directory + viper.AddConfigPath("$HOME/.stash") // Look for the config in the home directory + + // for Docker compatibility, if STASH_CONFIG_FILE is set, then touch the + // given filename + envConfigFile := os.Getenv("STASH_CONFIG_FILE") + if envConfigFile != "" { + utils.Touch(envConfigFile) + viper.SetConfigFile(envConfigFile) + } + + err := viper.ReadInConfig() // Find and read the config file + // continue, but set an error to be handled by caller + + postInitConfig() + instance.SetInitialConfig() + + return err +} + +func postInitConfig() { + c := instance + if c.GetConfigFile() != "" { + viper.SetDefault(Database, c.GetDefaultDatabaseFilePath()) + } + + // Set generated to the metadata path for backwards compat + viper.SetDefault(Generated, viper.GetString(Metadata)) + + // Set default scrapers and plugins paths + viper.SetDefault(ScrapersPath, c.GetDefaultScrapersPath()) + viper.SetDefault(PluginsPath, c.GetDefaultPluginsPath()) + + viper.WriteConfig() +} + +func initFlags() flagStruct { + flags := flagStruct{} + + pflag.IP("host", net.IPv4(0, 0, 0, 0), "ip address for the host") + pflag.Int("port", 9999, "port to serve from") + pflag.StringVarP(&flags.configFilePath, "config", "c", "", "config file to use") + + pflag.Parse() + if err := viper.BindPFlags(pflag.CommandLine); err != nil { + logger.Infof("failed to bind flags: %s", err.Error()) + } + + return flags +} + +func initEnvs() { + viper.SetEnvPrefix("stash") // will be uppercased automatically + viper.BindEnv("host") // STASH_HOST + viper.BindEnv("port") // STASH_PORT + viper.BindEnv("external_host") // STASH_EXTERNAL_HOST + viper.BindEnv("generated") // STASH_GENERATED + viper.BindEnv("metadata") // STASH_METADATA + viper.BindEnv("cache") // STASH_CACHE + + // only set stash config flag if not already set + if instance.GetStashPaths() == nil { + viper.BindEnv("stash") // STASH_STASH + } +} diff --git a/pkg/manager/manager.go b/pkg/manager/manager.go index acd23f8bd..9d4aabea9 100644 --- a/pkg/manager/manager.go +++ b/pkg/manager/manager.go @@ -1,11 +1,13 @@ package manager import ( - "net" + "errors" + "fmt" + "os" + "path/filepath" "sync" - "github.com/spf13/pflag" - "github.com/spf13/viper" + "github.com/stashapp/stash/pkg/database" "github.com/stashapp/stash/pkg/ffmpeg" "github.com/stashapp/stash/pkg/logger" "github.com/stashapp/stash/pkg/manager/config" @@ -18,6 +20,8 @@ import ( ) type singleton struct { + Config *config.Instance + Status TaskStatus Paths *paths.Paths @@ -48,29 +52,35 @@ func GetInstance() *singleton { func Initialize() *singleton { once.Do(func() { - _ = utils.EnsureDir(paths.GetConfigDirectory()) - initFlags() - initConfig() + _ = utils.EnsureDir(paths.GetStashHomeDirectory()) + cfg, err := config.Initialize() initLog() - initEnvs() + instance = &singleton{ - Status: TaskStatus{Status: Idle, Progress: -1}, - Paths: paths.NewPaths(), - - PluginCache: initPluginCache(), - + Config: cfg, + Status: TaskStatus{Status: Idle, Progress: -1}, DownloadStore: NewDownloadStore(), - TxnManager: sqlite.NewTransactionManager(), + + TxnManager: sqlite.NewTransactionManager(), } - instance.ScraperCache = instance.initScraperCache() - instance.RefreshConfig() + cfgFile := cfg.GetConfigFile() + if cfgFile != "" { + logger.Infof("using config file: %s", cfg.GetConfigFile()) - // clear the downloads and tmp directories - // #1021 - only clear these directories if the generated folder is non-empty - if config.GetGeneratedPath() != "" { - utils.EmptyDir(instance.Paths.Generated.Downloads) - utils.EmptyDir(instance.Paths.Generated.Tmp) + if err == nil { + err = cfg.Validate() + } + + if err != nil { + panic(fmt.Sprintf("error initializing configuration: %s", err.Error())) + } else { + if err := instance.PostInit(); err != nil { + panic(err) + } + } + } else { + logger.Warn("config file not found. Assuming new system...") } initFFMPEG() @@ -79,78 +89,8 @@ func Initialize() *singleton { return instance } -func initConfig() { - // The config file is called config. Leave off the file extension. - viper.SetConfigName("config") - - if flagConfigFileExists, _ := utils.FileExists(flags.configFilePath); flagConfigFileExists { - viper.SetConfigFile(flags.configFilePath) - } - viper.AddConfigPath(".") // Look for config in the working directory - viper.AddConfigPath("$HOME/.stash") // Look for the config in the home directory - - err := viper.ReadInConfig() // Find and read the config file - if err != nil { // Handle errors reading the config file - _ = utils.Touch(paths.GetDefaultConfigFilePath()) - if err = viper.ReadInConfig(); err != nil { - panic(err) - } - } - logger.Infof("using config file: %s", viper.ConfigFileUsed()) - - config.SetInitialConfig() - - viper.SetDefault(config.Database, paths.GetDefaultDatabaseFilePath()) - - // Set generated to the metadata path for backwards compat - viper.SetDefault(config.Generated, viper.GetString(config.Metadata)) - - // Set default scrapers and plugins paths - viper.SetDefault(config.ScrapersPath, config.GetDefaultScrapersPath()) - viper.SetDefault(config.PluginsPath, config.GetDefaultPluginsPath()) - - // Disabling config watching due to race condition issue - // See: https://github.com/spf13/viper/issues/174 - // Changes to the config outside the system will require a restart - // Watch for changes - // viper.WatchConfig() - // viper.OnConfigChange(func(e fsnotify.Event) { - // fmt.Println("Config file changed:", e.Name) - // instance.refreshConfig() - // }) - - //viper.Set("stash", []string{"/", "/stuff"}) - //viper.WriteConfig() -} - -func initFlags() { - pflag.IP("host", net.IPv4(0, 0, 0, 0), "ip address for the host") - pflag.Int("port", 9999, "port to serve from") - pflag.StringVarP(&flags.configFilePath, "config", "c", "", "config file to use") - - pflag.Parse() - if err := viper.BindPFlags(pflag.CommandLine); err != nil { - logger.Infof("failed to bind flags: %s", err.Error()) - } -} - -func initEnvs() { - viper.SetEnvPrefix("stash") // will be uppercased automatically - viper.BindEnv("host") // STASH_HOST - viper.BindEnv("port") // STASH_PORT - viper.BindEnv("external_host") // STASH_EXTERNAL_HOST - viper.BindEnv("generated") // STASH_GENERATED - viper.BindEnv("metadata") // STASH_METADATA - viper.BindEnv("cache") // STASH_CACHE - - // only set stash config flag if not already set - if config.GetStashPaths() == nil { - viper.BindEnv("stash") // STASH_STASH - } -} - func initFFMPEG() { - configDirectory := paths.GetConfigDirectory() + configDirectory := paths.GetStashHomeDirectory() ffmpegPath, ffprobePath := ffmpeg.GetPaths(configDirectory) if ffmpegPath == "" || ffprobePath == "" { logger.Infof("couldn't find FFMPEG, attempting to download it") @@ -174,10 +114,12 @@ The error was: %s } func initLog() { + config := config.GetInstance() logger.Init(config.GetLogFile(), config.GetLogOut(), config.GetLogLevel()) } func initPluginCache() *plugin.Cache { + config := config.GetInstance() ret, err := plugin.NewCache(config.GetPluginsPath()) if err != nil { @@ -187,14 +129,37 @@ func initPluginCache() *plugin.Cache { return ret } +// PostInit initialises the paths, caches and txnManager after the initial +// configuration has been set. Should only be called if the configuration +// is valid. +func (s *singleton) PostInit() error { + s.Paths = paths.NewPaths(s.Config.GetGeneratedPath()) + s.PluginCache = initPluginCache() + s.ScraperCache = instance.initScraperCache() + + s.RefreshConfig() + + // clear the downloads and tmp directories + // #1021 - only clear these directories if the generated folder is non-empty + if s.Config.GetGeneratedPath() != "" { + utils.EmptyDir(instance.Paths.Generated.Downloads) + utils.EmptyDir(instance.Paths.Generated.Tmp) + } + + if err := database.Initialize(s.Config.GetDatabasePath()); err != nil { + return err + } + + if database.Ready() == nil { + s.PostMigrate() + } + + return nil +} + // initScraperCache initializes a new scraper cache and returns it. func (s *singleton) initScraperCache() *scraper.Cache { - scraperConfig := scraper.GlobalConfig{ - Path: config.GetScrapersPath(), - UserAgent: config.GetScraperUserAgent(), - CDPPath: config.GetScraperCDPPath(), - } - ret, err := scraper.NewCache(scraperConfig, s.TxnManager) + ret, err := scraper.NewCache(config.GetInstance(), s.TxnManager) if err != nil { logger.Errorf("Error reading scraper configs: %s", err.Error()) @@ -204,14 +169,14 @@ func (s *singleton) initScraperCache() *scraper.Cache { } func (s *singleton) RefreshConfig() { - s.Paths = paths.NewPaths() - if config.IsValid() { + s.Paths = paths.NewPaths(s.Config.GetGeneratedPath()) + config := s.Config + if config.Validate() == nil { utils.EnsureDir(s.Paths.Generated.Screenshots) utils.EnsureDir(s.Paths.Generated.Vtt) utils.EnsureDir(s.Paths.Generated.Markers) utils.EnsureDir(s.Paths.Generated.Transcodes) utils.EnsureDir(s.Paths.Generated.Downloads) - paths.EnsureJSONDirs(config.GetMetadataPath()) } } @@ -220,3 +185,110 @@ func (s *singleton) RefreshConfig() { func (s *singleton) RefreshScraperCache() { s.ScraperCache = s.initScraperCache() } + +func setSetupDefaults(input *models.SetupInput) { + if input.ConfigLocation == "" { + input.ConfigLocation = filepath.Join(utils.GetHomeDirectory(), ".stash", "config.yml") + } + + configDir := filepath.Dir(input.ConfigLocation) + if input.GeneratedLocation == "" { + input.GeneratedLocation = filepath.Join(configDir, "generated") + } + + if input.DatabaseFile == "" { + input.DatabaseFile = filepath.Join(configDir, "stash-go.sqlite") + } +} + +func (s *singleton) Setup(input models.SetupInput) error { + setSetupDefaults(&input) + + // create the generated directory if it does not exist + if exists, _ := utils.DirExists(input.GeneratedLocation); !exists { + if err := os.Mkdir(input.GeneratedLocation, 0755); err != nil { + return fmt.Errorf("error creating generated directory: %s", err.Error()) + } + } + + if err := utils.Touch(input.ConfigLocation); err != nil { + return fmt.Errorf("error creating config file: %s", err.Error()) + } + + s.Config.SetConfigFile(input.ConfigLocation) + + // set the configuration + s.Config.Set(config.Generated, input.GeneratedLocation) + s.Config.Set(config.Database, input.DatabaseFile) + s.Config.Set(config.Stash, input.Stashes) + if err := s.Config.Write(); err != nil { + return fmt.Errorf("error writing configuration file: %s", err.Error()) + } + + // initialise the database + if err := s.PostInit(); err != nil { + return fmt.Errorf("error initializing the database: %s", err.Error()) + } + + return nil +} + +func (s *singleton) Migrate(input models.MigrateInput) error { + // always backup so that we can roll back to the previous version if + // migration fails + backupPath := input.BackupPath + if backupPath == "" { + backupPath = database.DatabaseBackupPath() + } + + // perform database backup + if err := database.Backup(database.DB, backupPath); err != nil { + return fmt.Errorf("error backing up database: %s", err) + } + + if err := database.RunMigrations(); err != nil { + errStr := fmt.Sprintf("error performing migration: %s", err) + + // roll back to the backed up version + restoreErr := database.RestoreFromBackup(backupPath) + if restoreErr != nil { + errStr = fmt.Sprintf("ERROR: unable to restore database from backup after migration failure: %s\n%s", restoreErr.Error(), errStr) + } else { + errStr = "An error occurred migrating the database to the latest schema version. The backup database file was automatically renamed to restore the database.\n" + errStr + } + + return errors.New(errStr) + } + + // perform post-migration operations + s.PostMigrate() + + // if no backup path was provided, then delete the created backup + if input.BackupPath == "" { + if err := os.Remove(backupPath); err != nil { + logger.Warnf("error removing unwanted database backup (%s): %s", backupPath, err.Error()) + } + } + + return nil +} + +func (s *singleton) GetSystemStatus() *models.SystemStatus { + status := models.SystemStatusEnumOk + dbSchema := int(database.Version()) + dbPath := database.DatabasePath() + appSchema := int(database.AppSchemaVersion()) + + if s.Config.GetConfigFile() == "" { + status = models.SystemStatusEnumSetup + } else if dbSchema < appSchema { + status = models.SystemStatusEnumNeedsMigration + } + + return &models.SystemStatus{ + DatabaseSchema: &dbSchema, + DatabasePath: &dbPath, + AppSchema: appSchema, + Status: status, + } +} diff --git a/pkg/manager/manager_tasks.go b/pkg/manager/manager_tasks.go index ff8116bda..182a938f3 100644 --- a/pkg/manager/manager_tasks.go +++ b/pkg/manager/manager_tasks.go @@ -18,17 +18,17 @@ import ( ) func isGallery(pathname string) bool { - gExt := config.GetGalleryExtensions() + gExt := config.GetInstance().GetGalleryExtensions() return matchExtension(pathname, gExt) } func isVideo(pathname string) bool { - vidExt := config.GetVideoExtensions() + vidExt := config.GetInstance().GetVideoExtensions() return matchExtension(pathname, vidExt) } func isImage(pathname string) bool { - imgExt := config.GetImageExtensions() + imgExt := config.GetInstance().GetImageExtensions() return matchExtension(pathname, imgExt) } @@ -84,7 +84,7 @@ func (t *TaskStatus) updated() { func getScanPaths(inputPaths []string) []*models.StashConfig { if len(inputPaths) == 0 { - return config.GetStashPaths() + return config.GetInstance().GetStashPaths() } var ret []*models.StashConfig @@ -181,6 +181,7 @@ func (s *singleton) Scan(input models.ScanMetadataInput) { } start := time.Now() + config := config.GetInstance() parallelTasks := config.GetParallelTasksWithAutoDetection() logger.Infof("Scan started with %d parallel tasks", parallelTasks) wg := sizedwaitgroup.New(parallelTasks) @@ -264,9 +265,15 @@ func (s *singleton) Scan(input models.ScanMetadataInput) { }() } -func (s *singleton) Import() { +func (s *singleton) Import() error { + config := config.GetInstance() + metadataPath := config.GetMetadataPath() + if metadataPath == "" { + return errors.New("metadata path must be set in config") + } + if s.Status.Status != Idle { - return + return nil } s.Status.SetStatus(Import) s.Status.indefiniteProgress() @@ -276,9 +283,10 @@ func (s *singleton) Import() { var wg sync.WaitGroup wg.Add(1) + task := ImportTask{ txnManager: s.TxnManager, - BaseDir: config.GetMetadataPath(), + BaseDir: metadataPath, Reset: true, DuplicateBehaviour: models.ImportDuplicateEnumFail, MissingRefBehaviour: models.ImportMissingRefEnumFail, @@ -287,11 +295,19 @@ func (s *singleton) Import() { go task.Start(&wg) wg.Wait() }() + + return nil } -func (s *singleton) Export() { +func (s *singleton) Export() error { + config := config.GetInstance() + metadataPath := config.GetMetadataPath() + if metadataPath == "" { + return errors.New("metadata path must be set in config") + } + if s.Status.Status != Idle { - return + return nil } s.Status.SetStatus(Export) s.Status.indefiniteProgress() @@ -309,6 +325,8 @@ func (s *singleton) Export() { go task.Start(&wg) wg.Wait() }() + + return nil } func (s *singleton) RunSingleTask(t Task) (*sync.WaitGroup, error) { @@ -332,6 +350,7 @@ func (s *singleton) RunSingleTask(t Task) (*sync.WaitGroup, error) { } func setGeneratePreviewOptionsInput(optionsInput *models.GeneratePreviewOptionsInput) { + config := config.GetInstance() if optionsInput.PreviewSegments == nil { val := config.GetPreviewSegments() optionsInput.PreviewSegments = &val @@ -409,6 +428,7 @@ func (s *singleton) Generate(input models.GenerateMetadataInput) { return } + config := config.GetInstance() parallelTasks := config.GetParallelTasksWithAutoDetection() logger.Infof("Generate started with %d parallel tasks", parallelTasks) @@ -587,7 +607,7 @@ func (s *singleton) generateScreenshot(sceneId string, at *float64) { txnManager: s.TxnManager, Scene: *scene, ScreenshotAt: at, - fileNamingAlgorithm: config.GetVideoFileNamingAlgorithm(), + fileNamingAlgorithm: config.GetInstance().GetVideoFileNamingAlgorithm(), } var wg sync.WaitGroup @@ -862,7 +882,7 @@ func (s *singleton) Clean(input models.CleanMetadataInput) { var wg sync.WaitGroup s.Status.Progress = 0 total := len(scenes) + len(images) + len(galleries) - fileNamingAlgo := config.GetVideoFileNamingAlgorithm() + fileNamingAlgo := config.GetInstance().GetVideoFileNamingAlgorithm() for i, scene := range scenes { s.Status.setProgress(i, total) if s.Status.stopping { @@ -944,7 +964,7 @@ func (s *singleton) MigrateHash() { go func() { defer s.returnToIdleState() - fileNamingAlgo := config.GetVideoFileNamingAlgorithm() + fileNamingAlgo := config.GetInstance().GetVideoFileNamingAlgorithm() logger.Infof("Migrating generated files for %s naming hash", fileNamingAlgo.String()) var scenes []*models.Scene @@ -1020,7 +1040,7 @@ func (s *singleton) neededGenerate(scenes []*models.Scene, input models.Generate chTimeout <- struct{}{} }() - fileNamingAlgo := config.GetVideoFileNamingAlgorithm() + fileNamingAlgo := config.GetInstance().GetVideoFileNamingAlgorithm() overwrite := false if input.Overwrite != nil { overwrite = *input.Overwrite diff --git a/pkg/manager/paths/paths.go b/pkg/manager/paths/paths.go index 459c60943..0d06af2c0 100644 --- a/pkg/manager/paths/paths.go +++ b/pkg/manager/paths/paths.go @@ -13,31 +13,27 @@ type Paths struct { SceneMarkers *sceneMarkerPaths } -func NewPaths() *Paths { +func NewPaths(generatedPath string) *Paths { p := Paths{} - p.Generated = newGeneratedPaths() + p.Generated = newGeneratedPaths(generatedPath) p.Scene = newScenePaths(p) p.SceneMarkers = newSceneMarkerPaths(p) return &p } -func GetConfigDirectory() string { +func GetStashHomeDirectory() string { return filepath.Join(utils.GetHomeDirectory(), ".stash") } func GetDefaultDatabaseFilePath() string { - return filepath.Join(GetConfigDirectory(), "stash-go.sqlite") -} - -func GetDefaultConfigFilePath() string { - return filepath.Join(GetConfigDirectory(), "config.yml") + return filepath.Join(GetStashHomeDirectory(), "stash-go.sqlite") } func GetSSLKey() string { - return filepath.Join(GetConfigDirectory(), "stash.key") + return filepath.Join(GetStashHomeDirectory(), "stash.key") } func GetSSLCert() string { - return filepath.Join(GetConfigDirectory(), "stash.crt") + return filepath.Join(GetStashHomeDirectory(), "stash.crt") } diff --git a/pkg/manager/paths/paths_generated.go b/pkg/manager/paths/paths_generated.go index 25aef7f45..234f3918b 100644 --- a/pkg/manager/paths/paths_generated.go +++ b/pkg/manager/paths/paths_generated.go @@ -5,7 +5,6 @@ import ( "io/ioutil" "path/filepath" - "github.com/stashapp/stash/pkg/manager/config" "github.com/stashapp/stash/pkg/utils" ) @@ -22,15 +21,15 @@ type generatedPaths struct { Tmp string } -func newGeneratedPaths() *generatedPaths { +func newGeneratedPaths(path string) *generatedPaths { gp := generatedPaths{} - gp.Screenshots = filepath.Join(config.GetGeneratedPath(), "screenshots") - gp.Thumbnails = filepath.Join(config.GetGeneratedPath(), "thumbnails") - gp.Vtt = filepath.Join(config.GetGeneratedPath(), "vtt") - gp.Markers = filepath.Join(config.GetGeneratedPath(), "markers") - gp.Transcodes = filepath.Join(config.GetGeneratedPath(), "transcodes") - gp.Downloads = filepath.Join(config.GetGeneratedPath(), "download_stage") - gp.Tmp = filepath.Join(config.GetGeneratedPath(), "tmp") + gp.Screenshots = filepath.Join(path, "screenshots") + gp.Thumbnails = filepath.Join(path, "thumbnails") + gp.Vtt = filepath.Join(path, "vtt") + gp.Markers = filepath.Join(path, "markers") + gp.Transcodes = filepath.Join(path, "transcodes") + gp.Downloads = filepath.Join(path, "download_stage") + gp.Tmp = filepath.Join(path, "tmp") return &gp } diff --git a/pkg/manager/scene.go b/pkg/manager/scene.go index d40d673e9..e262dda18 100644 --- a/pkg/manager/scene.go +++ b/pkg/manager/scene.go @@ -54,7 +54,7 @@ func DestroySceneMarker(scene *models.Scene, sceneMarker *models.SceneMarker, qb // delete the preview for the marker return func() { seconds := int(sceneMarker.Seconds) - DeleteSceneMarkerFiles(scene, seconds, config.GetVideoFileNamingAlgorithm()) + DeleteSceneMarkerFiles(scene, seconds, config.GetInstance().GetVideoFileNamingAlgorithm()) }, nil } @@ -247,7 +247,7 @@ func GetSceneStreamPaths(scene *models.Scene, directStreamURL string, maxStreami // don't care if we can't get the container container, _ := GetSceneFileContainer(scene) - if HasTranscode(scene, config.GetVideoFileNamingAlgorithm()) || ffmpeg.IsValidAudioForContainer(audioCodec, container) { + if HasTranscode(scene, config.GetInstance().GetVideoFileNamingAlgorithm()) || ffmpeg.IsValidAudioForContainer(audioCodec, container) { label := "Direct stream" ret = append(ret, &models.SceneStreamEndpoint{ URL: directStreamURL, diff --git a/pkg/manager/task_clean.go b/pkg/manager/task_clean.go index fd13277b5..a36fcebb8 100644 --- a/pkg/manager/task_clean.go +++ b/pkg/manager/task_clean.go @@ -42,7 +42,7 @@ func (t *CleanTask) shouldClean(path string) bool { fileExists := image.FileExists(path) // #1102 - clean anything in generated path - generatedPath := config.GetGeneratedPath() + generatedPath := config.GetInstance().GetGeneratedPath() if !fileExists || getStashFromPath(path) == nil || utils.IsPathInDir(generatedPath, path) { logger.Infof("File not found. Cleaning: \"%s\"", path) return true @@ -62,6 +62,7 @@ func (t *CleanTask) shouldCleanScene(s *models.Scene) bool { return true } + config := config.GetInstance() if !matchExtension(s.Path, config.GetVideoExtensions()) { logger.Infof("File extension does not match video extensions. Cleaning: \"%s\"", s.Path) return true @@ -92,6 +93,7 @@ func (t *CleanTask) shouldCleanGallery(g *models.Gallery) bool { return true } + config := config.GetInstance() if !matchExtension(path, config.GetGalleryExtensions()) { logger.Infof("File extension does not match gallery extensions. Cleaning: \"%s\"", path) return true @@ -121,6 +123,7 @@ func (t *CleanTask) shouldCleanImage(s *models.Image) bool { return true } + config := config.GetInstance() if !matchExtension(s.Path, config.GetImageExtensions()) { logger.Infof("File extension does not match image extensions. Cleaning: \"%s\"", s.Path) return true @@ -199,7 +202,7 @@ func (t *CleanTask) fileExists(filename string) (bool, error) { } func getStashFromPath(pathToCheck string) *models.StashConfig { - for _, s := range config.GetStashPaths() { + for _, s := range config.GetInstance().GetStashPaths() { if utils.IsPathInDir(s.Path, filepath.Dir(pathToCheck)) { return s } @@ -208,7 +211,7 @@ func getStashFromPath(pathToCheck string) *models.StashConfig { } func getStashFromDirPath(pathToCheck string) *models.StashConfig { - for _, s := range config.GetStashPaths() { + for _, s := range config.GetInstance().GetStashPaths() { if utils.IsPathInDir(s.Path, pathToCheck) { return s } diff --git a/pkg/manager/task_export.go b/pkg/manager/task_export.go index b949b9389..59bacca24 100644 --- a/pkg/manager/task_export.go +++ b/pkg/manager/task_export.go @@ -107,7 +107,7 @@ func (t *ExportTask) Start(wg *sync.WaitGroup) { startTime := time.Now() if t.full { - t.baseDir = config.GetMetadataPath() + t.baseDir = config.GetInstance().GetMetadataPath() } else { var err error t.baseDir, err = instance.Paths.Generated.TempDir("export") diff --git a/pkg/manager/task_import.go b/pkg/manager/task_import.go index d5f8b720b..76a9a2954 100644 --- a/pkg/manager/task_import.go +++ b/pkg/manager/task_import.go @@ -120,7 +120,7 @@ func (t *ImportTask) Start(wg *sync.WaitGroup) { t.scraped = scraped if t.Reset { - err := database.Reset(config.GetDatabasePath()) + err := database.Reset(config.GetInstance().GetDatabasePath()) if err != nil { logger.Errorf("Error resetting database: %s", err.Error()) diff --git a/pkg/manager/task_scan.go b/pkg/manager/task_scan.go index d9ce9581c..361793c10 100644 --- a/pkg/manager/task_scan.go +++ b/pkg/manager/task_scan.go @@ -69,6 +69,7 @@ func (t *ScanTask) Start(wg *sizedwaitgroup.SizedWaitGroup) { if t.GeneratePreview { iwg.Add() + config := config.GetInstance() var previewSegmentDuration = config.GetPreviewSegmentDuration() var previewSegments = config.GetPreviewSegments() var previewExcludeStart = config.GetPreviewExcludeStart() @@ -313,7 +314,7 @@ func (t *ScanTask) associateGallery(wg *sizedwaitgroup.SizedWaitGroup) { basename := strings.TrimSuffix(t.FilePath, filepath.Ext(t.FilePath)) var relatedFiles []string - vExt := config.GetVideoExtensions() + vExt := config.GetInstance().GetVideoExtensions() // make a list of media files that can be related to the gallery for _, ext := range vExt { related := basename + "." + ext @@ -398,6 +399,7 @@ func (t *ScanTask) scanScene() *models.Scene { // if the mod time of the file is different than that of the associated // scene, then recalculate the checksum and regenerate the thumbnail modified := t.isFileModified(fileModTime, s.FileModTime) + config := config.GetInstance() if modified || !s.Size.Valid { oldHash := s.GetHash(config.GetVideoFileNamingAlgorithm()) s, err = t.rescanScene(s, fileModTime) @@ -874,7 +876,7 @@ func (t *ScanTask) scanImage() { logger.Error(err.Error()) return } - } else if config.GetCreateGalleriesFromFolders() { + } else if config.GetInstance().GetCreateGalleriesFromFolders() { // create gallery from folder or associate with existing gallery logger.Infof("Associating image %s with folder gallery", i.Path) if err := t.TxnManager.WithTxn(context.TODO(), func(r models.Repository) error { @@ -1027,6 +1029,7 @@ func (t *ScanTask) calculateImageChecksum() (string, error) { } func (t *ScanTask) doesPathExist() bool { + config := config.GetInstance() vidExt := config.GetVideoExtensions() imgExt := config.GetImageExtensions() gExt := config.GetGalleryExtensions() @@ -1057,6 +1060,7 @@ func (t *ScanTask) doesPathExist() bool { } func walkFilesToScan(s *models.StashConfig, f filepath.WalkFunc) error { + config := config.GetInstance() vidExt := config.GetVideoExtensions() imgExt := config.GetImageExtensions() gExt := config.GetGalleryExtensions() diff --git a/pkg/manager/task_transcode.go b/pkg/manager/task_transcode.go index f5a46e58f..7b1ed33cf 100644 --- a/pkg/manager/task_transcode.go +++ b/pkg/manager/task_transcode.go @@ -57,7 +57,7 @@ func (t *GenerateTranscodeTask) Start(wg *sizedwaitgroup.SizedWaitGroup) { sceneHash := t.Scene.GetHash(t.fileNamingAlgorithm) outputPath := instance.Paths.Generated.GetTmpPath(sceneHash + ".mp4") - transcodeSize := config.GetMaxTranscodeSize() + transcodeSize := config.GetInstance().GetMaxTranscodeSize() options := ffmpeg.TranscodeOptions{ OutputPath: outputPath, MaxTranscodeSize: transcodeSize, diff --git a/pkg/scraper/image.go b/pkg/scraper/image.go index 08cb6725d..ab09f28da 100644 --- a/pkg/scraper/image.go +++ b/pkg/scraper/image.go @@ -8,7 +8,6 @@ import ( "strings" "time" - stashConfig "github.com/stashapp/stash/pkg/manager/config" "github.com/stashapp/stash/pkg/models" "github.com/stashapp/stash/pkg/utils" ) @@ -87,7 +86,7 @@ func setMovieBackImage(m *models.ScrapedMovie, globalConfig GlobalConfig) error func getImage(url string, globalConfig GlobalConfig) (*string, error) { client := &http.Client{ Transport: &http.Transport{ // ignore insecure certificates - TLSClientConfig: &tls.Config{InsecureSkipVerify: !stashConfig.GetScraperCertCheck()}}, + TLSClientConfig: &tls.Config{InsecureSkipVerify: !globalConfig.GetScraperCertCheck()}}, Timeout: imageGetTimeout, } @@ -96,7 +95,7 @@ func getImage(url string, globalConfig GlobalConfig) (*string, error) { return nil, err } - userAgent := globalConfig.UserAgent + userAgent := globalConfig.GetScraperUserAgent() if userAgent != "" { req.Header.Set("User-Agent", userAgent) } diff --git a/pkg/scraper/scrapers.go b/pkg/scraper/scrapers.go index 6e2ee3fd2..f82dbd223 100644 --- a/pkg/scraper/scrapers.go +++ b/pkg/scraper/scrapers.go @@ -14,21 +14,19 @@ import ( ) // GlobalConfig contains the global scraper options. -type GlobalConfig struct { - // User Agent used when scraping using http. - UserAgent string - - // Path (file or remote address) to a Chrome CDP instance. - CDPPath string - Path string +type GlobalConfig interface { + GetScraperUserAgent() string + GetScrapersPath() string + GetScraperCDPPath() string + GetScraperCertCheck() bool } -func (c GlobalConfig) isCDPPathHTTP() bool { - return strings.HasPrefix(c.CDPPath, "http://") || strings.HasPrefix(c.CDPPath, "https://") +func isCDPPathHTTP(c GlobalConfig) bool { + return strings.HasPrefix(c.GetScraperCDPPath(), "http://") || strings.HasPrefix(c.GetScraperCDPPath(), "https://") } -func (c GlobalConfig) isCDPPathWS() bool { - return strings.HasPrefix(c.CDPPath, "ws://") +func isCDPPathWS(c GlobalConfig) bool { + return strings.HasPrefix(c.GetScraperCDPPath(), "ws://") } // Cache stores scraper details. @@ -45,7 +43,7 @@ type Cache struct { // Scraper configurations are loaded from yml files in the provided scrapers // directory and any subdirectories. func NewCache(globalConfig GlobalConfig, txnManager models.TransactionManager) (*Cache, error) { - scrapers, err := loadScrapers(globalConfig.Path) + scrapers, err := loadScrapers(globalConfig.GetScrapersPath()) if err != nil { return nil, err } @@ -93,7 +91,7 @@ func loadScrapers(path string) ([]config, error) { // In the event of an error during loading, the cache will be left empty. func (c *Cache) ReloadScrapers() error { c.scrapers = nil - scrapers, err := loadScrapers(c.globalConfig.Path) + scrapers, err := loadScrapers(c.globalConfig.GetScrapersPath()) if err != nil { return err } @@ -102,6 +100,7 @@ func (c *Cache) ReloadScrapers() error { return nil } +// TODO - don't think this is needed // UpdateConfig updates the global config for the cache. If the scraper path // has changed, ReloadScrapers will need to be called separately. func (c *Cache) UpdateConfig(globalConfig GlobalConfig) { diff --git a/pkg/scraper/url.go b/pkg/scraper/url.go index 85e1590ee..4404dc067 100644 --- a/pkg/scraper/url.go +++ b/pkg/scraper/url.go @@ -23,7 +23,6 @@ import ( "golang.org/x/net/publicsuffix" "github.com/stashapp/stash/pkg/logger" - stashConfig "github.com/stashapp/stash/pkg/manager/config" ) // Timeout for the scrape http request. Includes transfer time. May want to make this @@ -52,7 +51,7 @@ func loadURL(url string, scraperConfig config, globalConfig GlobalConfig) (io.Re client := &http.Client{ Transport: &http.Transport{ // ignore insecure certificates - TLSClientConfig: &tls.Config{InsecureSkipVerify: !stashConfig.GetScraperCertCheck()}, + TLSClientConfig: &tls.Config{InsecureSkipVerify: !globalConfig.GetScraperCertCheck()}, }, Timeout: scrapeGetTimeout, // defaultCheckRedirect code with max changed from 10 to 20 @@ -70,7 +69,7 @@ func loadURL(url string, scraperConfig config, globalConfig GlobalConfig) (io.Re return nil, err } - userAgent := globalConfig.UserAgent + userAgent := globalConfig.GetScraperUserAgent() if userAgent != "" { req.Header.Set("User-Agent", userAgent) } @@ -114,14 +113,15 @@ func urlFromCDP(url string, driverOptions scraperDriverOptions, globalConfig Glo act := context.Background() // if scraperCDPPath is a remote address, then allocate accordingly - if globalConfig.CDPPath != "" { + cdpPath := globalConfig.GetScraperCDPPath() + if cdpPath != "" { var cancelAct context.CancelFunc - if globalConfig.isCDPPathHTTP() || globalConfig.isCDPPathWS() { - remote := globalConfig.CDPPath + if isCDPPathHTTP(globalConfig) || isCDPPathWS(globalConfig) { + remote := cdpPath // if CDPPath is http(s) then we need to get the websocket URL - if globalConfig.isCDPPathHTTP() { + if isCDPPathHTTP(globalConfig) { var err error remote, err = getRemoteCDPWSAddress(remote) if err != nil { @@ -140,7 +140,7 @@ func urlFromCDP(url string, driverOptions scraperDriverOptions, globalConfig Glo opts := append(chromedp.DefaultExecAllocatorOptions[:], chromedp.UserDataDir(dir), - chromedp.ExecPath(globalConfig.CDPPath), + chromedp.ExecPath(cdpPath), ) act, cancelAct = chromedp.NewExecAllocator(act, opts...) } diff --git a/pkg/scraper/xpath_test.go b/pkg/scraper/xpath_test.go index 275d59830..efc5968a5 100644 --- a/pkg/scraper/xpath_test.go +++ b/pkg/scraper/xpath_test.go @@ -758,6 +758,24 @@ func TestLoadInvalidXPath(t *testing.T) { config.process(q, nil) } +type mockGlobalConfig struct{} + +func (mockGlobalConfig) GetScraperUserAgent() string { + return "" +} + +func (mockGlobalConfig) GetScrapersPath() string { + return "" +} + +func (mockGlobalConfig) GetScraperCDPPath() string { + return "" +} + +func (mockGlobalConfig) GetScraperCertCheck() bool { + return false +} + func TestSubScrape(t *testing.T) { retHTML := `
    @@ -805,7 +823,7 @@ xPathScrapers: return } - globalConfig := GlobalConfig{} + globalConfig := mockGlobalConfig{} performer, err := c.ScrapePerformerURL(ts.URL, nil, globalConfig) diff --git a/pkg/sqlite/transaction.go b/pkg/sqlite/transaction.go index 50016db45..b9d3157ff 100644 --- a/pkg/sqlite/transaction.go +++ b/pkg/sqlite/transaction.go @@ -29,6 +29,10 @@ func (t *transaction) Begin() error { return errors.New("transaction already begun") } + if err := database.Ready(); err != nil { + return err + } + var err error t.tx, err = database.DB.BeginTxx(t.Ctx, nil) if err != nil { @@ -124,6 +128,10 @@ func (t *transaction) Tag() models.TagReaderWriter { type ReadTransaction struct{} func (t *ReadTransaction) Begin() error { + if err := database.Ready(); err != nil { + return err + } + return nil } diff --git a/ui/setup/index.html b/ui/setup/index.html deleted file mode 100644 index 38825d717..000000000 --- a/ui/setup/index.html +++ /dev/null @@ -1,38 +0,0 @@ - - - - - Stash - - - - - - - -
    -
    -
    - - - - - - - - - - - - - - -
    - -
    -
    -
    -
    - - - \ No newline at end of file diff --git a/ui/setup/migrate.html b/ui/setup/migrate.html deleted file mode 100644 index 632f30144..000000000 --- a/ui/setup/migrate.html +++ /dev/null @@ -1,37 +0,0 @@ - - - - - Stash - - - - - - - -
    -

    - Your current stash database is schema version {{.ExistingVersion}} and needs to be migrated to version {{.MigrateVersion}}. - This version of Stash will not function without migrating the database. The schema migration process is not reversible. Once the migration is - performed, your database will be incompatible with previous versions of stash. -

    - -

    - It is recommended that you backup your existing database before you migrate. We can do this for you, writing a backup to {{.BackupPath}} if required. -

    - -
    -
    - - - -
    - -
    -
    -
    -
    - - - \ No newline at end of file diff --git a/ui/setup/milligram.min.css b/ui/setup/milligram.min.css deleted file mode 100755 index 85f877b9f..000000000 --- a/ui/setup/milligram.min.css +++ /dev/null @@ -1,11 +0,0 @@ -/*! - * Milligram v1.3.0 - * https://milligram.github.io - * - * Copyright (c) 2017 CJ Patoilo - * Licensed under the MIT license - */ - -*,*:after,*:before{box-sizing:inherit}html{box-sizing:border-box;font-size:62.5%}body{color:#606c76;font-family:'Roboto', 'Helvetica Neue', 'Helvetica', 'Arial', sans-serif;font-size:1.6em;font-weight:300;letter-spacing:.01em;line-height:1.6}blockquote{border-left:0.3rem solid #d1d1d1;margin-left:0;margin-right:0;padding:1rem 1.5rem}blockquote *:last-child{margin-bottom:0}.button,button,input[type='button'],input[type='reset'],input[type='submit']{background-color:#9b4dca;border:0.1rem solid #9b4dca;border-radius:.4rem;color:#fff;cursor:pointer;display:inline-block;font-size:1.1rem;font-weight:700;height:3.8rem;letter-spacing:.1rem;line-height:3.8rem;padding:0 3.0rem;text-align:center;text-decoration:none;text-transform:uppercase;white-space:nowrap}.button:focus,.button:hover,button:focus,button:hover,input[type='button']:focus,input[type='button']:hover,input[type='reset']:focus,input[type='reset']:hover,input[type='submit']:focus,input[type='submit']:hover{background-color:#606c76;border-color:#606c76;color:#fff;outline:0}.button[disabled],button[disabled],input[type='button'][disabled],input[type='reset'][disabled],input[type='submit'][disabled]{cursor:default;opacity:.5}.button[disabled]:focus,.button[disabled]:hover,button[disabled]:focus,button[disabled]:hover,input[type='button'][disabled]:focus,input[type='button'][disabled]:hover,input[type='reset'][disabled]:focus,input[type='reset'][disabled]:hover,input[type='submit'][disabled]:focus,input[type='submit'][disabled]:hover{background-color:#9b4dca;border-color:#9b4dca}.button.button-outline,button.button-outline,input[type='button'].button-outline,input[type='reset'].button-outline,input[type='submit'].button-outline{background-color:transparent;color:#9b4dca}.button.button-outline:focus,.button.button-outline:hover,button.button-outline:focus,button.button-outline:hover,input[type='button'].button-outline:focus,input[type='button'].button-outline:hover,input[type='reset'].button-outline:focus,input[type='reset'].button-outline:hover,input[type='submit'].button-outline:focus,input[type='submit'].button-outline:hover{background-color:transparent;border-color:#606c76;color:#606c76}.button.button-outline[disabled]:focus,.button.button-outline[disabled]:hover,button.button-outline[disabled]:focus,button.button-outline[disabled]:hover,input[type='button'].button-outline[disabled]:focus,input[type='button'].button-outline[disabled]:hover,input[type='reset'].button-outline[disabled]:focus,input[type='reset'].button-outline[disabled]:hover,input[type='submit'].button-outline[disabled]:focus,input[type='submit'].button-outline[disabled]:hover{border-color:inherit;color:#9b4dca}.button.button-clear,button.button-clear,input[type='button'].button-clear,input[type='reset'].button-clear,input[type='submit'].button-clear{background-color:transparent;border-color:transparent;color:#9b4dca}.button.button-clear:focus,.button.button-clear:hover,button.button-clear:focus,button.button-clear:hover,input[type='button'].button-clear:focus,input[type='button'].button-clear:hover,input[type='reset'].button-clear:focus,input[type='reset'].button-clear:hover,input[type='submit'].button-clear:focus,input[type='submit'].button-clear:hover{background-color:transparent;border-color:transparent;color:#606c76}.button.button-clear[disabled]:focus,.button.button-clear[disabled]:hover,button.button-clear[disabled]:focus,button.button-clear[disabled]:hover,input[type='button'].button-clear[disabled]:focus,input[type='button'].button-clear[disabled]:hover,input[type='reset'].button-clear[disabled]:focus,input[type='reset'].button-clear[disabled]:hover,input[type='submit'].button-clear[disabled]:focus,input[type='submit'].button-clear[disabled]:hover{color:#9b4dca}code{background:#f4f5f6;border-radius:.4rem;font-size:86%;margin:0 .2rem;padding:.2rem .5rem;white-space:nowrap}pre{background:#f4f5f6;border-left:0.3rem solid #9b4dca;overflow-y:hidden}pre>code{border-radius:0;display:block;padding:1rem 1.5rem;white-space:pre}hr{border:0;border-top:0.1rem solid #f4f5f6;margin:3.0rem 0}input[type='email'],input[type='number'],input[type='password'],input[type='search'],input[type='tel'],input[type='text'],input[type='url'],textarea,select{-webkit-appearance:none;-moz-appearance:none;appearance:none;background-color:transparent;border:0.1rem solid #d1d1d1;border-radius:.4rem;box-shadow:none;box-sizing:inherit;height:3.8rem;padding:.6rem 1.0rem;width:100%}input[type='email']:focus,input[type='number']:focus,input[type='password']:focus,input[type='search']:focus,input[type='tel']:focus,input[type='text']:focus,input[type='url']:focus,textarea:focus,select:focus{border-color:#9b4dca;outline:0}select{background:url('data:image/svg+xml;utf8,') center right no-repeat;padding-right:3.0rem}select:focus{background-image:url('data:image/svg+xml;utf8,')}textarea{min-height:6.5rem}label,legend{display:block;font-size:1.6rem;font-weight:700;margin-bottom:.5rem}fieldset{border-width:0;padding:0}input[type='checkbox'],input[type='radio']{display:inline}.label-inline{display:inline-block;font-weight:normal;margin-left:.5rem}.container{margin:0 auto;max-width:112.0rem;padding:0 2.0rem;position:relative;width:100%}.row{display:flex;flex-direction:column;padding:0;width:100%}.row.row-no-padding{padding:0}.row.row-no-padding>.column{padding:0}.row.row-wrap{flex-wrap:wrap}.row.row-top{align-items:flex-start}.row.row-bottom{align-items:flex-end}.row.row-center{align-items:center}.row.row-stretch{align-items:stretch}.row.row-baseline{align-items:baseline}.row .column{display:block;flex:1 1 auto;margin-left:0;max-width:100%;width:100%}.row .column.column-offset-10{margin-left:10%}.row .column.column-offset-20{margin-left:20%}.row .column.column-offset-25{margin-left:25%}.row .column.column-offset-33,.row .column.column-offset-34{margin-left:33.3333%}.row .column.column-offset-50{margin-left:50%}.row .column.column-offset-66,.row .column.column-offset-67{margin-left:66.6666%}.row .column.column-offset-75{margin-left:75%}.row .column.column-offset-80{margin-left:80%}.row .column.column-offset-90{margin-left:90%}.row .column.column-10{flex:0 0 10%;max-width:10%}.row .column.column-20{flex:0 0 20%;max-width:20%}.row .column.column-25{flex:0 0 25%;max-width:25%}.row .column.column-33,.row .column.column-34{flex:0 0 33.3333%;max-width:33.3333%}.row .column.column-40{flex:0 0 40%;max-width:40%}.row .column.column-50{flex:0 0 50%;max-width:50%}.row .column.column-60{flex:0 0 60%;max-width:60%}.row .column.column-66,.row .column.column-67{flex:0 0 66.6666%;max-width:66.6666%}.row .column.column-75{flex:0 0 75%;max-width:75%}.row .column.column-80{flex:0 0 80%;max-width:80%}.row .column.column-90{flex:0 0 90%;max-width:90%}.row .column .column-top{align-self:flex-start}.row .column .column-bottom{align-self:flex-end}.row .column .column-center{-ms-grid-row-align:center;align-self:center}@media (min-width: 40rem){.row{flex-direction:row;margin-left:-1.0rem;width:calc(100% + 2.0rem)}.row .column{margin-bottom:inherit;padding:0 1.0rem}}a{color:#9b4dca;text-decoration:none}a:focus,a:hover{color:#606c76}dl,ol,ul{list-style:none;margin-top:0;padding-left:0}dl dl,dl ol,dl ul,ol dl,ol ol,ol ul,ul dl,ul ol,ul ul{font-size:90%;margin:1.5rem 0 1.5rem 3.0rem}ol{list-style:decimal inside}ul{list-style:circle inside}.button,button,dd,dt,li{margin-bottom:1.0rem}fieldset,input,select,textarea{margin-bottom:1.5rem}blockquote,dl,figure,form,ol,p,pre,table,ul{margin-bottom:2.5rem}table{border-spacing:0;width:100%}td,th{border-bottom:0.1rem solid #e1e1e1;padding:1.2rem 1.5rem;text-align:left}td:first-child,th:first-child{padding-left:0}td:last-child,th:last-child{padding-right:0}b,strong{font-weight:bold}p{margin-top:0}h1,h2,h3,h4,h5,h6{font-weight:300;letter-spacing:-.1rem;margin-bottom:2.0rem;margin-top:0}h1{font-size:4.6rem;line-height:1.2}h2{font-size:3.6rem;line-height:1.25}h3{font-size:2.8rem;line-height:1.3}h4{font-size:2.2rem;letter-spacing:-.08rem;line-height:1.35}h5{font-size:1.8rem;letter-spacing:-.05rem;line-height:1.5}h6{font-size:1.6rem;letter-spacing:0;line-height:1.4}img{max-width:100%}.clearfix:after{clear:both;content:' ';display:table}.float-left{float:left}.float-right{float:right} - -/*# sourceMappingURL=milligram.min.css.map */ \ No newline at end of file diff --git a/ui/v2.5/src/App.tsx b/ui/v2.5/src/App.tsx index 5be2b0268..efee69e4a 100755 --- a/ui/v2.5/src/App.tsx +++ b/ui/v2.5/src/App.tsx @@ -1,5 +1,5 @@ -import React from "react"; -import { Route, Switch } from "react-router-dom"; +import React, { useEffect } from "react"; +import { Route, Switch, useRouteMatch } from "react-router-dom"; import { IntlProvider } from "react-intl"; import { ToastProvider } from "src/hooks/Toast"; import LightboxProvider from "src/hooks/Lightbox/context"; @@ -8,7 +8,7 @@ import { fas } from "@fortawesome/free-solid-svg-icons"; import { initPolyfills } from "src/polyfills"; import locales from "src/locale"; -import { useConfiguration } from "src/core/StashService"; +import { useConfiguration, useSystemStatus } from "src/core/StashService"; import { flattenMessages } from "src/utils"; import Mousetrap from "mousetrap"; import MousetrapPause from "mousetrap-pause"; @@ -25,6 +25,10 @@ import { SceneFilenameParser } from "./components/SceneFilenameParser/SceneFilen import Movies from "./components/Movies/Movies"; import Tags from "./components/Tags/Tags"; import Images from "./components/Images/Images"; +import { Setup } from "./components/Setup/Setup"; +import { Migrate } from "./components/Setup/Migrate"; +import * as GQL from "./core/generated-graphql"; +import { LoadingIndicator } from "./components/Shared"; initPolyfills(); @@ -41,35 +45,78 @@ const intlFormats = { export const App: React.FC = () => { const config = useConfiguration(); + const { data: systemStatusData } = useSystemStatus(); const language = config.data?.configuration?.interface?.language ?? "en-GB"; const messageLanguage = language.replace(/-/, ""); // eslint-disable-next-line @typescript-eslint/no-explicit-any const messages = flattenMessages((locales as any)[messageLanguage]); + const setupMatch = useRouteMatch(["/setup", "/migrate"]); + + // redirect to setup or migrate as needed + useEffect(() => { + if (!systemStatusData) { + return; + } + + if ( + window.location.pathname !== "/setup" && + systemStatusData.systemStatus.status === GQL.SystemStatusEnum.Setup + ) { + // redirect to setup page + const newURL = new URL("/setup", window.location.toString()); + window.location.href = newURL.toString(); + } + + if ( + window.location.pathname !== "/migrate" && + systemStatusData.systemStatus.status === + GQL.SystemStatusEnum.NeedsMigration + ) { + // redirect to setup page + const newURL = new URL("/migrate", window.location.toString()); + window.location.href = newURL.toString(); + } + }, [systemStatusData]); + + function maybeRenderNavbar() { + // don't render navbar for setup views + if (!setupMatch) { + return ; + } + } + + function renderContent() { + if (!systemStatusData) { + return ; + } + + return ( + + + + + + + + + + + + + + + + ); + } + return ( - -
    - - - - - - - - - - - - - -
    + {maybeRenderNavbar()} +
    {renderContent()}
    diff --git a/ui/v2.5/src/components/Changelog/versions/v070.md b/ui/v2.5/src/components/Changelog/versions/v070.md index 9dbfdf5ab..14accf8ab 100644 --- a/ui/v2.5/src/components/Changelog/versions/v070.md +++ b/ui/v2.5/src/components/Changelog/versions/v070.md @@ -4,6 +4,7 @@ * Added scene queue. ### 🎨 Improvements +* Revamped setup wizard and migration UI. * Add various `count` filter criteria and sort options. * Scroll to top when changing page number. * Add URL filter criteria for scenes, galleries, movies, performers and studios. diff --git a/ui/v2.5/src/components/Settings/SettingsTasksPanel/SettingsTasksPanel.tsx b/ui/v2.5/src/components/Settings/SettingsTasksPanel/SettingsTasksPanel.tsx index ffba16f41..f4f371e24 100644 --- a/ui/v2.5/src/components/Settings/SettingsTasksPanel/SettingsTasksPanel.tsx +++ b/ui/v2.5/src/components/Settings/SettingsTasksPanel/SettingsTasksPanel.tsx @@ -116,9 +116,13 @@ export const SettingsTasksPanel: React.FC = () => { function onImport() { setIsImportAlertOpen(false); - mutateMetadataImport().then(() => { - jobStatus.refetch(); - }); + mutateMetadataImport() + .then(() => { + jobStatus.refetch(); + }) + .catch((e) => { + Toast.error(e); + }); } function renderImportAlert() { @@ -535,9 +539,11 @@ export const SettingsTasksPanel: React.FC = () => { variant="secondary" type="submit" onClick={() => - mutateMetadataExport().then(() => { - jobStatus.refetch(); - }) + mutateMetadataExport() + .then(() => { + jobStatus.refetch(); + }) + .catch((e) => Toast.error(e)) } > Full Export diff --git a/ui/v2.5/src/components/Setup/Migrate.tsx b/ui/v2.5/src/components/Setup/Migrate.tsx new file mode 100644 index 000000000..c84405d58 --- /dev/null +++ b/ui/v2.5/src/components/Setup/Migrate.tsx @@ -0,0 +1,158 @@ +import React, { useEffect, useState } from "react"; +import { Button, Card, Container, Form } from "react-bootstrap"; +import * as GQL from "src/core/generated-graphql"; +import { useSystemStatus, mutateMigrate } from "src/core/StashService"; +import { LoadingIndicator } from "../Shared"; + +export const Migrate: React.FC = () => { + const { data: systemStatus, loading } = useSystemStatus(); + const [backupPath, setBackupPath] = useState(); + const [migrateLoading, setMigrateLoading] = useState(false); + const [migrateError, setMigrateError] = useState(""); + + // make suffix based on current time + const now = new Date() + .toISOString() + .replace(/T/g, "_") + .replace(/-/g, "") + .replace(/:/g, "") + .replace(/\..*/, ""); + const defaultBackupPath = systemStatus + ? `${systemStatus.systemStatus.databasePath}.${systemStatus.systemStatus.databaseSchema}.${now}` + : ""; + + const discordLink = ( + + Discord + + ); + const githubLink = ( + + Github repository + + ); + + useEffect(() => { + if (backupPath === undefined && defaultBackupPath) { + setBackupPath(defaultBackupPath); + } + }, [defaultBackupPath, backupPath]); + + // only display setup wizard if system is not setup + if (loading || !systemStatus) { + return ; + } + + if (migrateLoading) { + return ; + } + + if ( + systemStatus.systemStatus.status !== GQL.SystemStatusEnum.NeedsMigration + ) { + // redirect to main page + const newURL = new URL("/", window.location.toString()); + window.location.href = newURL.toString(); + return ; + } + + const status = systemStatus.systemStatus; + + async function onMigrate() { + try { + setMigrateLoading(true); + setMigrateError(""); + await mutateMigrate({ + backupPath: backupPath ?? "", + }); + + const newURL = new URL("/", window.location.toString()); + window.location.href = newURL.toString(); + } catch (e) { + setMigrateError(e.message ?? e.toString()); + setMigrateLoading(false); + } + } + + function maybeRenderError() { + if (!migrateError) { + return; + } + + return ( +
    +

    Migration failed

    + +

    The following error was encountered while migrating the database:

    + + +
    {migrateError}
    +
    + +

    + Please make any necessary corrections and try again. Otherwise, raise + a bug on the {githubLink} or seek help in the {discordLink}. +

    +
    + ); + } + + return ( + +

    Migration required

    + +
    +

    + Your current stash database is schema version{" "} + {status.databaseSchema} and needs to be migrated to + version {status.appSchema}. This version of Stash + will not function without migrating the database. +

    + +

    + The schema migration process is not reversible. Once the migration + is performed, your database will be incompatible with previous + versions of stash. +

    + +

    + It is recommended that you backup your existing database before you + migrate. We can do this for you, making a copy of your writing a + backup to {defaultBackupPath} if required. +

    +
    + +
    + + + Backup database path (leave empty to disable backup): + + ) => + setBackupPath(e.currentTarget.value) + } + /> + +
    + +
    +
    + +
    +
    + + {maybeRenderError()} +
    +
    + ); +}; diff --git a/ui/v2.5/src/components/Setup/Setup.tsx b/ui/v2.5/src/components/Setup/Setup.tsx new file mode 100644 index 000000000..14fb46aa3 --- /dev/null +++ b/ui/v2.5/src/components/Setup/Setup.tsx @@ -0,0 +1,460 @@ +import React, { useState } from "react"; +import { + Alert, + Button, + Card, + Container, + Form, + InputGroup, +} from "react-bootstrap"; +import * as GQL from "src/core/generated-graphql"; +import { mutateSetup, useSystemStatus } from "src/core/StashService"; +import { Link } from "react-router-dom"; +import StashConfiguration from "../Settings/StashConfiguration"; +import { Icon, LoadingIndicator } from "../Shared"; +import { FolderSelectDialog } from "../Shared/FolderSelect/FolderSelectDialog"; + +export const Setup: React.FC = () => { + const [step, setStep] = useState(0); + const [configLocation, setConfigLocation] = useState(""); + const [stashes, setStashes] = useState([]); + const [generatedLocation, setGeneratedLocation] = useState(""); + const [databaseFile, setDatabaseFile] = useState(""); + const [loading, setLoading] = useState(false); + const [setupError, setSetupError] = useState(""); + + const [showGeneratedDialog, setShowGeneratedDialog] = useState(false); + + const { data: systemStatus, loading: statusLoading } = useSystemStatus(); + + const discordLink = ( + + Discord + + ); + const githubLink = ( + + Github repository + + ); + + function onConfigLocationChosen(loc: string) { + setConfigLocation(loc); + next(); + } + + function goBack(n?: number) { + let dec = n; + if (!dec) { + dec = 1; + } + setStep(Math.max(0, step - dec)); + } + + function next() { + setStep(step + 1); + } + + function renderWelcome() { + return ( + <> +
    +

    Welcome to Stash

    +

    + If you're reading this, then Stash couldn't find an + existing configuration. This wizard will guide you through the + process of setting up a new configuration. +

    +

    + Stash tries to find its configuration file (config.yml) + from the current working directory first, and if it does not find it + there, it falls back to $HOME/.stash/config.yml (on + Windows, this will be %USERPROFILE%\.stash\config.yml). + You can also make Stash read from a specific configuration file by + running it with the -c <path to config file> or{" "} + --config <path to config file> options. +

    + + If you're getting this screen unexpectedly, please try + restarting Stash in the correct working directory or with the{" "} + -c flag. + +

    + With all of that out of the way, if you're ready to proceed + with setting up a new system, choose where you'd like to store + your configuration file and click Next. +

    +
    + +
    +

    + Where do you want to store your Stash configuration? +

    + +
    + + +
    +
    + + ); + } + + function onGeneratedClosed(d?: string) { + if (d) { + setGeneratedLocation(d); + } + + setShowGeneratedDialog(false); + } + + function maybeRenderGeneratedSelectDialog() { + if (!showGeneratedDialog) { + return; + } + + return ; + } + + function renderSetPaths() { + return ( + <> +
    +

    Set up your paths

    +

    + Next up, we need to determine where to find your porn collection, + where to store the stash database and generated files. These + settings can be changed later if needed. +

    +
    +
    + +

    Where is your porn located?

    +

    + Add directories containing your porn videos and images. Stash will + use these directories to find videos and images during scanning. +

    + + setStashes(s)} + /> + +
    + +

    Where can Stash store its database?

    +

    + Stash uses an sqlite database to store your porn metadata. By + default, this will be created as stash-go.sqlite in + the directory containing your config file. If you want to change + this, please enter an absolute or relative (to the current working + directory) filename. +

    + ) => + setDatabaseFile(e.currentTarget.value) + } + /> +
    + +

    Where can Stash store its generated content?

    +

    + In order to provide thumbnails, previews and sprites, Stash + generates images and videos. This also includes transcodes for + unsupported file formats. By default, Stash will create a{" "} + generated directory within the directory containing + your config file. If you want to change where this generated media + will be stored, please enter an absolute or relative (to the + current working directory) path. Stash will create this directory + if it does not already exist. +

    + + ) => + setGeneratedLocation(e.currentTarget.value) + } + /> + + + + +
    +
    +
    +
    + + +
    +
    + + ); + } + + function renderConfigLocation() { + if (configLocation === "config.yml") { + return <current working directory>/config.yml; + } + + return {configLocation}; + } + + function maybeRenderExclusions(s: GQL.StashConfig) { + if (!s.excludeImage && !s.excludeVideo) { + return; + } + + const excludes = []; + if (s.excludeVideo) { + excludes.push("videos"); + } + if (s.excludeImage) { + excludes.push("images"); + } + + return `(excludes ${excludes.join(" and ")})`; + } + + function renderStashLibraries() { + return ( +
      + {stashes.map((s) => ( +
    • + {s.path} + {maybeRenderExclusions(s)} +
    • + ))} +
    + ); + } + + async function onSave() { + try { + setLoading(true); + await mutateSetup({ + configLocation, + databaseFile, + generatedLocation, + stashes, + }); + } catch (e) { + setSetupError(e.message ?? e.toString()); + } finally { + setLoading(false); + next(); + } + } + + function renderConfirm() { + return ( + <> +
    +

    Nearly there!

    +

    + We're almost ready to complete the configuration. Please + confirm the following settings. You can click back to change + anything incorrect. If everything looks good, click Confirm to + create your system. +

    +
    +
    Configuration file location:
    +
    {renderConfigLocation()}
    +
    +
    +
    Stash library directories
    +
    {renderStashLibraries()}
    +
    +
    +
    Database file path
    +
    + + {databaseFile !== "" + ? databaseFile + : `/stash-go.sqlite`} + +
    +
    +
    +
    Generated directory
    +
    + + {generatedLocation !== "" + ? generatedLocation + : `/generated`} + +
    +
    +
    +
    +
    + + +
    +
    + + ); + } + + function renderError() { + return ( + <> +
    +

    Oh no! Something went wrong!

    +

    + Something went wrong while setting up your system. Here is the error + we received: +

    {setupError}
    +

    +

    + If this looks like a problem with your inputs, go ahead and click + back to fix them up. Otherwise, raise a bug on the {githubLink} + or seek help in the {discordLink}. +

    +
    +
    +
    + +
    +
    + + ); + } + + function renderSuccess() { + return ( + <> +
    +

    Success! Your system has been created!

    +

    + You will be taken to the Configuration page next. This page will + allow you to customize what files to include and exclude, set a + username and password to protect your system, and a whole bunch of + other options. +

    +

    + When you are satisfied with these settings, you can begin scanning + your content into Stash by clicking on Tasks, then{" "} + Scan. +

    +
    +
    +

    Getting help

    +

    + You are encouraged to check out the in-app manual which can be + accessed from the icon in the top-right corner of the screen that + looks like this: +

    +

    + If you run into issues or have any questions or suggestions, feel + free to open an issue in the {githubLink}, or ask the community in + the {discordLink}. +

    +
    +
    +

    Support us

    +

    + Check out our{" "} + + OpenCollective + {" "} + to see how you can contribute to the continued development of Stash. +

    +

    + We also welcome contributions in the form of code (bug fixes, + improvements and new features), testing, bug reports, improvement + and feature requests, and user support. Details can be found in the + Contribution section of the in-app manual. +

    +
    +
    +

    Thanks for trying Stash!

    +
    +
    +
    + + + +
    +
    + + ); + } + + function renderFinish() { + if (setupError) { + return renderError(); + } + + return renderSuccess(); + } + + const steps = [renderWelcome, renderSetPaths, renderConfirm, renderFinish]; + + // only display setup wizard if system is not setup + if (statusLoading) { + return ; + } + + if ( + systemStatus && + systemStatus.systemStatus.status !== GQL.SystemStatusEnum.Setup + ) { + // redirect to main page + const newURL = new URL("/", window.location.toString()); + window.location.href = newURL.toString(); + return ; + } + + return ( + + {maybeRenderGeneratedSelectDialog()} +

    Stash Setup Wizard

    + {loading ? ( + + ) : ( + {steps[step]()} + )} +
    + ); +}; diff --git a/ui/v2.5/src/core/StashService.ts b/ui/v2.5/src/core/StashService.ts index e13e49bae..1ac8e6d80 100644 --- a/ui/v2.5/src/core/StashService.ts +++ b/ui/v2.5/src/core/StashService.ts @@ -281,6 +281,20 @@ export const useLatestVersion = () => }); export const useConfiguration = () => GQL.useConfigurationQuery(); +export const mutateSetup = (input: GQL.SetupInput) => + client.mutate({ + mutation: GQL.SetupDocument, + variables: { input }, + refetchQueries: getQueryNames([GQL.ConfigurationDocument]), + update: deleteCache([GQL.ConfigurationDocument]), + }); + +export const mutateMigrate = (input: GQL.MigrateInput) => + client.mutate({ + mutation: GQL.MigrateDocument, + variables: { input }, + }); + export const useDirectory = (path?: string) => GQL.useDirectoryQuery({ variables: { path } }); @@ -690,6 +704,17 @@ export const useMetadataUpdate = () => GQL.useMetadataUpdateSubscription(); export const useLoggingSubscribe = () => GQL.useLoggingSubscribeSubscription(); +export const querySystemStatus = () => + client.query({ + query: GQL.SystemStatusDocument, + fetchPolicy: "no-cache", + }); + +export const useSystemStatus = () => + GQL.useSystemStatusQuery({ + fetchPolicy: "no-cache", + }); + export const useLogs = () => GQL.useLogsQuery({ fetchPolicy: "no-cache", From f5dc654f6bb665e7f09aa5f56ca553bf5ae28206 Mon Sep 17 00:00:00 2001 From: WithoutPants <53250216+WithoutPants@users.noreply.github.com> Date: Mon, 12 Apr 2021 11:05:49 +1000 Subject: [PATCH 22/66] Support streaming via API key (#1279) * Support api key via url query parameter * Add api key to stream URL --- pkg/api/resolver_model_scene.go | 2 ++ pkg/api/server.go | 10 +++++++++- pkg/api/urlbuilders/scene.go | 8 +++++++- ui/v2.5/src/components/Changelog/versions/v070.md | 1 + 4 files changed, 19 insertions(+), 2 deletions(-) diff --git a/pkg/api/resolver_model_scene.go b/pkg/api/resolver_model_scene.go index dcec7fa86..f657d8371 100644 --- a/pkg/api/resolver_model_scene.go +++ b/pkg/api/resolver_model_scene.go @@ -4,6 +4,7 @@ import ( "context" "github.com/stashapp/stash/pkg/api/urlbuilders" + "github.com/stashapp/stash/pkg/manager/config" "github.com/stashapp/stash/pkg/models" "github.com/stashapp/stash/pkg/utils" ) @@ -78,6 +79,7 @@ func (r *sceneResolver) File(ctx context.Context, obj *models.Scene) (*models.Sc func (r *sceneResolver) Paths(ctx context.Context, obj *models.Scene) (*models.ScenePathsType, error) { baseURL, _ := ctx.Value(BaseURLCtxKey).(string) builder := urlbuilders.NewSceneURLBuilder(baseURL, obj.ID) + builder.APIKey = config.GetInstance().GetAPIKey() screenshotPath := builder.GetScreenshotURL(obj.UpdatedAt.Timestamp) previewPath := builder.GetStreamPreviewURL() streamPath := builder.GetStreamURL() diff --git a/pkg/api/server.go b/pkg/api/server.go index 26dd34fbe..a59ef4cee 100644 --- a/pkg/api/server.go +++ b/pkg/api/server.go @@ -37,7 +37,10 @@ var uiBox *packr.Box //var legacyUiBox *packr.Box var loginUIBox *packr.Box -const ApiKeyHeader = "ApiKey" +const ( + ApiKeyHeader = "ApiKey" + ApiKeyParameter = "apikey" +) func allowUnauthenticated(r *http.Request) bool { return strings.HasPrefix(r.URL.Path, "/login") || r.URL.Path == "/css" @@ -54,6 +57,11 @@ func authenticateHandler() func(http.Handler) http.Handler { apiKey := r.Header.Get(ApiKeyHeader) var err error + // try getting the api key as a query parameter + if apiKey == "" { + apiKey = r.URL.Query().Get(ApiKeyParameter) + } + if apiKey != "" { // match against configured API and set userID to the // configured username. In future, we'll want to diff --git a/pkg/api/urlbuilders/scene.go b/pkg/api/urlbuilders/scene.go index 5d7af407c..9a31e504f 100644 --- a/pkg/api/urlbuilders/scene.go +++ b/pkg/api/urlbuilders/scene.go @@ -1,6 +1,7 @@ package urlbuilders import ( + "fmt" "strconv" "time" ) @@ -8,6 +9,7 @@ import ( type SceneURLBuilder struct { BaseURL string SceneID string + APIKey string } func NewSceneURLBuilder(baseURL string, sceneID int) SceneURLBuilder { @@ -18,7 +20,11 @@ func NewSceneURLBuilder(baseURL string, sceneID int) SceneURLBuilder { } func (b SceneURLBuilder) GetStreamURL() string { - return b.BaseURL + "/scene/" + b.SceneID + "/stream" + var apiKeyParam string + if b.APIKey != "" { + apiKeyParam = fmt.Sprintf("?apikey=%s", b.APIKey) + } + return fmt.Sprintf("%s/scene/%s/stream%s", b.BaseURL, b.SceneID, apiKeyParam) } func (b SceneURLBuilder) GetStreamPreviewURL() string { diff --git a/ui/v2.5/src/components/Changelog/versions/v070.md b/ui/v2.5/src/components/Changelog/versions/v070.md index 14accf8ab..f005c915a 100644 --- a/ui/v2.5/src/components/Changelog/versions/v070.md +++ b/ui/v2.5/src/components/Changelog/versions/v070.md @@ -4,6 +4,7 @@ * Added scene queue. ### 🎨 Improvements +* Support API key via URL query parameter, and added API key to stream link in Scene File Info. * Revamped setup wizard and migration UI. * Add various `count` filter criteria and sort options. * Scroll to top when changing page number. From 6a4421f8e15b4f0bede153730f12e4027d9aa3b7 Mon Sep 17 00:00:00 2001 From: julien0221 <68500525+julien0221@users.noreply.github.com> Date: Tue, 13 Apr 2021 01:32:52 +0100 Subject: [PATCH 23/66] Whitespace is not trimmed from the end of query strings (#1263) * fixed whitespace not trimmed query string * fixed whitespace trimming on backend * added query trim tests and fixed double space --- pkg/sqlite/scene_test.go | 47 ++++++++++++++++++- pkg/sqlite/setup_test.go | 16 ++++++- pkg/sqlite/sql.go | 7 ++- .../src/components/Changelog/versions/v070.md | 1 + ui/v2.5/src/models/list-filter/filter.ts | 2 +- 5 files changed, 67 insertions(+), 6 deletions(-) diff --git a/pkg/sqlite/scene_test.go b/pkg/sqlite/scene_test.go index cb4927523..c61f71eb0 100644 --- a/pkg/sqlite/scene_test.go +++ b/pkg/sqlite/scene_test.go @@ -1164,7 +1164,7 @@ func TestSceneQuerySorting(t *testing.T) { lastScene := scenes[len(scenes)-1] assert.Equal(t, sceneIDs[0], firstScene.ID) - assert.Equal(t, sceneIDs[len(sceneIDs)-1], lastScene.ID) + assert.Equal(t, sceneIDs[sceneIdxWithSpacedName], lastScene.ID) // sort in descending order direction = models.SortDirectionEnumDesc @@ -1173,7 +1173,7 @@ func TestSceneQuerySorting(t *testing.T) { firstScene = scenes[0] lastScene = scenes[len(scenes)-1] - assert.Equal(t, sceneIDs[len(sceneIDs)-1], firstScene.ID) + assert.Equal(t, sceneIDs[sceneIdxWithSpacedName], firstScene.ID) assert.Equal(t, sceneIDs[0], lastScene.ID) return nil @@ -1519,6 +1519,49 @@ func TestSceneStashIDs(t *testing.T) { } } +func TestSceneQueryQTrim(t *testing.T) { + if err := withTxn(func(r models.Repository) error { + qb := r.Scene() + + expectedID := sceneIDs[sceneIdxWithSpacedName] + + type test struct { + query string + id int + count int + } + tests := []test{ + {query: " zzz yyy ", id: expectedID, count: 1}, + {query: " \"zzz yyy xxx\" ", id: expectedID, count: 1}, + {query: "zzz", id: expectedID, count: 1}, + {query: "\" zzz yyy \"", count: 0}, + {query: "\"zzz yyy\"", count: 0}, + {query: "\" zzz yyy\"", count: 0}, + {query: "\"zzz yyy \"", count: 0}, + } + + for _, tst := range tests { + f := models.FindFilterType{ + Q: &tst.query, + } + scenes := queryScene(t, qb, nil, &f) + + assert.Len(t, scenes, tst.count) + if len(scenes) > 0 { + assert.Equal(t, tst.id, scenes[0].ID) + } + } + + findFilter := models.FindFilterType{} + scenes := queryScene(t, qb, nil, &findFilter) + assert.NotEqual(t, 0, len(scenes)) + + return nil + }); err != nil { + t.Error(err.Error()) + } +} + // TODO Update // TODO IncrementOCounter // TODO DecrementOCounter diff --git a/pkg/sqlite/setup_test.go b/pkg/sqlite/setup_test.go index de5930fd4..7996f9516 100644 --- a/pkg/sqlite/setup_test.go +++ b/pkg/sqlite/setup_test.go @@ -20,6 +20,10 @@ import ( "github.com/stashapp/stash/pkg/utils" ) +const ( + spacedSceneTitle = "zzz yyy xxx" +) + const ( sceneIdxWithMovie = iota sceneIdxWithGallery @@ -33,6 +37,7 @@ const ( sceneIdxWithMarker sceneIdxWithPerformerTag sceneIdxWithPerformerTwoTags + sceneIdxWithSpacedName // new indexes above lastSceneIdx @@ -452,6 +457,15 @@ func getSceneNullStringValue(index int, field string) sql.NullString { return getPrefixedNullStringValue("scene", index, field) } +func getSceneTitle(index int) string { + switch index { + case sceneIdxWithSpacedName: + return spacedSceneTitle + default: + return getSceneStringValue(index, titleField) + } +} + func getRating(index int) sql.NullInt64 { rating := index % 6 return sql.NullInt64{Int64: int64(rating), Valid: rating > 0} @@ -493,7 +507,7 @@ func createScenes(sqb models.SceneReaderWriter, n int) error { for i := 0; i < n; i++ { scene := models.Scene{ Path: getSceneStringValue(i, pathField), - Title: sql.NullString{String: getSceneStringValue(i, titleField), Valid: true}, + Title: sql.NullString{String: getSceneTitle(i), Valid: true}, Checksum: sql.NullString{String: getSceneStringValue(i, checksumField), Valid: true}, Details: sql.NullString{String: getSceneStringValue(i, "Details"), Valid: true}, URL: getSceneNullStringValue(i, urlField), diff --git a/pkg/sqlite/sql.go b/pkg/sqlite/sql.go index bbd8fde13..b683e1a07 100644 --- a/pkg/sqlite/sql.go +++ b/pkg/sqlite/sql.go @@ -4,6 +4,7 @@ import ( "database/sql" "fmt" "math/rand" + "regexp" "strconv" "strings" @@ -116,10 +117,12 @@ func getSearchBinding(columns []string, q string, not bool) (string, []interface notStr = " NOT" binaryType = " AND " } - - queryWords := strings.Split(q, " ") + q = strings.TrimSpace(q) trimmedQuery := strings.Trim(q, "\"") + if trimmedQuery == q { + q = regexp.MustCompile(`\s+`).ReplaceAllString(q, " ") + queryWords := strings.Split(q, " ") // Search for any word for _, word := range queryWords { for _, column := range columns { diff --git a/ui/v2.5/src/components/Changelog/versions/v070.md b/ui/v2.5/src/components/Changelog/versions/v070.md index f005c915a..c3bfdd6e4 100644 --- a/ui/v2.5/src/components/Changelog/versions/v070.md +++ b/ui/v2.5/src/components/Changelog/versions/v070.md @@ -17,6 +17,7 @@ * Change performer text query to search by name and alias only. ### 🐛 Bug fixes +* Fix whitespace in query string returning all objects. * Fix hang on Login page when not connected to internet. * Fix `Clear Image` button not updating image preview. * Fix processing some webp files. diff --git a/ui/v2.5/src/models/list-filter/filter.ts b/ui/v2.5/src/models/list-filter/filter.ts index 58116771b..fe775f41e 100644 --- a/ui/v2.5/src/models/list-filter/filter.ts +++ b/ui/v2.5/src/models/list-filter/filter.ts @@ -366,7 +366,7 @@ export class ListFilterModel { this.displayMode = Number.parseInt(params.disp, 10); } if (params.q) { - this.searchTerm = params.q; + this.searchTerm = params.q.trim(); } if (params.p) { this.currentPage = Number.parseInt(params.p, 10); From f443223d16629db83d4af31b8f76f68fb9ef497b Mon Sep 17 00:00:00 2001 From: Elad Lachmi Date: Tue, 13 Apr 2021 07:59:37 +0300 Subject: [PATCH 24/66] [Feature] Added slideshow to gallery in wall display mode (#1224) --- graphql/documents/data/config.graphql | 3 +- graphql/schema/types/config.graphql | 4 + pkg/api/resolver_mutation_configure.go | 4 + pkg/api/resolver_query_configuration.go | 2 + pkg/manager/config/config.go | 6 + .../src/components/Changelog/versions/v070.md | 1 + ui/v2.5/src/components/Images/ImageList.tsx | 32 ++- .../SettingsInterfacePanel.tsx | 22 ++ ui/v2.5/src/hooks/Interval.ts | 65 +++++ ui/v2.5/src/hooks/Lightbox/Lightbox.tsx | 266 +++++++++++++++--- ui/v2.5/src/hooks/Lightbox/context.tsx | 15 +- ui/v2.5/src/hooks/Lightbox/hooks.ts | 7 +- ui/v2.5/src/hooks/Lightbox/lightbox.scss | 39 ++- ui/v2.5/src/hooks/PageVisibility.ts | 50 ++++ ui/v2.5/src/hooks/index.ts | 2 + 15 files changed, 463 insertions(+), 55 deletions(-) create mode 100644 ui/v2.5/src/hooks/Interval.ts create mode 100644 ui/v2.5/src/hooks/PageVisibility.ts diff --git a/graphql/documents/data/config.graphql b/graphql/documents/data/config.graphql index 250c937b4..e1597c0ca 100644 --- a/graphql/documents/data/config.graphql +++ b/graphql/documents/data/config.graphql @@ -2,7 +2,7 @@ fragment ConfigGeneralData on ConfigGeneralResult { stashes { path excludeVideo - excludeImage + excludeImage } databasePath generatedPath @@ -52,6 +52,7 @@ fragment ConfigInterfaceData on ConfigInterfaceResult { css cssEnabled language + slideshowDelay } fragment ConfigData on ConfigResult { diff --git a/graphql/schema/types/config.graphql b/graphql/schema/types/config.graphql index 9cf463125..fd13b7419 100644 --- a/graphql/schema/types/config.graphql +++ b/graphql/schema/types/config.graphql @@ -188,6 +188,8 @@ input ConfigInterfaceInput { cssEnabled: Boolean """Interface language""" language: String + """Slideshow Delay""" + slideshowDelay: Int } type ConfigInterfaceResult { @@ -210,6 +212,8 @@ type ConfigInterfaceResult { cssEnabled: Boolean """Interface language""" language: String + """Slideshow Delay""" + slideshowDelay: Int } """All configuration settings""" diff --git a/pkg/api/resolver_mutation_configure.go b/pkg/api/resolver_mutation_configure.go index e5cd71c9e..b3734dfc7 100644 --- a/pkg/api/resolver_mutation_configure.go +++ b/pkg/api/resolver_mutation_configure.go @@ -219,6 +219,10 @@ func (r *mutationResolver) ConfigureInterface(ctx context.Context, input models. c.Set(config.Language, *input.Language) } + if input.SlideshowDelay != nil { + c.Set(config.SlideshowDelay, *input.SlideshowDelay) + } + css := "" if input.CSS != nil { diff --git a/pkg/api/resolver_query_configuration.go b/pkg/api/resolver_query_configuration.go index c11d8dc0c..1d47acb38 100644 --- a/pkg/api/resolver_query_configuration.go +++ b/pkg/api/resolver_query_configuration.go @@ -93,6 +93,7 @@ func makeConfigInterfaceResult() *models.ConfigInterfaceResult { css := config.GetCSS() cssEnabled := config.GetCSSEnabled() language := config.GetLanguage() + slideshowDelay := config.GetSlideshowDelay() return &models.ConfigInterfaceResult{ MenuItems: menuItems, @@ -105,5 +106,6 @@ func makeConfigInterfaceResult() *models.ConfigInterfaceResult { CSS: &css, CSSEnabled: &cssEnabled, Language: &language, + SlideshowDelay: &slideshowDelay, } } diff --git a/pkg/manager/config/config.go b/pkg/manager/config/config.go index 1ce880b1a..b852ca835 100644 --- a/pkg/manager/config/config.go +++ b/pkg/manager/config/config.go @@ -118,6 +118,7 @@ const AutostartVideo = "autostart_video" const ShowStudioAsText = "show_studio_as_text" const CSSEnabled = "cssEnabled" const WallPlayback = "wall_playback" +const SlideshowDelay = "slideshow_delay" // Logging options const LogFile = "logFile" @@ -560,6 +561,11 @@ func (i *Instance) GetShowStudioAsText() bool { return viper.GetBool(ShowStudioAsText) } +func (i *Instance) GetSlideshowDelay() int { + viper.SetDefault(SlideshowDelay, 5000) + return viper.GetInt(SlideshowDelay) +} + func (i *Instance) GetCSSPath() string { // use custom.css in the same directory as the config file configFileUsed := viper.ConfigFileUsed() diff --git a/ui/v2.5/src/components/Changelog/versions/v070.md b/ui/v2.5/src/components/Changelog/versions/v070.md index c3bfdd6e4..acce2e74c 100644 --- a/ui/v2.5/src/components/Changelog/versions/v070.md +++ b/ui/v2.5/src/components/Changelog/versions/v070.md @@ -4,6 +4,7 @@ * Added scene queue. ### 🎨 Improvements +* Add slideshow to image wall view. * Support API key via URL query parameter, and added API key to stream link in Scene File Info. * Revamped setup wizard and migration UI. * Add various `count` filter criteria and sort options. diff --git a/ui/v2.5/src/components/Images/ImageList.tsx b/ui/v2.5/src/components/Images/ImageList.tsx index 103e1799f..b304e12a7 100644 --- a/ui/v2.5/src/components/Images/ImageList.tsx +++ b/ui/v2.5/src/components/Images/ImageList.tsx @@ -17,6 +17,7 @@ import { showWhenSelected, PersistanceLevel, } from "src/hooks/ListHook"; + import { ImageCard } from "./ImageCard"; import { EditImagesDialog } from "./EditImagesDialog"; import { DeleteImagesDialog } from "./DeleteImagesDialog"; @@ -36,34 +37,57 @@ const ImageWall: React.FC = ({ currentPage, pageCount, }) => { + const [slideshowRunning, setSlideshowRunning] = useState(false); const handleLightBoxPage = useCallback( (direction: number) => { if (direction === -1) { if (currentPage === 1) return false; onChangePage(currentPage - 1); } else { - if (currentPage === pageCount) return false; + if (currentPage === pageCount) { + // if the slideshow is running + // return to the first page + if (slideshowRunning) { + onChangePage(0); + return true; + } + return false; + } onChangePage(currentPage + 1); } return direction === -1 || direction === 1; }, - [onChangePage, currentPage, pageCount] + [onChangePage, currentPage, pageCount, slideshowRunning] ); + const handleClose = useCallback(() => { + setSlideshowRunning(false); + }, [setSlideshowRunning]); + const showLightbox = useLightbox({ images, showNavigation: false, pageCallback: handleLightBoxPage, pageHeader: `Page ${currentPage} / ${pageCount}`, + slideshowEnabled: slideshowRunning, + onClose: handleClose, }); + const handleImageOpen = useCallback( + (index) => { + setSlideshowRunning(true); + showLightbox(index, true); + }, + [showLightbox] + ); + const thumbs = images.map((image, index) => (
    showLightbox(index)} - onKeyPress={() => showLightbox(index)} + onClick={() => handleImageOpen(index)} + onKeyPress={() => handleImageOpen(index)} > { const Toast = useToast(); const { data: config, error, loading } = useConfiguration(); @@ -27,6 +29,7 @@ export const SettingsInterfacePanel: React.FC = () => { const [wallPlayback, setWallPlayback] = useState("video"); const [maximumLoopDuration, setMaximumLoopDuration] = useState(0); const [autostartVideo, setAutostartVideo] = useState(false); + const [slideshowDelay, setSlideshowDelay] = useState(0); const [showStudioAsText, setShowStudioAsText] = useState(false); const [css, setCSS] = useState(); const [cssEnabled, setCSSEnabled] = useState(false); @@ -43,6 +46,7 @@ export const SettingsInterfacePanel: React.FC = () => { css, cssEnabled, language, + slideshowDelay, }); useEffect(() => { @@ -57,6 +61,7 @@ export const SettingsInterfacePanel: React.FC = () => { setCSS(iCfg?.css ?? ""); setCSSEnabled(iCfg?.cssEnabled ?? false); setLanguage(iCfg?.language ?? "en-US"); + setSlideshowDelay(iCfg?.slideshowDelay ?? 5000); }, [config]); async function onSave() { @@ -187,6 +192,23 @@ export const SettingsInterfacePanel: React.FC = () => { + +
    Slideshow Delay
    + ) => { + setSlideshowDelay( + Number.parseInt(e.currentTarget.value, 10) * SECONDS_TO_MS + ); + }} + /> + + Slideshow is available in galleries when in wall view mode + +
    +
    Custom CSS
    void, + delay: number | null = 5000 +): (() => void)[] => { + const savedCallback = useRef<() => void>(); + const savedIntervalId = useRef(); + const [savedDelay, setSavedDelay] = useState(delay); + + useEffect(() => { + savedCallback.current = callback; + }, [callback]); + + useEffect(() => { + let validDelay; + if (delay !== null) { + validDelay = delay >= MIN_VALID_INTERVAL ? delay : MIN_VALID_INTERVAL; + } else { + validDelay = delay; + } + + setSavedDelay(validDelay); + }, [delay]); + + const cancel = () => { + const intervalId = savedIntervalId.current; + if (intervalId) { + savedIntervalId.current = undefined; + clearInterval(intervalId); + } + }; + + const reset = () => { + cancel(); + + const tick = () => { + if (savedCallback.current) savedCallback.current(); + }; + + if (savedDelay !== null) { + savedIntervalId.current = setInterval(tick, savedDelay); + } + }; + + useEffect(() => { + cancel(); + + const tick = () => { + if (savedCallback.current) savedCallback.current(); + }; + + if (savedDelay !== null) { + savedIntervalId.current = setInterval(tick, savedDelay); + return cancel; + } + }, [callback, savedDelay]); + + return delay ? [cancel, reset] : [noop, noop]; +}; + +export default useInterval; diff --git a/ui/v2.5/src/hooks/Lightbox/Lightbox.tsx b/ui/v2.5/src/hooks/Lightbox/Lightbox.tsx index 43e429935..0ee1a342a 100644 --- a/ui/v2.5/src/hooks/Lightbox/Lightbox.tsx +++ b/ui/v2.5/src/hooks/Lightbox/Lightbox.tsx @@ -1,15 +1,30 @@ import React, { useCallback, useEffect, useRef, useState } from "react"; import * as GQL from "src/core/generated-graphql"; -import { Button } from "react-bootstrap"; +import { + Button, + Col, + FormControl, + InputGroup, + FormLabel, + OverlayTrigger, + Popover, +} from "react-bootstrap"; import cx from "classnames"; import Mousetrap from "mousetrap"; -import { debounce } from "lodash"; +import debounce from "lodash/debounce"; import { Icon, LoadingIndicator } from "src/components/Shared"; +import { useInterval, usePageVisibility } from "src/hooks"; +import { useConfiguration } from "src/core/StashService"; const CLASSNAME = "Lightbox"; const CLASSNAME_HEADER = `${CLASSNAME}-header`; +const CLASSNAME_LEFT_SPACER = `${CLASSNAME_HEADER}-left-spacer`; const CLASSNAME_INDICATOR = `${CLASSNAME_HEADER}-indicator`; +const CLASSNAME_DELAY = `${CLASSNAME_HEADER}-delay`; +const CLASSNAME_DELAY_ICON = `${CLASSNAME_DELAY}-icon`; +const CLASSNAME_DELAY_INLINE = `${CLASSNAME_DELAY}-inline`; +const CLASSNAME_RIGHT = `${CLASSNAME_HEADER}-right`; const CLASSNAME_DISPLAY = `${CLASSNAME}-display`; const CLASSNAME_CAROUSEL = `${CLASSNAME}-carousel`; const CLASSNAME_INSTANT = `${CLASSNAME_CAROUSEL}-instant`; @@ -19,6 +34,10 @@ const CLASSNAME_NAV = `${CLASSNAME}-nav`; const CLASSNAME_NAVIMAGE = `${CLASSNAME_NAV}-image`; const CLASSNAME_NAVSELECTED = `${CLASSNAME_NAV}-selected`; +const DEFAULT_SLIDESHOW_DELAY = 5000; +const SECONDS_TO_MS = 1000; +const MIN_VALID_INTERVAL_SECONDS = 1; + type Image = Pick; interface IProps { images: Image[]; @@ -26,6 +45,7 @@ interface IProps { isLoading: boolean; initialIndex?: number; showNavigation: boolean; + slideshowEnabled?: boolean; pageHeader?: string; pageCallback?: (direction: number) => boolean; hide: () => void; @@ -37,6 +57,7 @@ export const LightboxComponent: React.FC = ({ isLoading, initialIndex = 0, showNavigation, + slideshowEnabled = false, pageHeader, pageCallback, hide, @@ -49,6 +70,27 @@ export const LightboxComponent: React.FC = ({ const carouselRef = useRef(null); const indicatorRef = useRef(null); const navRef = useRef(null); + const clearIntervalCallback = useRef<() => void>(); + const resetIntervalCallback = useRef<() => void>(); + const config = useConfiguration(); + + const userSelectedSlideshowDelayOrDefault = + config?.data?.configuration.interface.slideshowDelay ?? + DEFAULT_SLIDESHOW_DELAY; + + // slideshowInterval is used for controlling the logic + // displaySlideshowInterval is for display purposes only + // keeping them separate and independant allows us to handle the logic however we want + // while still displaying something that makes sense to the user + const [slideshowInterval, setSlideshowInterval] = useState( + null + ); + const [ + displayedSlideshowInterval, + setDisplayedSlideshowInterval, + ] = useState( + (userSelectedSlideshowDelayOrDefault / SECONDS_TO_MS).toString() + ); useEffect(() => { setIsSwitchingPage(false); @@ -59,6 +101,7 @@ export const LightboxComponent: React.FC = ({ () => setInstantTransition(false), 400 ); + const setInstant = useCallback(() => { setInstantTransition(true); disableInstantTransition(); @@ -108,6 +151,28 @@ export const LightboxComponent: React.FC = ({ } }, [initialIndex, isVisible, setIndex]); + const toggleSlideshow = useCallback(() => { + if (slideshowInterval) { + setSlideshowInterval(null); + } else if ( + displayedSlideshowInterval !== null && + typeof displayedSlideshowInterval !== "undefined" + ) { + const intervalNumber = Number.parseInt(displayedSlideshowInterval, 10); + setSlideshowInterval(intervalNumber * SECONDS_TO_MS); + } else { + setSlideshowInterval(userSelectedSlideshowDelayOrDefault); + } + }, [ + slideshowInterval, + userSelectedSlideshowDelayOrDefault, + displayedSlideshowInterval, + ]); + + usePageVisibility(() => { + toggleSlideshow(); + }); + const close = useCallback(() => { if (!isFullscreen) { hide(); @@ -122,37 +187,52 @@ export const LightboxComponent: React.FC = ({ if (nodeName === "DIV" || nodeName === "PICTURE") close(); }; - const handleLeft = useCallback(() => { - if (isSwitchingPage || index.current === -1) return; + const handleLeft = useCallback( + (isUserAction = true) => { + if (isSwitchingPage || index.current === -1) return; - if (index.current === 0) { - if (pageCallback) { - setIsSwitchingPage(true); - setIndex(-1); - // Check if calling page wants to swap page - const repage = pageCallback(-1); - if (!repage) { - setIsSwitchingPage(false); + if (index.current === 0) { + if (pageCallback) { + setIsSwitchingPage(true); + setIndex(-1); + // Check if calling page wants to swap page + const repage = pageCallback(-1); + if (!repage) { + setIsSwitchingPage(false); + setIndex(0); + } + } else setIndex(images.length - 1); + } else setIndex((index.current ?? 0) - 1); + + if (isUserAction && resetIntervalCallback.current) { + resetIntervalCallback.current(); + } + }, + [images, setIndex, pageCallback, isSwitchingPage, resetIntervalCallback] + ); + + const handleRight = useCallback( + (isUserAction = true) => { + if (isSwitchingPage) return; + + if (index.current === images.length - 1) { + if (pageCallback) { + setIsSwitchingPage(true); setIndex(0); - } - } else setIndex(images.length - 1); - } else setIndex((index.current ?? 0) - 1); - }, [images, setIndex, pageCallback, isSwitchingPage]); - const handleRight = useCallback(() => { - if (isSwitchingPage) return; + const repage = pageCallback?.(1); + if (!repage) { + setIsSwitchingPage(false); + setIndex(images.length - 1); + } + } else setIndex(0); + } else setIndex((index.current ?? 0) + 1); - if (index.current === images.length - 1) { - if (pageCallback) { - setIsSwitchingPage(true); - setIndex(0); - const repage = pageCallback?.(1); - if (!repage) { - setIsSwitchingPage(false); - setIndex(images.length - 1); - } - } else setIndex(0); - } else setIndex((index.current ?? 0) + 1); - }, [images, setIndex, pageCallback, isSwitchingPage]); + if (isUserAction && resetIntervalCallback.current) { + resetIntervalCallback.current(); + } + }, + [images, setIndex, pageCallback, isSwitchingPage, resetIntervalCallback] + ); const handleKey = useCallback( (e: KeyboardEvent) => { @@ -164,8 +244,12 @@ export const LightboxComponent: React.FC = ({ }, [setInstant, handleLeft, handleRight, close] ); - const handleFullScreenChange = () => + const handleFullScreenChange = () => { + if (clearIntervalCallback.current) { + clearIntervalCallback.current(); + } setFullscreen(document.fullscreenElement !== null); + }; const handleTouchStart = (ev: React.TouchEvent) => { setInstantTransition(true); @@ -212,6 +296,16 @@ export const LightboxComponent: React.FC = ({ el.addEventListener("touchcancel", handleCancel); }; + const [clearCallback, resetCallback] = useInterval( + () => { + handleRight(false); + }, + slideshowEnabled ? slideshowInterval : null + ); + + resetIntervalCallback.current = resetCallback; + clearIntervalCallback.current = clearCallback; + useEffect(() => { if (isVisible) { document.addEventListener("keydown", handleKey); @@ -228,6 +322,10 @@ export const LightboxComponent: React.FC = ({ else document.exitFullscreen(); }, [isFullscreen]); + const handleSlideshowIntervalChange = (newSlideshowInterval: number) => { + setSlideshowInterval(newSlideshowInterval); + }; + const navItems = images.map((image, i) => ( = ({ /> )); + const onDelayChange = (e: React.ChangeEvent) => { + let numberValue = Number.parseInt(e.currentTarget.value, 10); + // Without this exception, the blocking of updates for invalid values is even weirder + if (e.currentTarget.value === "-" || e.currentTarget.value === "") { + setDisplayedSlideshowInterval(e.currentTarget.value); + return; + } + + setDisplayedSlideshowInterval(e.currentTarget.value); + if (slideshowInterval !== null) { + numberValue = + numberValue >= MIN_VALID_INTERVAL_SECONDS + ? numberValue + : MIN_VALID_INTERVAL_SECONDS; + handleSlideshowIntervalChange(numberValue * SECONDS_TO_MS); + } + }; + const currentIndex = index.current === null ? initialIndex : index.current; + const DelayForm: React.FC<{}> = () => ( + <> + + Delay (Sec) + + + + + + ); + + const delayPopover = ( + + Set slideshow delay + + + + + + + ); + const element = isVisible ? (
    {images.length > 0 && !isLoading && !isSwitchingPage ? ( <>
    +
    {pageHeader} {`${currentIndex + 1} / ${images.length}`}
    - {document.fullscreenEnabled && ( +
    + {slideshowEnabled && ( + <> +
    +
    + + + +
    + + + +
    + + + )} + {document.fullscreenEnabled && ( + + )} - )} - +
    {images.length > 1 && ( diff --git a/ui/v2.5/src/hooks/Lightbox/context.tsx b/ui/v2.5/src/hooks/Lightbox/context.tsx index cff2e2e28..8a54ccf93 100644 --- a/ui/v2.5/src/hooks/Lightbox/context.tsx +++ b/ui/v2.5/src/hooks/Lightbox/context.tsx @@ -12,6 +12,8 @@ export interface IState { initialIndex?: number; pageCallback?: (direction: number) => boolean; pageHeader?: string; + slideshowEnabled: boolean; + onClose?: () => void; } interface IContext { setLightboxState: (state: Partial) => void; @@ -26,6 +28,7 @@ const Lightbox: React.FC = ({ children }) => { isVisible: false, isLoading: false, showNavigation: true, + slideshowEnabled: false, }); const setPartialState = useCallback( @@ -38,14 +41,18 @@ const Lightbox: React.FC = ({ children }) => { [setLightboxState] ); + const onHide = () => { + setLightboxState({ ...lightboxState, isVisible: false }); + if (lightboxState.onClose) { + lightboxState.onClose(); + } + }; + return ( {children} {lightboxState.isVisible && ( - setLightboxState({ ...lightboxState, isVisible: false })} - /> + )} ); diff --git a/ui/v2.5/src/hooks/Lightbox/hooks.ts b/ui/v2.5/src/hooks/Lightbox/hooks.ts index fa5685e17..f2863b55b 100644 --- a/ui/v2.5/src/hooks/Lightbox/hooks.ts +++ b/ui/v2.5/src/hooks/Lightbox/hooks.ts @@ -12,6 +12,8 @@ export const useLightbox = (state: Partial>) => { pageCallback: state.pageCallback, initialIndex: state.initialIndex, pageHeader: state.pageHeader, + slideshowEnabled: state.slideshowEnabled, + onClose: state.onClose, }); }, [ setLightboxState, @@ -20,13 +22,16 @@ export const useLightbox = (state: Partial>) => { state.pageCallback, state.initialIndex, state.pageHeader, + state.slideshowEnabled, + state.onClose, ]); const show = useCallback( - (index?: number) => { + (index?: number, slideshowEnabled = false) => { setLightboxState({ initialIndex: index, isVisible: true, + slideshowEnabled, }); }, [setLightboxState] diff --git a/ui/v2.5/src/hooks/Lightbox/lightbox.scss b/ui/v2.5/src/hooks/Lightbox/lightbox.scss index 895c7bd80..fa83980d0 100644 --- a/ui/v2.5/src/hooks/Lightbox/lightbox.scss +++ b/ui/v2.5/src/hooks/Lightbox/lightbox.scss @@ -26,14 +26,51 @@ flex-shrink: 0; height: 4rem; + &-left-spacer { + display: flex; + flex: 1; + justify-content: center; + } + &-indicator { display: flex; + flex: 1; flex-direction: column; - margin-left: 49%; margin-right: auto; text-align: center; } + &-delay { + display: flex; + flex-direction: column; + margin-left: 100px; + text-align: left; + + &-icon { + display: inline-block; + } + + &-inline { + display: none; + } + + @media screen and (min-width: 1300px) { + &-icon { + display: none; + } + + &-inline { + display: flex; + } + } + } + + &-right { + display: flex; + flex: 1; + justify-content: flex-end; + } + .fa-icon { height: 1.5rem; opacity: 1; diff --git a/ui/v2.5/src/hooks/PageVisibility.ts b/ui/v2.5/src/hooks/PageVisibility.ts new file mode 100644 index 000000000..afc7c6af1 --- /dev/null +++ b/ui/v2.5/src/hooks/PageVisibility.ts @@ -0,0 +1,50 @@ +import { useEffect, useRef } from "react"; + +const usePageVisibility = (visibilityChangeCallback: () => void): void => { + const savedVisibilityChangedCallback = useRef<() => void>(); + + useEffect(() => { + // resolve event names for different browsers + let hidden = ""; + let visibilityChange = ""; + + if (typeof document.hidden !== "undefined") { + hidden = "hidden"; + visibilityChange = "visibilitychange"; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + } else if (typeof (document as any).msHidden !== "undefined") { + hidden = "msHidden"; + visibilityChange = "msvisibilitychange"; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + } else if (typeof (document as any).webkitHidden !== "undefined") { + hidden = "webkitHidden"; + visibilityChange = "webkitvisibilitychange"; + } + + if ( + typeof document.addEventListener === "undefined" || + hidden === undefined + ) { + // this browser doesn't have support for modern event listeners or the Page Visibility API + return; + } + + savedVisibilityChangedCallback.current = visibilityChangeCallback; + + document.addEventListener( + visibilityChange, + savedVisibilityChangedCallback.current + ); + + return () => { + if (savedVisibilityChangedCallback.current) { + document.removeEventListener( + visibilityChange, + savedVisibilityChangedCallback.current + ); + } + }; + }, [visibilityChangeCallback]); +}; + +export default usePageVisibility; diff --git a/ui/v2.5/src/hooks/index.ts b/ui/v2.5/src/hooks/index.ts index 55f5ddeba..457ce6cd0 100644 --- a/ui/v2.5/src/hooks/index.ts +++ b/ui/v2.5/src/hooks/index.ts @@ -1,4 +1,6 @@ export { default as useToast } from "./Toast"; +export { default as useInterval } from "./Interval"; +export { default as usePageVisibility } from "./PageVisibility"; export { useInterfaceLocalForage, useChangelogStorage, From 34f114faff046839daa4f684f7679d1d1cd12ec9 Mon Sep 17 00:00:00 2001 From: stashist <81412921+stashist@users.noreply.github.com> Date: Tue, 13 Apr 2021 08:11:19 +0200 Subject: [PATCH 25/66] Simplify GH build pipeline. (#1268) The toolchain is already bundled in the stashapp/compiler image. Rather than introducing a second one via GH actions standardize on that one instead. Also * Clear up what "Cross Compile" actually does * Still pull stashapp/compiler separately for easier debugability. --- .github/workflows/build.yml | 35 +++++++++++++---------------------- 1 file changed, 13 insertions(+), 22 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 6bbaf07bb..7a90be6f4 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -8,6 +8,9 @@ on: release: types: [ published ] +env: + COMPILER_IMAGE: stashapp/compiler:4 + jobs: build: runs-on: ubuntu-20.04 @@ -17,44 +20,32 @@ jobs: - name: Checkout run: git fetch --prune --unshallow --tags - - name: Set up Go - uses: actions/setup-go@v2 - with: - go-version: 1.13 + - name: Pull compiler image + run: docker pull $COMPILER_IMAGE - - name: Set up Node - uses: actions/setup-node@v2 - with: - node-version: '12' - - name: Cache node modules uses: actions/cache@v2 env: cache-name: cache-node_modules with: path: ui/v2.5/node_modules - key: ${{ runner.os }}-build-${{ env.cache-name }}-${{ hashFiles('**/package-lock.json') }} - restore-keys: | - ${{ runner.os }}-build-${{ env.cache-name }}- - ${{ runner.os }}-build- - ${{ runner.os }}- + key: ${{ runner.os }}-build-${{ env.cache-name }}-${{ hashFiles('ui/v2.5/yarn.lock') }} - name: Pre-install - run: make pre-ui + run: docker run --rm --mount type=bind,source="$(pwd)",target=/stash,consistency=delegated -w /stash $COMPILER_IMAGE /bin/bash -c "make pre-ui" - name: Generate - run: make generate + run: docker run --rm --mount type=bind,source="$(pwd)",target=/stash,consistency=delegated -w /stash $COMPILER_IMAGE /bin/bash -c "make generate" + # TODO: Replace with `make validate` once `revive` is bundled in COMPILER_IMAGE - name: Validate - run: make ui-validate fmt-check vet it + run: docker run --rm --mount type=bind,source="$(pwd)",target=/stash,consistency=delegated -w /stash $COMPILER_IMAGE /bin/bash -c "make ui-validate fmt-check vet it" - name: Build UI - run: make ui-only + run: docker run --rm --mount type=bind,source="$(pwd)",target=/stash,consistency=delegated -w /stash $COMPILER_IMAGE /bin/bash -c "make ui-only" - - name: Cross Compile - run: | - docker pull stashapp/compiler:4 - ./scripts/cross-compile.sh + - name: Compile for all supported platforms + run: ./scripts/cross-compile.sh - name: Generate checksums run: | From e6aaa196f35fd135879a4ba056188e6509341014 Mon Sep 17 00:00:00 2001 From: InfiniteTF Date: Thu, 15 Apr 2021 02:01:44 +0200 Subject: [PATCH 26/66] Load settings panels on demand (#1302) --- ui/v2.5/src/components/Settings/Settings.tsx | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/ui/v2.5/src/components/Settings/Settings.tsx b/ui/v2.5/src/components/Settings/Settings.tsx index 043d073a7..7fa3e200f 100644 --- a/ui/v2.5/src/components/Settings/Settings.tsx +++ b/ui/v2.5/src/components/Settings/Settings.tsx @@ -66,19 +66,19 @@ export const Settings: React.FC = () => { - + - + - + - + - + From ea54a6779857c95945e3814e7068bd5658c69b2b Mon Sep 17 00:00:00 2001 From: WithoutPants <53250216+WithoutPants@users.noreply.github.com> Date: Thu, 15 Apr 2021 10:46:31 +1000 Subject: [PATCH 27/66] Add scene/image/gallery popover count buttons for performer/studio/tag cards (#1293) * Add counts to graphql schema * Add count resolvers and query refactor * Add count popover buttons --- graphql/documents/data/performer.graphql | 2 + graphql/documents/data/studio.graphql | 6 ++ graphql/documents/data/tag.graphql | 2 + graphql/schema/types/performer.graphql | 2 + graphql/schema/types/studio.graphql | 2 + graphql/schema/types/tag.graphql | 2 + pkg/api/resolver_model_performer.go | 26 +++++++ pkg/api/resolver_model_studio.go | 26 +++++++ pkg/api/resolver_model_tag.go | 26 +++++++ pkg/gallery/query.go | 40 +++++++++++ pkg/image/query.go | 40 +++++++++++ pkg/models/gallery.go | 1 + pkg/models/image.go | 1 + pkg/models/mocks/GalleryReaderWriter.go | 21 ++++++ pkg/models/mocks/ImageReaderWriter.go | 21 ++++++ pkg/models/mocks/SceneReaderWriter.go | 70 ++++++------------- pkg/sqlite/gallery.go | 15 +++- pkg/sqlite/image.go | 15 +++- pkg/sqlite/image_test.go | 6 ++ pkg/sqlite/query.go | 13 ++++ pkg/sqlite/repository.go | 8 ++- .../src/components/Changelog/versions/v070.md | 1 + .../components/Performers/PerformerCard.tsx | 45 ++++++++++-- .../components/Shared/PopoverCountButton.tsx | 64 +++++++++++++++++ ui/v2.5/src/components/Studios/StudioCard.tsx | 64 ++++++++++++++--- ui/v2.5/src/components/Tags/TagCard.tsx | 38 ++++++++-- ui/v2.5/src/utils/navigation.ts | 52 ++++++++++++++ 27 files changed, 536 insertions(+), 73 deletions(-) create mode 100644 pkg/gallery/query.go create mode 100644 pkg/image/query.go create mode 100644 ui/v2.5/src/components/Shared/PopoverCountButton.tsx diff --git a/graphql/documents/data/performer.graphql b/graphql/documents/data/performer.graphql index 253412b8a..2048da256 100644 --- a/graphql/documents/data/performer.graphql +++ b/graphql/documents/data/performer.graphql @@ -20,6 +20,8 @@ fragment PerformerData on Performer { favorite image_path scene_count + image_count + gallery_count tags { ...TagData diff --git a/graphql/documents/data/studio.graphql b/graphql/documents/data/studio.graphql index d2f60a44b..2c6c8d0a3 100644 --- a/graphql/documents/data/studio.graphql +++ b/graphql/documents/data/studio.graphql @@ -10,6 +10,8 @@ fragment StudioData on Studio { url image_path scene_count + image_count + gallery_count } child_studios { id @@ -18,9 +20,13 @@ fragment StudioData on Studio { url image_path scene_count + image_count + gallery_count } image_path scene_count + image_count + gallery_count stash_ids { stash_id endpoint diff --git a/graphql/documents/data/tag.graphql b/graphql/documents/data/tag.graphql index 3a0e84e1c..17d65b908 100644 --- a/graphql/documents/data/tag.graphql +++ b/graphql/documents/data/tag.graphql @@ -4,5 +4,7 @@ fragment TagData on Tag { image_path scene_count scene_marker_count + image_count + gallery_count performer_count } diff --git a/graphql/schema/types/performer.graphql b/graphql/schema/types/performer.graphql index e9ea68519..1c6d642e0 100644 --- a/graphql/schema/types/performer.graphql +++ b/graphql/schema/types/performer.graphql @@ -31,6 +31,8 @@ type Performer { image_path: String # Resolver scene_count: Int # Resolver + image_count: Int # Resolver + gallery_count: Int # Resolver scenes: [Scene!]! stash_ids: [StashID!]! } diff --git a/graphql/schema/types/studio.graphql b/graphql/schema/types/studio.graphql index 8eca28659..1adb0aa63 100644 --- a/graphql/schema/types/studio.graphql +++ b/graphql/schema/types/studio.graphql @@ -8,6 +8,8 @@ type Studio { image_path: String # Resolver scene_count: Int # Resolver + image_count: Int # Resolver + gallery_count: Int # Resolver stash_ids: [StashID!]! } diff --git a/graphql/schema/types/tag.graphql b/graphql/schema/types/tag.graphql index 2f1f0d0d5..1b855fd36 100644 --- a/graphql/schema/types/tag.graphql +++ b/graphql/schema/types/tag.graphql @@ -5,6 +5,8 @@ type Tag { image_path: String # Resolver scene_count: Int # Resolver scene_marker_count: Int # Resolver + image_count: Int # Resolver + gallery_count: Int # Resolver performer_count: Int } diff --git a/pkg/api/resolver_model_performer.go b/pkg/api/resolver_model_performer.go index cef67c22a..ab3d2363f 100644 --- a/pkg/api/resolver_model_performer.go +++ b/pkg/api/resolver_model_performer.go @@ -4,6 +4,8 @@ import ( "context" "github.com/stashapp/stash/pkg/api/urlbuilders" + "github.com/stashapp/stash/pkg/gallery" + "github.com/stashapp/stash/pkg/image" "github.com/stashapp/stash/pkg/models" ) @@ -161,6 +163,30 @@ func (r *performerResolver) SceneCount(ctx context.Context, obj *models.Performe return &res, nil } +func (r *performerResolver) ImageCount(ctx context.Context, obj *models.Performer) (ret *int, err error) { + var res int + if err := r.withReadTxn(ctx, func(repo models.ReaderRepository) error { + res, err = image.CountByPerformerID(repo.Image(), obj.ID) + return err + }); err != nil { + return nil, err + } + + return &res, nil +} + +func (r *performerResolver) GalleryCount(ctx context.Context, obj *models.Performer) (ret *int, err error) { + var res int + if err := r.withReadTxn(ctx, func(repo models.ReaderRepository) error { + res, err = gallery.CountByPerformerID(repo.Gallery(), obj.ID) + return err + }); err != nil { + return nil, err + } + + return &res, nil +} + func (r *performerResolver) Scenes(ctx context.Context, obj *models.Performer) (ret []*models.Scene, err error) { if err := r.withReadTxn(ctx, func(repo models.ReaderRepository) error { ret, err = repo.Scene().FindByPerformerID(obj.ID) diff --git a/pkg/api/resolver_model_studio.go b/pkg/api/resolver_model_studio.go index 1f866b004..553c8cc5c 100644 --- a/pkg/api/resolver_model_studio.go +++ b/pkg/api/resolver_model_studio.go @@ -4,6 +4,8 @@ import ( "context" "github.com/stashapp/stash/pkg/api/urlbuilders" + "github.com/stashapp/stash/pkg/gallery" + "github.com/stashapp/stash/pkg/image" "github.com/stashapp/stash/pkg/models" ) @@ -54,6 +56,30 @@ func (r *studioResolver) SceneCount(ctx context.Context, obj *models.Studio) (re return &res, err } +func (r *studioResolver) ImageCount(ctx context.Context, obj *models.Studio) (ret *int, err error) { + var res int + if err := r.withReadTxn(ctx, func(repo models.ReaderRepository) error { + res, err = image.CountByStudioID(repo.Image(), obj.ID) + return err + }); err != nil { + return nil, err + } + + return &res, nil +} + +func (r *studioResolver) GalleryCount(ctx context.Context, obj *models.Studio) (ret *int, err error) { + var res int + if err := r.withReadTxn(ctx, func(repo models.ReaderRepository) error { + res, err = gallery.CountByStudioID(repo.Gallery(), obj.ID) + return err + }); err != nil { + return nil, err + } + + return &res, nil +} + func (r *studioResolver) ParentStudio(ctx context.Context, obj *models.Studio) (ret *models.Studio, err error) { if !obj.ParentID.Valid { return nil, nil diff --git a/pkg/api/resolver_model_tag.go b/pkg/api/resolver_model_tag.go index 1cbb3acf3..a4fb2cc4e 100644 --- a/pkg/api/resolver_model_tag.go +++ b/pkg/api/resolver_model_tag.go @@ -4,6 +4,8 @@ import ( "context" "github.com/stashapp/stash/pkg/api/urlbuilders" + "github.com/stashapp/stash/pkg/gallery" + "github.com/stashapp/stash/pkg/image" "github.com/stashapp/stash/pkg/models" ) @@ -31,6 +33,30 @@ func (r *tagResolver) SceneMarkerCount(ctx context.Context, obj *models.Tag) (re return &count, err } +func (r *tagResolver) ImageCount(ctx context.Context, obj *models.Tag) (ret *int, err error) { + var res int + if err := r.withReadTxn(ctx, func(repo models.ReaderRepository) error { + res, err = image.CountByTagID(repo.Image(), obj.ID) + return err + }); err != nil { + return nil, err + } + + return &res, nil +} + +func (r *tagResolver) GalleryCount(ctx context.Context, obj *models.Tag) (ret *int, err error) { + var res int + if err := r.withReadTxn(ctx, func(repo models.ReaderRepository) error { + res, err = gallery.CountByTagID(repo.Gallery(), obj.ID) + return err + }); err != nil { + return nil, err + } + + return &res, nil +} + func (r *tagResolver) PerformerCount(ctx context.Context, obj *models.Tag) (ret *int, err error) { var count int if err := r.withReadTxn(ctx, func(repo models.ReaderRepository) error { diff --git a/pkg/gallery/query.go b/pkg/gallery/query.go new file mode 100644 index 000000000..6cae24321 --- /dev/null +++ b/pkg/gallery/query.go @@ -0,0 +1,40 @@ +package gallery + +import ( + "strconv" + + "github.com/stashapp/stash/pkg/models" +) + +func CountByPerformerID(r models.GalleryReader, id int) (int, error) { + filter := &models.GalleryFilterType{ + Performers: &models.MultiCriterionInput{ + Value: []string{strconv.Itoa(id)}, + Modifier: models.CriterionModifierIncludes, + }, + } + + return r.QueryCount(filter, nil) +} + +func CountByStudioID(r models.GalleryReader, id int) (int, error) { + filter := &models.GalleryFilterType{ + Studios: &models.MultiCriterionInput{ + Value: []string{strconv.Itoa(id)}, + Modifier: models.CriterionModifierIncludes, + }, + } + + return r.QueryCount(filter, nil) +} + +func CountByTagID(r models.GalleryReader, id int) (int, error) { + filter := &models.GalleryFilterType{ + Tags: &models.MultiCriterionInput{ + Value: []string{strconv.Itoa(id)}, + Modifier: models.CriterionModifierIncludes, + }, + } + + return r.QueryCount(filter, nil) +} diff --git a/pkg/image/query.go b/pkg/image/query.go new file mode 100644 index 000000000..58e276632 --- /dev/null +++ b/pkg/image/query.go @@ -0,0 +1,40 @@ +package image + +import ( + "strconv" + + "github.com/stashapp/stash/pkg/models" +) + +func CountByPerformerID(r models.ImageReader, id int) (int, error) { + filter := &models.ImageFilterType{ + Performers: &models.MultiCriterionInput{ + Value: []string{strconv.Itoa(id)}, + Modifier: models.CriterionModifierIncludes, + }, + } + + return r.QueryCount(filter, nil) +} + +func CountByStudioID(r models.ImageReader, id int) (int, error) { + filter := &models.ImageFilterType{ + Studios: &models.MultiCriterionInput{ + Value: []string{strconv.Itoa(id)}, + Modifier: models.CriterionModifierIncludes, + }, + } + + return r.QueryCount(filter, nil) +} + +func CountByTagID(r models.ImageReader, id int) (int, error) { + filter := &models.ImageFilterType{ + Tags: &models.MultiCriterionInput{ + Value: []string{strconv.Itoa(id)}, + Modifier: models.CriterionModifierIncludes, + }, + } + + return r.QueryCount(filter, nil) +} diff --git a/pkg/models/gallery.go b/pkg/models/gallery.go index 71f19a666..75fcfc896 100644 --- a/pkg/models/gallery.go +++ b/pkg/models/gallery.go @@ -11,6 +11,7 @@ type GalleryReader interface { Count() (int, error) All() ([]*Gallery, error) Query(galleryFilter *GalleryFilterType, findFilter *FindFilterType) ([]*Gallery, int, error) + QueryCount(galleryFilter *GalleryFilterType, findFilter *FindFilterType) (int, error) GetPerformerIDs(galleryID int) ([]int, error) GetTagIDs(galleryID int) ([]int, error) GetSceneIDs(galleryID int) ([]int, error) diff --git a/pkg/models/image.go b/pkg/models/image.go index d160aeba5..c3f3c5b2e 100644 --- a/pkg/models/image.go +++ b/pkg/models/image.go @@ -17,6 +17,7 @@ type ImageReader interface { // CountByTagID(tagID int) (int, error) All() ([]*Image, error) Query(imageFilter *ImageFilterType, findFilter *FindFilterType) ([]*Image, int, error) + QueryCount(imageFilter *ImageFilterType, findFilter *FindFilterType) (int, error) GetGalleryIDs(imageID int) ([]int, error) GetTagIDs(imageID int) ([]int, error) GetPerformerIDs(imageID int) ([]int, error) diff --git a/pkg/models/mocks/GalleryReaderWriter.go b/pkg/models/mocks/GalleryReaderWriter.go index 3585c3036..8bbd2e78b 100644 --- a/pkg/models/mocks/GalleryReaderWriter.go +++ b/pkg/models/mocks/GalleryReaderWriter.go @@ -376,6 +376,27 @@ func (_m *GalleryReaderWriter) Query(galleryFilter *models.GalleryFilterType, fi return r0, r1, r2 } +// QueryCount provides a mock function with given fields: galleryFilter, findFilter +func (_m *GalleryReaderWriter) QueryCount(galleryFilter *models.GalleryFilterType, findFilter *models.FindFilterType) (int, error) { + ret := _m.Called(galleryFilter, findFilter) + + var r0 int + if rf, ok := ret.Get(0).(func(*models.GalleryFilterType, *models.FindFilterType) int); ok { + r0 = rf(galleryFilter, findFilter) + } else { + r0 = ret.Get(0).(int) + } + + var r1 error + if rf, ok := ret.Get(1).(func(*models.GalleryFilterType, *models.FindFilterType) error); ok { + r1 = rf(galleryFilter, findFilter) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + // Update provides a mock function with given fields: updatedGallery func (_m *GalleryReaderWriter) Update(updatedGallery models.Gallery) (*models.Gallery, error) { ret := _m.Called(updatedGallery) diff --git a/pkg/models/mocks/ImageReaderWriter.go b/pkg/models/mocks/ImageReaderWriter.go index b00cacfc9..a8a8c4b4a 100644 --- a/pkg/models/mocks/ImageReaderWriter.go +++ b/pkg/models/mocks/ImageReaderWriter.go @@ -370,6 +370,27 @@ func (_m *ImageReaderWriter) Query(imageFilter *models.ImageFilterType, findFilt return r0, r1, r2 } +// QueryCount provides a mock function with given fields: imageFilter, findFilter +func (_m *ImageReaderWriter) QueryCount(imageFilter *models.ImageFilterType, findFilter *models.FindFilterType) (int, error) { + ret := _m.Called(imageFilter, findFilter) + + var r0 int + if rf, ok := ret.Get(0).(func(*models.ImageFilterType, *models.FindFilterType) int); ok { + r0 = rf(imageFilter, findFilter) + } else { + r0 = ret.Get(0).(int) + } + + var r1 error + if rf, ok := ret.Get(1).(func(*models.ImageFilterType, *models.FindFilterType) error); ok { + r1 = rf(imageFilter, findFilter) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + // ResetOCounter provides a mock function with given fields: id func (_m *ImageReaderWriter) ResetOCounter(id int) (int, error) { ret := _m.Called(id) diff --git a/pkg/models/mocks/SceneReaderWriter.go b/pkg/models/mocks/SceneReaderWriter.go index b5c3af191..796c23878 100644 --- a/pkg/models/mocks/SceneReaderWriter.go +++ b/pkg/models/mocks/SceneReaderWriter.go @@ -415,6 +415,29 @@ func (_m *SceneReaderWriter) FindByPerformerID(performerID int) ([]*models.Scene return r0, r1 } +// FindDuplicates provides a mock function with given fields: distance +func (_m *SceneReaderWriter) FindDuplicates(distance int) ([][]*models.Scene, error) { + ret := _m.Called(distance) + + var r0 [][]*models.Scene + if rf, ok := ret.Get(0).(func(int) [][]*models.Scene); ok { + r0 = rf(distance) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).([][]*models.Scene) + } + } + + var r1 error + if rf, ok := ret.Get(1).(func(int) error); ok { + r1 = rf(distance) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + // FindMany provides a mock function with given fields: ids func (_m *SceneReaderWriter) FindMany(ids []int) ([]*models.Scene, error) { ret := _m.Called(ids) @@ -438,30 +461,6 @@ func (_m *SceneReaderWriter) FindMany(ids []int) ([]*models.Scene, error) { return r0, r1 } -// FindDuplicates provides a mock function with given fields: distance -func (_m *SceneReaderWriter) FindDuplicates(distance int) ([][]*models.Scene, error) { - ret := _m.Called(distance) - - var r0 [][]*models.Scene - if rf, ok := ret.Get(0).(func(int) [][]*models.Scene); ok { - r0 = rf(distance) - - } else { - if ret.Get(0) != nil { - r0 = ret.Get(0).([][]*models.Scene) - } - } - - var r1 error - if rf, ok := ret.Get(1).(func(int) error); ok { - r1 = rf(distance) - } else { - r1 = ret.Error(1) - } - - return r0, r1 -} - // GetCover provides a mock function with given fields: sceneID func (_m *SceneReaderWriter) GetCover(sceneID int) ([]byte, error) { ret := _m.Called(sceneID) @@ -651,29 +650,6 @@ func (_m *SceneReaderWriter) Query(sceneFilter *models.SceneFilterType, findFilt return r0, r1, r2 } -// QueryForAutoTag provides a mock function with given fields: regex, pathPrefixes -func (_m *SceneReaderWriter) QueryForAutoTag(regex string, pathPrefixes []string) ([]*models.Scene, error) { - ret := _m.Called(regex, pathPrefixes) - - var r0 []*models.Scene - if rf, ok := ret.Get(0).(func(string, []string) []*models.Scene); ok { - r0 = rf(regex, pathPrefixes) - } else { - if ret.Get(0) != nil { - r0 = ret.Get(0).([]*models.Scene) - } - } - - var r1 error - if rf, ok := ret.Get(1).(func(string, []string) error); ok { - r1 = rf(regex, pathPrefixes) - } else { - r1 = ret.Error(1) - } - - return r0, r1 -} - // ResetOCounter provides a mock function with given fields: id func (_m *SceneReaderWriter) ResetOCounter(id int) (int, error) { ret := _m.Called(id) diff --git a/pkg/sqlite/gallery.go b/pkg/sqlite/gallery.go index 2fb59bcc0..d2e475ffe 100644 --- a/pkg/sqlite/gallery.go +++ b/pkg/sqlite/gallery.go @@ -159,7 +159,7 @@ func (qb *galleryQueryBuilder) All() ([]*models.Gallery, error) { return qb.queryGalleries(selectAll("galleries")+qb.getGallerySort(nil), nil) } -func (qb *galleryQueryBuilder) Query(galleryFilter *models.GalleryFilterType, findFilter *models.FindFilterType) ([]*models.Gallery, int, error) { +func (qb *galleryQueryBuilder) makeQuery(galleryFilter *models.GalleryFilterType, findFilter *models.FindFilterType) queryBuilder { if galleryFilter == nil { galleryFilter = &models.GalleryFilterType{} } @@ -283,6 +283,13 @@ func (qb *galleryQueryBuilder) Query(galleryFilter *models.GalleryFilterType, fi handleGalleryPerformerTagsCriterion(&query, galleryFilter.PerformerTags) query.sortAndPagination = qb.getGallerySort(findFilter) + getPagination(findFilter) + + return query +} + +func (qb *galleryQueryBuilder) Query(galleryFilter *models.GalleryFilterType, findFilter *models.FindFilterType) ([]*models.Gallery, int, error) { + query := qb.makeQuery(galleryFilter, findFilter) + idsResult, countResult, err := query.executeFind() if err != nil { return nil, 0, err @@ -301,6 +308,12 @@ func (qb *galleryQueryBuilder) Query(galleryFilter *models.GalleryFilterType, fi return galleries, countResult, nil } +func (qb *galleryQueryBuilder) QueryCount(galleryFilter *models.GalleryFilterType, findFilter *models.FindFilterType) (int, error) { + query := qb.makeQuery(galleryFilter, findFilter) + + return query.executeCount() +} + func (qb *galleryQueryBuilder) handleAverageResolutionFilter(query *queryBuilder, resolutionFilter *models.ResolutionEnum) { if resolutionFilter == nil { return diff --git a/pkg/sqlite/image.go b/pkg/sqlite/image.go index bf7815bb5..261fedd95 100644 --- a/pkg/sqlite/image.go +++ b/pkg/sqlite/image.go @@ -216,7 +216,7 @@ func (qb *imageQueryBuilder) All() ([]*models.Image, error) { return qb.queryImages(selectAll(imageTable)+qb.getImageSort(nil), nil) } -func (qb *imageQueryBuilder) Query(imageFilter *models.ImageFilterType, findFilter *models.FindFilterType) ([]*models.Image, int, error) { +func (qb *imageQueryBuilder) makeQuery(imageFilter *models.ImageFilterType, findFilter *models.FindFilterType) queryBuilder { if imageFilter == nil { imageFilter = &models.ImageFilterType{} } @@ -383,6 +383,13 @@ func (qb *imageQueryBuilder) Query(imageFilter *models.ImageFilterType, findFilt handleImagePerformerTagsCriterion(&query, imageFilter.PerformerTags) query.sortAndPagination = qb.getImageSort(findFilter) + getPagination(findFilter) + + return query +} + +func (qb *imageQueryBuilder) Query(imageFilter *models.ImageFilterType, findFilter *models.FindFilterType) ([]*models.Image, int, error) { + query := qb.makeQuery(imageFilter, findFilter) + idsResult, countResult, err := query.executeFind() if err != nil { return nil, 0, err @@ -401,6 +408,12 @@ func (qb *imageQueryBuilder) Query(imageFilter *models.ImageFilterType, findFilt return images, countResult, nil } +func (qb *imageQueryBuilder) QueryCount(imageFilter *models.ImageFilterType, findFilter *models.FindFilterType) (int, error) { + query := qb.makeQuery(imageFilter, findFilter) + + return query.executeCount() +} + func handleImagePerformerTagsCriterion(query *queryBuilder, performerTagsFilter *models.MultiCriterionInput) { if performerTagsFilter != nil && len(performerTagsFilter.Value) > 0 { for _, tagID := range performerTagsFilter.Value { diff --git a/pkg/sqlite/image_test.go b/pkg/sqlite/image_test.go index 153162420..d7260e477 100644 --- a/pkg/sqlite/image_test.go +++ b/pkg/sqlite/image_test.go @@ -95,6 +95,12 @@ func imageQueryQ(t *testing.T, sqb models.ImageReader, q string, expectedImageId image := images[0] assert.Equal(t, imageIDs[expectedImageIdx], image.ID) + count, err := sqb.QueryCount(nil, &filter) + if err != nil { + t.Errorf("Error querying image: %s", err.Error()) + } + assert.Equal(t, len(images), count) + // no Q should return all results filter.Q = nil images, _, err = sqb.Query(nil, &filter) diff --git a/pkg/sqlite/query.go b/pkg/sqlite/query.go index cf4cf0b5f..4c549cdf1 100644 --- a/pkg/sqlite/query.go +++ b/pkg/sqlite/query.go @@ -33,6 +33,19 @@ func (qb queryBuilder) executeFind() ([]int, int, error) { return qb.repository.executeFindQuery(body, qb.args, qb.sortAndPagination, qb.whereClauses, qb.havingClauses) } +func (qb queryBuilder) executeCount() (int, error) { + if qb.err != nil { + return 0, qb.err + } + + body := qb.body + body += qb.joins.toSQL() + + body = qb.repository.buildQueryBody(body, qb.whereClauses, qb.havingClauses) + countQuery := qb.repository.buildCountQuery(body) + return qb.repository.runCountQuery(countQuery, qb.args) +} + func (qb *queryBuilder) addWhere(clauses ...string) { for _, clause := range clauses { if len(clause) > 0 { diff --git a/pkg/sqlite/repository.go b/pkg/sqlite/repository.go index 681e68376..568d9c30b 100644 --- a/pkg/sqlite/repository.go +++ b/pkg/sqlite/repository.go @@ -234,7 +234,7 @@ func (r *repository) querySimple(query string, args []interface{}, out interface return nil } -func (r *repository) executeFindQuery(body string, args []interface{}, sortAndPagination string, whereClauses []string, havingClauses []string) ([]int, int, error) { +func (r *repository) buildQueryBody(body string, whereClauses []string, havingClauses []string) string { if len(whereClauses) > 0 { body = body + " WHERE " + strings.Join(whereClauses, " AND ") // TODO handle AND or OR } @@ -243,6 +243,12 @@ func (r *repository) executeFindQuery(body string, args []interface{}, sortAndPa body = body + " HAVING " + strings.Join(havingClauses, " AND ") // TODO handle AND or OR } + return body +} + +func (r *repository) executeFindQuery(body string, args []interface{}, sortAndPagination string, whereClauses []string, havingClauses []string) ([]int, int, error) { + body = r.buildQueryBody(body, whereClauses, havingClauses) + countQuery := r.buildCountQuery(body) idsQuery := body + sortAndPagination diff --git a/ui/v2.5/src/components/Changelog/versions/v070.md b/ui/v2.5/src/components/Changelog/versions/v070.md index acce2e74c..680e64e81 100644 --- a/ui/v2.5/src/components/Changelog/versions/v070.md +++ b/ui/v2.5/src/components/Changelog/versions/v070.md @@ -4,6 +4,7 @@ * Added scene queue. ### 🎨 Improvements +* Add popover buttons for scenes/images/galleries on performer/studio/tag cards. * Add slideshow to image wall view. * Support API key via URL query parameter, and added API key to stream link in Scene File Info. * Revamped setup wizard and migration UI. diff --git a/ui/v2.5/src/components/Performers/PerformerCard.tsx b/ui/v2.5/src/components/Performers/PerformerCard.tsx index cff36a880..4abf9bcf4 100644 --- a/ui/v2.5/src/components/Performers/PerformerCard.tsx +++ b/ui/v2.5/src/components/Performers/PerformerCard.tsx @@ -12,6 +12,7 @@ import { TruncatedText, } from "src/components/Shared"; import { Button, ButtonGroup } from "react-bootstrap"; +import { PopoverCountButton } from "../Shared/PopoverCountButton"; interface IPerformerCardProps { performer: GQL.PerformerDataFragment; @@ -46,12 +47,35 @@ export const PerformerCard: React.FC = ({ if (!performer.scene_count) return; return ( - - - + + ); + } + + function maybeRenderImagesPopoverButton() { + if (!performer.image_count) return; + + return ( + + ); + } + + function maybeRenderGalleriesPopoverButton() { + if (!performer.gallery_count) return; + + return ( + ); } @@ -73,12 +97,19 @@ export const PerformerCard: React.FC = ({ } function maybeRenderPopoverButtonGroup() { - if (performer.scene_count || performer.tags.length > 0) { + if ( + performer.scene_count || + performer.image_count || + performer.gallery_count || + performer.tags.length > 0 + ) { return ( <>
    {maybeRenderScenesPopoverButton()} + {maybeRenderImagesPopoverButton()} + {maybeRenderGalleriesPopoverButton()} {maybeRenderTagPopoverButton()} diff --git a/ui/v2.5/src/components/Shared/PopoverCountButton.tsx b/ui/v2.5/src/components/Shared/PopoverCountButton.tsx new file mode 100644 index 000000000..6031e23fe --- /dev/null +++ b/ui/v2.5/src/components/Shared/PopoverCountButton.tsx @@ -0,0 +1,64 @@ +import React from "react"; +import { Button } from "react-bootstrap"; +import { useIntl } from "react-intl"; +import { Link } from "react-router-dom"; +import Icon from "./Icon"; + +type PopoverLinkType = "scene" | "image" | "gallery"; + +interface IProps { + url: string; + type: PopoverLinkType; + count: number; +} + +export const PopoverCountButton: React.FC = ({ url, type, count }) => { + const intl = useIntl(); + + function getIcon() { + switch (type) { + case "scene": + return "play-circle"; + case "image": + return "image"; + case "gallery": + return "images"; + } + } + + function getPluralOptions() { + switch (type) { + case "scene": + return { + one: "scene", + other: "scenes", + }; + case "image": + return { + one: "image", + other: "images", + }; + case "gallery": + return { + one: "gallery", + other: "galleries", + }; + } + } + + function getTitle() { + const pluralCategory = intl.formatPlural(count); + const options = getPluralOptions(); + const plural = options[pluralCategory as "one"] || options.other; + return `${count} ${plural}`; + } + + return ( + + + + ); +}; diff --git a/ui/v2.5/src/components/Studios/StudioCard.tsx b/ui/v2.5/src/components/Studios/StudioCard.tsx index 5e8db8fa9..da6cb03ee 100644 --- a/ui/v2.5/src/components/Studios/StudioCard.tsx +++ b/ui/v2.5/src/components/Studios/StudioCard.tsx @@ -1,9 +1,10 @@ import React from "react"; import { Link } from "react-router-dom"; import * as GQL from "src/core/generated-graphql"; -import { FormattedPlural } from "react-intl"; import { NavUtils } from "src/utils"; import { BasicCard, TruncatedText } from "src/components/Shared"; +import { ButtonGroup } from "react-bootstrap"; +import { PopoverCountButton } from "../Shared/PopoverCountButton"; interface IProps { studio: GQL.StudioDataFragment; @@ -51,6 +52,57 @@ export const StudioCard: React.FC = ({ selected, onSelectedChanged, }) => { + function maybeRenderScenesPopoverButton() { + if (!studio.scene_count) return; + + return ( + + ); + } + + function maybeRenderImagesPopoverButton() { + if (!studio.image_count) return; + + return ( + + ); + } + + function maybeRenderGalleriesPopoverButton() { + if (!studio.gallery_count) return; + + return ( + + ); + } + + function maybeRenderPopoverButtonGroup() { + if (studio.scene_count || studio.image_count || studio.gallery_count) { + return ( + <> +
    + + {maybeRenderScenesPopoverButton()} + {maybeRenderImagesPopoverButton()} + {maybeRenderGalleriesPopoverButton()} + + + ); + } + } + return ( = ({
    - - {studio.scene_count}  - - . - {maybeRenderParent(studio, hideParent)} {maybeRenderChildren(studio)} + {maybeRenderPopoverButtonGroup()} } selected={selected} diff --git a/ui/v2.5/src/components/Tags/TagCard.tsx b/ui/v2.5/src/components/Tags/TagCard.tsx index 5f5db0358..8d2561389 100644 --- a/ui/v2.5/src/components/Tags/TagCard.tsx +++ b/ui/v2.5/src/components/Tags/TagCard.tsx @@ -5,6 +5,7 @@ import * as GQL from "src/core/generated-graphql"; import { NavUtils } from "src/utils"; import { Icon, TruncatedText } from "../Shared"; import { BasicCard } from "../Shared/BasicCard"; +import { PopoverCountButton } from "../Shared/PopoverCountButton"; interface IProps { tag: GQL.TagDataFragment; @@ -25,12 +26,11 @@ export const TagCard: React.FC = ({ if (!tag.scene_count) return; return ( - - - + ); } @@ -47,6 +47,30 @@ export const TagCard: React.FC = ({ ); } + function maybeRenderImagesPopoverButton() { + if (!tag.image_count) return; + + return ( + + ); + } + + function maybeRenderGalleriesPopoverButton() { + if (!tag.gallery_count) return; + + return ( + + ); + } + function maybeRenderPerformersPopoverButton() { if (!tag.performer_count) return; @@ -67,6 +91,8 @@ export const TagCard: React.FC = ({
    {maybeRenderScenesPopoverButton()} + {maybeRenderImagesPopoverButton()} + {maybeRenderGalleriesPopoverButton()} {maybeRenderSceneMarkersPopoverButton()} {maybeRenderPerformersPopoverButton()} diff --git a/ui/v2.5/src/utils/navigation.ts b/ui/v2.5/src/utils/navigation.ts index a85e54be7..31f420987 100644 --- a/ui/v2.5/src/utils/navigation.ts +++ b/ui/v2.5/src/utils/navigation.ts @@ -23,6 +23,32 @@ const makePerformerScenesUrl = ( return `/scenes?${filter.makeQueryParameters()}`; }; +const makePerformerImagesUrl = ( + performer: Partial +) => { + if (!performer.id) return "#"; + const filter = new ListFilterModel(FilterMode.Images); + const criterion = new PerformersCriterion(); + criterion.value = [ + { id: performer.id, label: performer.name || `Performer ${performer.id}` }, + ]; + filter.criteria.push(criterion); + return `/images?${filter.makeQueryParameters()}`; +}; + +const makePerformerGalleriesUrl = ( + performer: Partial +) => { + if (!performer.id) return "#"; + const filter = new ListFilterModel(FilterMode.Galleries); + const criterion = new PerformersCriterion(); + criterion.value = [ + { id: performer.id, label: performer.name || `Performer ${performer.id}` }, + ]; + filter.criteria.push(criterion); + return `/galleries?${filter.makeQueryParameters()}`; +}; + const makePerformersCountryUrl = ( performer: Partial ) => { @@ -45,6 +71,28 @@ const makeStudioScenesUrl = (studio: Partial) => { return `/scenes?${filter.makeQueryParameters()}`; }; +const makeStudioImagesUrl = (studio: Partial) => { + if (!studio.id) return "#"; + const filter = new ListFilterModel(FilterMode.Images); + const criterion = new StudiosCriterion(); + criterion.value = [ + { id: studio.id, label: studio.name || `Studio ${studio.id}` }, + ]; + filter.criteria.push(criterion); + return `/images?${filter.makeQueryParameters()}`; +}; + +const makeStudioGalleriesUrl = (studio: Partial) => { + if (!studio.id) return "#"; + const filter = new ListFilterModel(FilterMode.Galleries); + const criterion = new StudiosCriterion(); + criterion.value = [ + { id: studio.id, label: studio.name || `Studio ${studio.id}` }, + ]; + filter.criteria.push(criterion); + return `/galleries?${filter.makeQueryParameters()}`; +}; + const makeChildStudiosUrl = (studio: Partial) => { if (!studio.id) return "#"; const filter = new ListFilterModel(FilterMode.Studios); @@ -121,8 +169,12 @@ const makeSceneMarkerUrl = ( export default { makePerformerScenesUrl, + makePerformerImagesUrl, + makePerformerGalleriesUrl, makePerformersCountryUrl, makeStudioScenesUrl, + makeStudioImagesUrl, + makeStudioGalleriesUrl, makeTagSceneMarkersUrl, makeTagScenesUrl, makeTagPerformersUrl, From e59018acfb92d5a4107579864a9326014daa40a3 Mon Sep 17 00:00:00 2001 From: InfiniteTF Date: Thu, 15 Apr 2021 03:01:31 +0200 Subject: [PATCH 28/66] Skip validation of existing paths when adding new paths (#1301) --- pkg/api/resolver_mutation_configure.go | 17 ++++++++++++++--- .../src/components/Changelog/versions/v070.md | 1 + 2 files changed, 15 insertions(+), 3 deletions(-) diff --git a/pkg/api/resolver_mutation_configure.go b/pkg/api/resolver_mutation_configure.go index b3734dfc7..ea4ae082c 100644 --- a/pkg/api/resolver_mutation_configure.go +++ b/pkg/api/resolver_mutation_configure.go @@ -25,11 +25,22 @@ func (r *mutationResolver) Migrate(ctx context.Context, input models.MigrateInpu func (r *mutationResolver) ConfigureGeneral(ctx context.Context, input models.ConfigGeneralInput) (*models.ConfigGeneralResult, error) { c := config.GetInstance() + existingPaths := c.GetStashPaths() if len(input.Stashes) > 0 { for _, s := range input.Stashes { - exists, err := utils.DirExists(s.Path) - if !exists { - return makeConfigGeneralResult(), err + // Only validate existence of new paths + isNew := true + for _, path := range existingPaths { + if path.Path == s.Path { + isNew = false + break + } + } + if isNew { + exists, err := utils.DirExists(s.Path) + if !exists { + return makeConfigGeneralResult(), err + } } } c.Set(config.Stash, input.Stashes) diff --git a/ui/v2.5/src/components/Changelog/versions/v070.md b/ui/v2.5/src/components/Changelog/versions/v070.md index 680e64e81..132a6f8ac 100644 --- a/ui/v2.5/src/components/Changelog/versions/v070.md +++ b/ui/v2.5/src/components/Changelog/versions/v070.md @@ -19,6 +19,7 @@ * Change performer text query to search by name and alias only. ### 🐛 Bug fixes +* Fix error preventing adding a new library path when an existing library path is missing. * Fix whitespace in query string returning all objects. * Fix hang on Login page when not connected to internet. * Fix `Clear Image` button not updating image preview. From 0b40017b098a931a79f9c81b882cfe8747b43389 Mon Sep 17 00:00:00 2001 From: WithoutPants <53250216+WithoutPants@users.noreply.github.com> Date: Thu, 15 Apr 2021 11:33:20 +1000 Subject: [PATCH 29/66] Sort performers in popover and card views (#1294) --- graphql/documents/data/image-slim.graphql | 1 + graphql/documents/data/scene-slim.graphql | 1 + .../src/components/Changelog/versions/v070.md | 1 + .../src/components/Galleries/GalleryCard.tsx | 26 +----------- .../GalleryDetails/GalleryDetailPanel.tsx | 4 +- ui/v2.5/src/components/Images/ImageCard.tsx | 26 +----------- .../Images/ImageDetails/ImageDetailPanel.tsx | 4 +- ui/v2.5/src/components/Scenes/SceneCard.tsx | 26 +----------- .../Scenes/SceneDetails/SceneDetailPanel.tsx | 4 +- .../Shared/PerformerPopoverButton.tsx | 40 +++++++++++++++++++ ui/v2.5/src/core/performers.ts | 35 ++++++++++++++++ 11 files changed, 93 insertions(+), 75 deletions(-) create mode 100644 ui/v2.5/src/components/Shared/PerformerPopoverButton.tsx diff --git a/graphql/documents/data/image-slim.graphql b/graphql/documents/data/image-slim.graphql index ce80786ee..b1c066ee2 100644 --- a/graphql/documents/data/image-slim.graphql +++ b/graphql/documents/data/image-slim.graphql @@ -38,6 +38,7 @@ fragment SlimImageData on Image { performers { id name + gender favorite image_path } diff --git a/graphql/documents/data/scene-slim.graphql b/graphql/documents/data/scene-slim.graphql index f427eb904..4aacf27e4 100644 --- a/graphql/documents/data/scene-slim.graphql +++ b/graphql/documents/data/scene-slim.graphql @@ -68,6 +68,7 @@ fragment SlimSceneData on Scene { performers { id name + gender favorite image_path } diff --git a/ui/v2.5/src/components/Changelog/versions/v070.md b/ui/v2.5/src/components/Changelog/versions/v070.md index 132a6f8ac..6a76e8fb4 100644 --- a/ui/v2.5/src/components/Changelog/versions/v070.md +++ b/ui/v2.5/src/components/Changelog/versions/v070.md @@ -4,6 +4,7 @@ * Added scene queue. ### 🎨 Improvements +* Sort performers by gender in scene/image/gallery cards and details. * Add popover buttons for scenes/images/galleries on performer/studio/tag cards. * Add slideshow to image wall view. * Support API key via URL query parameter, and added API key to stream link in Scene File Info. diff --git a/ui/v2.5/src/components/Galleries/GalleryCard.tsx b/ui/v2.5/src/components/Galleries/GalleryCard.tsx index 7bfb14398..6bbb4a952 100644 --- a/ui/v2.5/src/components/Galleries/GalleryCard.tsx +++ b/ui/v2.5/src/components/Galleries/GalleryCard.tsx @@ -12,6 +12,7 @@ import { TruncatedText, } from "src/components/Shared"; import { TextUtils } from "src/utils"; +import { PerformerPopoverButton } from "../Shared/PerformerPopoverButton"; interface IProps { gallery: GQL.GallerySlimDataFragment; @@ -63,30 +64,7 @@ export const GalleryCard: React.FC = (props) => { function maybeRenderPerformerPopoverButton() { if (props.gallery.performers.length <= 0) return; - const popoverContent = props.gallery.performers.map((performer) => ( -
    - - {performer.name - - -
    - )); - - return ( - - - - ); + return ; } function maybeRenderSceneStudioOverlay() { diff --git a/ui/v2.5/src/components/Galleries/GalleryDetails/GalleryDetailPanel.tsx b/ui/v2.5/src/components/Galleries/GalleryDetails/GalleryDetailPanel.tsx index 3b9bf1b47..878137d0f 100644 --- a/ui/v2.5/src/components/Galleries/GalleryDetails/GalleryDetailPanel.tsx +++ b/ui/v2.5/src/components/Galleries/GalleryDetails/GalleryDetailPanel.tsx @@ -6,6 +6,7 @@ import { TextUtils } from "src/utils"; import { TagLink, TruncatedText } from "src/components/Shared"; import { PerformerCard } from "src/components/Performers/PerformerCard"; import { RatingStars } from "src/components/Scenes/SceneDetails/RatingStars"; +import { sortPerformers } from "src/core/performers"; interface IGalleryDetailProps { gallery: Partial; @@ -38,7 +39,8 @@ export const GalleryDetailPanel: React.FC = (props) => { function renderPerformers() { if (!props.gallery.performers || props.gallery.performers.length === 0) return; - const cards = props.gallery.performers.map((performer) => ( + const performers = sortPerformers(props.gallery.performers); + const cards = performers.map((performer) => ( = ( function maybeRenderPerformerPopoverButton() { if (props.image.performers.length <= 0) return; - const popoverContent = props.image.performers.map((performer) => ( -
    - - {performer.name - - -
    - )); - - return ( - - - - ); + return ; } function maybeRenderOCounter() { diff --git a/ui/v2.5/src/components/Images/ImageDetails/ImageDetailPanel.tsx b/ui/v2.5/src/components/Images/ImageDetails/ImageDetailPanel.tsx index ff741f39f..237312411 100644 --- a/ui/v2.5/src/components/Images/ImageDetails/ImageDetailPanel.tsx +++ b/ui/v2.5/src/components/Images/ImageDetails/ImageDetailPanel.tsx @@ -5,6 +5,7 @@ import { TextUtils } from "src/utils"; import { TagLink, TruncatedText } from "src/components/Shared"; import { PerformerCard } from "src/components/Performers/PerformerCard"; import { RatingStars } from "src/components/Scenes/SceneDetails/RatingStars"; +import { sortPerformers } from "src/core/performers"; interface IImageDetailProps { image: GQL.ImageDataFragment; @@ -26,7 +27,8 @@ export const ImageDetailPanel: React.FC = (props) => { function renderPerformers() { if (props.image.performers.length === 0) return; - const cards = props.image.performers.map((performer) => ( + const performers = sortPerformers(props.image.performers); + const cards = performers.map((performer) => ( )); diff --git a/ui/v2.5/src/components/Scenes/SceneCard.tsx b/ui/v2.5/src/components/Scenes/SceneCard.tsx index 99844a722..49bee3148 100644 --- a/ui/v2.5/src/components/Scenes/SceneCard.tsx +++ b/ui/v2.5/src/components/Scenes/SceneCard.tsx @@ -12,6 +12,7 @@ import { TruncatedText, } from "src/components/Shared"; import { TextUtils } from "src/utils"; +import { PerformerPopoverButton } from "../Shared/PerformerPopoverButton"; interface IScenePreviewProps { isPortrait: boolean; @@ -161,30 +162,7 @@ export const SceneCard: React.FC = ( function maybeRenderPerformerPopoverButton() { if (props.scene.performers.length <= 0) return; - const popoverContent = props.scene.performers.map((performer) => ( -
    - - {performer.name - - -
    - )); - - return ( - - - - ); + return ; } function maybeRenderMoviePopoverButton() { diff --git a/ui/v2.5/src/components/Scenes/SceneDetails/SceneDetailPanel.tsx b/ui/v2.5/src/components/Scenes/SceneDetails/SceneDetailPanel.tsx index a811ebc60..18bc0a985 100644 --- a/ui/v2.5/src/components/Scenes/SceneDetails/SceneDetailPanel.tsx +++ b/ui/v2.5/src/components/Scenes/SceneDetails/SceneDetailPanel.tsx @@ -5,6 +5,7 @@ import * as GQL from "src/core/generated-graphql"; import { TextUtils } from "src/utils"; import { TagLink, TruncatedText } from "src/components/Shared"; import { PerformerCard } from "src/components/Performers/PerformerCard"; +import { sortPerformers } from "src/core/performers"; import { RatingStars } from "./RatingStars"; interface ISceneDetailProps { @@ -37,7 +38,8 @@ export const SceneDetailPanel: React.FC = (props) => { function renderPerformers() { if (props.scene.performers.length === 0) return; - const cards = props.scene.performers.map((performer) => ( + const performers = sortPerformers(props.scene.performers); + const cards = performers.map((performer) => ( []; +} + +export const PerformerPopoverButton: React.FC = ({ performers }) => { + const sorted = sortPerformers(performers); + const popoverContent = sorted.map((performer) => ( +
    + + {performer.name + + +
    + )); + + return ( + + + + ); +}; diff --git a/ui/v2.5/src/core/performers.ts b/ui/v2.5/src/core/performers.ts index e7552b447..ecada4495 100644 --- a/ui/v2.5/src/core/performers.ts +++ b/ui/v2.5/src/core/performers.ts @@ -37,3 +37,38 @@ export const performerFilterHook = ( return filter; }; }; + +interface IPerformerFragment { + name?: GQL.Maybe; + gender?: GQL.Maybe; +} + +export function sortPerformers(performers: T[]) { + const ret = performers.slice(); + ret.sort((a, b) => { + if (a.gender === b.gender) { + // sort by name + return (a.name ?? "").localeCompare(b.name ?? ""); + } + + // TODO - may want to customise gender order + const genderOrder = [ + GQL.GenderEnum.Female, + GQL.GenderEnum.TransgenderFemale, + GQL.GenderEnum.Male, + GQL.GenderEnum.TransgenderMale, + GQL.GenderEnum.Intersex, + GQL.GenderEnum.NonBinary, + ]; + + const aIndex = a.gender + ? genderOrder.indexOf(a.gender) + : genderOrder.length; + const bIndex = b.gender + ? genderOrder.indexOf(b.gender) + : genderOrder.length; + return aIndex - bIndex; + }); + + return ret; +} From cd6b6b74eb48f7f79451b7f61637ee942aab2158 Mon Sep 17 00:00:00 2001 From: bnkai <48220860+bnkai@users.noreply.github.com> Date: Fri, 16 Apr 2021 08:42:56 +0300 Subject: [PATCH 30/66] Add http headers support to scraper (#1273) --- pkg/scraper/config.go | 6 ++ pkg/scraper/url.go | 27 +++++++- .../src/components/Changelog/versions/v070.md | 1 + ui/v2.5/src/docs/en/Scraping.md | 61 ++++++++++++++++--- 4 files changed, 84 insertions(+), 11 deletions(-) diff --git a/pkg/scraper/config.go b/pkg/scraper/config.go index 7d5d49ebf..4eeb97af3 100644 --- a/pkg/scraper/config.go +++ b/pkg/scraper/config.go @@ -175,11 +175,17 @@ type clickOptions struct { Sleep int `yaml:"sleep"` } +type header struct { + Key string `yaml:"Key"` + Value string `yaml:"Value"` +} + type scraperDriverOptions struct { UseCDP bool `yaml:"useCDP"` Sleep int `yaml:"sleep"` Clicks []*clickOptions `yaml:"clicks"` Cookies []*cookieOptions `yaml:"cookies"` + Headers []*header `yaml:"headers"` } func loadScraperFromYAML(id string, reader io.Reader) (*config, error) { diff --git a/pkg/scraper/url.go b/pkg/scraper/url.go index 4404dc067..baa35b07e 100644 --- a/pkg/scraper/url.go +++ b/pkg/scraper/url.go @@ -74,12 +74,21 @@ func loadURL(url string, scraperConfig config, globalConfig GlobalConfig) (io.Re req.Header.Set("User-Agent", userAgent) } + if driverOptions != nil { // setting the Headers after the UA allows us to override it from inside the scraper + for _, h := range driverOptions.Headers { + if h.Key != "" { + req.Header.Set(h.Key, h.Value) + logger.Debugf("[scraper] adding header <%s:%s>", h.Key, h.Value) + } + } + } + resp, err := client.Do(req) if err != nil { return nil, err } if resp.StatusCode >= 400 { - return nil, fmt.Errorf("http error %d", resp.StatusCode) + return nil, fmt.Errorf("http error %d:%s", resp.StatusCode, http.StatusText(resp.StatusCode)) } defer resp.Body.Close() @@ -156,10 +165,13 @@ func urlFromCDP(url string, driverOptions scraperDriverOptions, globalConfig Glo defer cancel() var res string + headers := cdpHeaders(driverOptions) + err := chromedp.Run(ctx, network.Enable(), setCDPCookies(driverOptions), printCDPCookies(driverOptions, "Cookies found"), + network.SetExtraHTTPHeaders(network.Headers(headers)), chromedp.Navigate(url), chromedp.Sleep(sleepDuration), setCDPClicks(driverOptions), @@ -241,3 +253,16 @@ func cdpNetwork(enable bool) chromedp.Action { return nil }) } + +func cdpHeaders(driverOptions scraperDriverOptions) map[string]interface{} { + headers := map[string]interface{}{} + if driverOptions.Headers != nil { + for _, h := range driverOptions.Headers { + if h.Key != "" { + headers[h.Key] = h.Value + logger.Debugf("[scraper] adding header <%s:%s>", h.Key, h.Value) + } + } + } + return headers +} diff --git a/ui/v2.5/src/components/Changelog/versions/v070.md b/ui/v2.5/src/components/Changelog/versions/v070.md index 6a76e8fb4..f0e5ebffa 100644 --- a/ui/v2.5/src/components/Changelog/versions/v070.md +++ b/ui/v2.5/src/components/Changelog/versions/v070.md @@ -4,6 +4,7 @@ * Added scene queue. ### 🎨 Improvements +* Support http request headers in scrapers. * Sort performers by gender in scene/image/gallery cards and details. * Add popover buttons for scenes/images/galleries on performer/studio/tag cards. * Add slideshow to image wall view. diff --git a/ui/v2.5/src/docs/en/Scraping.md b/ui/v2.5/src/docs/en/Scraping.md index 6f47d0070..43a4407e3 100644 --- a/ui/v2.5/src/docs/en/Scraping.md +++ b/ui/v2.5/src/docs/en/Scraping.md @@ -544,6 +544,24 @@ When developing a scraper you can have a look at the cookies set by a site by ad and having a look at the log / console in debug mode. +### Headers + +Sending request headers is possible when using a scraper. +Headers can be set in the `driver` section and are supported for plain, CDP enabled and JSON scrapers. +They consist of a Key and a Value. If the the Key is empty or not defined then the header is ignored. + +```yaml +driver: + headers: + - Key: User-Agent + Value: My Stash Scraper + - Key: Authorization + Value: Bearer ds3sdfcFdfY17p4qBkTVF03zscUU2glSjWF17bZyoe8 +``` + +* headers are set after stash's `User-Agent` configuration option is applied. +This means setting a `User-Agent` header from the scraper overrides the one in the configuration settings. + ### XPath scraper example A performer and scene xpath scraper is shown as an example below: @@ -614,31 +632,42 @@ A performer and scene scraper for ThePornDB is shown below: name: ThePornDB performerByName: action: scrapeJson - queryURL: https://metadataapi.net/api/performers?q={} + queryURL: https://api.metadataapi.net/performers?q={} scraper: performerSearch performerByURL: - action: scrapeJson url: - - https://metadataapi.net/api/performers/ + - https://api.metadataapi.net/performers/ scraper: performerScraper sceneByURL: - action: scrapeJson url: - - https://metadataapi.net/api/scenes/ + - https://api.metadataapi.net/scenes/ scraper: sceneScraper sceneByFragment: action: scrapeJson - queryURL: https://metadataapi.net/api/scenes?parse={filename}&limit=1 + queryURL: https://api.metadataapi.net/scenes?parse={filename}&hash={oshash}&limit=1 scraper: sceneQueryScraper + queryURLReplace: + filename: + - regex: "[^a-zA-Z\\d\\-._~]" # clean filename so that it can contruct a valid url + with: "." # "%20" + - regex: HEVC + with: + - regex: x265 + with: + - regex: \.+ + with: "." jsonScrapers: performerSearch: performer: Name: data.#.name URL: selector: data.#.id - replace: - - regex: ^ - with: https://metadataapi.net/api/performers/ + postProcess: + - replace: + - regex: ^ + with: https://api.metadataapi.net/performers/ performerScraper: common: @@ -648,7 +677,12 @@ jsonScrapers: Gender: $extras.gender Birthdate: $extras.birthday Ethnicity: $extras.ethnicity - Height: $extras.height + Height: + selector: $extras.height + postProcess: + - replace: + - regex: cm + with: Measurements: $extras.measurements Tattoos: $extras.tattoos Piercings: $extras.piercings @@ -670,7 +704,7 @@ jsonScrapers: Name: data.site.name Tags: Name: data.tags.#.tag - + sceneQueryScraper: common: $data: data.0 @@ -686,7 +720,14 @@ jsonScrapers: Studio: Name: $data.site.name Tags: - Name: $data.tags.#.tag + Name: $data.tags.#.tag +driver: + headers: + - Key: User-Agent + Value: Stash JSON Scraper + - Key: Authorization + Value: Bearer lPdwFdfY17p4qBkTVF03zscUU2glSjdf17bZyoe # use an actual API Key here +# Last Updated April 7, 2021 ``` ## Object fields From d673c4ce033a139f085763555ae7643368d011ad Mon Sep 17 00:00:00 2001 From: julien0221 <68500525+julien0221@users.noreply.github.com> Date: Fri, 16 Apr 2021 07:06:35 +0100 Subject: [PATCH 31/66] added details, deathdate, hair color, weight to performers and added details to studios (#1274) * added details to performers and studios * added deathdate, hair_color and weight to performers * Simplify performer/studio create mutations * Add changelog and recategorised Co-authored-by: WithoutPants <53250216+WithoutPants@users.noreply.github.com> --- graphql/documents/data/performer.graphql | 4 + graphql/documents/data/scrapers.graphql | 8 ++ graphql/documents/data/studio-slim.graphql | 1 + graphql/documents/data/studio.graphql | 1 + graphql/documents/mutations/performer.graphql | 44 +---------- graphql/documents/mutations/studio.graphql | 8 +- .../queries/scrapers/freeones.graphql | 4 + graphql/schema/types/filters.graphql | 6 ++ graphql/schema/types/performer.graphql | 16 ++++ .../schema/types/scraped-performer.graphql | 8 ++ graphql/schema/types/scraper.graphql | 4 + graphql/schema/types/studio.graphql | 3 + graphql/stash-box/query.graphql | 5 ++ pkg/api/resolver_model_performer.go | 29 ++++++++ pkg/api/resolver_model_studio.go | 7 ++ pkg/api/resolver_mutation_performer.go | 74 +++++++++++++++++-- pkg/api/resolver_mutation_studio.go | 5 ++ pkg/database/database.go | 2 +- .../21_performers_studios_details.up.sql | 5 ++ pkg/manager/jsonschema/performer.go | 4 + pkg/manager/jsonschema/studio.go | 1 + pkg/models/model_performer.go | 8 ++ pkg/models/model_scraped_item.go | 12 +++ pkg/models/model_studio.go | 2 + pkg/performer/export.go | 12 +++ pkg/performer/export_test.go | 20 ++++- pkg/performer/import.go | 12 +++ pkg/performer/validate.go | 37 ++++++++++ pkg/performer/validate_test.go | 70 ++++++++++++++++++ pkg/scraper/freeones.go | 18 ++++- pkg/scraper/json_test.go | 8 +- pkg/scraper/xpath_test.go | 25 +++++++ pkg/sqlite/performer.go | 63 ++++++++-------- pkg/sqlite/performer_test.go | 10 ++- pkg/sqlite/scene_test.go | 4 +- pkg/sqlite/setup_test.go | 15 ++++ pkg/studio/export.go | 4 + pkg/studio/export_test.go | 34 ++++++--- pkg/studio/import.go | 1 + pkg/studio/import_test.go | 12 +++ .../src/components/Changelog/versions/v070.md | 10 ++- .../GalleryDetails/GalleryScrapeDialog.tsx | 8 +- .../components/Performers/PerformerCard.tsx | 5 +- .../Performers/PerformerDetails/Performer.tsx | 4 +- .../PerformerDetailsPanel.tsx | 18 +++++ .../PerformerDetails/PerformerEditPanel.tsx | 41 +++++++++- .../PerformerScrapeDialog.tsx | 49 ++++++++++++ .../Scenes/SceneDetails/SceneScrapeDialog.tsx | 8 +- ui/v2.5/src/components/Shared/Select.tsx | 8 +- .../Studios/StudioDetails/Studio.tsx | 9 +++ .../src/components/Tagger/PerformerModal.tsx | 18 +++++ .../components/Tagger/StashSearchResult.tsx | 4 + ui/v2.5/src/components/Tagger/queries.ts | 4 +- ui/v2.5/src/components/Tagger/utils.ts | 7 ++ ui/v2.5/src/core/StashService.ts | 2 +- ui/v2.5/src/docs/en/JSONSpec.md | 27 ++++++- ui/v2.5/src/docs/en/Scraping.md | 4 + .../models/list-filter/criteria/criterion.ts | 9 +++ .../models/list-filter/criteria/is-missing.ts | 5 +- .../src/models/list-filter/criteria/utils.ts | 3 + ui/v2.5/src/models/list-filter/filter.ts | 29 +++++++- ui/v2.5/src/utils/text.ts | 2 +- 62 files changed, 748 insertions(+), 132 deletions(-) create mode 100644 pkg/database/migrations/21_performers_studios_details.up.sql create mode 100644 pkg/performer/validate.go create mode 100644 pkg/performer/validate_test.go diff --git a/graphql/documents/data/performer.graphql b/graphql/documents/data/performer.graphql index 2048da256..09b9e1e68 100644 --- a/graphql/documents/data/performer.graphql +++ b/graphql/documents/data/performer.graphql @@ -31,4 +31,8 @@ fragment PerformerData on Performer { stash_id endpoint } + details + death_date + hair_color + weight } diff --git a/graphql/documents/data/scrapers.graphql b/graphql/documents/data/scrapers.graphql index b4397cdf3..cda034f73 100644 --- a/graphql/documents/data/scrapers.graphql +++ b/graphql/documents/data/scrapers.graphql @@ -19,6 +19,10 @@ fragment ScrapedPerformerData on ScrapedPerformer { ...ScrapedSceneTagData } image + details + death_date + hair_color + weight } fragment ScrapedScenePerformerData on ScrapedScenePerformer { @@ -44,6 +48,10 @@ fragment ScrapedScenePerformerData on ScrapedScenePerformer { } remote_site_id images + details + death_date + hair_color + weight } fragment ScrapedMovieStudioData on ScrapedMovieStudio { diff --git a/graphql/documents/data/studio-slim.graphql b/graphql/documents/data/studio-slim.graphql index a247b4e34..563375e78 100644 --- a/graphql/documents/data/studio-slim.graphql +++ b/graphql/documents/data/studio-slim.graphql @@ -9,4 +9,5 @@ fragment SlimStudioData on Studio { parent_studio { id } + details } diff --git a/graphql/documents/data/studio.graphql b/graphql/documents/data/studio.graphql index 2c6c8d0a3..ae8d1d0d8 100644 --- a/graphql/documents/data/studio.graphql +++ b/graphql/documents/data/studio.graphql @@ -31,4 +31,5 @@ fragment StudioData on Studio { stash_id endpoint } + details } diff --git a/graphql/documents/mutations/performer.graphql b/graphql/documents/mutations/performer.graphql index e4ccf442e..0e2ad9fa3 100644 --- a/graphql/documents/mutations/performer.graphql +++ b/graphql/documents/mutations/performer.graphql @@ -1,47 +1,7 @@ mutation PerformerCreate( - $name: String!, - $url: String, - $gender: GenderEnum, - $birthdate: String, - $ethnicity: String, - $country: String, - $eye_color: String, - $height: String, - $measurements: String, - $fake_tits: String, - $career_length: String, - $tattoos: String, - $piercings: String, - $aliases: String, - $twitter: String, - $instagram: String, - $favorite: Boolean, - $tag_ids: [ID!], - $stash_ids: [StashIDInput!], - $image: String) { + $input: PerformerCreateInput!) { - performerCreate(input: { - name: $name, - url: $url, - gender: $gender, - birthdate: $birthdate, - ethnicity: $ethnicity, - country: $country, - eye_color: $eye_color, - height: $height, - measurements: $measurements, - fake_tits: $fake_tits, - career_length: $career_length, - tattoos: $tattoos, - piercings: $piercings, - aliases: $aliases, - twitter: $twitter, - instagram: $instagram, - favorite: $favorite, - tag_ids: $tag_ids, - stash_ids: $stash_ids, - image: $image - }) { + performerCreate(input: $input) { ...PerformerData } } diff --git a/graphql/documents/mutations/studio.graphql b/graphql/documents/mutations/studio.graphql index d2d11d222..e4cffae84 100644 --- a/graphql/documents/mutations/studio.graphql +++ b/graphql/documents/mutations/studio.graphql @@ -1,11 +1,7 @@ mutation StudioCreate( - $name: String!, - $url: String, - $image: String, - $stash_ids: [StashIDInput!], - $parent_id: ID) { + $input: StudioCreateInput!) { - studioCreate(input: { name: $name, url: $url, image: $image, stash_ids: $stash_ids, parent_id: $parent_id }) { + studioCreate(input: $input) { ...StudioData } } diff --git a/graphql/documents/queries/scrapers/freeones.graphql b/graphql/documents/queries/scrapers/freeones.graphql index 27f6eb926..9f366786d 100644 --- a/graphql/documents/queries/scrapers/freeones.graphql +++ b/graphql/documents/queries/scrapers/freeones.graphql @@ -15,6 +15,10 @@ query ScrapeFreeones($performer_name: String!) { tattoos piercings aliases + details + death_date + hair_color + weight } } diff --git a/graphql/schema/types/filters.graphql b/graphql/schema/types/filters.graphql index db4c6c426..6152174eb 100644 --- a/graphql/schema/types/filters.graphql +++ b/graphql/schema/types/filters.graphql @@ -73,6 +73,12 @@ input PerformerFilterType { stash_id: String """Filter by url""" url: StringCriterionInput + """Filter by hair color""" + hair_color: StringCriterionInput + """Filter by weight""" + weight: StringCriterionInput + """Filter by death year""" + death_year: IntCriterionInput } input SceneMarkerFilterType { diff --git a/graphql/schema/types/performer.graphql b/graphql/schema/types/performer.graphql index 1c6d642e0..e85bb5d9c 100644 --- a/graphql/schema/types/performer.graphql +++ b/graphql/schema/types/performer.graphql @@ -35,6 +35,10 @@ type Performer { gallery_count: Int # Resolver scenes: [Scene!]! stash_ids: [StashID!]! + details: String + death_date: String + hair_color: String + weight: Int } input PerformerCreateInput { @@ -59,6 +63,10 @@ input PerformerCreateInput { """This should be a URL or a base64 encoded data URL""" image: String stash_ids: [StashIDInput!] + details: String + death_date: String + hair_color: String + weight: Int } input PerformerUpdateInput { @@ -84,6 +92,10 @@ input PerformerUpdateInput { """This should be a URL or a base64 encoded data URL""" image: String stash_ids: [StashIDInput!] + details: String + death_date: String + hair_color: String + weight: Int } input BulkPerformerUpdateInput { @@ -106,6 +118,10 @@ input BulkPerformerUpdateInput { instagram: String favorite: Boolean tag_ids: BulkUpdateIds + details: String + death_date: String + hair_color: String + weight: Int } input PerformerDestroyInput { diff --git a/graphql/schema/types/scraped-performer.graphql b/graphql/schema/types/scraped-performer.graphql index 9f4583600..db6c216a3 100644 --- a/graphql/schema/types/scraped-performer.graphql +++ b/graphql/schema/types/scraped-performer.graphql @@ -21,6 +21,10 @@ type ScrapedPerformer { """This should be a base64 encoded data URL""" image: String + details: String + death_date: String + hair_color: String + weight: String } input ScrapedPerformerInput { @@ -43,4 +47,8 @@ input ScrapedPerformerInput { # not including tags for the input # not including image for the input + details: String + death_date: String + hair_color: String + weight: String } \ No newline at end of file diff --git a/graphql/schema/types/scraper.graphql b/graphql/schema/types/scraper.graphql index 929c13f2b..0a0cec8c5 100644 --- a/graphql/schema/types/scraper.graphql +++ b/graphql/schema/types/scraper.graphql @@ -49,6 +49,10 @@ type ScrapedScenePerformer { remote_site_id: String images: [String!] + details: String + death_date: String + hair_color: String + weight: String } type ScrapedSceneMovie { diff --git a/graphql/schema/types/studio.graphql b/graphql/schema/types/studio.graphql index 1adb0aa63..25145b823 100644 --- a/graphql/schema/types/studio.graphql +++ b/graphql/schema/types/studio.graphql @@ -11,6 +11,7 @@ type Studio { image_count: Int # Resolver gallery_count: Int # Resolver stash_ids: [StashID!]! + details: String } input StudioCreateInput { @@ -20,6 +21,7 @@ input StudioCreateInput { """This should be a URL or a base64 encoded data URL""" image: String stash_ids: [StashIDInput!] + details: String } input StudioUpdateInput { @@ -30,6 +32,7 @@ input StudioUpdateInput { """This should be a URL or a base64 encoded data URL""" image: String stash_ids: [StashIDInput!] + details: String } input StudioDestroyInput { diff --git a/graphql/stash-box/query.graphql b/graphql/stash-box/query.graphql index 4424ce8db..0e74c92c3 100644 --- a/graphql/stash-box/query.graphql +++ b/graphql/stash-box/query.graphql @@ -75,6 +75,11 @@ fragment PerformerFragment on Performer { piercings { ...BodyModificationFragment } + details + death_date { + ...FuzzyDateFragment + } + weight } fragment PerformerAppearanceFragment on PerformerAppearance { diff --git a/pkg/api/resolver_model_performer.go b/pkg/api/resolver_model_performer.go index ab3d2363f..c74ffe95d 100644 --- a/pkg/api/resolver_model_performer.go +++ b/pkg/api/resolver_model_performer.go @@ -208,3 +208,32 @@ func (r *performerResolver) StashIds(ctx context.Context, obj *models.Performer) return ret, nil } + +func (r *performerResolver) Details(ctx context.Context, obj *models.Performer) (*string, error) { + if obj.Details.Valid { + return &obj.Details.String, nil + } + return nil, nil +} + +func (r *performerResolver) DeathDate(ctx context.Context, obj *models.Performer) (*string, error) { + if obj.DeathDate.Valid { + return &obj.DeathDate.String, nil + } + return nil, nil +} + +func (r *performerResolver) HairColor(ctx context.Context, obj *models.Performer) (*string, error) { + if obj.HairColor.Valid { + return &obj.HairColor.String, nil + } + return nil, nil +} + +func (r *performerResolver) Weight(ctx context.Context, obj *models.Performer) (*int, error) { + if obj.Weight.Valid { + weight := int(obj.Weight.Int64) + return &weight, nil + } + return nil, nil +} diff --git a/pkg/api/resolver_model_studio.go b/pkg/api/resolver_model_studio.go index 553c8cc5c..da5a1b8b7 100644 --- a/pkg/api/resolver_model_studio.go +++ b/pkg/api/resolver_model_studio.go @@ -116,3 +116,10 @@ func (r *studioResolver) StashIds(ctx context.Context, obj *models.Studio) (ret return ret, nil } + +func (r *studioResolver) Details(ctx context.Context, obj *models.Studio) (*string, error) { + if obj.Details.Valid { + return &obj.Details.String, nil + } + return nil, nil +} diff --git a/pkg/api/resolver_mutation_performer.go b/pkg/api/resolver_mutation_performer.go index 69eb5832c..9b7feee5c 100644 --- a/pkg/api/resolver_mutation_performer.go +++ b/pkg/api/resolver_mutation_performer.go @@ -3,10 +3,12 @@ package api import ( "context" "database/sql" + "fmt" "strconv" "time" "github.com/stashapp/stash/pkg/models" + "github.com/stashapp/stash/pkg/performer" "github.com/stashapp/stash/pkg/utils" ) @@ -83,6 +85,25 @@ func (r *mutationResolver) PerformerCreate(ctx context.Context, input models.Per } else { newPerformer.Favorite = sql.NullBool{Bool: false, Valid: true} } + if input.Details != nil { + newPerformer.Details = sql.NullString{String: *input.Details, Valid: true} + } + if input.DeathDate != nil { + newPerformer.DeathDate = models.SQLiteDate{String: *input.DeathDate, Valid: true} + } + if input.HairColor != nil { + newPerformer.HairColor = sql.NullString{String: *input.HairColor, Valid: true} + } + if input.Weight != nil { + weight := int64(*input.Weight) + newPerformer.Weight = sql.NullInt64{Int64: weight, Valid: true} + } + + if err := performer.ValidateDeathDate(nil, input.Birthdate, input.DeathDate); err != nil { + if err != nil { + return nil, err + } + } // Start the transaction and save the performer var performer *models.Performer @@ -177,33 +198,52 @@ func (r *mutationResolver) PerformerUpdate(ctx context.Context, input models.Per updatedPerformer.Twitter = translator.nullString(input.Twitter, "twitter") updatedPerformer.Instagram = translator.nullString(input.Instagram, "instagram") updatedPerformer.Favorite = translator.nullBool(input.Favorite, "favorite") + updatedPerformer.Details = translator.nullString(input.Details, "details") + updatedPerformer.DeathDate = translator.sqliteDate(input.DeathDate, "death_date") + updatedPerformer.HairColor = translator.nullString(input.HairColor, "hair_color") + updatedPerformer.Weight = translator.nullInt64(input.Weight, "weight") - // Start the transaction and save the performer - var performer *models.Performer + // Start the transaction and save the p + var p *models.Performer if err := r.withTxn(ctx, func(repo models.Repository) error { qb := repo.Performer() - var err error - performer, err = qb.Update(updatedPerformer) + // need to get existing performer + existing, err := qb.Find(updatedPerformer.ID) + if err != nil { + return err + } + + if existing == nil { + return fmt.Errorf("performer with id %d not found", updatedPerformer.ID) + } + + if err := performer.ValidateDeathDate(existing, input.Birthdate, input.DeathDate); err != nil { + if err != nil { + return err + } + } + + p, err = qb.Update(updatedPerformer) if err != nil { return err } // Save the tags if translator.hasField("tag_ids") { - if err := r.updatePerformerTags(qb, performer.ID, input.TagIds); err != nil { + if err := r.updatePerformerTags(qb, p.ID, input.TagIds); err != nil { return err } } // update image table if len(imageData) > 0 { - if err := qb.UpdateImage(performer.ID, imageData); err != nil { + if err := qb.UpdateImage(p.ID, imageData); err != nil { return err } } else if imageIncluded { // must be unsetting - if err := qb.DestroyImage(performer.ID); err != nil { + if err := qb.DestroyImage(p.ID); err != nil { return err } } @@ -221,7 +261,7 @@ func (r *mutationResolver) PerformerUpdate(ctx context.Context, input models.Per return nil, err } - return performer, nil + return p, nil } func (r *mutationResolver) updatePerformerTags(qb models.PerformerReaderWriter, performerID int, tagsIDs []string) error { @@ -264,6 +304,10 @@ func (r *mutationResolver) BulkPerformerUpdate(ctx context.Context, input models updatedPerformer.Twitter = translator.nullString(input.Twitter, "twitter") updatedPerformer.Instagram = translator.nullString(input.Instagram, "instagram") updatedPerformer.Favorite = translator.nullBool(input.Favorite, "favorite") + updatedPerformer.Details = translator.nullString(input.Details, "details") + updatedPerformer.DeathDate = translator.sqliteDate(input.DeathDate, "death_date") + updatedPerformer.HairColor = translator.nullString(input.HairColor, "hair_color") + updatedPerformer.Weight = translator.nullInt64(input.Weight, "weight") if translator.hasField("gender") { if input.Gender != nil { @@ -282,6 +326,20 @@ func (r *mutationResolver) BulkPerformerUpdate(ctx context.Context, input models for _, performerID := range performerIDs { updatedPerformer.ID = performerID + // need to get existing performer + existing, err := qb.Find(performerID) + if err != nil { + return err + } + + if existing == nil { + return fmt.Errorf("performer with id %d not found", performerID) + } + + if err := performer.ValidateDeathDate(existing, input.Birthdate, input.DeathDate); err != nil { + return err + } + performer, err := qb.Update(updatedPerformer) if err != nil { return err diff --git a/pkg/api/resolver_mutation_studio.go b/pkg/api/resolver_mutation_studio.go index 82be5d1e9..8ec804765 100644 --- a/pkg/api/resolver_mutation_studio.go +++ b/pkg/api/resolver_mutation_studio.go @@ -42,6 +42,10 @@ func (r *mutationResolver) StudioCreate(ctx context.Context, input models.Studio newStudio.ParentID = sql.NullInt64{Int64: parentID, Valid: true} } + if input.Details != nil { + newStudio.Details = sql.NullString{String: *input.Details, Valid: true} + } + // Start the transaction and save the studio var studio *models.Studio if err := r.withTxn(ctx, func(repo models.Repository) error { @@ -109,6 +113,7 @@ func (r *mutationResolver) StudioUpdate(ctx context.Context, input models.Studio } updatedStudio.URL = translator.nullString(input.URL, "url") + updatedStudio.Details = translator.nullString(input.Details, "details") updatedStudio.ParentID = translator.nullInt64FromString(input.ParentID, "parent_id") // Start the transaction and save the studio diff --git a/pkg/database/database.go b/pkg/database/database.go index 95bcb9081..d210e1215 100644 --- a/pkg/database/database.go +++ b/pkg/database/database.go @@ -23,7 +23,7 @@ import ( var DB *sqlx.DB var WriteMu *sync.Mutex var dbPath string -var appSchemaVersion uint = 20 +var appSchemaVersion uint = 21 var databaseSchemaVersion uint var ( diff --git a/pkg/database/migrations/21_performers_studios_details.up.sql b/pkg/database/migrations/21_performers_studios_details.up.sql new file mode 100644 index 000000000..d41cf4779 --- /dev/null +++ b/pkg/database/migrations/21_performers_studios_details.up.sql @@ -0,0 +1,5 @@ +ALTER TABLE `performers` ADD COLUMN `details` text; +ALTER TABLE `performers` ADD COLUMN `death_date` date; +ALTER TABLE `performers` ADD COLUMN `hair_color` varchar(255); +ALTER TABLE `performers` ADD COLUMN `weight` integer; +ALTER TABLE `studios` ADD COLUMN `details` text; \ No newline at end of file diff --git a/pkg/manager/jsonschema/performer.go b/pkg/manager/jsonschema/performer.go index a145f9bce..a12a617db 100644 --- a/pkg/manager/jsonschema/performer.go +++ b/pkg/manager/jsonschema/performer.go @@ -30,6 +30,10 @@ type Performer struct { Image string `json:"image,omitempty"` CreatedAt models.JSONTime `json:"created_at,omitempty"` UpdatedAt models.JSONTime `json:"updated_at,omitempty"` + Details string `json:"details,omitempty"` + DeathDate string `json:"death_date,omitempty"` + HairColor string `json:"hair_color,omitempty"` + Weight int `json:"weight,omitempty"` } func LoadPerformerFile(filePath string) (*Performer, error) { diff --git a/pkg/manager/jsonschema/studio.go b/pkg/manager/jsonschema/studio.go index ed1f7dea0..d3e55cb08 100644 --- a/pkg/manager/jsonschema/studio.go +++ b/pkg/manager/jsonschema/studio.go @@ -15,6 +15,7 @@ type Studio struct { Image string `json:"image,omitempty"` CreatedAt models.JSONTime `json:"created_at,omitempty"` UpdatedAt models.JSONTime `json:"updated_at,omitempty"` + Details string `json:"details,omitempty"` } func LoadStudioFile(filePath string) (*Studio, error) { diff --git a/pkg/models/model_performer.go b/pkg/models/model_performer.go index 4d6134b8a..0a0ce3f09 100644 --- a/pkg/models/model_performer.go +++ b/pkg/models/model_performer.go @@ -29,6 +29,10 @@ type Performer struct { Favorite sql.NullBool `db:"favorite" json:"favorite"` CreatedAt SQLiteTimestamp `db:"created_at" json:"created_at"` UpdatedAt SQLiteTimestamp `db:"updated_at" json:"updated_at"` + Details sql.NullString `db:"details" json:"details"` + DeathDate SQLiteDate `db:"death_date" json:"death_date"` + HairColor sql.NullString `db:"hair_color" json:"hair_color"` + Weight sql.NullInt64 `db:"weight" json:"weight"` } type PerformerPartial struct { @@ -53,6 +57,10 @@ type PerformerPartial struct { Favorite *sql.NullBool `db:"favorite" json:"favorite"` CreatedAt *SQLiteTimestamp `db:"created_at" json:"created_at"` UpdatedAt *SQLiteTimestamp `db:"updated_at" json:"updated_at"` + Details *sql.NullString `db:"details" json:"details"` + DeathDate *SQLiteDate `db:"death_date" json:"death_date"` + HairColor *sql.NullString `db:"hair_color" json:"hair_color"` + Weight *sql.NullInt64 `db:"weight" json:"weight"` } func NewPerformer(name string) *Performer { diff --git a/pkg/models/model_scraped_item.go b/pkg/models/model_scraped_item.go index e9fa33118..0c102bcbe 100644 --- a/pkg/models/model_scraped_item.go +++ b/pkg/models/model_scraped_item.go @@ -42,6 +42,10 @@ type ScrapedPerformer struct { Aliases *string `graphql:"aliases" json:"aliases"` Tags []*ScrapedSceneTag `graphql:"tags" json:"tags"` Image *string `graphql:"image" json:"image"` + Details *string `graphql:"details" json:"details"` + DeathDate *string `graphql:"death_date" json:"death_date"` + HairColor *string `graphql:"hair_color" json:"hair_color"` + Weight *string `graphql:"weight" json:"weight"` } // this type has no Image field @@ -63,6 +67,10 @@ type ScrapedPerformerStash struct { Piercings *string `graphql:"piercings" json:"piercings"` Aliases *string `graphql:"aliases" json:"aliases"` Tags []*ScrapedSceneTag `graphql:"tags" json:"tags"` + Details *string `graphql:"details" json:"details"` + DeathDate *string `graphql:"death_date" json:"death_date"` + HairColor *string `graphql:"hair_color" json:"hair_color"` + Weight *string `graphql:"weight" json:"weight"` } type ScrapedScene struct { @@ -128,6 +136,10 @@ type ScrapedScenePerformer struct { Tags []*ScrapedSceneTag `graphql:"tags" json:"tags"` RemoteSiteID *string `graphql:"remote_site_id" json:"remote_site_id"` Images []string `graphql:"images" json:"images"` + Details *string `graphql:"details" json:"details"` + DeathDate *string `graphql:"death_date" json:"death_date"` + HairColor *string `graphql:"hair_color" json:"hair_color"` + Weight *string `graphql:"weight" json:"weight"` } type ScrapedSceneStudio struct { diff --git a/pkg/models/model_studio.go b/pkg/models/model_studio.go index 4bc687526..6336d9163 100644 --- a/pkg/models/model_studio.go +++ b/pkg/models/model_studio.go @@ -15,6 +15,7 @@ type Studio struct { ParentID sql.NullInt64 `db:"parent_id,omitempty" json:"parent_id"` CreatedAt SQLiteTimestamp `db:"created_at" json:"created_at"` UpdatedAt SQLiteTimestamp `db:"updated_at" json:"updated_at"` + Details sql.NullString `db:"details" json:"details"` } type StudioPartial struct { @@ -25,6 +26,7 @@ type StudioPartial struct { ParentID *sql.NullInt64 `db:"parent_id,omitempty" json:"parent_id"` CreatedAt *SQLiteTimestamp `db:"created_at" json:"created_at"` UpdatedAt *SQLiteTimestamp `db:"updated_at" json:"updated_at"` + Details *sql.NullString `db:"details" json:"details"` } var DefaultStudioImage = "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAGQAAABkCAYAAABw4pVUAAAABmJLR0QA/wD/AP+gvaeTAAAACXBIWXMAAA3XAAAN1wFCKJt4AAAAB3RJTUUH4wgVBQsJl1CMZAAAASJJREFUeNrt3N0JwyAYhlEj3cj9R3Cm5rbkqtAP+qrnGaCYHPwJpLlaa++mmLpbAERAgAgIEAEBIiBABERAgAgIEAEBIiBABERAgAgIEAHZuVflj40x4i94zhk9vqsVvEq6AsQqMP1EjORx20OACAgQRRx7T+zzcFBxcjNDfoB4ntQqTm5Awo7MlqywZxcgYQ+RlqywJ3ozJAQCSBiEJSsQA0gYBpDAgAARECACAkRAgAgIEAERECACAmSjUv6eAOSB8m8YIGGzBUjYbAESBgMkbBkDEjZbgITBAClcxiqQvEoatreYIWEBASIgJ4Gkf11ntXH3nS9uxfGWfJ5J9hAgAgJEQAQEiIAAERAgAgJEQAQEiIAAERAgAgJEQAQEiL7qBuc6RKLHxr0CAAAAAElFTkSuQmCC" diff --git a/pkg/performer/export.go b/pkg/performer/export.go index a038a2560..8433353a7 100644 --- a/pkg/performer/export.go +++ b/pkg/performer/export.go @@ -66,6 +66,18 @@ func ToJSON(reader models.PerformerReader, performer *models.Performer) (*jsonsc if performer.Favorite.Valid { newPerformerJSON.Favorite = performer.Favorite.Bool } + if performer.Details.Valid { + newPerformerJSON.Details = performer.Details.String + } + if performer.DeathDate.Valid { + newPerformerJSON.DeathDate = utils.GetYMDFromDatabaseDate(performer.DeathDate.String) + } + if performer.HairColor.Valid { + newPerformerJSON.HairColor = performer.HairColor.String + } + if performer.Weight.Valid { + newPerformerJSON.Weight = int(performer.Weight.Int64) + } image, err := reader.GetImage(performer.ID) if err != nil { diff --git a/pkg/performer/export_test.go b/pkg/performer/export_test.go index aa880e40c..0f082163e 100644 --- a/pkg/performer/export_test.go +++ b/pkg/performer/export_test.go @@ -36,6 +36,9 @@ const ( piercings = "piercings" tattoos = "tattoos" twitter = "twitter" + details = "details" + hairColor = "hairColor" + weight = 60 ) var imageBytes = []byte("imageBytes") @@ -46,6 +49,10 @@ var birthDate = models.SQLiteDate{ String: "2001-01-01", Valid: true, } +var deathDate = models.SQLiteDate{ + String: "2021-02-02", + Valid: true, +} var createTime time.Time = time.Date(2001, 01, 01, 0, 0, 0, 0, time.Local) var updateTime time.Time = time.Date(2002, 01, 01, 0, 0, 0, 0, time.Local) @@ -79,6 +86,13 @@ func createFullPerformer(id int, name string) *models.Performer { UpdatedAt: models.SQLiteTimestamp{ Timestamp: updateTime, }, + Details: models.NullString(details), + DeathDate: deathDate, + HairColor: models.NullString(hairColor), + Weight: sql.NullInt64{ + Int64: weight, + Valid: true, + }, } } @@ -119,7 +133,11 @@ func createFullJSONPerformer(name string, image string) *jsonschema.Performer { UpdatedAt: models.JSONTime{ Time: updateTime, }, - Image: image, + Image: image, + Details: details, + DeathDate: deathDate.String, + HairColor: hairColor, + Weight: weight, } } diff --git a/pkg/performer/import.go b/pkg/performer/import.go index 2131b1e57..09e0a56a2 100644 --- a/pkg/performer/import.go +++ b/pkg/performer/import.go @@ -224,6 +224,18 @@ func performerJSONToPerformer(performerJSON jsonschema.Performer) models.Perform if performerJSON.Instagram != "" { newPerformer.Instagram = sql.NullString{String: performerJSON.Instagram, Valid: true} } + if performerJSON.Details != "" { + newPerformer.Details = sql.NullString{String: performerJSON.Details, Valid: true} + } + if performerJSON.DeathDate != "" { + newPerformer.DeathDate = models.SQLiteDate{String: performerJSON.DeathDate, Valid: true} + } + if performerJSON.HairColor != "" { + newPerformer.HairColor = sql.NullString{String: performerJSON.HairColor, Valid: true} + } + if performerJSON.Weight != 0 { + newPerformer.Weight = sql.NullInt64{Int64: int64(performerJSON.Weight), Valid: true} + } return newPerformer } diff --git a/pkg/performer/validate.go b/pkg/performer/validate.go new file mode 100644 index 000000000..374262590 --- /dev/null +++ b/pkg/performer/validate.go @@ -0,0 +1,37 @@ +package performer + +import ( + "errors" + + "github.com/stashapp/stash/pkg/models" + "github.com/stashapp/stash/pkg/utils" +) + +func ValidateDeathDate(performer *models.Performer, birthdate *string, deathDate *string) error { + // don't validate existing values + if birthdate == nil && deathDate == nil { + return nil + } + + if performer != nil { + if birthdate == nil && performer.Birthdate.Valid { + birthdate = &performer.Birthdate.String + } + if deathDate == nil && performer.DeathDate.Valid { + deathDate = &performer.DeathDate.String + } + } + + if birthdate == nil || deathDate == nil || *birthdate == "" || *deathDate == "" { + return nil + } + + f, _ := utils.ParseDateStringAsTime(*birthdate) + t, _ := utils.ParseDateStringAsTime(*deathDate) + + if f.After(t) { + return errors.New("the date of death should be higher than the date of birth") + } + + return nil +} diff --git a/pkg/performer/validate_test.go b/pkg/performer/validate_test.go new file mode 100644 index 000000000..33616e184 --- /dev/null +++ b/pkg/performer/validate_test.go @@ -0,0 +1,70 @@ +package performer + +import ( + "testing" + + "github.com/stashapp/stash/pkg/models" + "github.com/stretchr/testify/assert" +) + +func TestValidateDeathDate(t *testing.T) { + assert := assert.New(t) + + date1 := "2001-01-01" + date2 := "2002-01-01" + date3 := "2003-01-01" + date4 := "2004-01-01" + empty := "" + + emptyPerformer := models.Performer{} + invalidPerformer := models.Performer{ + Birthdate: models.SQLiteDate{ + String: date3, + Valid: true, + }, + DeathDate: models.SQLiteDate{ + String: date2, + Valid: true, + }, + } + validPerformer := models.Performer{ + Birthdate: models.SQLiteDate{ + String: date2, + Valid: true, + }, + DeathDate: models.SQLiteDate{ + String: date3, + Valid: true, + }, + } + + // nil values should always return nil + assert.Nil(ValidateDeathDate(nil, nil, &date1)) + assert.Nil(ValidateDeathDate(nil, &date2, nil)) + assert.Nil(ValidateDeathDate(&emptyPerformer, nil, &date1)) + assert.Nil(ValidateDeathDate(&emptyPerformer, &date2, nil)) + + // empty strings should always return nil + assert.Nil(ValidateDeathDate(nil, &empty, &date1)) + assert.Nil(ValidateDeathDate(nil, &date2, &empty)) + assert.Nil(ValidateDeathDate(&emptyPerformer, &empty, &date1)) + assert.Nil(ValidateDeathDate(&emptyPerformer, &date2, &empty)) + assert.Nil(ValidateDeathDate(&validPerformer, &empty, &date1)) + assert.Nil(ValidateDeathDate(&validPerformer, &date2, &empty)) + + // nil inputs should return nil even if performer is invalid + assert.Nil(ValidateDeathDate(&invalidPerformer, nil, nil)) + + // invalid input values should return error + assert.NotNil(ValidateDeathDate(nil, &date2, &date1)) + assert.NotNil(ValidateDeathDate(&validPerformer, &date2, &date1)) + + // valid input values should return nil + assert.Nil(ValidateDeathDate(nil, &date1, &date2)) + + // use performer values if performer set and values available + assert.NotNil(ValidateDeathDate(&validPerformer, nil, &date1)) + assert.NotNil(ValidateDeathDate(&validPerformer, &date4, nil)) + assert.Nil(ValidateDeathDate(&validPerformer, nil, &date4)) + assert.Nil(ValidateDeathDate(&validPerformer, &date1, nil)) +} diff --git a/pkg/scraper/freeones.go b/pkg/scraper/freeones.go index 8b72e9df3..c229e874a 100644 --- a/pkg/scraper/freeones.go +++ b/pkg/scraper/freeones.go @@ -103,7 +103,23 @@ xPathScrapers: selector: //div[contains(@class,'image-container')]//a/img/@src Gender: fixed: "Female" -# Last updated March 24, 2021 + Details: //div[@data-test="biography"] + DeathDate: + selector: //div[contains(text(),'Passed away on')] + postProcess: + - replace: + - regex: Passed away on (.+) at the age of \d+ + with: $1 + - parseDate: January 2, 2006 + HairColor: //span[text()='Hair Color']/following-sibling::span/a + Weight: + selector: //span[text()='Weight']/following-sibling::span/a + postProcess: + - replace: + - regex: \D+[\s\S]+ + with: "" + +# Last updated April 13, 2021 ` func getFreeonesScraper() config { diff --git a/pkg/scraper/json_test.go b/pkg/scraper/json_test.go index 6145cc88b..271d83235 100644 --- a/pkg/scraper/json_test.go +++ b/pkg/scraper/json_test.go @@ -23,6 +23,9 @@ jsonScrapers: Piercings: $extras.piercings Aliases: data.aliases Image: data.image + Details: data.bio + HairColor: $extras.hair_colour + Weight: $extras.weight ` const json = ` @@ -41,7 +44,7 @@ jsonScrapers: "ethnicity": "Caucasian", "nationality": "United States", "hair_colour": "Blonde", - "weight": "126 lbs (or 57 kg)", + "weight": 57, "height": "5'6\" (or 167 cm)", "measurements": "34-26-36", "cupsize": "34C (75C)", @@ -90,4 +93,7 @@ jsonScrapers: verifyField(t, "5'6\" (or 167 cm)", scrapedPerformer.Height, "Height") verifyField(t, "None", scrapedPerformer.Tattoos, "Tattoos") verifyField(t, "Navel", scrapedPerformer.Piercings, "Piercings") + verifyField(t, "Some girls are so damn hot that they can get you bent out of shape, and you will not even be mad at them for doing so. Well, tawny blonde Mia Malkova can bend her body into any shape she pleases, and that’s sure to satisfy all of the horny cocks and wet pussies out there. This girl has acrobatic and contortionist abilities that could even twist a pretzel into a new knot, which can be very helpful in the ... arrow_drop_down Some girls are so damn hot that they can get you bent out of shape, and you will not even be mad at them for doing so. Well, tawny blonde Mia Malkova can bend her body into any shape she pleases, and that’s sure to satisfy all of the horny cocks and wet pussies out there. This girl has acrobatic and contortionist abilities that could even twist a pretzel into a new knot, which can be very helpful in the VR Porn movies – trust us. Ankles behind her neck and feet over her back so she can kiss her toes, turned, twisted and gyrating, she can fuck any which way she wants (and that ass!), will surely make you fall in love with this hot Virtual Reality Porn slut, as she is one of the finest of them all. Talking about perfection, maybe it’s all the acrobatic work that keeps it in such gorgeous shape? Who cares really, because you just want to take a big bite out of it and never let go. But it’s not all about the body. Mia’s also got a great smile, which might not sound kinky, but believe us, it is a smile that will heat up your innards and drop your pants. Is it her golden skin, her innocent pink lips or that heart-shaped face? There is just too much good stuff going on with Mia Malkova, which is maybe why these past few years have heaped awards upon awards on this Southern California native. Mia came to VR Bangers for her first VR Porn video, so you know she’s only going for top-notch scenes with top-game performers, men, and women. Better hit up that yoga studio if you ever dream of being able to bang a flexible and talented chick like lady Malkova. arrow_drop_up", scrapedPerformer.Details, "Details") + verifyField(t, "Blonde", scrapedPerformer.HairColor, "HairColor") + verifyField(t, "57", scrapedPerformer.Weight, "Weight") } diff --git a/pkg/scraper/xpath_test.go b/pkg/scraper/xpath_test.go index efc5968a5..d0883e16f 100644 --- a/pkg/scraper/xpath_test.go +++ b/pkg/scraper/xpath_test.go @@ -100,6 +100,14 @@ const htmlDoc1 = ` 5ft7 + + + Weight: + + + 57 + + Measurements: @@ -141,6 +149,14 @@ const htmlDoc1 = ` ; + + + Details: + + + Some girls are so damn hot that they can get you bent out of shape, and you will not even be mad at them for doing so. Well, tawny blonde Mia Malkova can bend her body into any shape she pleases, and that’s sure to satisfy all of the horny cocks and wet pussies out there. This girl has acrobatic and contortionist abilities that could even twist a pretzel into a new knot, which can be very helpful in the ... arrow_drop_down Some girls are so damn hot that they can get you bent out of shape, and you will not even be mad at them for doing so. Well, tawny blonde Mia Malkova can bend her body into any shape she pleases, and that’s sure to satisfy all of the horny cocks and wet pussies out there. This girl has acrobatic and contortionist abilities that could even twist a pretzel into a new knot, which can be very helpful in the VR Porn movies – trust us. Ankles behind her neck and feet over her back so she can kiss her toes, turned, twisted and gyrating, she can fuck any which way she wants (and that ass!), will surely make you fall in love with this hot Virtual Reality Porn slut, as she is one of the finest of them all. Talking about perfection, maybe it’s all the acrobatic work that keeps it in such gorgeous shape? Who cares really, because you just want to take a big bite out of it and never let go. But it’s not all about the body. Mia’s also got a great smile, which might not sound kinky, but believe us, it is a smile that will heat up your innards and drop your pants. Is it her golden skin, her innocent pink lips or that heart-shaped face? There is just too much good stuff going on with Mia Malkova, which is maybe why these past few years have heaped awards upon awards on this Southern California native. Mia came to VR Bangers for her first VR Porn video, so you know she’s only going for top-notch scenes with top-game performers, men, and women. Better hit up that yoga studio if you ever dream of being able to bang a flexible and talented chick like lady Malkova. + +
    Social Network Links:
    @@ -194,6 +210,9 @@ func makeXPathConfig() mappedPerformerScraperConfig { config.mappedConfig["FakeTits"] = makeSimpleAttrConfig(makeCommonXPath("Fake boobs:")) config.mappedConfig["Tattoos"] = makeSimpleAttrConfig(makeCommonXPath("Tattoos:")) config.mappedConfig["Piercings"] = makeSimpleAttrConfig(makeCommonXPath("Piercings:") + "/comment()") + config.mappedConfig["Details"] = makeSimpleAttrConfig(makeCommonXPath("Details:")) + config.mappedConfig["HairColor"] = makeSimpleAttrConfig(makeCommonXPath("Hair Color:")) + config.mappedConfig["Weight"] = makeSimpleAttrConfig(makeCommonXPath("Weight:")) // special handling for birthdate birthdateAttrConfig := makeSimpleAttrConfig(makeCommonXPath("Date of Birth:")) @@ -299,6 +318,9 @@ func TestScrapePerformerXPath(t *testing.T) { const piercings = "" const gender = "Female" const height = "170" + const details = "Some girls are so damn hot that they can get you bent out of shape, and you will not even be mad at them for doing so. Well, tawny blonde Mia Malkova can bend her body into any shape she pleases, and that’s sure to satisfy all of the horny cocks and wet pussies out there. This girl has acrobatic and contortionist abilities that could even twist a pretzel into a new knot, which can be very helpful in the ... arrow_drop_down Some girls are so damn hot that they can get you bent out of shape, and you will not even be mad at them for doing so. Well, tawny blonde Mia Malkova can bend her body into any shape she pleases, and that’s sure to satisfy all of the horny cocks and wet pussies out there. This girl has acrobatic and contortionist abilities that could even twist a pretzel into a new knot, which can be very helpful in the VR Porn movies – trust us. Ankles behind her neck and feet over her back so she can kiss her toes, turned, twisted and gyrating, she can fuck any which way she wants (and that ass!), will surely make you fall in love with this hot Virtual Reality Porn slut, as she is one of the finest of them all. Talking about perfection, maybe it’s all the acrobatic work that keeps it in such gorgeous shape? Who cares really, because you just want to take a big bite out of it and never let go. But it’s not all about the body. Mia’s also got a great smile, which might not sound kinky, but believe us, it is a smile that will heat up your innards and drop your pants. Is it her golden skin, her innocent pink lips or that heart-shaped face? There is just too much good stuff going on with Mia Malkova, which is maybe why these past few years have heaped awards upon awards on this Southern California native. Mia came to VR Bangers for her first VR Porn video, so you know she’s only going for top-notch scenes with top-game performers, men, and women. Better hit up that yoga studio if you ever dream of being able to bang a flexible and talented chick like lady Malkova." + const hairColor = "Blonde" + const weight = "57" verifyField(t, performerName, performer.Name, "Name") verifyField(t, gender, performer.Gender, "Gender") @@ -317,6 +339,9 @@ func TestScrapePerformerXPath(t *testing.T) { verifyField(t, tattoos, performer.Tattoos, "Tattoos") verifyField(t, piercings, performer.Piercings, "Piercings") verifyField(t, height, performer.Height, "Height") + verifyField(t, details, performer.Details, "Details") + verifyField(t, hairColor, performer.HairColor, "HairColor") + verifyField(t, weight, performer.Weight, "Weight") } func TestConcatXPath(t *testing.T) { diff --git a/pkg/sqlite/performer.go b/pkg/sqlite/performer.go index fd744f983..bbfcbee92 100644 --- a/pkg/sqlite/performer.go +++ b/pkg/sqlite/performer.go @@ -4,7 +4,6 @@ import ( "database/sql" "fmt" "strconv" - "time" "github.com/stashapp/stash/pkg/models" ) @@ -209,7 +208,13 @@ func (qb *performerQueryBuilder) Query(performerFilter *models.PerformerFilterTy } if birthYear := performerFilter.BirthYear; birthYear != nil { - clauses, thisArgs := getBirthYearFilterClause(birthYear.Modifier, birthYear.Value) + clauses, thisArgs := getYearFilterClause(birthYear.Modifier, birthYear.Value, "birthdate") + query.addWhere(clauses...) + query.addArg(thisArgs...) + } + + if deathYear := performerFilter.DeathYear; deathYear != nil { + clauses, thisArgs := getYearFilterClause(deathYear.Modifier, deathYear.Value, "death_date") query.addWhere(clauses...) query.addArg(thisArgs...) } @@ -254,6 +259,8 @@ func (qb *performerQueryBuilder) Query(performerFilter *models.PerformerFilterTy query.handleStringCriterionInput(performerFilter.CareerLength, tableName+".career_length") query.handleStringCriterionInput(performerFilter.Tattoos, tableName+".tattoos") query.handleStringCriterionInput(performerFilter.Piercings, tableName+".piercings") + query.handleStringCriterionInput(performerFilter.HairColor, tableName+".hair_color") + query.handleStringCriterionInput(performerFilter.Weight, tableName+".weight") query.handleStringCriterionInput(performerFilter.URL, tableName+".url") // TODO - need better handling of aliases @@ -294,7 +301,7 @@ func (qb *performerQueryBuilder) Query(performerFilter *models.PerformerFilterTy return performers, countResult, nil } -func getBirthYearFilterClause(criterionModifier models.CriterionModifier, value int) ([]string, []interface{}) { +func getYearFilterClause(criterionModifier models.CriterionModifier, value int, col string) ([]string, []interface{}) { var clauses []string var args []interface{} @@ -306,22 +313,22 @@ func getBirthYearFilterClause(criterionModifier models.CriterionModifier, value switch modifier { case "EQUALS": // between yyyy-01-01 and yyyy-12-31 - clauses = append(clauses, "performers.birthdate >= ?") - clauses = append(clauses, "performers.birthdate <= ?") + clauses = append(clauses, "performers."+col+" >= ?") + clauses = append(clauses, "performers."+col+" <= ?") args = append(args, startOfYear) args = append(args, endOfYear) case "NOT_EQUALS": // outside of yyyy-01-01 to yyyy-12-31 - clauses = append(clauses, "performers.birthdate < ? OR performers.birthdate > ?") + clauses = append(clauses, "performers."+col+" < ? OR performers."+col+" > ?") args = append(args, startOfYear) args = append(args, endOfYear) case "GREATER_THAN": // > yyyy-12-31 - clauses = append(clauses, "performers.birthdate > ?") + clauses = append(clauses, "performers."+col+" > ?") args = append(args, endOfYear) case "LESS_THAN": // < yyyy-01-01 - clauses = append(clauses, "performers.birthdate < ?") + clauses = append(clauses, "performers."+col+" < ?") args = append(args, startOfYear) } } @@ -332,33 +339,23 @@ func getBirthYearFilterClause(criterionModifier models.CriterionModifier, value func getAgeFilterClause(criterionModifier models.CriterionModifier, value int) ([]string, []interface{}) { var clauses []string var args []interface{} + var clause string - // get the date at which performer would turn the age specified - dt := time.Now() - birthDate := dt.AddDate(-value-1, 0, 0) - yearAfter := birthDate.AddDate(1, 0, 0) + if criterionModifier.IsValid() { + switch criterionModifier { + case models.CriterionModifierEquals: + clause = " == ?" + case models.CriterionModifierNotEquals: + clause = " != ?" + case models.CriterionModifierGreaterThan: + clause = " > ?" + case models.CriterionModifierLessThan: + clause = " < ?" + } - if modifier := criterionModifier.String(); criterionModifier.IsValid() { - switch modifier { - case "EQUALS": - // between birthDate and yearAfter - clauses = append(clauses, "performers.birthdate >= ?") - clauses = append(clauses, "performers.birthdate < ?") - args = append(args, birthDate) - args = append(args, yearAfter) - case "NOT_EQUALS": - // outside of birthDate and yearAfter - clauses = append(clauses, "performers.birthdate < ? OR performers.birthdate >= ?") - args = append(args, birthDate) - args = append(args, yearAfter) - case "GREATER_THAN": - // < birthDate - clauses = append(clauses, "performers.birthdate < ?") - args = append(args, birthDate) - case "LESS_THAN": - // > yearAfter - clauses = append(clauses, "performers.birthdate >= ?") - args = append(args, yearAfter) + if clause != "" { + clauses = append(clauses, "cast(IFNULL(strftime('%Y.%m%d', performers.death_date), strftime('%Y.%m%d', 'now')) - strftime('%Y.%m%d', performers.birthdate) as int)"+clause) + args = append(args, value) } } diff --git a/pkg/sqlite/performer_test.go b/pkg/sqlite/performer_test.go index 8fb1a7df3..d2f32182b 100644 --- a/pkg/sqlite/performer_test.go +++ b/pkg/sqlite/performer_test.go @@ -214,10 +214,16 @@ func verifyPerformerAge(t *testing.T, ageCriterion models.IntCriterionInput) { now := time.Now() for _, performer := range performers { + cd := now + + if performer.DeathDate.Valid { + cd, _ = time.Parse("2006-01-02", performer.DeathDate.String) + } + bd := performer.Birthdate.String d, _ := time.Parse("2006-01-02", bd) - age := now.Year() - d.Year() - if now.YearDay() < d.YearDay() { + age := cd.Year() - d.Year() + if cd.YearDay() < d.YearDay() { age = age - 1 } diff --git a/pkg/sqlite/scene_test.go b/pkg/sqlite/scene_test.go index c61f71eb0..c0c36922f 100644 --- a/pkg/sqlite/scene_test.go +++ b/pkg/sqlite/scene_test.go @@ -558,10 +558,10 @@ func verifyInt(t *testing.T, value int, criterion models.IntCriterionInput) { assert.NotEqual(criterion.Value, value) } if criterion.Modifier == models.CriterionModifierGreaterThan { - assert.True(value > criterion.Value) + assert.Greater(value, criterion.Value) } if criterion.Modifier == models.CriterionModifierLessThan { - assert.True(value < criterion.Value) + assert.Less(value, criterion.Value) } } diff --git a/pkg/sqlite/setup_test.go b/pkg/sqlite/setup_test.go index 7996f9516..f8a630095 100644 --- a/pkg/sqlite/setup_test.go +++ b/pkg/sqlite/setup_test.go @@ -657,6 +657,19 @@ func getPerformerBirthdate(index int) string { return birthdate.Format("2006-01-02") } +func getPerformerDeathDate(index int) models.SQLiteDate { + if index != 5 { + return models.SQLiteDate{} + } + + deathDate := time.Now() + deathDate = deathDate.AddDate(-index+1, -1, -1) + return models.SQLiteDate{ + String: deathDate.Format("2006-01-02"), + Valid: true, + } +} + func getPerformerCareerLength(index int) *string { if index%5 == 0 { return nil @@ -691,6 +704,8 @@ func createPerformers(pqb models.PerformerReaderWriter, n int, o int) error { String: getPerformerBirthdate(i), Valid: true, }, + DeathDate: getPerformerDeathDate(i), + Details: sql.NullString{String: getPerformerStringValue(i, "Details"), Valid: true}, } careerLength := getPerformerCareerLength(i) diff --git a/pkg/studio/export.go b/pkg/studio/export.go index f7e72d53d..5f1e3008f 100644 --- a/pkg/studio/export.go +++ b/pkg/studio/export.go @@ -23,6 +23,10 @@ func ToJSON(reader models.StudioReader, studio *models.Studio) (*jsonschema.Stud newStudioJSON.URL = studio.URL.String } + if studio.Details.Valid { + newStudioJSON.Details = studio.Details.String + } + if studio.ParentID.Valid { parent, err := reader.Find(int(studio.ParentID.Int64)) if err != nil { diff --git a/pkg/studio/export_test.go b/pkg/studio/export_test.go index 72807df48..1a453ec2d 100644 --- a/pkg/studio/export_test.go +++ b/pkg/studio/export_test.go @@ -24,10 +24,13 @@ const ( errParentStudioID = 12 ) -const studioName = "testStudio" -const url = "url" +const ( + studioName = "testStudio" + url = "url" + details = "details" -const parentStudioName = "parentStudio" + parentStudioName = "parentStudio" +) var parentStudio models.Studio = models.Studio{ Name: models.NullString(parentStudioName), @@ -37,15 +40,15 @@ var imageBytes = []byte("imageBytes") const image = "aW1hZ2VCeXRlcw==" -var createTime time.Time = time.Date(2001, 01, 01, 0, 0, 0, 0, time.UTC) -var updateTime time.Time = time.Date(2002, 01, 01, 0, 0, 0, 0, time.UTC) +var createTime time.Time = time.Date(2001, 01, 01, 0, 0, 0, 0, time.Local) +var updateTime time.Time = time.Date(2002, 01, 01, 0, 0, 0, 0, time.Local) func createFullStudio(id int, parentID int) models.Studio { - return models.Studio{ - ID: id, - Name: models.NullString(studioName), - URL: models.NullString(url), - ParentID: models.NullInt64(int64(parentID)), + ret := models.Studio{ + ID: id, + Name: models.NullString(studioName), + URL: models.NullString(url), + Details: models.NullString(details), CreatedAt: models.SQLiteTimestamp{ Timestamp: createTime, }, @@ -53,6 +56,12 @@ func createFullStudio(id int, parentID int) models.Studio { Timestamp: updateTime, }, } + + if parentID != 0 { + ret.ParentID = models.NullInt64(int64(parentID)) + } + + return ret } func createEmptyStudio(id int) models.Studio { @@ -69,8 +78,9 @@ func createEmptyStudio(id int) models.Studio { func createFullJSONStudio(parentStudio, image string) *jsonschema.Studio { return &jsonschema.Studio{ - Name: studioName, - URL: url, + Name: studioName, + URL: url, + Details: details, CreatedAt: models.JSONTime{ Time: createTime, }, diff --git a/pkg/studio/import.go b/pkg/studio/import.go index 1e8afcc67..6e38290f6 100644 --- a/pkg/studio/import.go +++ b/pkg/studio/import.go @@ -28,6 +28,7 @@ func (i *Importer) PreImport() error { Checksum: checksum, Name: sql.NullString{String: i.Input.Name, Valid: true}, URL: sql.NullString{String: i.Input.URL, Valid: true}, + Details: sql.NullString{String: i.Input.Details, Valid: true}, CreatedAt: models.SQLiteTimestamp{Timestamp: i.Input.CreatedAt.GetTime()}, UpdatedAt: models.SQLiteTimestamp{Timestamp: i.Input.UpdatedAt.GetTime()}, } diff --git a/pkg/studio/import_test.go b/pkg/studio/import_test.go index 339e8b9b6..29a0d8813 100644 --- a/pkg/studio/import_test.go +++ b/pkg/studio/import_test.go @@ -7,6 +7,7 @@ import ( "github.com/stashapp/stash/pkg/manager/jsonschema" "github.com/stashapp/stash/pkg/models" "github.com/stashapp/stash/pkg/models/mocks" + "github.com/stashapp/stash/pkg/utils" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/mock" ) @@ -51,6 +52,17 @@ func TestImporterPreImport(t *testing.T) { err = i.PreImport() assert.Nil(t, err) + + i.Input = *createFullJSONStudio(studioName, image) + i.Input.ParentStudio = "" + + err = i.PreImport() + + assert.Nil(t, err) + expectedStudio := createFullStudio(0, 0) + expectedStudio.ParentID.Valid = false + expectedStudio.Checksum = utils.MD5FromString(studioName) + assert.Equal(t, expectedStudio, i.studio) } func TestImporterPreImportWithParent(t *testing.T) { diff --git a/ui/v2.5/src/components/Changelog/versions/v070.md b/ui/v2.5/src/components/Changelog/versions/v070.md index f0e5ebffa..153372279 100644 --- a/ui/v2.5/src/components/Changelog/versions/v070.md +++ b/ui/v2.5/src/components/Changelog/versions/v070.md @@ -1,5 +1,11 @@ ### ✨ New Features +* Added details, death date, hair color, and weight to Performers. +* Added details to Studios. * Added [perceptual dupe checker](/settings?tab=duplicates). +* Add various `count` filter criteria and sort options. +* Add URL filter criteria for scenes, galleries, movies, performers and studios. +* Add HTTP endpoint for health checking at `/healthz`. +* Add random sorting option for galleries, studios, movies and tags. * Support access to system without logging in via API key. * Added scene queue. @@ -10,12 +16,8 @@ * Add slideshow to image wall view. * Support API key via URL query parameter, and added API key to stream link in Scene File Info. * Revamped setup wizard and migration UI. -* Add various `count` filter criteria and sort options. * Scroll to top when changing page number. -* Add URL filter criteria for scenes, galleries, movies, performers and studios. -* Add HTTP endpoint for health checking at `/healthz`. * Support `today` and `yesterday` for `parseDate` in scrapers. -* Add random sorting option for galleries, studios, movies and tags. * Disable sounds on scene/marker wall previews by default. * Improve Movie UI. * Change performer text query to search by name and alias only. diff --git a/ui/v2.5/src/components/Galleries/GalleryDetails/GalleryScrapeDialog.tsx b/ui/v2.5/src/components/Galleries/GalleryDetails/GalleryScrapeDialog.tsx index 9a71b33ad..147545bae 100644 --- a/ui/v2.5/src/components/Galleries/GalleryDetails/GalleryScrapeDialog.tsx +++ b/ui/v2.5/src/components/Galleries/GalleryDetails/GalleryScrapeDialog.tsx @@ -273,8 +273,10 @@ export const GalleryScrapeDialog: React.FC = ( try { const result = await createStudio({ variables: { - name: toCreate.name, - url: toCreate.url, + input: { + name: toCreate.name, + url: toCreate.url, + }, }, }); @@ -299,7 +301,7 @@ export const GalleryScrapeDialog: React.FC = ( try { performerInput = Object.assign(performerInput, toCreate); const result = await createPerformer({ - variables: performerInput, + variables: { input: performerInput }, }); // add the new performer to the new performers value diff --git a/ui/v2.5/src/components/Performers/PerformerCard.tsx b/ui/v2.5/src/components/Performers/PerformerCard.tsx index 4abf9bcf4..298f5c0d6 100644 --- a/ui/v2.5/src/components/Performers/PerformerCard.tsx +++ b/ui/v2.5/src/components/Performers/PerformerCard.tsx @@ -29,7 +29,10 @@ export const PerformerCard: React.FC = ({ selected, onSelectedChanged, }) => { - const age = TextUtils.age(performer.birthdate, ageFromDate); + const age = TextUtils.age( + performer.birthdate, + ageFromDate ?? performer.death_date + ); const ageString = `${age} years old${ageFromDate ? " in this scene." : "."}`; function maybeRenderFavoriteBanner() { diff --git a/ui/v2.5/src/components/Performers/PerformerDetails/Performer.tsx b/ui/v2.5/src/components/Performers/PerformerDetails/Performer.tsx index b4e2699c0..5cf92383f 100644 --- a/ui/v2.5/src/components/Performers/PerformerDetails/Performer.tsx +++ b/ui/v2.5/src/components/Performers/PerformerDetails/Performer.tsx @@ -160,7 +160,9 @@ export const Performer: React.FC = () => { // provided by the server return (
    - {TextUtils.age(performer.birthdate)} + + {TextUtils.age(performer.birthdate, performer.death_date)} + years old
    ); diff --git a/ui/v2.5/src/components/Performers/PerformerDetails/PerformerDetailsPanel.tsx b/ui/v2.5/src/components/Performers/PerformerDetails/PerformerDetailsPanel.tsx index be15b3db6..79980287c 100644 --- a/ui/v2.5/src/components/Performers/PerformerDetails/PerformerDetailsPanel.tsx +++ b/ui/v2.5/src/components/Performers/PerformerDetails/PerformerDetailsPanel.tsx @@ -81,6 +81,17 @@ export const PerformerDetailsPanel: React.FC = ({ }); }; + const formatWeight = (weight?: number | null) => { + if (!weight) { + return ""; + } + return intl.formatNumber(weight, { + style: "unit", + unit: "kilogram", + unitDisplay: "narrow", + }); + }; + return ( <> = ({ name="Birthdate" value={TextUtils.formatDate(intl, performer.birthdate ?? undefined)} /> + + + + = ({ tag_ids: yup.array(yup.string().required()).optional(), stash_ids: yup.mixed().optional(), image: yup.string().optional().nullable(), + details: yup.string().optional(), + death_date: yup.string().optional(), + hair_color: yup.string().optional(), + weight: yup.number().optional(), }); const initialValues = { @@ -131,6 +135,10 @@ export const PerformerEditPanel: React.FC = ({ tag_ids: (performer.tags ?? []).map((t) => t.id), stash_ids: performer.stash_ids ?? undefined, image: undefined, + details: performer.details ?? "", + death_date: performer.death_date ?? "", + hair_color: performer.hair_color ?? "", + weight: performer.weight ?? "", }; type InputValues = typeof initialValues; @@ -306,6 +314,18 @@ export const PerformerEditPanel: React.FC = ({ const imageStr = (state as GQL.ScrapedPerformerDataFragment).image; formik.setFieldValue("image", imageStr ?? undefined); } + if (state.details) { + formik.setFieldValue("details", state.details); + } + if (state.death_date) { + formik.setFieldValue("death_date", state.death_date); + } + if (state.hair_color) { + formik.setFieldValue("hair_color", state.hair_color); + } + if (state.weight) { + formik.setFieldValue("weight", state.weight); + } } function onImageLoad(imageData: string) { @@ -334,7 +354,7 @@ export const PerformerEditPanel: React.FC = ({ history.push(`/performers/${performer.id}`); } else { const result = await createPerformer({ - variables: performerInput as GQL.PerformerCreateInput, + variables: { input: performerInput as GQL.PerformerCreateInput }, }); if (result.data?.performerCreate) { history.push(`/performers/${result.data.performerCreate.id}`); @@ -399,6 +419,7 @@ export const PerformerEditPanel: React.FC = ({ > = { ...values, gender: stringToGender(values.gender), + weight: Number(values.weight), }; if (!isNew) { @@ -550,6 +571,7 @@ export const PerformerEditPanel: React.FC = ({ ...formik.values, gender: stringToGender(formik.values.gender), image: formik.values.image ?? performer.image_path, + weight: Number(formik.values.weight), }; return ( @@ -806,10 +828,13 @@ export const PerformerEditPanel: React.FC = ({ {renderTextField("birthdate", "Birthdate", "YYYY-MM-DD")} + {renderTextField("death_date", "Death Date", "YYYY-MM-DD")} {renderTextField("country", "Country")} {renderTextField("ethnicity", "Ethnicity")} + {renderTextField("hair_color", "Hair Color")} {renderTextField("eye_color", "Eye Color")} {renderTextField("height", "Height (cm)")} + {renderTextField("weight", "Weight (kg)")} {renderTextField("measurements", "Measurements")} {renderTextField("fake_tits", "Fake Tits")} @@ -861,7 +886,19 @@ export const PerformerEditPanel: React.FC = ({ {renderTextField("twitter", "Twitter")} {renderTextField("instagram", "Instagram")} - + + + Details + + + + + {renderTagsField()} {renderStashIDs()} diff --git a/ui/v2.5/src/components/Performers/PerformerDetails/PerformerScrapeDialog.tsx b/ui/v2.5/src/components/Performers/PerformerDetails/PerformerScrapeDialog.tsx index 4e6305cbb..204a52811 100644 --- a/ui/v2.5/src/components/Performers/PerformerDetails/PerformerScrapeDialog.tsx +++ b/ui/v2.5/src/components/Performers/PerformerDetails/PerformerScrapeDialog.tsx @@ -153,18 +153,36 @@ export const PerformerScrapeDialog: React.FC = ( const [birthdate, setBirthdate] = useState>( new ScrapeResult(props.performer.birthdate, props.scraped.birthdate) ); + const [deathDate, setDeathDate] = useState>( + new ScrapeResult( + props.performer.death_date, + props.scraped.death_date + ) + ); const [ethnicity, setEthnicity] = useState>( new ScrapeResult(props.performer.ethnicity, props.scraped.ethnicity) ); const [country, setCountry] = useState>( new ScrapeResult(props.performer.country, props.scraped.country) ); + const [hairColor, setHairColor] = useState>( + new ScrapeResult( + props.performer.hair_color, + props.scraped.hair_color + ) + ); const [eyeColor, setEyeColor] = useState>( new ScrapeResult(props.performer.eye_color, props.scraped.eye_color) ); const [height, setHeight] = useState>( new ScrapeResult(props.performer.height, props.scraped.height) ); + const [weight, setWeight] = useState>( + new ScrapeResult( + props.performer.weight?.toString(), + props.scraped.weight + ) + ); const [measurements, setMeasurements] = useState>( new ScrapeResult( props.performer.measurements, @@ -201,6 +219,9 @@ export const PerformerScrapeDialog: React.FC = ( translateScrapedGender(props.scraped.gender) ) ); + const [details, setDetails] = useState>( + new ScrapeResult(props.performer.details, props.scraped.details) + ); const [createTag] = useTagCreate({ name: "" }); const Toast = useToast(); @@ -281,6 +302,10 @@ export const PerformerScrapeDialog: React.FC = ( gender, image, tags, + details, + deathDate, + hairColor, + weight, ]; // don't show the dialog if nothing was scraped if (allFields.every((r) => !r.scraped)) { @@ -348,6 +373,10 @@ export const PerformerScrapeDialog: React.FC = ( }; }), image: image.getNewValue(), + details: details.getNewValue(), + death_date: deathDate.getNewValue(), + hair_color: hairColor.getNewValue(), + weight: weight.getNewValue(), }; } @@ -370,6 +399,11 @@ export const PerformerScrapeDialog: React.FC = ( result={birthdate} onChange={(value) => setBirthdate(value)} /> + setDeathDate(value)} + /> = ( result={country} onChange={(value) => setCountry(value)} /> + setHairColor(value)} + /> setEyeColor(value)} /> + setWeight(value)} + /> = ( result={instagram} onChange={(value) => setInstagram(value)} /> + setDetails(value)} + /> {renderScrapedTagsRow( tags, (value) => setTags(value), diff --git a/ui/v2.5/src/components/Scenes/SceneDetails/SceneScrapeDialog.tsx b/ui/v2.5/src/components/Scenes/SceneDetails/SceneScrapeDialog.tsx index 77838220c..70b5db8d5 100644 --- a/ui/v2.5/src/components/Scenes/SceneDetails/SceneScrapeDialog.tsx +++ b/ui/v2.5/src/components/Scenes/SceneDetails/SceneScrapeDialog.tsx @@ -336,8 +336,10 @@ export const SceneScrapeDialog: React.FC = ( try { const result = await createStudio({ variables: { - name: toCreate.name, - url: toCreate.url, + input: { + name: toCreate.name, + url: toCreate.url, + }, }, }); @@ -362,7 +364,7 @@ export const SceneScrapeDialog: React.FC = ( try { performerInput = Object.assign(performerInput, toCreate); const result = await createPerformer({ - variables: performerInput, + variables: { input: performerInput }, }); // add the new performer to the new performers value diff --git a/ui/v2.5/src/components/Shared/Select.tsx b/ui/v2.5/src/components/Shared/Select.tsx index 8ed94b245..e89840bca 100644 --- a/ui/v2.5/src/components/Shared/Select.tsx +++ b/ui/v2.5/src/components/Shared/Select.tsx @@ -381,7 +381,7 @@ export const PerformerSelect: React.FC = (props) => { const onCreate = async (name: string) => { const result = await createPerformer({ - variables: { name }, + variables: { input: { name } }, }); return { item: result.data!.performerCreate!, @@ -415,7 +415,11 @@ export const StudioSelect: React.FC< ); const onCreate = async (name: string) => { - const result = await createStudio({ variables: { name } }); + const result = await createStudio({ + variables: { + input: { name }, + }, + }); return { item: result.data!.studioCreate!, message: "Created studio" }; }; diff --git a/ui/v2.5/src/components/Studios/StudioDetails/Studio.tsx b/ui/v2.5/src/components/Studios/StudioDetails/Studio.tsx index 67dc73454..0cd7c3813 100644 --- a/ui/v2.5/src/components/Studios/StudioDetails/Studio.tsx +++ b/ui/v2.5/src/components/Studios/StudioDetails/Studio.tsx @@ -45,6 +45,7 @@ export const Studio: React.FC = () => { const [name, setName] = useState(); const [url, setUrl] = useState(); const [parentStudioId, setParentStudioId] = useState(); + const [details, setDetails] = useState(); // Studio state const [studio, setStudio] = useState>({}); @@ -63,6 +64,7 @@ export const Studio: React.FC = () => { setName(state.name); setUrl(state.url ?? undefined); setParentStudioId(state?.parent_studio?.id ?? undefined); + setDetails(state.details ?? undefined); } function updateStudioData(studioData: Partial) { @@ -117,6 +119,7 @@ export const Studio: React.FC = () => { name, url, image, + details, parent_id: parentStudioId ?? null, }; @@ -301,6 +304,12 @@ export const Studio: React.FC = () => { isEditing: !!isEditing, onChange: setUrl, })} + {TableUtils.renderTextArea({ + title: "Details", + value: details, + isEditing: !!isEditing, + onChange: setDetails, + })} Parent Studio {renderStudio()} diff --git a/ui/v2.5/src/components/Tagger/PerformerModal.tsx b/ui/v2.5/src/components/Tagger/PerformerModal.tsx index c5deb3eee..41233e2ad 100755 --- a/ui/v2.5/src/components/Tagger/PerformerModal.tsx +++ b/ui/v2.5/src/components/Tagger/PerformerModal.tsx @@ -85,6 +85,13 @@ const PerformerModal: React.FC = ({ text={performer.birthdate ?? "Unknown"} />
    +
    + Death Date: + +
    Ethnicity: = ({ Country:
    +
    + Hair Color: + +
    Eye Color: = ({ Height:
    +
    + Weight: + +
    Measurements: diff --git a/ui/v2.5/src/components/Tagger/StashSearchResult.tsx b/ui/v2.5/src/components/Tagger/StashSearchResult.tsx index 9a678b094..bb121e612 100755 --- a/ui/v2.5/src/components/Tagger/StashSearchResult.tsx +++ b/ui/v2.5/src/components/Tagger/StashSearchResult.tsx @@ -222,6 +222,10 @@ const StashSearchResult: React.FC = ({ stash_id: stashID, }, ], + details: performer.data.details, + death_date: performer.data.death_date, + hair_color: performer.data.hair_color, + weight: Number(performer.data.weight), }; const res = await createPerformer(performerInput, stashID); diff --git a/ui/v2.5/src/components/Tagger/queries.ts b/ui/v2.5/src/components/Tagger/queries.ts index 66a91e106..d2ef882e4 100644 --- a/ui/v2.5/src/components/Tagger/queries.ts +++ b/ui/v2.5/src/components/Tagger/queries.ts @@ -55,7 +55,7 @@ export const useCreatePerformer = () => { const handleCreate = (performer: GQL.PerformerCreateInput, stashID: string) => createPerformer({ - variables: performer, + variables: { input: performer }, update: (store, newPerformer) => { if (!newPerformer?.data?.performerCreate) return; @@ -159,7 +159,7 @@ export const useCreateStudio = () => { const handleCreate = (studio: GQL.StudioCreateInput, stashID: string) => createStudio({ - variables: studio, + variables: { input: studio }, update: (store, result) => { if (!result?.data?.studioCreate) return; diff --git a/ui/v2.5/src/components/Tagger/utils.ts b/ui/v2.5/src/components/Tagger/utils.ts index 39b34bd94..b5666bcf0 100644 --- a/ui/v2.5/src/components/Tagger/utils.ts +++ b/ui/v2.5/src/components/Tagger/utils.ts @@ -56,6 +56,10 @@ export interface IStashBoxPerformer { piercings?: string; aliases?: string; images: string[]; + details?: string; + death_date?: string; + hair_color?: string; + weight?: string; } export interface IStashBoxTag { @@ -126,6 +130,9 @@ const selectPerformers = ( piercings: p.piercings ? toTitleCase(p.piercings) : undefined, aliases: p.aliases ?? undefined, images: p.images ?? [], + details: p.details ?? undefined, + death_date: p.death_date ?? undefined, + hair_color: p.hair_color ?? undefined, })); export const selectScenes = ( diff --git a/ui/v2.5/src/core/StashService.ts b/ui/v2.5/src/core/StashService.ts index 1ac8e6d80..2016da8e6 100644 --- a/ui/v2.5/src/core/StashService.ts +++ b/ui/v2.5/src/core/StashService.ts @@ -584,7 +584,7 @@ export const studioMutationImpactedQueries = [ export const useStudioCreate = (input: GQL.StudioCreateInput) => GQL.useStudioCreateMutation({ - variables: input, + variables: { input }, refetchQueries: getQueryNames([GQL.AllStudiosForFilterDocument]), update: deleteCache([ GQL.FindStudiosDocument, diff --git a/ui/v2.5/src/docs/en/JSONSpec.md b/ui/v2.5/src/docs/en/JSONSpec.md index f8d036d0b..e83141891 100644 --- a/ui/v2.5/src/docs/en/JSONSpec.md +++ b/ui/v2.5/src/docs/en/JSONSpec.md @@ -52,10 +52,13 @@ url twitter instagram birthdate +death_date ethnicity country +hair_color eye_color height +weight measurements fake_tits career_length @@ -64,6 +67,7 @@ piercings image (base64 encoding of the image file) created_at updated_at +details ``` ## Studio @@ -72,7 +76,8 @@ name url image (base64 encoding of the image file) created_at -updated_at +updated_at +details ``` ## Scene @@ -229,6 +234,10 @@ For those preferring the json-format, defined [here](https://json-schema.org/), "description": "Birthdate of the performer. Format is YYYY-MM-DD", "type": "string" }, + "death_date": { + "description": "Death date of the performer. Format is YYYY-MM-DD", + "type": "string" + }, "ethnicity": { "description": "Ethnicity of the Performer. Possible values are black, white, asian or hispanic", "type": "string" @@ -237,6 +246,10 @@ For those preferring the json-format, defined [here](https://json-schema.org/), "description": "Country of the performer", "type": "string" }, + "hair_color": { + "description": "Hair color of the performer", + "type": "string" + }, "eye_color": { "description": "Eye color of the performer", "type": "string" @@ -245,6 +258,10 @@ For those preferring the json-format, defined [here](https://json-schema.org/), "description": "Height of the performer in centimeters", "type": "string" }, + "weight": { + "description": "Weight of the performer in kilograms", + "type": "string" + }, "measurements": { "description": "Measurements of the performer", "type": "string" @@ -276,6 +293,10 @@ For those preferring the json-format, defined [here](https://json-schema.org/), "updated_at": { "description": "The time this performers data was last changed in the database. Format is YYYY-MM-DDThh:mm:ssTZD", "type": "string" + }, + "details": { + "description": "Description of the performer", + "type": "string" } }, "required": ["name", "ethnicity", "image", "created_at", "updated_at"] @@ -312,6 +333,10 @@ For those preferring the json-format, defined [here](https://json-schema.org/), "updated_at": { "description": "The time this studios data was last changed in the database. Format is YYYY-MM-DDThh:mm:ssTZD", "type": "string" + }, + "details": { + "description": "Description of the studio", + "type": "string" } }, "required": ["name", "image", "created_at", "updated_at"] diff --git a/ui/v2.5/src/docs/en/Scraping.md b/ui/v2.5/src/docs/en/Scraping.md index 43a4407e3..40171b62d 100644 --- a/ui/v2.5/src/docs/en/Scraping.md +++ b/ui/v2.5/src/docs/en/Scraping.md @@ -740,10 +740,13 @@ URL Twitter Instagram Birthdate +DeathDate Ethnicity Country +HairColor EyeColor Height +Weight Measurements FakeTits CareerLength @@ -752,6 +755,7 @@ Piercings Aliases Tags (see Tag fields) Image +Details ``` *Note:* - `Gender` must be one of `male`, `female`, `transgender_male`, `transgender_female`, `intersex`, `non_binary` (case insensitive). diff --git a/ui/v2.5/src/models/list-filter/criteria/criterion.ts b/ui/v2.5/src/models/list-filter/criteria/criterion.ts index ee351a3c5..382024976 100644 --- a/ui/v2.5/src/models/list-filter/criteria/criterion.ts +++ b/ui/v2.5/src/models/list-filter/criteria/criterion.ts @@ -34,8 +34,10 @@ export type CriterionType = | "age" | "ethnicity" | "country" + | "hair_color" | "eye_color" | "height" + | "weight" | "measurements" | "fake_tits" | "career_length" @@ -49,6 +51,7 @@ export type CriterionType = | "image_count" | "gallery_count" | "performer_count" + | "death_year" | "url"; type Option = string | number | IOptionType; @@ -103,16 +106,22 @@ export abstract class Criterion { return "Galleries"; case "birth_year": return "Birth Year"; + case "death_year": + return "Death Year"; case "age": return "Age"; case "ethnicity": return "Ethnicity"; case "country": return "Country"; + case "hair_color": + return "Hair Color"; case "eye_color": return "Eye Color"; case "height": return "Height"; + case "weight": + return "Weight"; case "measurements": return "Measurements"; case "fake_tits": diff --git a/ui/v2.5/src/models/list-filter/criteria/is-missing.ts b/ui/v2.5/src/models/list-filter/criteria/is-missing.ts index 6e3cd75d6..d77557e8b 100644 --- a/ui/v2.5/src/models/list-filter/criteria/is-missing.ts +++ b/ui/v2.5/src/models/list-filter/criteria/is-missing.ts @@ -53,8 +53,10 @@ export class PerformerIsMissingCriterion extends IsMissingCriterion { "instagram", "ethnicity", "country", + "hair_color", "eye_color", "height", + "weight", "measurements", "fake_tits", "career_length", @@ -65,6 +67,7 @@ export class PerformerIsMissingCriterion extends IsMissingCriterion { "scenes", "image", "stash_id", + "details", ]; } @@ -104,7 +107,7 @@ export class TagIsMissingCriterionOption implements ICriterionOption { export class StudioIsMissingCriterion extends IsMissingCriterion { public type: CriterionType = "studioIsMissing"; - public options: string[] = ["image", "stash_id"]; + public options: string[] = ["image", "stash_id", "details"]; } export class StudioIsMissingCriterionOption implements ICriterionOption { diff --git a/ui/v2.5/src/models/list-filter/criteria/utils.ts b/ui/v2.5/src/models/list-filter/criteria/utils.ts index 81d37e750..2f1e82030 100644 --- a/ui/v2.5/src/models/list-filter/criteria/utils.ts +++ b/ui/v2.5/src/models/list-filter/criteria/utils.ts @@ -88,6 +88,8 @@ export function makeCriteria(type: CriterionType = "none") { case "galleries": return new GalleriesCriterion(); case "birth_year": + case "death_year": + case "weight": return new NumberCriterion(type, type); case "age": return new MandatoryNumberCriterion(type, type); @@ -95,6 +97,7 @@ export function makeCriteria(type: CriterionType = "none") { return new GenderCriterion(); case "ethnicity": case "country": + case "hair_color": case "eye_color": case "height": case "measurements": diff --git a/ui/v2.5/src/models/list-filter/filter.ts b/ui/v2.5/src/models/list-filter/filter.ts index fe775f41e..c17d2527f 100644 --- a/ui/v2.5/src/models/list-filter/filter.ts +++ b/ui/v2.5/src/models/list-filter/filter.ts @@ -200,12 +200,18 @@ export class ListFilterModel { ]; this.displayModeOptions = [DisplayMode.Grid, DisplayMode.List]; - const numberCriteria: CriterionType[] = ["birth_year", "age"]; + const numberCriteria: CriterionType[] = [ + "birth_year", + "death_year", + "age", + ]; const stringCriteria: CriterionType[] = [ "ethnicity", "country", + "hair_color", "eye_color", "height", + "weight", "measurements", "fake_tits", "career_length", @@ -650,6 +656,14 @@ export class ListFilterModel { }; break; } + case "death_year": { + const dyCrit = criterion as NumberCriterion; + result.death_year = { + value: dyCrit.value, + modifier: dyCrit.modifier, + }; + break; + } case "age": { const ageCrit = criterion as NumberCriterion; result.age = { value: ageCrit.value, modifier: ageCrit.modifier }; @@ -671,6 +685,14 @@ export class ListFilterModel { }; break; } + case "hair_color": { + const hcCrit = criterion as StringCriterion; + result.hair_color = { + value: hcCrit.value, + modifier: hcCrit.modifier, + }; + break; + } case "eye_color": { const ecCrit = criterion as StringCriterion; result.eye_color = { value: ecCrit.value, modifier: ecCrit.modifier }; @@ -681,6 +703,11 @@ export class ListFilterModel { result.height = { value: hCrit.value, modifier: hCrit.modifier }; break; } + case "weight": { + const wCrit = criterion as StringCriterion; + result.weight = { value: wCrit.value, modifier: wCrit.modifier }; + break; + } case "measurements": { const mCrit = criterion as StringCriterion; result.measurements = { diff --git a/ui/v2.5/src/utils/text.ts b/ui/v2.5/src/utils/text.ts index b69c440dc..cf6c512fa 100644 --- a/ui/v2.5/src/utils/text.ts +++ b/ui/v2.5/src/utils/text.ts @@ -83,7 +83,7 @@ const stringToDate = (dateString: string) => { return new Date(year, monthIndex, day, 0, 0, 0, 0); }; -const getAge = (dateString?: string | null, fromDateString?: string) => { +const getAge = (dateString?: string | null, fromDateString?: string | null) => { if (!dateString) return 0; const birthdate = stringToDate(dateString); From a5e9e7abce196fa3831b65e72f8e16571cba47a9 Mon Sep 17 00:00:00 2001 From: bnkai <48220860+bnkai@users.noreply.github.com> Date: Fri, 16 Apr 2021 09:20:20 +0300 Subject: [PATCH 32/66] Update README with currently used ffmpeg URLs (#1304) --- README.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 5282f936c..14041e4ed 100644 --- a/README.md +++ b/README.md @@ -35,9 +35,9 @@ Run the executable (double click the exe on windows or run `./stash-osx` / `./st If stash is unable to find or download FFMPEG then download it yourself from the link for your platform: -* [macOS](https://ffmpeg.zeranoe.com/builds/macos64/static/ffmpeg-4.0-macos64-static.zip) -* [Windows](https://ffmpeg.zeranoe.com/builds/win64/static/ffmpeg-4.0-win64-static.zip) -* [Linux](https://johnvansickle.com/ffmpeg/releases/ffmpeg-release-amd64-static.tar.xz) +* [macOS ffmpeg](https://evermeet.cx/ffmpeg/ffmpeg-4.3.1.zip), [macOS ffprobe](https://evermeet.cx/ffmpeg/ffprobe-4.3.1.zip) +* [Windows](https://www.gyan.dev/ffmpeg/builds/ffmpeg-release-essentials.zip) +* [Linux](https://www.johnvansickle.com/ffmpeg/) The `ffmpeg(.exe)` and `ffprobe(.exe)` files should be placed in `~/.stash` on macOS / Linux or `C:\Users\YourUsername\.stash` on Windows. From e3fa8f7b248f52747fc530d4c34956806b01f274 Mon Sep 17 00:00:00 2001 From: InfiniteTF Date: Fri, 16 Apr 2021 19:15:47 +0200 Subject: [PATCH 33/66] Fix fingerprint search when scene only has phash match (#1312) --- ui/v2.5/src/components/Tagger/Tagger.tsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/ui/v2.5/src/components/Tagger/Tagger.tsx b/ui/v2.5/src/components/Tagger/Tagger.tsx index 281996f1d..d18827b1e 100755 --- a/ui/v2.5/src/components/Tagger/Tagger.tsx +++ b/ui/v2.5/src/components/Tagger/Tagger.tsx @@ -290,7 +290,8 @@ const TaggerList: React.FC = ({ (s) => s.stash_ids.length === 0 && ((s.checksum && fingerprints[s.checksum]) || - (s.oshash && fingerprints[s.oshash])) + (s.oshash && fingerprints[s.oshash]) || + (s.phash && fingerprints[s.phash])) ).length; }; @@ -320,6 +321,7 @@ const TaggerList: React.FC = ({ const fingerprintMatch = fingerprints[scene.checksum ?? ""] ?? fingerprints[scene.oshash ?? ""] ?? + fingerprints[scene.phash ?? ""] ?? null; const isTagged = taggedScenes[scene.id]; const hasStashIDs = scene.stash_ids.length > 0; From cd0a9a1d627285ff9d1a7dcb243262b4d5aa6030 Mon Sep 17 00:00:00 2001 From: InfiniteTF Date: Sat, 17 Apr 2021 00:52:18 +0200 Subject: [PATCH 34/66] Fix performer scraping (#1314) --- .../PerformerDetails/PerformerEditPanel.tsx | 56 +++++++++++-------- .../Scenes/SceneDetails/SceneScrapeDialog.tsx | 35 ++++++++++-- ui/v2.5/src/core/StashService.ts | 7 ++- ui/v2.5/src/utils/data.ts | 2 + ui/v2.5/src/utils/index.ts | 1 + 5 files changed, 71 insertions(+), 30 deletions(-) create mode 100644 ui/v2.5/src/utils/data.ts diff --git a/ui/v2.5/src/components/Performers/PerformerDetails/PerformerEditPanel.tsx b/ui/v2.5/src/components/Performers/PerformerDetails/PerformerEditPanel.tsx index db57daa88..dce51dd37 100644 --- a/ui/v2.5/src/components/Performers/PerformerDetails/PerformerEditPanel.tsx +++ b/ui/v2.5/src/components/Performers/PerformerDetails/PerformerEditPanel.tsx @@ -146,7 +146,7 @@ export const PerformerEditPanel: React.FC = ({ const formik = useFormik({ initialValues, validationSchema: schema, - onSubmit: (values) => onSave(getPerformerInput(values)), + onSubmit: (values) => onSave(values), }); function translateScrapedGender(scrapedGender?: string) { @@ -158,7 +158,7 @@ export const PerformerEditPanel: React.FC = ({ // try to translate from enum values first const upperGender = scrapedGender?.toUpperCase(); - const asEnum = genderToString(upperGender as GQL.GenderEnum); + const asEnum = genderToString(upperGender); if (asEnum) { retEnum = stringToGender(asEnum); } else { @@ -214,9 +214,14 @@ export const PerformerEditPanel: React.FC = ({ variables: tagInput, }); + if (!result.data?.tagCreate) { + Toast.error(new Error("Failed to create tag")); + return; + } + // add the new tag to the new tags value const newTagIds = formik.values.tag_ids.concat([ - result.data!.tagCreate!.id, + result.data.tagCreate.id, ]); formik.setFieldValue("tag_ids", newTagIds); @@ -298,7 +303,7 @@ export const PerformerEditPanel: React.FC = ({ if (state.tags) { // map tags to their ids and filter out those not found const newTagIds = state.tags.map((t) => t.stored_id).filter((t) => t); - formik.setFieldValue("tag_ids", newTagIds as string[]); + formik.setFieldValue("tag_ids", newTagIds); setNewTags(state.tags.filter((t) => !t.stored_id)); } @@ -309,9 +314,9 @@ export const PerformerEditPanel: React.FC = ({ // otherwise follow existing behaviour if ( (!isNew || formik.values.image === undefined) && - (state as GQL.ScrapedPerformerDataFragment).image !== undefined + state.image !== undefined ) { - const imageStr = (state as GQL.ScrapedPerformerDataFragment).image; + const imageStr = state.image; formik.setFieldValue("image", imageStr ?? undefined); } if (state.details) { @@ -332,29 +337,30 @@ export const PerformerEditPanel: React.FC = ({ formik.setFieldValue("image", imageData); } - async function onSave( - performerInput: - | Partial - | Partial - ) { + async function onSave(performerInput: InputValues) { setIsLoading(true); try { if (!isNew) { + const input = getUpdateValues(performerInput); + await updatePerformer({ variables: { input: { - ...performerInput, + ...input, stash_ids: performerInput?.stash_ids?.map((s) => ({ endpoint: s.endpoint, stash_id: s.stash_id, })), - } as GQL.PerformerUpdateInput, + }, }, }); history.push(`/performers/${performer.id}`); } else { + const input = getCreateValues(performerInput); const result = await createPerformer({ - variables: { input: performerInput as GQL.PerformerCreateInput }, + variables: { + input, + }, }); if (result.data?.performerCreate) { history.push(`/performers/${result.data.performerCreate.id}`); @@ -370,7 +376,7 @@ export const PerformerEditPanel: React.FC = ({ useEffect(() => { if (isVisible) { Mousetrap.bind("s s", () => { - onSave?.(getPerformerInput(formik.values)); + onSave?.(formik.values); }); if (!isNew) { @@ -413,19 +419,21 @@ export const PerformerEditPanel: React.FC = ({ if (isLoading) return ; - function getPerformerInput(values: InputValues) { - const performerInput: Partial< - GQL.PerformerCreateInput | GQL.PerformerUpdateInput - > = { + function getUpdateValues(values: InputValues): GQL.PerformerUpdateInput { + return { + ...values, + gender: stringToGender(values.gender), + weight: Number(values.weight), + id: performer.id ?? "", + }; + } + + function getCreateValues(values: InputValues): GQL.PerformerCreateInput { + return { ...values, gender: stringToGender(values.gender), weight: Number(values.weight), }; - - if (!isNew) { - (performerInput as GQL.PerformerUpdateInput).id = performer.id!; - } - return performerInput; } function onImageChangeHandler(event: React.FormEvent) { diff --git a/ui/v2.5/src/components/Scenes/SceneDetails/SceneScrapeDialog.tsx b/ui/v2.5/src/components/Scenes/SceneDetails/SceneScrapeDialog.tsx index 70b5db8d5..f05267d04 100644 --- a/ui/v2.5/src/components/Scenes/SceneDetails/SceneScrapeDialog.tsx +++ b/ui/v2.5/src/components/Scenes/SceneDetails/SceneScrapeDialog.tsx @@ -16,9 +16,10 @@ import { usePerformerCreate, useMovieCreate, useTagCreate, + stringToGender, } from "src/core/StashService"; import { useToast } from "src/hooks"; -import { DurationUtils } from "src/utils"; +import { DurationUtils, filterData } from "src/utils"; function renderScrapedStudio( result: ScrapeResult, @@ -360,11 +361,37 @@ export const SceneScrapeDialog: React.FC = ( } async function createNewPerformer(toCreate: GQL.ScrapedScenePerformer) { - let performerInput: GQL.PerformerCreateInput = { name: "" }; + const input: GQL.PerformerCreateInput = { + name: toCreate.name, + url: toCreate.url, + gender: stringToGender(toCreate.gender), + birthdate: toCreate.birthdate, + ethnicity: toCreate.ethnicity, + country: toCreate.country, + eye_color: toCreate.eye_color, + height: toCreate.height, + measurements: toCreate.measurements, + fake_tits: toCreate.fake_tits, + career_length: toCreate.career_length, + tattoos: toCreate.tattoos, + piercings: toCreate.piercings, + aliases: toCreate.aliases, + twitter: toCreate.twitter, + instagram: toCreate.instagram, + tag_ids: filterData((toCreate.tags ?? []).map((t) => t.stored_id)), + image: + (toCreate.images ?? []).length > 0 + ? (toCreate.images ?? [])[0] + : undefined, + details: toCreate.details, + death_date: toCreate.death_date, + hair_color: toCreate.hair_color, + weight: toCreate.weight ? Number(toCreate.weight) : undefined, + }; + try { - performerInput = Object.assign(performerInput, toCreate); const result = await createPerformer({ - variables: { input: performerInput }, + variables: { input }, }); // add the new performer to the new performers value diff --git a/ui/v2.5/src/core/StashService.ts b/ui/v2.5/src/core/StashService.ts index 2016da8e6..546824921 100644 --- a/ui/v2.5/src/core/StashService.ts +++ b/ui/v2.5/src/core/StashService.ts @@ -933,7 +933,7 @@ export const stringGenderMap = new Map([ ["Non-Binary", GQL.GenderEnum.NonBinary], ]); -export const genderToString = (value?: GQL.GenderEnum) => { +export const genderToString = (value?: GQL.GenderEnum | string) => { if (!value) { return undefined; } @@ -947,7 +947,10 @@ export const genderToString = (value?: GQL.GenderEnum) => { } }; -export const stringToGender = (value?: string, caseInsensitive?: boolean) => { +export const stringToGender = ( + value?: string | null, + caseInsensitive?: boolean +) => { if (!value) { return undefined; } diff --git a/ui/v2.5/src/utils/data.ts b/ui/v2.5/src/utils/data.ts new file mode 100644 index 000000000..b6b12ca0e --- /dev/null +++ b/ui/v2.5/src/utils/data.ts @@ -0,0 +1,2 @@ +export const filterData = (data?: (T | null | undefined)[] | null) => + data ? (data.filter((item) => item) as T[]) : []; diff --git a/ui/v2.5/src/utils/index.ts b/ui/v2.5/src/utils/index.ts index d9561cbfd..102c092dd 100644 --- a/ui/v2.5/src/utils/index.ts +++ b/ui/v2.5/src/utils/index.ts @@ -11,3 +11,4 @@ export { default as flattenMessages } from "./flattenMessages"; export { default as getISOCountry } from "./country"; export { default as useFocus } from "./focus"; export { default as downloadFile } from "./download"; +export * from "./data"; From 1759a99f65f8c4b4b94e58a454facce6b2f1229c Mon Sep 17 00:00:00 2001 From: peolic <66393006+peolic@users.noreply.github.com> Date: Sun, 18 Apr 2021 14:22:02 +0300 Subject: [PATCH 35/66] Fix creating performer from gallery scrape dialog (#1320) --- .../GalleryDetails/GalleryScrapeDialog.tsx | 7 ++-- .../Scenes/SceneDetails/SceneScrapeDialog.tsx | 32 ++--------------- ui/v2.5/src/core/StashService.ts | 34 +++++++++++++++++++ 3 files changed, 41 insertions(+), 32 deletions(-) diff --git a/ui/v2.5/src/components/Galleries/GalleryDetails/GalleryScrapeDialog.tsx b/ui/v2.5/src/components/Galleries/GalleryDetails/GalleryScrapeDialog.tsx index 147545bae..cd8f0f93f 100644 --- a/ui/v2.5/src/components/Galleries/GalleryDetails/GalleryScrapeDialog.tsx +++ b/ui/v2.5/src/components/Galleries/GalleryDetails/GalleryScrapeDialog.tsx @@ -14,6 +14,7 @@ import { useStudioCreate, usePerformerCreate, useTagCreate, + makePerformerCreateInput, } from "src/core/StashService"; import { useToast } from "src/hooks"; @@ -297,11 +298,11 @@ export const GalleryScrapeDialog: React.FC = ( } async function createNewPerformer(toCreate: GQL.ScrapedScenePerformer) { - let performerInput: GQL.PerformerCreateInput = { name: "" }; + const input = makePerformerCreateInput(toCreate); + try { - performerInput = Object.assign(performerInput, toCreate); const result = await createPerformer({ - variables: { input: performerInput }, + variables: { input }, }); // add the new performer to the new performers value diff --git a/ui/v2.5/src/components/Scenes/SceneDetails/SceneScrapeDialog.tsx b/ui/v2.5/src/components/Scenes/SceneDetails/SceneScrapeDialog.tsx index f05267d04..fd829e865 100644 --- a/ui/v2.5/src/components/Scenes/SceneDetails/SceneScrapeDialog.tsx +++ b/ui/v2.5/src/components/Scenes/SceneDetails/SceneScrapeDialog.tsx @@ -16,10 +16,10 @@ import { usePerformerCreate, useMovieCreate, useTagCreate, - stringToGender, + makePerformerCreateInput, } from "src/core/StashService"; import { useToast } from "src/hooks"; -import { DurationUtils, filterData } from "src/utils"; +import { DurationUtils } from "src/utils"; function renderScrapedStudio( result: ScrapeResult, @@ -361,33 +361,7 @@ export const SceneScrapeDialog: React.FC = ( } async function createNewPerformer(toCreate: GQL.ScrapedScenePerformer) { - const input: GQL.PerformerCreateInput = { - name: toCreate.name, - url: toCreate.url, - gender: stringToGender(toCreate.gender), - birthdate: toCreate.birthdate, - ethnicity: toCreate.ethnicity, - country: toCreate.country, - eye_color: toCreate.eye_color, - height: toCreate.height, - measurements: toCreate.measurements, - fake_tits: toCreate.fake_tits, - career_length: toCreate.career_length, - tattoos: toCreate.tattoos, - piercings: toCreate.piercings, - aliases: toCreate.aliases, - twitter: toCreate.twitter, - instagram: toCreate.instagram, - tag_ids: filterData((toCreate.tags ?? []).map((t) => t.stored_id)), - image: - (toCreate.images ?? []).length > 0 - ? (toCreate.images ?? [])[0] - : undefined, - details: toCreate.details, - death_date: toCreate.death_date, - hair_color: toCreate.hair_color, - weight: toCreate.weight ? Number(toCreate.weight) : undefined, - }; + const input = makePerformerCreateInput(toCreate); try { const result = await createPerformer({ diff --git a/ui/v2.5/src/core/StashService.ts b/ui/v2.5/src/core/StashService.ts index 546824921..780507119 100644 --- a/ui/v2.5/src/core/StashService.ts +++ b/ui/v2.5/src/core/StashService.ts @@ -5,6 +5,7 @@ import { getQueryDefinition, getOperationName, } from "@apollo/client/utilities"; +import { filterData } from "../utils"; import { ListFilterModel } from "../models/list-filter/filter"; import * as GQL from "./generated-graphql"; @@ -972,6 +973,39 @@ export const stringToGender = ( export const getGenderStrings = () => Array.from(stringGenderMap.keys()); +export const makePerformerCreateInput = ( + toCreate: GQL.ScrapedScenePerformer +) => { + const input: GQL.PerformerCreateInput = { + name: toCreate.name, + url: toCreate.url, + gender: stringToGender(toCreate.gender), + birthdate: toCreate.birthdate, + ethnicity: toCreate.ethnicity, + country: toCreate.country, + eye_color: toCreate.eye_color, + height: toCreate.height, + measurements: toCreate.measurements, + fake_tits: toCreate.fake_tits, + career_length: toCreate.career_length, + tattoos: toCreate.tattoos, + piercings: toCreate.piercings, + aliases: toCreate.aliases, + twitter: toCreate.twitter, + instagram: toCreate.instagram, + tag_ids: filterData((toCreate.tags ?? []).map((t) => t.stored_id)), + image: + (toCreate.images ?? []).length > 0 + ? (toCreate.images ?? [])[0] + : undefined, + details: toCreate.details, + death_date: toCreate.death_date, + hair_color: toCreate.hair_color, + weight: toCreate.weight ? Number(toCreate.weight) : undefined, + }; + return input; +}; + export const stashBoxQuery = (searchVal: string, stashBoxIndex: number) => client?.query< GQL.QueryStashBoxSceneQuery, From 9200f167bf0591e62480756eaed9d84f9487bcac Mon Sep 17 00:00:00 2001 From: peolic <66393006+peolic@users.noreply.github.com> Date: Tue, 20 Apr 2021 09:48:36 +0300 Subject: [PATCH 36/66] Add studio `*_count` filters and sort options (#1307) --- graphql/schema/types/filters.graphql | 6 + pkg/sqlite/image_test.go | 2 +- pkg/sqlite/setup_test.go | 55 ++++++--- pkg/sqlite/studio.go | 16 ++- pkg/sqlite/studio_test.go | 141 +++++++++++++++++++++++ ui/v2.5/src/models/list-filter/filter.ts | 39 ++++++- 6 files changed, 237 insertions(+), 22 deletions(-) diff --git a/graphql/schema/types/filters.graphql b/graphql/schema/types/filters.graphql index 6152174eb..8c1da2506 100644 --- a/graphql/schema/types/filters.graphql +++ b/graphql/schema/types/filters.graphql @@ -149,6 +149,12 @@ input StudioFilterType { stash_id: String """Filter to only include studios missing this property""" is_missing: String + """Filter by scene count""" + scene_count: IntCriterionInput + """Filter by image count""" + image_count: IntCriterionInput + """Filter by gallery count""" + gallery_count: IntCriterionInput """Filter by url""" url: StringCriterionInput } diff --git a/pkg/sqlite/image_test.go b/pkg/sqlite/image_test.go index d7260e477..50c3f35fc 100644 --- a/pkg/sqlite/image_test.go +++ b/pkg/sqlite/image_test.go @@ -126,7 +126,7 @@ func TestImageQueryPath(t *testing.T) { verifyImagePath(t, pathCriterion, totalImages-1) pathCriterion.Modifier = models.CriterionModifierMatchesRegex - pathCriterion.Value = "image_.*1_Path" + pathCriterion.Value = "image_.*01_Path" verifyImagePath(t, pathCriterion, 1) // TODO - 2 if zip path is included pathCriterion.Modifier = models.CriterionModifierNotMatchesRegex diff --git a/pkg/sqlite/setup_test.go b/pkg/sqlite/setup_test.go index f8a630095..481ebc629 100644 --- a/pkg/sqlite/setup_test.go +++ b/pkg/sqlite/setup_test.go @@ -34,6 +34,8 @@ const ( sceneIdxWithTag sceneIdxWithTwoTags sceneIdxWithStudio + sceneIdx1WithStudio + sceneIdx2WithStudio sceneIdxWithMarker sceneIdxWithPerformerTag sceneIdxWithPerformerTwoTags @@ -53,6 +55,8 @@ const ( imageIdxWithTag imageIdxWithTwoTags imageIdxWithStudio + imageIdx1WithStudio + imageIdx2WithStudio imageIdxInZip // TODO - not implemented imageIdxWithPerformerTag imageIdxWithPerformerTwoTags @@ -105,6 +109,8 @@ const ( galleryIdxWithTag galleryIdxWithTwoTags galleryIdxWithStudio + galleryIdx1WithStudio + galleryIdx2WithStudio galleryIdxWithPerformerTag galleryIdxWithPerformerTwoTags // new indexes above @@ -140,11 +146,14 @@ const ( const ( studioIdxWithScene = iota + studioIdxWithTwoScenes studioIdxWithMovie studioIdxWithChildStudio studioIdxWithParentStudio studioIdxWithImage + studioIdxWithTwoImages studioIdxWithGallery + studioIdxWithTwoGalleries // new indexes above // studios with dup names start from the end studioIdxWithDupName @@ -213,6 +222,8 @@ var ( sceneStudioLinks = [][2]int{ {sceneIdxWithStudio, studioIdxWithScene}, + {sceneIdx1WithStudio, studioIdxWithTwoScenes}, + {sceneIdx2WithStudio, studioIdxWithTwoScenes}, } ) @@ -222,6 +233,8 @@ var ( } imageStudioLinks = [][2]int{ {imageIdxWithStudio, studioIdxWithImage}, + {imageIdx1WithStudio, studioIdxWithTwoImages}, + {imageIdx2WithStudio, studioIdxWithTwoImages}, } imageTagLinks = [][2]int{ {imageIdxWithTag, tagIdxWithImage}, @@ -250,6 +263,12 @@ var ( {galleryIdx2WithPerformer, performerIdxWithTwoGalleries}, } + galleryStudioLinks = [][2]int{ + {galleryIdxWithStudio, studioIdxWithGallery}, + {galleryIdx1WithStudio, studioIdxWithTwoGalleries}, + {galleryIdx2WithStudio, studioIdxWithTwoGalleries}, + } + galleryTagLinks = [][2]int{ {galleryIdxWithTag, tagIdxWithGallery}, {galleryIdxWithTwoTags, tagIdx1WithGallery}, @@ -413,8 +432,8 @@ func populateDB() error { return fmt.Errorf("error linking gallery tags: %s", err.Error()) } - if err := linkGalleryStudio(r.Gallery(), galleryIdxWithStudio, studioIdxWithGallery); err != nil { - return fmt.Errorf("error linking gallery studio: %s", err.Error()) + if err := linkGalleryStudios(r.Gallery()); err != nil { + return fmt.Errorf("error linking gallery studios: %s", err.Error()) } if err := createMarker(r.SceneMarker(), sceneIdxWithMarker, tagIdxWithPrimaryMarker, []int{tagIdxWithMarker}); err != nil { @@ -1017,7 +1036,7 @@ func linkImagePerformers(qb models.ImageReaderWriter) error { func linkGalleryPerformers(qb models.GalleryReaderWriter) error { return doLinks(galleryPerformerLinks, func(galleryIndex, performerIndex int) error { - galleryID := imageIDs[galleryIndex] + galleryID := galleryIDs[galleryIndex] performers, err := qb.GetPerformerIDs(galleryID) if err != nil { return err @@ -1029,17 +1048,29 @@ func linkGalleryPerformers(qb models.GalleryReaderWriter) error { }) } -func linkGalleryTags(iqb models.GalleryReaderWriter) error { +func linkGalleryStudios(qb models.GalleryReaderWriter) error { + return doLinks(galleryStudioLinks, func(galleryIndex, studioIndex int) error { + gallery := models.GalleryPartial{ + ID: galleryIDs[galleryIndex], + StudioID: &sql.NullInt64{Int64: int64(studioIDs[studioIndex]), Valid: true}, + } + _, err := qb.UpdatePartial(gallery) + + return err + }) +} + +func linkGalleryTags(qb models.GalleryReaderWriter) error { return doLinks(galleryTagLinks, func(galleryIndex, tagIndex int) error { - galleryID := imageIDs[galleryIndex] - tags, err := iqb.GetTagIDs(galleryID) + galleryID := galleryIDs[galleryIndex] + tags, err := qb.GetTagIDs(galleryID) if err != nil { return err } tags = append(tags, tagIDs[tagIndex]) - return iqb.UpdateTags(galleryID, tags) + return qb.UpdateTags(galleryID, tags) }) } @@ -1070,13 +1101,3 @@ func linkStudiosParent(qb models.StudioWriter) error { func addTagImage(qb models.TagWriter, tagIndex int) error { return qb.UpdateImage(tagIDs[tagIndex], models.DefaultTagImage) } - -func linkGalleryStudio(qb models.GalleryWriter, galleryIndex, studioIndex int) error { - gallery := models.GalleryPartial{ - ID: galleryIDs[galleryIndex], - StudioID: &sql.NullInt64{Int64: int64(studioIDs[studioIndex]), Valid: true}, - } - _, err := qb.UpdatePartial(gallery) - - return err -} diff --git a/pkg/sqlite/studio.go b/pkg/sqlite/studio.go index c3a984c03..cbfb57f4d 100644 --- a/pkg/sqlite/studio.go +++ b/pkg/sqlite/studio.go @@ -133,7 +133,7 @@ func (qb *studioQueryBuilder) Query(studioFilter *models.StudioFilterType, findF query.body = selectDistinctIDs("studios") query.body += ` - left join scenes on studios.id = scenes.studio_id + left join scenes on studios.id = scenes.studio_id left join studio_stash_ids on studio_stash_ids.studio_id = studios.id ` @@ -165,6 +165,10 @@ func (qb *studioQueryBuilder) Query(studioFilter *models.StudioFilterType, findF query.addArg(stashIDFilter) } + query.handleCountCriterion(studioFilter.SceneCount, studioTable, sceneTable, studioIDColumn) + query.handleCountCriterion(studioFilter.ImageCount, studioTable, imageTable, studioIDColumn) + query.handleCountCriterion(studioFilter.GalleryCount, studioTable, galleryTable, studioIDColumn) + query.handleStringCriterionInput(studioFilter.URL, "studios.url") if isMissingFilter := studioFilter.IsMissing; isMissingFilter != nil && *isMissingFilter != "" { @@ -209,7 +213,15 @@ func (qb *studioQueryBuilder) getStudioSort(findFilter *models.FindFilterType) s sort = findFilter.GetSort("name") direction = findFilter.GetDirection() } - return getSort(sort, direction, "studios") + + switch sort { + case "images_count": + return getCountSort(studioTable, imageTable, studioIDColumn, direction) + case "galleries_count": + return getCountSort(studioTable, galleryTable, studioIDColumn, direction) + default: + return getSort(sort, direction, "studios") + } } func (qb *studioQueryBuilder) queryStudio(query string, args []interface{}) (*models.Studio, error) { diff --git a/pkg/sqlite/studio_test.go b/pkg/sqlite/studio_test.go index 9325c720a..ae17c1cf7 100644 --- a/pkg/sqlite/studio_test.go +++ b/pkg/sqlite/studio_test.go @@ -265,6 +265,147 @@ func TestStudioDestroyStudioImage(t *testing.T) { } } +func TestStudioQuerySceneCount(t *testing.T) { + const sceneCount = 1 + sceneCountCriterion := models.IntCriterionInput{ + Value: sceneCount, + Modifier: models.CriterionModifierEquals, + } + + verifyStudiosSceneCount(t, sceneCountCriterion) + + sceneCountCriterion.Modifier = models.CriterionModifierNotEquals + verifyStudiosSceneCount(t, sceneCountCriterion) + + sceneCountCriterion.Modifier = models.CriterionModifierGreaterThan + verifyStudiosSceneCount(t, sceneCountCriterion) + + sceneCountCriterion.Modifier = models.CriterionModifierLessThan + verifyStudiosSceneCount(t, sceneCountCriterion) +} + +func verifyStudiosSceneCount(t *testing.T, sceneCountCriterion models.IntCriterionInput) { + withTxn(func(r models.Repository) error { + sqb := r.Studio() + studioFilter := models.StudioFilterType{ + SceneCount: &sceneCountCriterion, + } + + studios := queryStudio(t, sqb, &studioFilter, nil) + assert.Greater(t, len(studios), 0) + + for _, studio := range studios { + sceneCount, err := r.Scene().CountByStudioID(studio.ID) + if err != nil { + return err + } + verifyInt(t, sceneCount, sceneCountCriterion) + } + + return nil + }) +} + +func TestStudioQueryImageCount(t *testing.T) { + const imageCount = 1 + imageCountCriterion := models.IntCriterionInput{ + Value: imageCount, + Modifier: models.CriterionModifierEquals, + } + + verifyStudiosImageCount(t, imageCountCriterion) + + imageCountCriterion.Modifier = models.CriterionModifierNotEquals + verifyStudiosImageCount(t, imageCountCriterion) + + imageCountCriterion.Modifier = models.CriterionModifierGreaterThan + verifyStudiosImageCount(t, imageCountCriterion) + + imageCountCriterion.Modifier = models.CriterionModifierLessThan + verifyStudiosImageCount(t, imageCountCriterion) +} + +func verifyStudiosImageCount(t *testing.T, imageCountCriterion models.IntCriterionInput) { + withTxn(func(r models.Repository) error { + sqb := r.Studio() + studioFilter := models.StudioFilterType{ + ImageCount: &imageCountCriterion, + } + + studios := queryStudio(t, sqb, &studioFilter, nil) + assert.Greater(t, len(studios), 0) + + for _, studio := range studios { + pp := 0 + + _, count, err := r.Image().Query(&models.ImageFilterType{ + Studios: &models.MultiCriterionInput{ + Value: []string{strconv.Itoa(studio.ID)}, + Modifier: models.CriterionModifierIncludes, + }, + }, &models.FindFilterType{ + PerPage: &pp, + }) + if err != nil { + return err + } + verifyInt(t, count, imageCountCriterion) + } + + return nil + }) +} + +func TestStudioQueryGalleryCount(t *testing.T) { + const galleryCount = 1 + galleryCountCriterion := models.IntCriterionInput{ + Value: galleryCount, + Modifier: models.CriterionModifierEquals, + } + + verifyStudiosGalleryCount(t, galleryCountCriterion) + + galleryCountCriterion.Modifier = models.CriterionModifierNotEquals + verifyStudiosGalleryCount(t, galleryCountCriterion) + + galleryCountCriterion.Modifier = models.CriterionModifierGreaterThan + verifyStudiosGalleryCount(t, galleryCountCriterion) + + galleryCountCriterion.Modifier = models.CriterionModifierLessThan + verifyStudiosGalleryCount(t, galleryCountCriterion) +} + +func verifyStudiosGalleryCount(t *testing.T, galleryCountCriterion models.IntCriterionInput) { + withTxn(func(r models.Repository) error { + sqb := r.Studio() + studioFilter := models.StudioFilterType{ + GalleryCount: &galleryCountCriterion, + } + + studios := queryStudio(t, sqb, &studioFilter, nil) + assert.Greater(t, len(studios), 0) + + for _, studio := range studios { + pp := 0 + + _, count, err := r.Gallery().Query(&models.GalleryFilterType{ + Studios: &models.MultiCriterionInput{ + Value: []string{strconv.Itoa(studio.ID)}, + Modifier: models.CriterionModifierIncludes, + }, + }, &models.FindFilterType{ + PerPage: &pp, + }) + if err != nil { + return err + } + verifyInt(t, count, galleryCountCriterion) + } + + return nil + }) +} + func TestStudioStashIDs(t *testing.T) { if err := withTxn(func(r models.Repository) error { qb := r.Studio() diff --git a/ui/v2.5/src/models/list-filter/filter.ts b/ui/v2.5/src/models/list-filter/filter.ts index c17d2527f..2aad3672f 100644 --- a/ui/v2.5/src/models/list-filter/filter.ts +++ b/ui/v2.5/src/models/list-filter/filter.ts @@ -240,12 +240,21 @@ export class ListFilterModel { } case FilterMode.Studios: this.sortBy = "name"; - this.sortByOptions = ["name", "scenes_count", "random"]; + this.sortByOptions = [ + "name", + "scenes_count", + "images_count", + "galleries_count", + "random", + ]; this.displayModeOptions = [DisplayMode.Grid]; this.criterionOptions = [ new NoneCriterionOption(), new ParentStudiosCriterionOption(), new StudioIsMissingCriterionOption(), + ListFilterModel.createCriterionOption("scene_count"), + ListFilterModel.createCriterionOption("image_count"), + ListFilterModel.createCriterionOption("gallery_count"), ListFilterModel.createCriterionOption("url"), ]; break; @@ -1034,8 +1043,34 @@ export class ListFilterModel { }; break; } - case "studioIsMissing": + case "studioIsMissing": { result.is_missing = (criterion as IsMissingCriterion).value; + break; + } + case "scene_count": { + const countCrit = criterion as NumberCriterion; + result.scene_count = { + value: countCrit.value, + modifier: countCrit.modifier, + }; + break; + } + case "image_count": { + const countCrit = criterion as NumberCriterion; + result.image_count = { + value: countCrit.value, + modifier: countCrit.modifier, + }; + break; + } + case "gallery_count": { + const countCrit = criterion as NumberCriterion; + result.gallery_count = { + value: countCrit.value, + modifier: countCrit.modifier, + }; + break; + } // no default } }); From 39512e1452cdbc0c14cf5110534b002451b264a1 Mon Sep 17 00:00:00 2001 From: WithoutPants <53250216+WithoutPants@users.noreply.github.com> Date: Tue, 20 Apr 2021 17:12:40 +1000 Subject: [PATCH 37/66] Separate UI (#1299) * Add custom_ui_location to serve UI from filesystem --- pkg/api/server.go | 12 ++++++++++++ pkg/manager/config/config.go | 8 ++++++++ .../src/components/Changelog/versions/v070.md | 1 + ui/v2.5/src/docs/en/Configuration.md | 16 ++++++++++++++++ 4 files changed, 37 insertions(+) diff --git a/pkg/api/server.go b/pkg/api/server.go index a59ef4cee..db7d51855 100644 --- a/pkg/api/server.go +++ b/pkg/api/server.go @@ -234,9 +234,21 @@ func Start() { }) } + customUILocation := c.GetCustomUILocation() + // Serve the web app r.HandleFunc("/*", func(w http.ResponseWriter, r *http.Request) { ext := path.Ext(r.URL.Path) + + if customUILocation != "" { + if r.URL.Path == "index.html" || ext == "" { + r.URL.Path = "/" + } + + http.FileServer(http.Dir(customUILocation)).ServeHTTP(w, r) + return + } + if ext == ".html" || ext == "" { data, _ := uiBox.Find("index.html") _, _ = w.Write(data) diff --git a/pkg/manager/config/config.go b/pkg/manager/config/config.go index b852ca835..1c6a17516 100644 --- a/pkg/manager/config/config.go +++ b/pkg/manager/config/config.go @@ -106,6 +106,10 @@ const Language = "language" // this should be manually configured only const CustomServedFolders = "custom_served_folders" +// UI directory. Overrides to serve the UI from a specific location +// rather than use the embedded UI. +const CustomUILocation = "custom_ui_location" + // Interface options const MenuItems = "menu_items" @@ -523,6 +527,10 @@ func (i *Instance) GetCustomServedFolders() URLMap { return viper.GetStringMapString(CustomServedFolders) } +func (i *Instance) GetCustomUILocation() string { + return viper.GetString(CustomUILocation) +} + // Interface options func (i *Instance) GetMenuItems() []string { if viper.IsSet(MenuItems) { diff --git a/ui/v2.5/src/components/Changelog/versions/v070.md b/ui/v2.5/src/components/Changelog/versions/v070.md index 153372279..a4a6c3bc2 100644 --- a/ui/v2.5/src/components/Changelog/versions/v070.md +++ b/ui/v2.5/src/components/Changelog/versions/v070.md @@ -1,4 +1,5 @@ ### ✨ New Features +* Support serving UI from specific directory location. * Added details, death date, hair color, and weight to Performers. * Added details to Studios. * Added [perceptual dupe checker](/settings?tab=duplicates). diff --git a/ui/v2.5/src/docs/en/Configuration.md b/ui/v2.5/src/docs/en/Configuration.md index ed5ab5edb..d25377149 100644 --- a/ui/v2.5/src/docs/en/Configuration.md +++ b/ui/v2.5/src/docs/en/Configuration.md @@ -115,4 +115,20 @@ These options are typically not exposed in the UI and must be changed manually i | Field | Remarks | |-------|---------| +| `custom_served_folders` | A map of URLs to file system folders. See below. | +| `custom_ui_location` | The file system folder where the UI files will be served from, instead of using the embedded UI. Empty to disable. Stash must be restarted to take effect. | | `max_upload_size` | Maximum file upload size for import files. Defaults to 1GB. | + +### Custom served folders + +Custom served folders are served when the server handles a request with the `/custom` URL prefix. The following is an example configuration: + +``` +custom_served_folders: + /: D:\stash\static + /foo: D:\bar +``` + +With the above configuration, a request for `/custom/foo/bar.png` would serve `D:\bar\bar.png`. + +The `/` entry matches anything that is not otherwise mapped by the other entries. For example, `/custom/baz/xyz.png` would serve `D:\stash\static\baz\xyz.png`. From 8705f78591a8459c7ea9064fa70e7e6c77cf5d07 Mon Sep 17 00:00:00 2001 From: WithoutPants <53250216+WithoutPants@users.noreply.github.com> Date: Tue, 20 Apr 2021 18:58:28 +1000 Subject: [PATCH 38/66] Duplicate checker UI improvements (#1309) * Add tools settings page * Add tools and move dupe checker * Make negative number get all * Show missing phashes * Add multi-edit button * Show scene details --- pkg/manager/task_autotag.go | 2 +- pkg/models/extension_find_filter.go | 12 +- ui/v2.5/src/App.tsx | 5 + .../src/components/Changelog/versions/v070.md | 2 +- .../SceneDuplicateChecker.tsx | 512 ++++++++++++++++++ .../SceneDuplicateChecker/styles.scss | 13 + ui/v2.5/src/components/Settings/Settings.tsx | 14 +- .../Settings/SettingsDuplicatePanel.tsx | 270 --------- .../SettingsTasksPanel/SettingsTasksPanel.tsx | 7 - .../Settings/SettingsToolsPanel.tsx | 19 + ui/v2.5/src/components/Settings/styles.scss | 53 -- ui/v2.5/src/index.scss | 1 + 12 files changed, 566 insertions(+), 344 deletions(-) create mode 100644 ui/v2.5/src/components/SceneDuplicateChecker/SceneDuplicateChecker.tsx create mode 100644 ui/v2.5/src/components/SceneDuplicateChecker/styles.scss delete mode 100644 ui/v2.5/src/components/Settings/SettingsDuplicatePanel.tsx create mode 100644 ui/v2.5/src/components/Settings/SettingsToolsPanel.tsx diff --git a/pkg/manager/task_autotag.go b/pkg/manager/task_autotag.go index cbd7cdc32..6632dc2b9 100644 --- a/pkg/manager/task_autotag.go +++ b/pkg/manager/task_autotag.go @@ -76,7 +76,7 @@ func (t *AutoTagTask) getQueryFilter(regex string) *models.SceneFilterType { } func (t *AutoTagTask) getFindFilter() *models.FindFilterType { - perPage := 0 + perPage := -1 return &models.FindFilterType{ PerPage: &perPage, } diff --git a/pkg/models/extension_find_filter.go b/pkg/models/extension_find_filter.go index e2d4f8d7c..f2e7e7ab1 100644 --- a/pkg/models/extension_find_filter.go +++ b/pkg/models/extension_find_filter.go @@ -35,17 +35,19 @@ func (ff FindFilterType) GetPage() int { func (ff FindFilterType) GetPageSize() int { const defaultPerPage = 25 - const minPerPage = 1 + const minPerPage = 0 const maxPerPage = 1000 if ff.PerPage == nil { return defaultPerPage } - if *ff.PerPage > 1000 { + if *ff.PerPage > maxPerPage { return maxPerPage - } else if *ff.PerPage < 0 { - // PerPage == 0 -> no limit + } else if *ff.PerPage < minPerPage { + // negative page sizes should return all results + // this is a sanity check in case GetPageSize is + // called with a negative page size. return minPerPage } @@ -53,5 +55,5 @@ func (ff FindFilterType) GetPageSize() int { } func (ff FindFilterType) IsGetAll() bool { - return ff.PerPage != nil && *ff.PerPage == 0 + return ff.PerPage != nil && *ff.PerPage < 0 } diff --git a/ui/v2.5/src/App.tsx b/ui/v2.5/src/App.tsx index efee69e4a..96f90da63 100755 --- a/ui/v2.5/src/App.tsx +++ b/ui/v2.5/src/App.tsx @@ -22,6 +22,7 @@ import { Settings } from "./components/Settings/Settings"; import { Stats } from "./components/Stats"; import Studios from "./components/Studios/Studios"; import { SceneFilenameParser } from "./components/SceneFilenameParser/SceneFilenameParser"; +import { SceneDuplicateChecker } from "./components/SceneDuplicateChecker/SceneDuplicateChecker"; import Movies from "./components/Movies/Movies"; import Tags from "./components/Tags/Tags"; import Images from "./components/Images/Images"; @@ -103,6 +104,10 @@ export const App: React.FC = () => { + diff --git a/ui/v2.5/src/components/Changelog/versions/v070.md b/ui/v2.5/src/components/Changelog/versions/v070.md index a4a6c3bc2..a0c890aed 100644 --- a/ui/v2.5/src/components/Changelog/versions/v070.md +++ b/ui/v2.5/src/components/Changelog/versions/v070.md @@ -2,7 +2,7 @@ * Support serving UI from specific directory location. * Added details, death date, hair color, and weight to Performers. * Added details to Studios. -* Added [perceptual dupe checker](/settings?tab=duplicates). +* Added [perceptual dupe checker](/sceneDuplicateChecker). * Add various `count` filter criteria and sort options. * Add URL filter criteria for scenes, galleries, movies, performers and studios. * Add HTTP endpoint for health checking at `/healthz`. diff --git a/ui/v2.5/src/components/SceneDuplicateChecker/SceneDuplicateChecker.tsx b/ui/v2.5/src/components/SceneDuplicateChecker/SceneDuplicateChecker.tsx new file mode 100644 index 000000000..512c64979 --- /dev/null +++ b/ui/v2.5/src/components/SceneDuplicateChecker/SceneDuplicateChecker.tsx @@ -0,0 +1,512 @@ +import React, { useState } from "react"; +import { + Button, + ButtonGroup, + Card, + Col, + Form, + OverlayTrigger, + Row, + Table, + Tooltip, +} from "react-bootstrap"; +import { Link, useHistory } from "react-router-dom"; +import { FormattedNumber } from "react-intl"; +import querystring from "query-string"; + +import * as GQL from "src/core/generated-graphql"; +import { + LoadingIndicator, + ErrorMessage, + HoverPopover, + Icon, + TagLink, + SweatDrops, +} from "src/components/Shared"; +import { Pagination } from "src/components/List/Pagination"; +import { TextUtils } from "src/utils"; +import { DeleteScenesDialog } from "src/components/Scenes/DeleteScenesDialog"; +import { EditScenesDialog } from "../Scenes/EditScenesDialog"; +import { PerformerPopoverButton } from "../Shared/PerformerPopoverButton"; + +const CLASSNAME = "duplicate-checker"; + +export const SceneDuplicateChecker: React.FC = () => { + const history = useHistory(); + const { page, size, distance } = querystring.parse(history.location.search); + const currentPage = Number.parseInt( + Array.isArray(page) ? page[0] : page ?? "1", + 10 + ); + const pageSize = Number.parseInt( + Array.isArray(size) ? size[0] : size ?? "20", + 10 + ); + const hashDistance = Number.parseInt( + Array.isArray(distance) ? distance[0] : distance ?? "0", + 10 + ); + const [isMultiDelete, setIsMultiDelete] = useState(false); + const [deletingScenes, setDeletingScenes] = useState(false); + const [editingScenes, setEditingScenes] = useState(false); + const [checkedScenes, setCheckedScenes] = useState>( + {} + ); + const { data, loading, refetch } = GQL.useFindDuplicateScenesQuery({ + fetchPolicy: "no-cache", + variables: { distance: hashDistance }, + }); + const { data: missingPhash } = GQL.useFindScenesQuery({ + variables: { + filter: { + per_page: 0, + }, + scene_filter: { + is_missing: "phash", + }, + }, + }); + + const [selectedScenes, setSelectedScenes] = useState< + GQL.SlimSceneDataFragment[] | null + >(null); + + if (loading) return ; + if (!data) return ; + + const scenes = data?.findDuplicateScenes ?? []; + const filteredScenes = scenes.slice( + (currentPage - 1) * pageSize, + currentPage * pageSize + ); + const checkCount = Object.keys(checkedScenes).filter( + (id) => checkedScenes[id] + ).length; + + const setQuery = (q: Record) => { + history.push({ + search: querystring.stringify({ + ...querystring.parse(history.location.search), + ...q, + }), + }); + }; + + function onDeleteDialogClosed(deleted: boolean) { + setDeletingScenes(false); + if (deleted) { + setSelectedScenes(null); + refetch(); + if (isMultiDelete) setCheckedScenes({}); + } + } + + const handleCheck = (checked: boolean, sceneID: string) => { + setCheckedScenes({ ...checkedScenes, [sceneID]: checked }); + }; + + const handleDeleteChecked = () => { + setSelectedScenes(scenes.flat().filter((s) => checkedScenes[s.id])); + setDeletingScenes(true); + setIsMultiDelete(true); + }; + + const handleDeleteScene = (scene: GQL.SlimSceneDataFragment) => { + setSelectedScenes([scene]); + setDeletingScenes(true); + setIsMultiDelete(false); + }; + + function onEdit() { + setSelectedScenes(scenes.flat().filter((s) => checkedScenes[s.id])); + setEditingScenes(true); + } + + const renderFilesize = (filesize: string | null | undefined) => { + const { size: parsedSize, unit } = TextUtils.fileSize( + Number.parseInt(filesize ?? "0", 10) + ); + return ( + + ); + }; + + function maybeRenderMissingPhashWarning() { + const missingPhashes = missingPhash?.findScenes.count ?? 0; + if (missingPhashes > 0) { + return ( +

    + + Missing phashes for {missingPhashes} scenes. Please run the phash + generation task. +

    + ); + } + } + + function maybeRenderEdit() { + if (editingScenes && selectedScenes) { + return ( + setEditingScenes(false)} + /> + ); + } + } + + function maybeRenderTagPopoverButton(scene: GQL.SlimSceneDataFragment) { + if (scene.tags.length <= 0) return; + + const popoverContent = scene.tags.map((tag) => ( + + )); + + return ( + + + + ); + } + + function maybeRenderPerformerPopoverButton(scene: GQL.SlimSceneDataFragment) { + if (scene.performers.length <= 0) return; + + return ; + } + + function maybeRenderMoviePopoverButton(scene: GQL.SlimSceneDataFragment) { + if (scene.movies.length <= 0) return; + + const popoverContent = scene.movies.map((sceneMovie) => ( +
    + + {sceneMovie.movie.name + + +
    + )); + + return ( + + + + ); + } + + function maybeRenderSceneMarkerPopoverButton( + scene: GQL.SlimSceneDataFragment + ) { + if (scene.scene_markers.length <= 0) return; + + const popoverContent = scene.scene_markers.map((marker) => { + const markerPopover = { ...marker, scene: { id: scene.id } }; + return ; + }); + + return ( + + + + ); + } + + function maybeRenderOCounter(scene: GQL.SlimSceneDataFragment) { + if (scene.o_counter) { + return ( +
    + +
    + ); + } + } + + function maybeRenderGallery(scene: GQL.SlimSceneDataFragment) { + if (scene.galleries.length <= 0) return; + + const popoverContent = scene.galleries.map((gallery) => ( + + )); + + return ( + + + + ); + } + + function maybeRenderOrganized(scene: GQL.SlimSceneDataFragment) { + if (scene.organized) { + return ( +
    + +
    + ); + } + } + + function maybeRenderPopoverButtonGroup(scene: GQL.SlimSceneDataFragment) { + if ( + scene.tags.length > 0 || + scene.performers.length > 0 || + scene.movies.length > 0 || + scene.scene_markers.length > 0 || + scene?.o_counter || + scene.galleries.length > 0 || + scene.organized + ) { + return ( + <> + + {maybeRenderTagPopoverButton(scene)} + {maybeRenderPerformerPopoverButton(scene)} + {maybeRenderMoviePopoverButton(scene)} + {maybeRenderSceneMarkerPopoverButton(scene)} + {maybeRenderOCounter(scene)} + {maybeRenderGallery(scene)} + {maybeRenderOrganized(scene)} + + + ); + } + } + + return ( + +
    + {deletingScenes && selectedScenes && ( + + )} + {maybeRenderEdit()} +

    Duplicate Scenes

    + + + Search Accuracy + + + setQuery({ + distance: + e.currentTarget.value === "0" + ? undefined + : e.currentTarget.value, + page: undefined, + }) + } + defaultValue={distance ?? 0} + className="input-control ml-4" + > + + + + + + + + + Levels below “Exact” can take longer to calculate. False + positives might also be returned on lower accuracy levels. + + + {maybeRenderMissingPhashWarning()} +
    +
    + {scenes.length} sets of duplicates found. +
    + {checkCount > 0 && ( + + Edit}> + + + Delete}> + + + + )} + + setQuery({ page: newPage === 1 ? undefined : newPage }) + } + /> + + setQuery({ + size: + e.currentTarget.value === "20" + ? undefined + : e.currentTarget.value, + }) + } + > + + + + + + +
    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + {filteredScenes.map((group, groupIndex) => + group.map((scene, i) => ( + <> + {i === 0 && groupIndex !== 0 ? ( + + ) : undefined} + + + + + + + + + + + + + + )) + )} + +
    Details DurationFilesizeResolutionBitrateCodecDelete
    + + handleCheck(e.currentTarget.checked, scene.id) + } + /> + + + } + placement="right" + > + + + +

    + + {scene.title ?? + TextUtils.fileNameFromPath(scene.path)} + +

    +

    {scene.path}

    +
    + {maybeRenderPopoverButtonGroup(scene)} + + {scene.file.duration && + TextUtils.secondsToTimestamp(scene.file.duration)} + {renderFilesize(scene.file.size)}{`${scene.file.width}x${scene.file.height}`} + +  mbps + {scene.file.video_codec} + +
    + {scenes.length === 0 && ( +

    No duplicates found.

    + )} +
    +
    + ); +}; diff --git a/ui/v2.5/src/components/SceneDuplicateChecker/styles.scss b/ui/v2.5/src/components/SceneDuplicateChecker/styles.scss new file mode 100644 index 000000000..24084527a --- /dev/null +++ b/ui/v2.5/src/components/SceneDuplicateChecker/styles.scss @@ -0,0 +1,13 @@ +#scene-duplicate-checker { + .scene-path { + font-size: 0.88em; + } + + .filter-container { + margin: 0; + } + + .separator { + height: 50px; + } +} diff --git a/ui/v2.5/src/components/Settings/Settings.tsx b/ui/v2.5/src/components/Settings/Settings.tsx index 7fa3e200f..1c580ec6e 100644 --- a/ui/v2.5/src/components/Settings/Settings.tsx +++ b/ui/v2.5/src/components/Settings/Settings.tsx @@ -9,7 +9,7 @@ import { SettingsLogsPanel } from "./SettingsLogsPanel"; import { SettingsTasksPanel } from "./SettingsTasksPanel/SettingsTasksPanel"; import { SettingsPluginsPanel } from "./SettingsPluginsPanel"; import { SettingsScrapersPanel } from "./SettingsScrapersPanel"; -import { SettingsDuplicatePanel } from "./SettingsDuplicatePanel"; +import { SettingsToolsPanel } from "./SettingsToolsPanel"; export const Settings: React.FC = () => { const location = useLocation(); @@ -37,6 +37,9 @@ export const Settings: React.FC = () => { Tasks + + Tools + Scrapers @@ -46,9 +49,6 @@ export const Settings: React.FC = () => { Logs - - Dupe Checker - About @@ -66,6 +66,9 @@ export const Settings: React.FC = () => { + + + @@ -75,9 +78,6 @@ export const Settings: React.FC = () => { - - - diff --git a/ui/v2.5/src/components/Settings/SettingsDuplicatePanel.tsx b/ui/v2.5/src/components/Settings/SettingsDuplicatePanel.tsx deleted file mode 100644 index 53916033a..000000000 --- a/ui/v2.5/src/components/Settings/SettingsDuplicatePanel.tsx +++ /dev/null @@ -1,270 +0,0 @@ -import React, { useState } from "react"; -import { Button, Col, Form, Row, Table } from "react-bootstrap"; -import { Link, useHistory } from "react-router-dom"; -import { FormattedNumber } from "react-intl"; -import querystring from "query-string"; - -import * as GQL from "src/core/generated-graphql"; -import { - LoadingIndicator, - ErrorMessage, - HoverPopover, -} from "src/components/Shared"; -import { Pagination } from "src/components/List/Pagination"; -import { TextUtils } from "src/utils"; -import { DeleteScenesDialog } from "src/components/Scenes/DeleteScenesDialog"; - -const CLASSNAME = "DuplicateChecker"; - -export const SettingsDuplicatePanel: React.FC = () => { - const history = useHistory(); - const { page, size, distance } = querystring.parse(history.location.search); - const currentPage = Number.parseInt( - Array.isArray(page) ? page[0] : page ?? "1", - 10 - ); - const pageSize = Number.parseInt( - Array.isArray(size) ? size[0] : size ?? "20", - 10 - ); - const hashDistance = Number.parseInt( - Array.isArray(distance) ? distance[0] : distance ?? "0", - 10 - ); - const [isMultiDelete, setIsMultiDelete] = useState(false); - const [checkedScenes, setCheckedScenes] = useState>( - {} - ); - const { data, loading, refetch } = GQL.useFindDuplicateScenesQuery({ - fetchPolicy: "no-cache", - variables: { distance: hashDistance }, - }); - const [deletingScene, setDeletingScene] = useState< - GQL.SlimSceneDataFragment[] | null - >(null); - - if (loading) return ; - if (!data) return ; - - const scenes = data?.findDuplicateScenes ?? []; - const filteredScenes = scenes.slice( - (currentPage - 1) * pageSize, - currentPage * pageSize - ); - const checkCount = Object.keys(checkedScenes).filter( - (id) => checkedScenes[id] - ).length; - - const setQuery = (q: Record) => { - history.push({ - search: querystring.stringify({ - ...querystring.parse(history.location.search), - ...q, - }), - }); - }; - - function onDeleteDialogClosed(deleted: boolean) { - setDeletingScene(null); - if (deleted) { - refetch(); - if (isMultiDelete) setCheckedScenes({}); - } - } - - const handleCheck = (checked: boolean, sceneID: string) => { - setCheckedScenes({ ...checkedScenes, [sceneID]: checked }); - }; - - const handleDeleteChecked = () => { - setDeletingScene(scenes.flat().filter((s) => checkedScenes[s.id])); - setIsMultiDelete(true); - }; - - const handleDeleteScene = (scene: GQL.SlimSceneDataFragment) => { - setDeletingScene([scene]); - setIsMultiDelete(false); - }; - - const renderFilesize = (filesize: string | null | undefined) => { - const { size: parsedSize, unit } = TextUtils.fileSize( - Number.parseInt(filesize ?? "0", 10) - ); - return ( - - ); - }; - - return ( -
    - {deletingScene && ( - - )} -

    Duplicate Scenes

    - - - Search Accuracy - - - setQuery({ - distance: - e.currentTarget.value === "0" - ? undefined - : e.currentTarget.value, - page: undefined, - }) - } - defaultValue={distance ?? 0} - className="ml-4" - > - - - - - - - - - Levels below “Exact” can take longer to calculate. False - positives might also be returned on lower accuracy levels. - - -
    -
    - {scenes.length} sets of duplicates found. -
    - {checkCount > 0 && ( - - )} - - setQuery({ page: newPage === 1 ? undefined : newPage }) - } - /> - - setQuery({ - size: - e.currentTarget.value === "20" - ? undefined - : e.currentTarget.value, - }) - } - > - - - - - - -
    - - - - - - - - - - - - - - - - - - - - - - - - - - - {filteredScenes.map((group) => - group.map((scene, i) => ( - - - - - - - - - - - - )) - )} - -
    TitleDurationFilesizeResolutionBitrateCodecDelete
    - - handleCheck(e.currentTarget.checked, scene.id) - } - /> - - - } - placement="right" - > - - - - - {scene.title ?? TextUtils.fileNameFromPath(scene.path)} - - - {scene.file.duration && - TextUtils.secondsToTimestamp(scene.file.duration)} - {renderFilesize(scene.file.size)}{`${scene.file.width}x${scene.file.height}`} - -  mbps - {scene.file.video_codec} - -
    - {scenes.length === 0 && ( -

    - No duplicates found. Make sure the phash task has been run. -

    - )} -
    - ); -}; diff --git a/ui/v2.5/src/components/Settings/SettingsTasksPanel/SettingsTasksPanel.tsx b/ui/v2.5/src/components/Settings/SettingsTasksPanel/SettingsTasksPanel.tsx index f4f371e24..1eeaf655e 100644 --- a/ui/v2.5/src/components/Settings/SettingsTasksPanel/SettingsTasksPanel.tsx +++ b/ui/v2.5/src/components/Settings/SettingsTasksPanel/SettingsTasksPanel.tsx @@ -1,6 +1,5 @@ import React, { useState, useEffect } from "react"; import { Button, Form, ProgressBar } from "react-bootstrap"; -import { Link } from "react-router-dom"; import { useJobStatus, useMetadataUpdate, @@ -495,12 +494,6 @@ export const SettingsTasksPanel: React.FC = () => { - - - - - -
    Generated Content
    diff --git a/ui/v2.5/src/components/Settings/SettingsToolsPanel.tsx b/ui/v2.5/src/components/Settings/SettingsToolsPanel.tsx new file mode 100644 index 000000000..02fb701c3 --- /dev/null +++ b/ui/v2.5/src/components/Settings/SettingsToolsPanel.tsx @@ -0,0 +1,19 @@ +import React from "react"; +import { Form } from "react-bootstrap"; +import { Link } from "react-router-dom"; + +export const SettingsToolsPanel: React.FC = () => { + return ( + <> +

    Scene Tools

    + + + Scene Filename Parser + + + + Scene Duplicate Checker + + + ); +}; diff --git a/ui/v2.5/src/components/Settings/styles.scss b/ui/v2.5/src/components/Settings/styles.scss index 9e58835dd..89844ce14 100644 --- a/ui/v2.5/src/components/Settings/styles.scss +++ b/ui/v2.5/src/components/Settings/styles.scss @@ -70,56 +70,3 @@ list-style: none; } } - -.DuplicateChecker { - min-width: 768px; - - .filter-container { - margin: 0; - } - - .duplicate-group { - border-top: 50px solid #30404d; - - &:first-child { - border-top: none; - } - } - - &-table { - table-layout: fixed; - width: 100%; - } - - &-checkbox { - width: 10px; - } - - &-sprite { - width: 110px; - } - - &-duration { - width: 80px; - } - - &-filesize { - width: 90px; - } - - &-resolution { - width: 100px; - } - - &-bitrate { - width: 100px; - } - - &-codec { - width: 70px; - } - - &-operations { - width: 70px; - } -} diff --git a/ui/v2.5/src/index.scss b/ui/v2.5/src/index.scss index 44aec70b9..9efb57a8e 100755 --- a/ui/v2.5/src/index.scss +++ b/ui/v2.5/src/index.scss @@ -9,6 +9,7 @@ @import "src/components/Movies/styles.scss"; @import "src/components/Performers/styles.scss"; @import "src/components/Scenes/styles.scss"; +@import "src/components/SceneDuplicateChecker/styles.scss"; @import "src/components/SceneFilenameParser/styles.scss"; @import "src/components/ScenePlayer/styles.scss"; @import "src/components/Settings/styles.scss"; From 79a180ba738618abef46ffed0685850fadb34ffd Mon Sep 17 00:00:00 2001 From: Jeremy Meyers Date: Tue, 20 Apr 2021 20:33:33 -0400 Subject: [PATCH 39/66] Fix README formatting (#1328) --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 14041e4ed..ff723d81b 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,7 @@ https://stashapp.cc * You can tag videos and find them later. * It provides statistics about performers, tags, studios and other things. -You can [watch a demo video](https://vimeo.com/275537038)to see it in action (password is stashapp). +You can [watch a demo video](https://vimeo.com/275537038) to see it in action (password is stashapp). For further information you can [read the in-app manual](https://github.com/stashapp/stash/tree/develop/ui/v2.5/src/docs/en). From 1767390e0d4244fddf1069397f5cfbf13aa4ac63 Mon Sep 17 00:00:00 2001 From: peolic <66393006+peolic@users.noreply.github.com> Date: Wed, 21 Apr 2021 07:19:40 +0300 Subject: [PATCH 40/66] Overwrite new performer image after clearing current image (#1321) --- ui/v2.5/src/components/Changelog/versions/v070.md | 1 + .../Performers/PerformerDetails/PerformerEditPanel.tsx | 5 +++-- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/ui/v2.5/src/components/Changelog/versions/v070.md b/ui/v2.5/src/components/Changelog/versions/v070.md index a0c890aed..83c9afc60 100644 --- a/ui/v2.5/src/components/Changelog/versions/v070.md +++ b/ui/v2.5/src/components/Changelog/versions/v070.md @@ -24,6 +24,7 @@ * Change performer text query to search by name and alias only. ### 🐛 Bug fixes +* Fix scraped performer image not updating after clearing the current image when creating a new performer. * Fix error preventing adding a new library path when an existing library path is missing. * Fix whitespace in query string returning all objects. * Fix hang on Login page when not connected to internet. diff --git a/ui/v2.5/src/components/Performers/PerformerDetails/PerformerEditPanel.tsx b/ui/v2.5/src/components/Performers/PerformerDetails/PerformerEditPanel.tsx index dce51dd37..499f1cae7 100644 --- a/ui/v2.5/src/components/Performers/PerformerDetails/PerformerEditPanel.tsx +++ b/ui/v2.5/src/components/Performers/PerformerDetails/PerformerEditPanel.tsx @@ -311,9 +311,10 @@ export const PerformerEditPanel: React.FC = ({ // image is a base64 string // #404: don't overwrite image if it has been modified by the user // overwrite if not new since it came from a dialog - // otherwise follow existing behaviour + // overwrite if image was cleared (`null`) + // otherwise follow existing behaviour (`undefined`) if ( - (!isNew || formik.values.image === undefined) && + (!isNew || [null, undefined].includes(formik.values.image)) && state.image !== undefined ) { const imageStr = state.image; From bf3f658091438b92ffa2dde7baf22ccf410af625 Mon Sep 17 00:00:00 2001 From: WithoutPants <53250216+WithoutPants@users.noreply.github.com> Date: Thu, 22 Apr 2021 12:22:51 +1000 Subject: [PATCH 41/66] Movie scene sort (#1325) * Add movie_scene_number sort order * Sort movie scenes by scene number by default --- pkg/sqlite/scene.go | 3 +++ .../src/components/Changelog/versions/v070.md | 1 + .../Movies/MovieDetails/MovieScenesPanel.tsx | 4 +++- ui/v2.5/src/components/Scenes/SceneList.tsx | 3 +++ ui/v2.5/src/hooks/ListHook.tsx | 7 +++++- ui/v2.5/src/models/list-filter/filter.ts | 23 +++++++++++-------- 6 files changed, 30 insertions(+), 11 deletions(-) diff --git a/pkg/sqlite/scene.go b/pkg/sqlite/scene.go index a727c7bfd..2ded07b9c 100644 --- a/pkg/sqlite/scene.go +++ b/pkg/sqlite/scene.go @@ -673,6 +673,9 @@ func (qb *sceneQueryBuilder) setSceneSort(query *queryBuilder, findFilter *model sort := findFilter.GetSort("title") direction := findFilter.GetDirection() switch sort { + case "movie_scene_number": + query.join(moviesScenesTable, "movies_join", "scenes.id") + query.sortAndPagination += fmt.Sprintf(" ORDER BY movies_join.scene_index %s", getSortDirection(direction)) case "tag_count": query.sortAndPagination += getCountSort(sceneTable, scenesTagsTable, sceneIDColumn, direction) case "performer_count": diff --git a/ui/v2.5/src/components/Changelog/versions/v070.md b/ui/v2.5/src/components/Changelog/versions/v070.md index 83c9afc60..170198522 100644 --- a/ui/v2.5/src/components/Changelog/versions/v070.md +++ b/ui/v2.5/src/components/Changelog/versions/v070.md @@ -11,6 +11,7 @@ * Added scene queue. ### 🎨 Improvements +* Sort movie scenes by scene number by default. * Support http request headers in scrapers. * Sort performers by gender in scene/image/gallery cards and details. * Add popover buttons for scenes/images/galleries on performer/studio/tag cards. diff --git a/ui/v2.5/src/components/Movies/MovieDetails/MovieScenesPanel.tsx b/ui/v2.5/src/components/Movies/MovieDetails/MovieScenesPanel.tsx index 8a6f8c7be..aae203f8b 100644 --- a/ui/v2.5/src/components/Movies/MovieDetails/MovieScenesPanel.tsx +++ b/ui/v2.5/src/components/Movies/MovieDetails/MovieScenesPanel.tsx @@ -42,7 +42,9 @@ export const MovieScenesPanel: React.FC = ({ movie }) => { } if (movie && movie.id) { - return ; + return ( + + ); } return <>; }; diff --git a/ui/v2.5/src/components/Scenes/SceneList.tsx b/ui/v2.5/src/components/Scenes/SceneList.tsx index 7dc01c2c6..22b8a18e0 100644 --- a/ui/v2.5/src/components/Scenes/SceneList.tsx +++ b/ui/v2.5/src/components/Scenes/SceneList.tsx @@ -23,11 +23,13 @@ import { SceneCardsGrid } from "./SceneCardsGrid"; interface ISceneList { filterHook?: (filter: ListFilterModel) => ListFilterModel; + defaultSort?: string; persistState?: PersistanceLevel.ALL; } export const SceneList: React.FC = ({ filterHook, + defaultSort, persistState, }) => { const history = useHistory(); @@ -83,6 +85,7 @@ export const SceneList: React.FC = ({ zoomable: true, selectable: true, otherOperations, + defaultSort, renderContent, renderEditDialog: renderEditScenesDialog, renderDeleteDialog, diff --git a/ui/v2.5/src/hooks/ListHook.tsx b/ui/v2.5/src/hooks/ListHook.tsx index 1365da170..5aec643ff 100644 --- a/ui/v2.5/src/hooks/ListHook.tsx +++ b/ui/v2.5/src/hooks/ListHook.tsx @@ -88,6 +88,7 @@ export enum PersistanceLevel { interface IListHookOptions { persistState?: PersistanceLevel; persistanceKey?: string; + defaultSort?: string; filterHook?: (filter: ListFilterModel) => ListFilterModel; zoomable?: boolean; selectable?: boolean; @@ -431,7 +432,11 @@ const useList = ( const persistanceKey = options.persistanceKey ?? options.filterMode; const [filter, setFilter] = useState( - new ListFilterModel(options.filterMode, queryString.parse(location.search)) + new ListFilterModel( + options.filterMode, + queryString.parse(location.search), + options.defaultSort + ) ); const updateInterfaceConfig = useCallback( diff --git a/ui/v2.5/src/models/list-filter/filter.ts b/ui/v2.5/src/models/list-filter/filter.ts index 2aad3672f..917aa07e2 100644 --- a/ui/v2.5/src/models/list-filter/filter.ts +++ b/ui/v2.5/src/models/list-filter/filter.ts @@ -111,11 +111,15 @@ export class ListFilterModel { return new CriterionOption(Criterion.getLabel(criterion), criterion); } - public constructor(filterMode: FilterMode, rawParms?: ParsedQuery) { + public constructor( + filterMode: FilterMode, + rawParms?: ParsedQuery, + defaultSort?: string + ) { const params = rawParms as IQueryParameters; switch (filterMode) { case FilterMode.Scenes: - this.sortBy = "date"; + this.sortBy = defaultSort ?? "date"; this.sortByOptions = [ "title", "path", @@ -131,6 +135,7 @@ export class ListFilterModel { "tag_count", "performer_count", "random", + "movie_scene_number", ]; this.displayModeOptions = [ DisplayMode.Grid, @@ -159,7 +164,7 @@ export class ListFilterModel { ]; break; case FilterMode.Images: - this.sortBy = "path"; + this.sortBy = defaultSort ?? "path"; this.sortByOptions = [ "title", "path", @@ -189,7 +194,7 @@ export class ListFilterModel { ]; break; case FilterMode.Performers: { - this.sortBy = "name"; + this.sortBy = defaultSort ?? "name"; this.sortByOptions = [ "name", "height", @@ -239,7 +244,7 @@ export class ListFilterModel { break; } case FilterMode.Studios: - this.sortBy = "name"; + this.sortBy = defaultSort ?? "name"; this.sortByOptions = [ "name", "scenes_count", @@ -259,7 +264,7 @@ export class ListFilterModel { ]; break; case FilterMode.Movies: - this.sortBy = "name"; + this.sortBy = defaultSort ?? "name"; this.sortByOptions = ["name", "scenes_count", "random"]; this.displayModeOptions = [DisplayMode.Grid]; this.criterionOptions = [ @@ -270,7 +275,7 @@ export class ListFilterModel { ]; break; case FilterMode.Galleries: - this.sortBy = "path"; + this.sortBy = defaultSort ?? "path"; this.sortByOptions = [ "path", "file_mod_time", @@ -302,7 +307,7 @@ export class ListFilterModel { ]; break; case FilterMode.SceneMarkers: - this.sortBy = "title"; + this.sortBy = defaultSort ?? "title"; this.sortByOptions = [ "title", "seconds", @@ -319,7 +324,7 @@ export class ListFilterModel { ]; break; case FilterMode.Tags: - this.sortBy = "name"; + this.sortBy = defaultSort ?? "name"; // scene markers count has been disabled for now due to performance // issues this.sortByOptions = [ From 7836a37d6ecd94df9231c199028f321bbbe0440d Mon Sep 17 00:00:00 2001 From: bnkai <48220860+bnkai@users.noreply.github.com> Date: Thu, 22 Apr 2021 06:51:51 +0300 Subject: [PATCH 42/66] Fix various generate issues (#1322) Co-authored-by: WithoutPants <53250216+WithoutPants@users.noreply.github.com> --- pkg/manager/generator_preview.go | 31 ++++++++++++++----- pkg/manager/manager.go | 13 ++++++-- pkg/manager/manager_tasks.go | 26 ++++++++++------ pkg/utils/time.go | 22 +++++++++++++ .../src/components/Changelog/versions/v070.md | 2 ++ ui/v2.5/src/docs/en/Tagger.md | 6 ++-- 6 files changed, 78 insertions(+), 22 deletions(-) create mode 100644 pkg/utils/time.go diff --git a/pkg/manager/generator_preview.go b/pkg/manager/generator_preview.go index c777aa073..c723b9e69 100644 --- a/pkg/manager/generator_preview.go +++ b/pkg/manager/generator_preview.go @@ -58,11 +58,6 @@ func (g *PreviewGenerator) Generate() error { } encoder := ffmpeg.NewEncoder(instance.FFMPEGPath) - - if err := g.generateConcatFile(); err != nil { - return err - } - if g.GenerateVideo { if err := g.generateVideo(&encoder, false); err != nil { logger.Warnf("[generator] failed generating scene preview, trying fallback") @@ -101,18 +96,32 @@ func (g *PreviewGenerator) generateVideo(encoder *ffmpeg.Encoder, fallback bool) if !g.Overwrite && outputExists { return nil } + err := g.generateConcatFile() + if err != nil { + return err + } + + var tmpFiles []string // a list of tmp files used during the preview generation + tmpFiles = append(tmpFiles, g.getConcatFilePath()) // add concat filename to tmpFiles + defer func() { removeFiles(tmpFiles) }() // remove tmpFiles when done stepSize, offset := g.Info.getStepSizeAndOffset() + durationSegment := g.Info.ChunkDuration + if durationSegment < 0.75 { // a very short duration can create files without a video stream + durationSegment = 0.75 // use 0.75 in that case + logger.Warnf("[generator] Segment duration (%f) too short.Using 0.75 instead.", g.Info.ChunkDuration) + } + for i := 0; i < g.Info.ChunkCount; i++ { time := offset + (float64(i) * stepSize) num := fmt.Sprintf("%.3d", i) filename := "preview_" + g.VideoChecksum + "_" + num + ".mp4" chunkOutputPath := instance.Paths.Generated.GetTmpPath(filename) - + tmpFiles = append(tmpFiles, chunkOutputPath) // add chunk filename to tmpFiles options := ffmpeg.ScenePreviewChunkOptions{ StartTime: time, - Duration: g.Info.ChunkDuration, + Duration: durationSegment, Width: 640, OutputPath: chunkOutputPath, } @@ -152,3 +161,11 @@ func (g *PreviewGenerator) generateImage(encoder *ffmpeg.Encoder) error { func (g *PreviewGenerator) getConcatFilePath() string { return instance.Paths.Generated.GetTmpPath(fmt.Sprintf("files_%s.txt", g.VideoChecksum)) } + +func removeFiles(list []string) { + for _, f := range list { + if err := os.Remove(f); err != nil { + logger.Warnf("[generator] Delete error: %s", err) + } + } +} diff --git a/pkg/manager/manager.go b/pkg/manager/manager.go index 9d4aabea9..1ead1e463 100644 --- a/pkg/manager/manager.go +++ b/pkg/manager/manager.go @@ -6,6 +6,7 @@ import ( "os" "path/filepath" "sync" + "time" "github.com/stashapp/stash/pkg/database" "github.com/stashapp/stash/pkg/ffmpeg" @@ -142,8 +143,16 @@ func (s *singleton) PostInit() error { // clear the downloads and tmp directories // #1021 - only clear these directories if the generated folder is non-empty if s.Config.GetGeneratedPath() != "" { - utils.EmptyDir(instance.Paths.Generated.Downloads) - utils.EmptyDir(instance.Paths.Generated.Tmp) + const deleteTimeout = 1 * time.Second + + utils.Timeout(func() { + utils.EmptyDir(instance.Paths.Generated.Downloads) + utils.EmptyDir(instance.Paths.Generated.Tmp) + }, deleteTimeout, func(done chan struct{}) { + logger.Info("Please wait. Deleting temporary files...") // print + <-done // and wait for deletion + logger.Info("Temporary files deleted.") + }) } if err := database.Initialize(s.Config.GetDatabasePath()); err != nil { diff --git a/pkg/manager/manager_tasks.go b/pkg/manager/manager_tasks.go index 182a938f3..8f1abf551 100644 --- a/pkg/manager/manager_tasks.go +++ b/pkg/manager/manager_tasks.go @@ -192,11 +192,12 @@ func (s *singleton) Scan(input models.ScanMetadataInput) { i := 0 stoppingErr := errors.New("stopping") + var err error var galleries []string for _, sp := range paths { - err := walkFilesToScan(sp, func(path string, info os.FileInfo, err error) error { + err = walkFilesToScan(sp, func(path string, info os.FileInfo, err error) error { if total != nil { s.Status.setProgress(i, *total) i++ @@ -231,26 +232,25 @@ func (s *singleton) Scan(input models.ScanMetadataInput) { }) if err == stoppingErr { + logger.Info("Stopping due to user request") break } if err != nil { logger.Errorf("Error encountered scanning files: %s", err.Error()) - return + break } } - if s.Status.stopping { - logger.Info("Stopping due to user request") - return - } - wg.Wait() instance.Paths.Generated.EmptyTmpDir() - elapsed := time.Since(start) logger.Info(fmt.Sprintf("Scan finished (%s)", elapsed)) + if s.Status.stopping || err != nil { + return + } + for _, path := range galleries { wg.Add() task := ScanTask{ @@ -464,7 +464,7 @@ func (s *singleton) Generate(input models.GenerateMetadataInput) { } setGeneratePreviewOptionsInput(generatePreviewOptions) - // Start measuring how long the scan has taken. (consider moving this up) + // Start measuring how long the generate has taken. (consider moving this up) start := time.Now() instance.Paths.Generated.EnsureTmpDir() @@ -472,6 +472,8 @@ func (s *singleton) Generate(input models.GenerateMetadataInput) { s.Status.setProgress(i, total) if s.Status.stopping { logger.Info("Stopping due to user request") + wg.Wait() + instance.Paths.Generated.EmptyTmpDir() return } @@ -540,6 +542,10 @@ func (s *singleton) Generate(input models.GenerateMetadataInput) { s.Status.setProgress(lenScenes+i, total) if s.Status.stopping { logger.Info("Stopping due to user request") + wg.Wait() + instance.Paths.Generated.EmptyTmpDir() + elapsed := time.Since(start) + logger.Info(fmt.Sprintf("Generate finished (%s)", elapsed)) return } @@ -616,7 +622,7 @@ func (s *singleton) generateScreenshot(sceneId string, at *float64) { wg.Wait() - logger.Infof("Generate finished") + logger.Infof("Generate screenshot finished") }() } diff --git a/pkg/utils/time.go b/pkg/utils/time.go new file mode 100644 index 000000000..59ed6bc93 --- /dev/null +++ b/pkg/utils/time.go @@ -0,0 +1,22 @@ +package utils + +import "time" + +// Timeout executes the provided todo function, and waits for it to return. If +// the function does not return before the waitTime duration is elapsed, then +// onTimeout is executed, passing a channel that will be closed when the +// function returns. +func Timeout(todo func(), waitTime time.Duration, onTimeout func(done chan struct{})) { + done := make(chan struct{}) + + go func() { + todo() + close(done) + }() + + select { + case <-done: // on time, just exit + case <-time.After(waitTime): + onTimeout(done) + } +} diff --git a/ui/v2.5/src/components/Changelog/versions/v070.md b/ui/v2.5/src/components/Changelog/versions/v070.md index 170198522..356ef3a1b 100644 --- a/ui/v2.5/src/components/Changelog/versions/v070.md +++ b/ui/v2.5/src/components/Changelog/versions/v070.md @@ -11,6 +11,8 @@ * Added scene queue. ### 🎨 Improvements +* Clean generation artifacts after generating each scene. +* Log message at startup when cleaning the `tmp` and `downloads` generated folders takes more than one second. * Sort movie scenes by scene number by default. * Support http request headers in scrapers. * Sort performers by gender in scene/image/gallery cards and details. diff --git a/ui/v2.5/src/docs/en/Tagger.md b/ui/v2.5/src/docs/en/Tagger.md index 9469997fa..272259021 100644 --- a/ui/v2.5/src/docs/en/Tagger.md +++ b/ui/v2.5/src/docs/en/Tagger.md @@ -6,16 +6,16 @@ Stash can be integrated with stash-box which acts as a centralized metadata data The fingerprint search matches your current selection of files against the remote stash-box instance. Any scenes with a matching fingerprint will be returned, although there is currently no validation of fingerprints so it’s recommended to double-check the validity before saving. -If no fingerprint match is found it’s possible to search by keywords. The search works by matching the query against a scene’s title_, release date_, _studio name_, and _performer names_. By default the tagger uses metadata set on the file, or parses the filename, this can be changed in the config. +If no fingerprint match is found it’s possible to search by keywords. The search works by matching the query against a scene’s _title_, _release date_, _studio name_, and _performer names_. By default the tagger uses metadata set on the file, or parses the filename, this can be changed in the config. An important thing to note is that it only returns a match *if all query terms are a match*. As an example, if a scene is titled `"A Trip to the Mall"` with the performer `"Jane Doe"`, a search for `"Trip to the Mall 1080p"` will *not* match, however `"trip mall doe"` would. Usually a few pieces of info is enough, for instance performer name + release date or studio name. To avoid common non-related keywords you can add them to the blacklist in the tagger config. Any items in the blacklist are stripped out of the query. #### Saving When a scene is matched stash will try to match the studio and performers against your local studios and performers. If you have previously matched them, they will automatically be selected. If not you either have to select the correct performer/studio from the dropdown, choose create to create a new entity, or skip to ignore it. -Once a scene is saved the scene and the matched studio/performers will have the stash_id saved which will then be used for future tagging. +Once a scene is saved the scene and the matched studio/performers will have the `stash_id` saved which will then be used for future tagging. By default male performers are not shown, this can be enabled in the tagger config. Likewise scene tags are by default not saved. They can be set to either merge with existing tags on the scene, or overwrite them. It is not recommended to set tags currently since they are hard to deduplicate and can litter your data. #### Submitting fingerprints -After a scene is saved you will prompted to submit the fingerprint back to the stash-box instance. This is optional, but can be helpful for other users who have an identical copy who will then be able to match via the fingerprint search. No other information than the stash_id and file fingerprint is submitted. +After a scene is saved you will prompted to submit the fingerprint back to the stash-box instance. This is optional, but can be helpful for other users who have an identical copy who will then be able to match via the fingerprint search. No other information than the `stash_id` and file fingerprint is submitted. From f66010a367460b790bd2c85e3bd0783b293d17e0 Mon Sep 17 00:00:00 2001 From: julien0221 <68500525+julien0221@users.noreply.github.com> Date: Mon, 26 Apr 2021 03:13:50 +0100 Subject: [PATCH 43/66] Fixed 0 for weight when a new performer is created and fixed the search is null (#1336) --- graphql/schema/types/filters.graphql | 2 +- pkg/sqlite/performer.go | 2 +- .../Performers/PerformerDetails/PerformerEditPanel.tsx | 2 +- ui/v2.5/src/models/list-filter/filter.ts | 4 ++-- 4 files changed, 5 insertions(+), 5 deletions(-) diff --git a/graphql/schema/types/filters.graphql b/graphql/schema/types/filters.graphql index 8c1da2506..b0d124bbe 100644 --- a/graphql/schema/types/filters.graphql +++ b/graphql/schema/types/filters.graphql @@ -76,7 +76,7 @@ input PerformerFilterType { """Filter by hair color""" hair_color: StringCriterionInput """Filter by weight""" - weight: StringCriterionInput + weight: IntCriterionInput """Filter by death year""" death_year: IntCriterionInput } diff --git a/pkg/sqlite/performer.go b/pkg/sqlite/performer.go index bbfcbee92..17406eb7c 100644 --- a/pkg/sqlite/performer.go +++ b/pkg/sqlite/performer.go @@ -260,8 +260,8 @@ func (qb *performerQueryBuilder) Query(performerFilter *models.PerformerFilterTy query.handleStringCriterionInput(performerFilter.Tattoos, tableName+".tattoos") query.handleStringCriterionInput(performerFilter.Piercings, tableName+".piercings") query.handleStringCriterionInput(performerFilter.HairColor, tableName+".hair_color") - query.handleStringCriterionInput(performerFilter.Weight, tableName+".weight") query.handleStringCriterionInput(performerFilter.URL, tableName+".url") + query.handleIntCriterionInput(performerFilter.Weight, tableName+".weight") // TODO - need better handling of aliases query.handleStringCriterionInput(performerFilter.Aliases, tableName+".aliases") diff --git a/ui/v2.5/src/components/Performers/PerformerDetails/PerformerEditPanel.tsx b/ui/v2.5/src/components/Performers/PerformerDetails/PerformerEditPanel.tsx index 499f1cae7..cf5916340 100644 --- a/ui/v2.5/src/components/Performers/PerformerDetails/PerformerEditPanel.tsx +++ b/ui/v2.5/src/components/Performers/PerformerDetails/PerformerEditPanel.tsx @@ -138,7 +138,7 @@ export const PerformerEditPanel: React.FC = ({ details: performer.details ?? "", death_date: performer.death_date ?? "", hair_color: performer.hair_color ?? "", - weight: performer.weight ?? "", + weight: performer.weight ?? undefined, }; type InputValues = typeof initialValues; diff --git a/ui/v2.5/src/models/list-filter/filter.ts b/ui/v2.5/src/models/list-filter/filter.ts index 917aa07e2..70dc2fee1 100644 --- a/ui/v2.5/src/models/list-filter/filter.ts +++ b/ui/v2.5/src/models/list-filter/filter.ts @@ -209,6 +209,7 @@ export class ListFilterModel { "birth_year", "death_year", "age", + "weight", ]; const stringCriteria: CriterionType[] = [ "ethnicity", @@ -216,7 +217,6 @@ export class ListFilterModel { "hair_color", "eye_color", "height", - "weight", "measurements", "fake_tits", "career_length", @@ -718,7 +718,7 @@ export class ListFilterModel { break; } case "weight": { - const wCrit = criterion as StringCriterion; + const wCrit = criterion as NumberCriterion; result.weight = { value: wCrit.value, modifier: wCrit.modifier }; break; } From 2eb2d865dc41669d915ad24a4a688ed56fe3dbb2 Mon Sep 17 00:00:00 2001 From: WithoutPants <53250216+WithoutPants@users.noreply.github.com> Date: Mon, 26 Apr 2021 12:51:31 +1000 Subject: [PATCH 44/66] Auto tag rewrite (#1324) --- .../integration_test.go} | 119 ++----- pkg/autotag/performer.go | 42 +++ pkg/autotag/performer_test.go | 81 +++++ pkg/autotag/scene.go | 117 ++++++ pkg/autotag/scene_test.go | 276 +++++++++++++++ pkg/autotag/studio.go | 66 ++++ pkg/autotag/studio_test.go | 85 +++++ pkg/autotag/tag.go | 41 +++ pkg/autotag/tag_test.go | 81 +++++ pkg/autotag/tagger.go | 198 +++++++++++ pkg/manager/manager_tasks.go | 335 ++++++++++++------ pkg/manager/task_autotag.go | 198 ++--------- pkg/models/extension_find_filter.go | 4 + pkg/models/mocks/PerformerReaderWriter.go | 23 ++ pkg/models/mocks/StudioReaderWriter.go | 23 ++ pkg/models/mocks/TagReaderWriter.go | 23 ++ pkg/models/performer.go | 3 + pkg/models/studio.go | 3 + pkg/models/tag.go | 3 + pkg/sqlite/performer.go | 20 ++ pkg/sqlite/performer_test.go | 20 ++ pkg/sqlite/studio.go | 18 + pkg/sqlite/studio_test.go | 20 ++ pkg/sqlite/tag.go | 18 + pkg/sqlite/tag_test.go | 20 ++ .../src/components/Changelog/versions/v070.md | 2 + 26 files changed, 1469 insertions(+), 370 deletions(-) rename pkg/{manager/task_autotag_test.go => autotag/integration_test.go} (71%) create mode 100644 pkg/autotag/performer.go create mode 100644 pkg/autotag/performer_test.go create mode 100644 pkg/autotag/scene.go create mode 100644 pkg/autotag/scene_test.go create mode 100644 pkg/autotag/studio.go create mode 100644 pkg/autotag/studio_test.go create mode 100644 pkg/autotag/tag.go create mode 100644 pkg/autotag/tag_test.go create mode 100644 pkg/autotag/tagger.go diff --git a/pkg/manager/task_autotag_test.go b/pkg/autotag/integration_test.go similarity index 71% rename from pkg/manager/task_autotag_test.go rename to pkg/autotag/integration_test.go index 0eb755c4d..32a0e7501 100644 --- a/pkg/manager/task_autotag_test.go +++ b/pkg/autotag/integration_test.go @@ -1,6 +1,6 @@ // +build integration -package manager +package autotag import ( "context" @@ -8,8 +8,6 @@ import ( "fmt" "io/ioutil" "os" - "strings" - "sync" "testing" "github.com/stashapp/stash/pkg/database" @@ -22,49 +20,12 @@ import ( ) const testName = "Foo's Bar" -const testExtension = ".mp4" const existingStudioName = "ExistingStudio" -const existingStudioSceneName = testName + ".dontChangeStudio" + testExtension +const existingStudioSceneName = testName + ".dontChangeStudio.mp4" var existingStudioID int -var testSeparators = []string{ - ".", - "-", - "_", - " ", -} - -var testEndSeparators = []string{ - "{", - "}", - "(", - ")", - ",", -} - -func generateNamePatterns(name, separator string) []string { - var ret []string - ret = append(ret, fmt.Sprintf("%s%saaa"+testExtension, name, separator)) - ret = append(ret, fmt.Sprintf("aaa%s%s"+testExtension, separator, name)) - ret = append(ret, fmt.Sprintf("aaa%s%s%sbbb"+testExtension, separator, name, separator)) - ret = append(ret, fmt.Sprintf("dir/%s%saaa"+testExtension, name, separator)) - ret = append(ret, fmt.Sprintf("dir\\%s%saaa"+testExtension, name, separator)) - ret = append(ret, fmt.Sprintf("%s%saaa/dir/bbb"+testExtension, name, separator)) - ret = append(ret, fmt.Sprintf("%s%saaa\\dir\\bbb"+testExtension, name, separator)) - ret = append(ret, fmt.Sprintf("dir/%s%s/aaa"+testExtension, name, separator)) - ret = append(ret, fmt.Sprintf("dir\\%s%s\\aaa"+testExtension, name, separator)) - - return ret -} - -func generateFalseNamePattern(name string, separator string) string { - splitted := strings.Split(name, " ") - - return fmt.Sprintf("%s%saaa%s%s"+testExtension, splitted[0], separator, separator, splitted[1]) -} - func testTeardown(databaseFile string) { err := database.DB.Close() @@ -126,7 +87,7 @@ func createStudio(qb models.StudioWriter, name string) (*models.Studio, error) { // create the studio studio := models.Studio{ Checksum: name, - Name: sql.NullString{Valid: true, String: testName}, + Name: sql.NullString{Valid: true, String: name}, } return qb.Create(studio) @@ -148,23 +109,7 @@ func createTag(qb models.TagWriter) error { func createScenes(sqb models.SceneReaderWriter) error { // create the scenes - var scenePatterns []string - var falseScenePatterns []string - - separators := append(testSeparators, testEndSeparators...) - - for _, separator := range separators { - scenePatterns = append(scenePatterns, generateNamePatterns(testName, separator)...) - scenePatterns = append(scenePatterns, generateNamePatterns(strings.ToLower(testName), separator)...) - falseScenePatterns = append(falseScenePatterns, generateFalseNamePattern(testName, separator)) - } - - // add test cases for intra-name separators - for _, separator := range testSeparators { - if separator != " " { - scenePatterns = append(scenePatterns, generateNamePatterns(strings.Replace(testName, " ", separator, -1), separator)...) - } - } + scenePatterns, falseScenePatterns := generateScenePaths(testName) for _, fn := range scenePatterns { err := createScene(sqb, makeScene(fn, true)) @@ -278,17 +223,14 @@ func TestParsePerformers(t *testing.T) { return } - task := AutoTagPerformerTask{ - AutoTagTask: AutoTagTask{ - txnManager: sqlite.NewTransactionManager(), - }, - performer: performers[0], + for _, p := range performers { + if err := withTxn(func(r models.Repository) error { + return PerformerScenes(p, nil, r.Scene()) + }); err != nil { + t.Errorf("Error auto-tagging performers: %s", err) + } } - var wg sync.WaitGroup - wg.Add(1) - task.Start(&wg) - // verify that scenes were tagged correctly withTxn(func(r models.Repository) error { pqb := r.Performer() @@ -328,17 +270,14 @@ func TestParseStudios(t *testing.T) { return } - task := AutoTagStudioTask{ - AutoTagTask: AutoTagTask{ - txnManager: sqlite.NewTransactionManager(), - }, - studio: studios[0], + for _, s := range studios { + if err := withTxn(func(r models.Repository) error { + return StudioScenes(s, nil, r.Scene()) + }); err != nil { + t.Errorf("Error auto-tagging performers: %s", err) + } } - var wg sync.WaitGroup - wg.Add(1) - task.Start(&wg) - // verify that scenes were tagged correctly withTxn(func(r models.Repository) error { scenes, err := r.Scene().All() @@ -354,9 +293,14 @@ func TestParseStudios(t *testing.T) { } } else { // title is only set on scenes where we expect studio to be set - if scene.Title.String == scene.Path && scene.StudioID.Int64 != int64(studios[0].ID) { - t.Errorf("Did not set studio '%s' for path '%s'", testName, scene.Path) - } else if scene.Title.String != scene.Path && scene.StudioID.Int64 == int64(studios[0].ID) { + if scene.Title.String == scene.Path { + if !scene.StudioID.Valid { + t.Errorf("Did not set studio '%s' for path '%s'", testName, scene.Path) + } else if scene.StudioID.Int64 != int64(studios[1].ID) { + t.Errorf("Incorrect studio id %d set for path '%s'", scene.StudioID.Int64, scene.Path) + } + + } else if scene.Title.String != scene.Path && scene.StudioID.Int64 == int64(studios[1].ID) { t.Errorf("Incorrectly set studio '%s' for path '%s'", testName, scene.Path) } } @@ -377,17 +321,14 @@ func TestParseTags(t *testing.T) { return } - task := AutoTagTagTask{ - AutoTagTask: AutoTagTask{ - txnManager: sqlite.NewTransactionManager(), - }, - tag: tags[0], + for _, s := range tags { + if err := withTxn(func(r models.Repository) error { + return TagScenes(s, nil, r.Scene()) + }); err != nil { + t.Errorf("Error auto-tagging performers: %s", err) + } } - var wg sync.WaitGroup - wg.Add(1) - task.Start(&wg) - // verify that scenes were tagged correctly withTxn(func(r models.Repository) error { scenes, err := r.Scene().All() diff --git a/pkg/autotag/performer.go b/pkg/autotag/performer.go new file mode 100644 index 000000000..b6f40c1ad --- /dev/null +++ b/pkg/autotag/performer.go @@ -0,0 +1,42 @@ +package autotag + +import ( + "github.com/stashapp/stash/pkg/models" + "github.com/stashapp/stash/pkg/scene" +) + +func getMatchingPerformers(path string, performerReader models.PerformerReader) ([]*models.Performer, error) { + words := getPathWords(path) + performers, err := performerReader.QueryForAutoTag(words) + + if err != nil { + return nil, err + } + + var ret []*models.Performer + for _, p := range performers { + // TODO - commenting out alias handling until both sides work correctly + if nameMatchesPath(p.Name.String, path) { // || nameMatchesPath(p.Aliases.String, path) { + ret = append(ret, p) + } + } + + return ret, nil +} + +func getPerformerTagger(p *models.Performer) tagger { + return tagger{ + ID: p.ID, + Type: "performer", + Name: p.Name.String, + } +} + +// PerformerScenes searches for scenes whose path matches the provided performer name and tags the scene with the performer. +func PerformerScenes(p *models.Performer, paths []string, rw models.SceneReaderWriter) error { + t := getPerformerTagger(p) + + return t.tagScenes(paths, rw, func(subjectID, otherID int) (bool, error) { + return scene.AddPerformer(rw, otherID, subjectID) + }) +} diff --git a/pkg/autotag/performer_test.go b/pkg/autotag/performer_test.go new file mode 100644 index 000000000..1e935c9d5 --- /dev/null +++ b/pkg/autotag/performer_test.go @@ -0,0 +1,81 @@ +package autotag + +import ( + "testing" + + "github.com/stashapp/stash/pkg/models" + "github.com/stashapp/stash/pkg/models/mocks" + "github.com/stretchr/testify/assert" +) + +func TestPerformerScenes(t *testing.T) { + type test struct { + performerName string + expectedRegex string + } + + performerNames := []test{ + { + "performer name", + `(?i)(?:^|_|[^\w\d])performer[.\-_ ]*name(?:$|_|[^\w\d])`, + }, + { + "performer + name", + `(?i)(?:^|_|[^\w\d])performer[.\-_ ]*\+[.\-_ ]*name(?:$|_|[^\w\d])`, + }, + } + + for _, p := range performerNames { + testPerformerScenes(t, p.performerName, p.expectedRegex) + } +} + +func testPerformerScenes(t *testing.T, performerName, expectedRegex string) { + mockSceneReader := &mocks.SceneReaderWriter{} + + const performerID = 2 + + var scenes []*models.Scene + matchingPaths, falsePaths := generateScenePaths(performerName) + for i, p := range append(matchingPaths, falsePaths...) { + scenes = append(scenes, &models.Scene{ + ID: i + 1, + Path: p, + }) + } + + performer := models.Performer{ + ID: performerID, + Name: models.NullString(performerName), + } + + organized := false + perPage := models.PerPageAll + + expectedSceneFilter := &models.SceneFilterType{ + Organized: &organized, + Path: &models.StringCriterionInput{ + Value: expectedRegex, + Modifier: models.CriterionModifierMatchesRegex, + }, + } + + expectedFindFilter := &models.FindFilterType{ + PerPage: &perPage, + } + + mockSceneReader.On("Query", expectedSceneFilter, expectedFindFilter).Return(scenes, len(scenes), nil).Once() + + for i := range matchingPaths { + sceneID := i + 1 + mockSceneReader.On("GetPerformerIDs", sceneID).Return(nil, nil).Once() + mockSceneReader.On("UpdatePerformers", sceneID, []int{performerID}).Return(nil).Once() + } + + err := PerformerScenes(&performer, nil, mockSceneReader) + + assert := assert.New(t) + + assert.Nil(err) + mockSceneReader.AssertExpectations(t) +} diff --git a/pkg/autotag/scene.go b/pkg/autotag/scene.go new file mode 100644 index 000000000..d9bffd630 --- /dev/null +++ b/pkg/autotag/scene.go @@ -0,0 +1,117 @@ +package autotag + +import ( + "fmt" + "path/filepath" + "strings" + + "github.com/stashapp/stash/pkg/models" + "github.com/stashapp/stash/pkg/scene" +) + +func pathsFilter(paths []string) *models.SceneFilterType { + if paths == nil { + return nil + } + + sep := string(filepath.Separator) + + var ret *models.SceneFilterType + var or *models.SceneFilterType + for _, p := range paths { + newOr := &models.SceneFilterType{} + if or != nil { + or.Or = newOr + } else { + ret = newOr + } + + or = newOr + + if !strings.HasSuffix(p, sep) { + p = p + sep + } + + or.Path = &models.StringCriterionInput{ + Modifier: models.CriterionModifierEquals, + Value: p + "%", + } + } + + return ret +} + +func getMatchingScenes(name string, paths []string, sceneReader models.SceneReader) ([]*models.Scene, error) { + regex := getPathQueryRegex(name) + organized := false + filter := models.SceneFilterType{ + Path: &models.StringCriterionInput{ + Value: "(?i)" + regex, + Modifier: models.CriterionModifierMatchesRegex, + }, + Organized: &organized, + } + + filter.And = pathsFilter(paths) + + pp := models.PerPageAll + scenes, _, err := sceneReader.Query(&filter, &models.FindFilterType{ + PerPage: &pp, + }) + + if err != nil { + return nil, fmt.Errorf("error querying scenes with regex '%s': %s", regex, err.Error()) + } + + var ret []*models.Scene + for _, p := range scenes { + if nameMatchesPath(name, p.Path) { + ret = append(ret, p) + } + } + + return ret, nil +} + +func getSceneFileTagger(s *models.Scene) tagger { + return tagger{ + ID: s.ID, + Type: "scene", + Name: s.GetTitle(), + Path: s.Path, + } +} + +// ScenePerformers tags the provided scene with performers whose name matches the scene's path. +func ScenePerformers(s *models.Scene, rw models.SceneReaderWriter, performerReader models.PerformerReader) error { + t := getSceneFileTagger(s) + + return t.tagPerformers(performerReader, func(subjectID, otherID int) (bool, error) { + return scene.AddPerformer(rw, subjectID, otherID) + }) +} + +// SceneStudios tags the provided scene with the first studio whose name matches the scene's path. +// +// Scenes will not be tagged if studio is already set. +func SceneStudios(s *models.Scene, rw models.SceneReaderWriter, studioReader models.StudioReader) error { + if s.StudioID.Valid { + // don't modify + return nil + } + + t := getSceneFileTagger(s) + + return t.tagStudios(studioReader, func(subjectID, otherID int) (bool, error) { + return addSceneStudio(rw, subjectID, otherID) + }) +} + +// SceneTags tags the provided scene with tags whose name matches the scene's path. +func SceneTags(s *models.Scene, rw models.SceneReaderWriter, tagReader models.TagReader) error { + t := getSceneFileTagger(s) + + return t.tagTags(tagReader, func(subjectID, otherID int) (bool, error) { + return scene.AddTag(rw, subjectID, otherID) + }) +} diff --git a/pkg/autotag/scene_test.go b/pkg/autotag/scene_test.go new file mode 100644 index 000000000..a71056885 --- /dev/null +++ b/pkg/autotag/scene_test.go @@ -0,0 +1,276 @@ +package autotag + +import ( + "fmt" + "strings" + "testing" + + "github.com/stashapp/stash/pkg/models" + "github.com/stashapp/stash/pkg/models/mocks" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" +) + +var testSeparators = []string{ + ".", + "-", + "_", + " ", +} + +var testEndSeparators = []string{ + "{", + "}", + "(", + ")", + ",", +} + +func generateNamePatterns(name, separator string) []string { + var ret []string + ret = append(ret, fmt.Sprintf("%s%saaa.mp4", name, separator)) + ret = append(ret, fmt.Sprintf("aaa%s%s.mp4", separator, name)) + ret = append(ret, fmt.Sprintf("aaa%s%s%sbbb.mp4", separator, name, separator)) + ret = append(ret, fmt.Sprintf("dir/%s%saaa.mp4", name, separator)) + ret = append(ret, fmt.Sprintf("dir\\%s%saaa.mp4", name, separator)) + ret = append(ret, fmt.Sprintf("%s%saaa/dir/bbb.mp4", name, separator)) + ret = append(ret, fmt.Sprintf("%s%saaa\\dir\\bbb.mp4", name, separator)) + ret = append(ret, fmt.Sprintf("dir/%s%s/aaa.mp4", name, separator)) + ret = append(ret, fmt.Sprintf("dir\\%s%s\\aaa.mp4", name, separator)) + + return ret +} + +func generateSplitNamePatterns(name, separator string) []string { + var ret []string + splitted := strings.Split(name, " ") + // only do this for names that are split into two + if len(splitted) == 2 { + ret = append(ret, fmt.Sprintf("%s%s%s.mp4", splitted[0], separator, splitted[1])) + } + + return ret +} + +func generateFalseNamePatterns(name string, separator string) []string { + splitted := strings.Split(name, " ") + + var ret []string + // only do this for names that are split into two + if len(splitted) == 2 { + ret = append(ret, fmt.Sprintf("%s%saaa%s%s.mp4", splitted[0], separator, separator, splitted[1])) + } + + return ret +} + +func generateScenePaths(testName string) (scenePatterns []string, falseScenePatterns []string) { + separators := append(testSeparators, testEndSeparators...) + + for _, separator := range separators { + scenePatterns = append(scenePatterns, generateNamePatterns(testName, separator)...) + scenePatterns = append(scenePatterns, generateNamePatterns(strings.ToLower(testName), separator)...) + scenePatterns = append(scenePatterns, generateNamePatterns(strings.ReplaceAll(testName, " ", ""), separator)...) + falseScenePatterns = append(falseScenePatterns, generateFalseNamePatterns(testName, separator)...) + } + + // add test cases for intra-name separators + for _, separator := range testSeparators { + if separator != " " { + scenePatterns = append(scenePatterns, generateNamePatterns(strings.Replace(testName, " ", separator, -1), separator)...) + } + } + + // add basic false scenarios + falseScenePatterns = append(falseScenePatterns, fmt.Sprintf("aaa%s.mp4", testName)) + falseScenePatterns = append(falseScenePatterns, fmt.Sprintf("%saaa.mp4", testName)) + + // add path separator false scenarios + falseScenePatterns = append(falseScenePatterns, generateFalseNamePatterns(testName, "/")...) + falseScenePatterns = append(falseScenePatterns, generateFalseNamePatterns(testName, "\\")...) + + // split patterns only valid for ._- and whitespace + for _, separator := range testSeparators { + scenePatterns = append(scenePatterns, generateSplitNamePatterns(testName, separator)...) + } + + // false patterns for other separators + for _, separator := range testEndSeparators { + falseScenePatterns = append(falseScenePatterns, generateSplitNamePatterns(testName, separator)...) + } + + return +} + +type pathTestTable struct { + ScenePath string + Matches bool +} + +func generateTestTable(testName string) []pathTestTable { + var ret []pathTestTable + + var scenePatterns []string + var falseScenePatterns []string + + separators := append(testSeparators, testEndSeparators...) + + for _, separator := range separators { + scenePatterns = append(scenePatterns, generateNamePatterns(testName, separator)...) + scenePatterns = append(scenePatterns, generateNamePatterns(strings.ToLower(testName), separator)...) + falseScenePatterns = append(falseScenePatterns, generateFalseNamePatterns(testName, separator)...) + } + + for _, p := range scenePatterns { + t := pathTestTable{ + ScenePath: p, + Matches: true, + } + + ret = append(ret, t) + } + + for _, p := range falseScenePatterns { + t := pathTestTable{ + ScenePath: p, + Matches: false, + } + + ret = append(ret, t) + } + + return ret +} + +func TestScenePerformers(t *testing.T) { + const sceneID = 1 + const performerName = "performer name" + const performerID = 2 + performer := models.Performer{ + ID: performerID, + Name: models.NullString(performerName), + } + + const reversedPerformerName = "name performer" + const reversedPerformerID = 3 + reversedPerformer := models.Performer{ + ID: reversedPerformerID, + Name: models.NullString(reversedPerformerName), + } + + testTables := generateTestTable(performerName) + + assert := assert.New(t) + + for _, test := range testTables { + mockPerformerReader := &mocks.PerformerReaderWriter{} + mockSceneReader := &mocks.SceneReaderWriter{} + + mockPerformerReader.On("QueryForAutoTag", mock.Anything).Return([]*models.Performer{&performer, &reversedPerformer}, nil).Once() + + if test.Matches { + mockSceneReader.On("GetPerformerIDs", sceneID).Return(nil, nil).Once() + mockSceneReader.On("UpdatePerformers", sceneID, []int{performerID}).Return(nil).Once() + } + + scene := models.Scene{ + ID: sceneID, + Path: test.ScenePath, + } + err := ScenePerformers(&scene, mockSceneReader, mockPerformerReader) + + assert.Nil(err) + mockPerformerReader.AssertExpectations(t) + mockSceneReader.AssertExpectations(t) + } +} + +func TestSceneStudios(t *testing.T) { + const sceneID = 1 + const studioName = "studio name" + const studioID = 2 + studio := models.Studio{ + ID: studioID, + Name: models.NullString(studioName), + } + + const reversedStudioName = "name studio" + const reversedStudioID = 3 + reversedStudio := models.Studio{ + ID: reversedStudioID, + Name: models.NullString(reversedStudioName), + } + + testTables := generateTestTable(studioName) + + assert := assert.New(t) + + for _, test := range testTables { + mockStudioReader := &mocks.StudioReaderWriter{} + mockSceneReader := &mocks.SceneReaderWriter{} + + mockStudioReader.On("QueryForAutoTag", mock.Anything).Return([]*models.Studio{&studio, &reversedStudio}, nil).Once() + + if test.Matches { + mockSceneReader.On("Find", sceneID).Return(&models.Scene{}, nil).Once() + expectedStudioID := models.NullInt64(studioID) + mockSceneReader.On("Update", models.ScenePartial{ + ID: sceneID, + StudioID: &expectedStudioID, + }).Return(nil, nil).Once() + } + + scene := models.Scene{ + ID: sceneID, + Path: test.ScenePath, + } + err := SceneStudios(&scene, mockSceneReader, mockStudioReader) + + assert.Nil(err) + mockStudioReader.AssertExpectations(t) + mockSceneReader.AssertExpectations(t) + } +} + +func TestSceneTags(t *testing.T) { + const sceneID = 1 + const tagName = "tag name" + const tagID = 2 + tag := models.Tag{ + ID: tagID, + Name: tagName, + } + + const reversedTagName = "name tag" + const reversedTagID = 3 + reversedTag := models.Tag{ + ID: reversedTagID, + Name: reversedTagName, + } + + testTables := generateTestTable(tagName) + + assert := assert.New(t) + + for _, test := range testTables { + mockTagReader := &mocks.TagReaderWriter{} + mockSceneReader := &mocks.SceneReaderWriter{} + + mockTagReader.On("QueryForAutoTag", mock.Anything).Return([]*models.Tag{&tag, &reversedTag}, nil).Once() + + if test.Matches { + mockSceneReader.On("GetTagIDs", sceneID).Return(nil, nil).Once() + mockSceneReader.On("UpdateTags", sceneID, []int{tagID}).Return(nil).Once() + } + + scene := models.Scene{ + ID: sceneID, + Path: test.ScenePath, + } + err := SceneTags(&scene, mockSceneReader, mockTagReader) + + assert.Nil(err) + mockTagReader.AssertExpectations(t) + mockSceneReader.AssertExpectations(t) + } +} diff --git a/pkg/autotag/studio.go b/pkg/autotag/studio.go new file mode 100644 index 000000000..c01eedecc --- /dev/null +++ b/pkg/autotag/studio.go @@ -0,0 +1,66 @@ +package autotag + +import ( + "database/sql" + + "github.com/stashapp/stash/pkg/models" +) + +func getMatchingStudios(path string, reader models.StudioReader) ([]*models.Studio, error) { + words := getPathWords(path) + candidates, err := reader.QueryForAutoTag(words) + + if err != nil { + return nil, err + } + + var ret []*models.Studio + for _, c := range candidates { + if nameMatchesPath(c.Name.String, path) { + ret = append(ret, c) + } + } + + return ret, nil +} + +func addSceneStudio(sceneWriter models.SceneReaderWriter, sceneID, studioID int) (bool, error) { + // don't set if already set + scene, err := sceneWriter.Find(sceneID) + if err != nil { + return false, err + } + + if scene.StudioID.Valid { + return false, nil + } + + // set the studio id + s := sql.NullInt64{Int64: int64(studioID), Valid: true} + scenePartial := models.ScenePartial{ + ID: sceneID, + StudioID: &s, + } + + if _, err := sceneWriter.Update(scenePartial); err != nil { + return false, err + } + return true, nil +} + +func getStudioTagger(p *models.Studio) tagger { + return tagger{ + ID: p.ID, + Type: "studio", + Name: p.Name.String, + } +} + +// StudioScenes searches for scenes whose path matches the provided studio name and tags the scene with the studio, if studio is not already set on the scene. +func StudioScenes(p *models.Studio, paths []string, rw models.SceneReaderWriter) error { + t := getStudioTagger(p) + + return t.tagScenes(paths, rw, func(subjectID, otherID int) (bool, error) { + return addSceneStudio(rw, otherID, subjectID) + }) +} diff --git a/pkg/autotag/studio_test.go b/pkg/autotag/studio_test.go new file mode 100644 index 000000000..d61ba7efb --- /dev/null +++ b/pkg/autotag/studio_test.go @@ -0,0 +1,85 @@ +package autotag + +import ( + "testing" + + "github.com/stashapp/stash/pkg/models" + "github.com/stashapp/stash/pkg/models/mocks" + "github.com/stretchr/testify/assert" +) + +func TestStudioScenes(t *testing.T) { + type test struct { + studioName string + expectedRegex string + } + + studioNames := []test{ + { + "studio name", + `(?i)(?:^|_|[^\w\d])studio[.\-_ ]*name(?:$|_|[^\w\d])`, + }, + { + "studio + name", + `(?i)(?:^|_|[^\w\d])studio[.\-_ ]*\+[.\-_ ]*name(?:$|_|[^\w\d])`, + }, + } + + for _, p := range studioNames { + testStudioScenes(t, p.studioName, p.expectedRegex) + } +} + +func testStudioScenes(t *testing.T, studioName, expectedRegex string) { + mockSceneReader := &mocks.SceneReaderWriter{} + + const studioID = 2 + + var scenes []*models.Scene + matchingPaths, falsePaths := generateScenePaths(studioName) + for i, p := range append(matchingPaths, falsePaths...) { + scenes = append(scenes, &models.Scene{ + ID: i + 1, + Path: p, + }) + } + + studio := models.Studio{ + ID: studioID, + Name: models.NullString(studioName), + } + + organized := false + perPage := models.PerPageAll + + expectedSceneFilter := &models.SceneFilterType{ + Organized: &organized, + Path: &models.StringCriterionInput{ + Value: expectedRegex, + Modifier: models.CriterionModifierMatchesRegex, + }, + } + + expectedFindFilter := &models.FindFilterType{ + PerPage: &perPage, + } + + mockSceneReader.On("Query", expectedSceneFilter, expectedFindFilter).Return(scenes, len(scenes), nil).Once() + + for i := range matchingPaths { + sceneID := i + 1 + mockSceneReader.On("Find", sceneID).Return(&models.Scene{}, nil).Once() + expectedStudioID := models.NullInt64(studioID) + mockSceneReader.On("Update", models.ScenePartial{ + ID: sceneID, + StudioID: &expectedStudioID, + }).Return(nil, nil).Once() + } + + err := StudioScenes(&studio, nil, mockSceneReader) + + assert := assert.New(t) + + assert.Nil(err) + mockSceneReader.AssertExpectations(t) +} diff --git a/pkg/autotag/tag.go b/pkg/autotag/tag.go new file mode 100644 index 000000000..4f08394cc --- /dev/null +++ b/pkg/autotag/tag.go @@ -0,0 +1,41 @@ +package autotag + +import ( + "github.com/stashapp/stash/pkg/models" + "github.com/stashapp/stash/pkg/scene" +) + +func getMatchingTags(path string, tagReader models.TagReader) ([]*models.Tag, error) { + words := getPathWords(path) + tags, err := tagReader.QueryForAutoTag(words) + + if err != nil { + return nil, err + } + + var ret []*models.Tag + for _, p := range tags { + if nameMatchesPath(p.Name, path) { + ret = append(ret, p) + } + } + + return ret, nil +} + +func getTagTagger(p *models.Tag) tagger { + return tagger{ + ID: p.ID, + Type: "tag", + Name: p.Name, + } +} + +// TagScenes searches for scenes whose path matches the provided tag name and tags the scene with the tag. +func TagScenes(p *models.Tag, paths []string, rw models.SceneReaderWriter) error { + t := getTagTagger(p) + + return t.tagScenes(paths, rw, func(subjectID, otherID int) (bool, error) { + return scene.AddTag(rw, otherID, subjectID) + }) +} diff --git a/pkg/autotag/tag_test.go b/pkg/autotag/tag_test.go new file mode 100644 index 000000000..47dd3356e --- /dev/null +++ b/pkg/autotag/tag_test.go @@ -0,0 +1,81 @@ +package autotag + +import ( + "testing" + + "github.com/stashapp/stash/pkg/models" + "github.com/stashapp/stash/pkg/models/mocks" + "github.com/stretchr/testify/assert" +) + +func TestTagScenes(t *testing.T) { + type test struct { + tagName string + expectedRegex string + } + + tagNames := []test{ + { + "tag name", + `(?i)(?:^|_|[^\w\d])tag[.\-_ ]*name(?:$|_|[^\w\d])`, + }, + { + "tag + name", + `(?i)(?:^|_|[^\w\d])tag[.\-_ ]*\+[.\-_ ]*name(?:$|_|[^\w\d])`, + }, + } + + for _, p := range tagNames { + testTagScenes(t, p.tagName, p.expectedRegex) + } +} + +func testTagScenes(t *testing.T, tagName, expectedRegex string) { + mockSceneReader := &mocks.SceneReaderWriter{} + + const tagID = 2 + + var scenes []*models.Scene + matchingPaths, falsePaths := generateScenePaths(tagName) + for i, p := range append(matchingPaths, falsePaths...) { + scenes = append(scenes, &models.Scene{ + ID: i + 1, + Path: p, + }) + } + + tag := models.Tag{ + ID: tagID, + Name: tagName, + } + + organized := false + perPage := models.PerPageAll + + expectedSceneFilter := &models.SceneFilterType{ + Organized: &organized, + Path: &models.StringCriterionInput{ + Value: expectedRegex, + Modifier: models.CriterionModifierMatchesRegex, + }, + } + + expectedFindFilter := &models.FindFilterType{ + PerPage: &perPage, + } + + mockSceneReader.On("Query", expectedSceneFilter, expectedFindFilter).Return(scenes, len(scenes), nil).Once() + + for i := range matchingPaths { + sceneID := i + 1 + mockSceneReader.On("GetTagIDs", sceneID).Return(nil, nil).Once() + mockSceneReader.On("UpdateTags", sceneID, []int{tagID}).Return(nil).Once() + } + + err := TagScenes(&tag, nil, mockSceneReader) + + assert := assert.New(t) + + assert.Nil(err) + mockSceneReader.AssertExpectations(t) +} diff --git a/pkg/autotag/tagger.go b/pkg/autotag/tagger.go new file mode 100644 index 000000000..8690ffc8b --- /dev/null +++ b/pkg/autotag/tagger.go @@ -0,0 +1,198 @@ +// Package autotag provides methods to auto-tag scenes with performers, +// studios and tags. +// +// The autotag engine tags scenes with performers/studios/tags if the scene's +// path matches the performer/studio/tag name. A scene's path is considered +// a match if it contains the performer/studio/tag's full name, ignoring any +// '.', '-', '_' characters in the path. +// +// For example, for a performer "foo bar", the following paths would be +// considered a match: "foo bar.mp4", "foobar.mp4", "foo.bar.mp4", +// "foo-bar.mp4", "aaa.foo bar.bbb.mp4". +// The following would not be considered a match: +// "aafoo bar.mp4", "foo barbb.mp4", "foo/bar.mp4" +package autotag + +import ( + "fmt" + "path/filepath" + "regexp" + "strings" + + "github.com/stashapp/stash/pkg/logger" + "github.com/stashapp/stash/pkg/models" +) + +const separatorChars = `.\-_ ` + +// fixes #1292 +func escapePathRegex(name string) string { + ret := name + + chars := `+*?()|[]{}^$` + for _, c := range chars { + cStr := string(c) + ret = strings.ReplaceAll(ret, cStr, `\`+cStr) + } + + return ret +} + +func getPathQueryRegex(name string) string { + // escape specific regex characters + name = escapePathRegex(name) + + // handle path separators + const separator = `[` + separatorChars + `]` + + ret := strings.Replace(name, " ", separator+"*", -1) + ret = `(?:^|_|[^\w\d])` + ret + `(?:$|_|[^\w\d])` + return ret +} + +func nameMatchesPath(name, path string) bool { + // escape specific regex characters + name = escapePathRegex(name) + + name = strings.ToLower(name) + path = strings.ToLower(path) + + // handle path separators + const separator = `[` + separatorChars + `]` + + reStr := strings.Replace(name, " ", separator+"*", -1) + reStr = `(?:^|_|[^\w\d])` + reStr + `(?:$|_|[^\w\d])` + + re := regexp.MustCompile(reStr) + return re.MatchString(path) +} + +func getPathWords(path string) []string { + retStr := path + + // remove the extension + ext := filepath.Ext(retStr) + if ext != "" { + retStr = strings.TrimSuffix(retStr, ext) + } + + // handle path separators + const separator = `(?:_|[^\w\d])+` + re := regexp.MustCompile(separator) + retStr = re.ReplaceAllString(retStr, " ") + + words := strings.Split(retStr, " ") + + // remove any single letter words + var ret []string + for _, w := range words { + if len(w) > 1 { + ret = append(ret, w) + } + } + + return ret +} + +type tagger struct { + ID int + Type string + Name string + Path string +} + +type addLinkFunc func(subjectID, otherID int) (bool, error) + +func (t *tagger) addError(otherType, otherName string, err error) error { + return fmt.Errorf("error adding %s '%s' to %s '%s': %s", otherType, otherName, t.Type, t.Name, err.Error()) +} + +func (t *tagger) addLog(otherType, otherName string) { + logger.Infof("Added %s '%s' to %s '%s'", otherType, otherName, t.Type, t.Name) +} + +func (t *tagger) tagPerformers(performerReader models.PerformerReader, addFunc addLinkFunc) error { + others, err := getMatchingPerformers(t.Path, performerReader) + if err != nil { + return err + } + + for _, p := range others { + added, err := addFunc(t.ID, p.ID) + + if err != nil { + return t.addError("performer", p.Name.String, err) + } + + if added { + t.addLog("performer", p.Name.String) + } + } + + return nil +} + +func (t *tagger) tagStudios(studioReader models.StudioReader, addFunc addLinkFunc) error { + others, err := getMatchingStudios(t.Path, studioReader) + if err != nil { + return err + } + + // only add first studio + if len(others) > 0 { + studio := others[0] + added, err := addFunc(t.ID, studio.ID) + + if err != nil { + return t.addError("studio", studio.Name.String, err) + } + + if added { + t.addLog("studio", studio.Name.String) + } + } + + return nil +} + +func (t *tagger) tagTags(tagReader models.TagReader, addFunc addLinkFunc) error { + others, err := getMatchingTags(t.Path, tagReader) + if err != nil { + return err + } + + for _, p := range others { + added, err := addFunc(t.ID, p.ID) + + if err != nil { + return t.addError("tag", p.Name, err) + } + + if added { + t.addLog("tag", p.Name) + } + } + + return nil +} + +func (t *tagger) tagScenes(paths []string, sceneReader models.SceneReader, addFunc addLinkFunc) error { + others, err := getMatchingScenes(t.Name, paths, sceneReader) + if err != nil { + return err + } + + for _, p := range others { + added, err := addFunc(t.ID, p.ID) + + if err != nil { + return t.addError("scene", p.GetTitle(), err) + } + + if added { + t.addLog("scene", p.GetTitle()) + } + } + + return nil +} diff --git a/pkg/manager/manager_tasks.go b/pkg/manager/manager_tasks.go index 8f1abf551..ffa7f1722 100644 --- a/pkg/manager/manager_tasks.go +++ b/pkg/manager/manager_tasks.go @@ -5,12 +5,15 @@ import ( "errors" "fmt" "os" + "path/filepath" "strconv" + "strings" "sync" "time" "github.com/remeh/sizedwaitgroup" + "github.com/stashapp/stash/pkg/autotag" "github.com/stashapp/stash/pkg/logger" "github.com/stashapp/stash/pkg/manager/config" "github.com/stashapp/stash/pkg/models" @@ -626,6 +629,15 @@ func (s *singleton) generateScreenshot(sceneId string, at *float64) { }() } +func (s *singleton) isFileBasedAutoTag(input models.AutoTagMetadataInput) bool { + const wildcard = "*" + performerIds := input.Performers + studioIds := input.Studios + tagIds := input.Tags + + return (len(performerIds) == 0 || performerIds[0] == wildcard) && (len(studioIds) == 0 || studioIds[0] == wildcard) && (len(tagIds) == 0 || tagIds[0] == wildcard) +} + func (s *singleton) AutoTag(input models.AutoTagMetadataInput) { if s.Status.Status != Idle { return @@ -636,58 +648,160 @@ func (s *singleton) AutoTag(input models.AutoTagMetadataInput) { go func() { defer s.returnToIdleState() - performerIds := input.Performers - studioIds := input.Studios - tagIds := input.Tags - - // calculate work load - performerCount := len(performerIds) - studioCount := len(studioIds) - tagCount := len(tagIds) - - if err := s.TxnManager.WithReadTxn(context.TODO(), func(r models.ReaderRepository) error { - performerQuery := r.Performer() - studioQuery := r.Studio() - tagQuery := r.Tag() - - const wildcard = "*" - var err error - if performerCount == 1 && performerIds[0] == wildcard { - performerCount, err = performerQuery.Count() - if err != nil { - return fmt.Errorf("Error getting performer count: %s", err.Error()) - } - } - if studioCount == 1 && studioIds[0] == wildcard { - studioCount, err = studioQuery.Count() - if err != nil { - return fmt.Errorf("Error getting studio count: %s", err.Error()) - } - } - if tagCount == 1 && tagIds[0] == wildcard { - tagCount, err = tagQuery.Count() - if err != nil { - return fmt.Errorf("Error getting tag count: %s", err.Error()) - } - } - - return nil - }); err != nil { - logger.Error(err.Error()) - return + if s.isFileBasedAutoTag(input) { + // doing file-based auto-tag + s.autoTagScenes(input.Paths, len(input.Performers) > 0, len(input.Studios) > 0, len(input.Tags) > 0) + } else { + // doing specific performer/studio/tag auto-tag + s.autoTagSpecific(input) } - - total := performerCount + studioCount + tagCount - s.Status.setProgress(0, total) - - s.autoTagPerformers(input.Paths, performerIds) - s.autoTagStudios(input.Paths, studioIds) - s.autoTagTags(input.Paths, tagIds) }() } +func (s *singleton) autoTagScenes(paths []string, performers, studios, tags bool) { + if err := s.TxnManager.WithReadTxn(context.TODO(), func(r models.ReaderRepository) error { + ret := &models.SceneFilterType{} + or := ret + sep := string(filepath.Separator) + + for _, p := range paths { + if !strings.HasSuffix(p, sep) { + p = p + sep + } + + if ret.Path == nil { + or = ret + } else { + newOr := &models.SceneFilterType{} + or.Or = newOr + or = newOr + } + + or.Path = &models.StringCriterionInput{ + Modifier: models.CriterionModifierEquals, + Value: p + "%", + } + } + + organized := false + ret.Organized = &organized + + // batch process scenes + batchSize := 1000 + page := 1 + findFilter := &models.FindFilterType{ + PerPage: &batchSize, + Page: &page, + } + + more := true + processed := 0 + for more { + scenes, total, err := r.Scene().Query(ret, findFilter) + if err != nil { + return err + } + + if processed == 0 { + logger.Infof("Starting autotag of %d scenes", total) + } + + for _, ss := range scenes { + if s.Status.stopping { + logger.Info("Stopping due to user request") + return nil + } + + t := autoTagSceneTask{ + txnManager: s.TxnManager, + scene: ss, + performers: performers, + studios: studios, + tags: tags, + } + + var wg sync.WaitGroup + wg.Add(1) + go t.Start(&wg) + wg.Wait() + + processed++ + s.Status.setProgress(processed, total) + } + + if len(scenes) != batchSize { + more = false + } else { + page++ + } + } + + return nil + }); err != nil { + logger.Error(err.Error()) + } + + logger.Info("Finished autotag") +} + +func (s *singleton) autoTagSpecific(input models.AutoTagMetadataInput) { + performerIds := input.Performers + studioIds := input.Studios + tagIds := input.Tags + + performerCount := len(performerIds) + studioCount := len(studioIds) + tagCount := len(tagIds) + + if err := s.TxnManager.WithReadTxn(context.TODO(), func(r models.ReaderRepository) error { + performerQuery := r.Performer() + studioQuery := r.Studio() + tagQuery := r.Tag() + + const wildcard = "*" + var err error + if performerCount == 1 && performerIds[0] == wildcard { + performerCount, err = performerQuery.Count() + if err != nil { + return fmt.Errorf("error getting performer count: %s", err.Error()) + } + } + if studioCount == 1 && studioIds[0] == wildcard { + studioCount, err = studioQuery.Count() + if err != nil { + return fmt.Errorf("error getting studio count: %s", err.Error()) + } + } + if tagCount == 1 && tagIds[0] == wildcard { + tagCount, err = tagQuery.Count() + if err != nil { + return fmt.Errorf("error getting tag count: %s", err.Error()) + } + } + + return nil + }); err != nil { + logger.Error(err.Error()) + return + } + + total := performerCount + studioCount + tagCount + s.Status.setProgress(0, total) + + logger.Infof("Starting autotag of %d performers, %d studios, %d tags", performerCount, studioCount, tagCount) + + s.autoTagPerformers(input.Paths, performerIds) + s.autoTagStudios(input.Paths, studioIds) + s.autoTagTags(input.Paths, tagIds) + + logger.Info("Finished autotag") +} + func (s *singleton) autoTagPerformers(paths []string, performerIds []string) { - var wg sync.WaitGroup + if s.Status.stopping { + return + } + for _, performerId := range performerIds { var performers []*models.Performer @@ -698,46 +812,53 @@ func (s *singleton) autoTagPerformers(paths []string, performerIds []string) { var err error performers, err = performerQuery.All() if err != nil { - return fmt.Errorf("Error querying performers: %s", err.Error()) + return fmt.Errorf("error querying performers: %s", err.Error()) } } else { performerIdInt, err := strconv.Atoi(performerId) if err != nil { - return fmt.Errorf("Error parsing performer id %s: %s", performerId, err.Error()) + return fmt.Errorf("error parsing performer id %s: %s", performerId, err.Error()) } performer, err := performerQuery.Find(performerIdInt) if err != nil { - return fmt.Errorf("Error finding performer id %s: %s", performerId, err.Error()) + return fmt.Errorf("error finding performer id %s: %s", performerId, err.Error()) + } + + if performer == nil { + return fmt.Errorf("performer with id %s not found", performerId) } performers = append(performers, performer) } + for _, performer := range performers { + if s.Status.stopping { + logger.Info("Stopping due to user request") + return nil + } + + if err := s.TxnManager.WithTxn(context.TODO(), func(r models.Repository) error { + return autotag.PerformerScenes(performer, paths, r.Scene()) + }); err != nil { + return fmt.Errorf("error auto-tagging performer '%s': %s", performer.Name.String, err.Error()) + } + + s.Status.incrementProgress() + } + return nil }); err != nil { logger.Error(err.Error()) continue } - - for _, performer := range performers { - wg.Add(1) - task := AutoTagPerformerTask{ - AutoTagTask: AutoTagTask{ - txnManager: s.TxnManager, - paths: paths, - }, - performer: performer, - } - go task.Start(&wg) - wg.Wait() - - s.Status.incrementProgress() - } } } func (s *singleton) autoTagStudios(paths []string, studioIds []string) { - var wg sync.WaitGroup + if s.Status.stopping { + return + } + for _, studioId := range studioIds { var studios []*models.Studio @@ -747,46 +868,54 @@ func (s *singleton) autoTagStudios(paths []string, studioIds []string) { var err error studios, err = studioQuery.All() if err != nil { - return fmt.Errorf("Error querying studios: %s", err.Error()) + return fmt.Errorf("error querying studios: %s", err.Error()) } } else { studioIdInt, err := strconv.Atoi(studioId) if err != nil { - return fmt.Errorf("Error parsing studio id %s: %s", studioId, err.Error()) + return fmt.Errorf("error parsing studio id %s: %s", studioId, err.Error()) } studio, err := studioQuery.Find(studioIdInt) if err != nil { - return fmt.Errorf("Error finding studio id %s: %s", studioId, err.Error()) + return fmt.Errorf("error finding studio id %s: %s", studioId, err.Error()) } + + if studio == nil { + return fmt.Errorf("studio with id %s not found", studioId) + } + studios = append(studios, studio) } + for _, studio := range studios { + if s.Status.stopping { + logger.Info("Stopping due to user request") + return nil + } + + if err := s.TxnManager.WithTxn(context.TODO(), func(r models.Repository) error { + return autotag.StudioScenes(studio, paths, r.Scene()) + }); err != nil { + return fmt.Errorf("error auto-tagging studio '%s': %s", studio.Name.String, err.Error()) + } + + s.Status.incrementProgress() + } + return nil }); err != nil { logger.Error(err.Error()) continue } - - for _, studio := range studios { - wg.Add(1) - task := AutoTagStudioTask{ - AutoTagTask: AutoTagTask{ - txnManager: s.TxnManager, - paths: paths, - }, - studio: studio, - } - go task.Start(&wg) - wg.Wait() - - s.Status.incrementProgress() - } } } func (s *singleton) autoTagTags(paths []string, tagIds []string) { - var wg sync.WaitGroup + if s.Status.stopping { + return + } + for _, tagId := range tagIds { var tags []*models.Tag if err := s.TxnManager.WithReadTxn(context.TODO(), func(r models.ReaderRepository) error { @@ -795,41 +924,41 @@ func (s *singleton) autoTagTags(paths []string, tagIds []string) { var err error tags, err = tagQuery.All() if err != nil { - return fmt.Errorf("Error querying tags: %s", err.Error()) + return fmt.Errorf("error querying tags: %s", err.Error()) } } else { tagIdInt, err := strconv.Atoi(tagId) if err != nil { - return fmt.Errorf("Error parsing tag id %s: %s", tagId, err.Error()) + return fmt.Errorf("error parsing tag id %s: %s", tagId, err.Error()) } tag, err := tagQuery.Find(tagIdInt) if err != nil { - return fmt.Errorf("Error finding tag id %s: %s", tagId, err.Error()) + return fmt.Errorf("error finding tag id %s: %s", tagId, err.Error()) } tags = append(tags, tag) } + for _, tag := range tags { + if s.Status.stopping { + logger.Info("Stopping due to user request") + return nil + } + + if err := s.TxnManager.WithTxn(context.TODO(), func(r models.Repository) error { + return autotag.TagScenes(tag, paths, r.Scene()) + }); err != nil { + return fmt.Errorf("error auto-tagging tag '%s': %s", tag.Name, err.Error()) + } + + s.Status.incrementProgress() + } + return nil }); err != nil { logger.Error(err.Error()) continue } - - for _, tag := range tags { - wg.Add(1) - task := AutoTagTagTask{ - AutoTagTask: AutoTagTask{ - txnManager: s.TxnManager, - paths: paths, - }, - tag: tag, - } - go task.Start(&wg) - wg.Wait() - - s.Status.incrementProgress() - } } } diff --git a/pkg/manager/task_autotag.go b/pkg/manager/task_autotag.go index 6632dc2b9..6fb0a08e0 100644 --- a/pkg/manager/task_autotag.go +++ b/pkg/manager/task_autotag.go @@ -2,196 +2,38 @@ package manager import ( "context" - "database/sql" - "fmt" - "path/filepath" - "strings" "sync" + "github.com/stashapp/stash/pkg/autotag" "github.com/stashapp/stash/pkg/logger" "github.com/stashapp/stash/pkg/models" - "github.com/stashapp/stash/pkg/scene" ) -type AutoTagTask struct { - paths []string +type autoTagSceneTask struct { txnManager models.TransactionManager + scene *models.Scene + + performers bool + studios bool + tags bool } -type AutoTagPerformerTask struct { - AutoTagTask - performer *models.Performer -} - -func (t *AutoTagPerformerTask) Start(wg *sync.WaitGroup) { +func (t *autoTagSceneTask) Start(wg *sync.WaitGroup) { defer wg.Done() - - t.autoTagPerformer() -} - -func (t *AutoTagTask) getQueryRegex(name string) string { - const separatorChars = `.\-_ ` - // handle path separators - const separator = `[` + separatorChars + `]` - - ret := strings.Replace(name, " ", separator+"*", -1) - ret = `(?:^|_|[^\w\d])` + ret + `(?:$|_|[^\w\d])` - return ret -} - -func (t *AutoTagTask) getQueryFilter(regex string) *models.SceneFilterType { - organized := false - ret := &models.SceneFilterType{ - Path: &models.StringCriterionInput{ - Modifier: models.CriterionModifierMatchesRegex, - Value: "(?i)" + regex, - }, - Organized: &organized, - } - - sep := string(filepath.Separator) - - var or *models.SceneFilterType - for _, p := range t.paths { - newOr := &models.SceneFilterType{} - if or == nil { - ret.And = newOr - } else { - or.Or = newOr - } - - or = newOr - - if !strings.HasSuffix(p, sep) { - p = p + sep - } - - or.Path = &models.StringCriterionInput{ - Modifier: models.CriterionModifierEquals, - Value: p + "%", - } - } - - return ret -} - -func (t *AutoTagTask) getFindFilter() *models.FindFilterType { - perPage := -1 - return &models.FindFilterType{ - PerPage: &perPage, - } -} - -func (t *AutoTagPerformerTask) autoTagPerformer() { - regex := t.getQueryRegex(t.performer.Name.String) - if err := t.txnManager.WithTxn(context.TODO(), func(r models.Repository) error { - qb := r.Scene() - - scenes, _, err := qb.Query(t.getQueryFilter(regex), t.getFindFilter()) - - if err != nil { - return fmt.Errorf("Error querying scenes with regex '%s': %s", regex, err.Error()) - } - - for _, s := range scenes { - added, err := scene.AddPerformer(qb, s.ID, t.performer.ID) - - if err != nil { - return fmt.Errorf("Error adding performer '%s' to scene '%s': %s", t.performer.Name.String, s.GetTitle(), err.Error()) + if t.performers { + if err := autotag.ScenePerformers(t.scene, r.Scene(), r.Performer()); err != nil { + return err } - - if added { - logger.Infof("Added performer '%s' to scene '%s'", t.performer.Name.String, s.GetTitle()) - } - } - - return nil - }); err != nil { - logger.Error(err.Error()) - } -} - -type AutoTagStudioTask struct { - AutoTagTask - studio *models.Studio -} - -func (t *AutoTagStudioTask) Start(wg *sync.WaitGroup) { - defer wg.Done() - - t.autoTagStudio() -} - -func (t *AutoTagStudioTask) autoTagStudio() { - regex := t.getQueryRegex(t.studio.Name.String) - - if err := t.txnManager.WithTxn(context.TODO(), func(r models.Repository) error { - qb := r.Scene() - scenes, _, err := qb.Query(t.getQueryFilter(regex), t.getFindFilter()) - - if err != nil { - return fmt.Errorf("Error querying scenes with regex '%s': %s", regex, err.Error()) - } - - for _, s := range scenes { - // #306 - don't overwrite studio if already present - if s.StudioID.Valid { - // don't modify - continue - } - - logger.Infof("Adding studio '%s' to scene '%s'", t.studio.Name.String, s.GetTitle()) - - // set the studio id - studioID := sql.NullInt64{Int64: int64(t.studio.ID), Valid: true} - scenePartial := models.ScenePartial{ - ID: s.ID, - StudioID: &studioID, - } - - if _, err := qb.Update(scenePartial); err != nil { - return fmt.Errorf("Error adding studio to scene: %s", err.Error()) - } - } - - return nil - }); err != nil { - logger.Error(err.Error()) - } -} - -type AutoTagTagTask struct { - AutoTagTask - tag *models.Tag -} - -func (t *AutoTagTagTask) Start(wg *sync.WaitGroup) { - defer wg.Done() - - t.autoTagTag() -} - -func (t *AutoTagTagTask) autoTagTag() { - regex := t.getQueryRegex(t.tag.Name) - - if err := t.txnManager.WithTxn(context.TODO(), func(r models.Repository) error { - qb := r.Scene() - scenes, _, err := qb.Query(t.getQueryFilter(regex), t.getFindFilter()) - - if err != nil { - return fmt.Errorf("Error querying scenes with regex '%s': %s", regex, err.Error()) - } - - for _, s := range scenes { - added, err := scene.AddTag(qb, s.ID, t.tag.ID) - - if err != nil { - return fmt.Errorf("Error adding tag '%s' to scene '%s': %s", t.tag.Name, s.GetTitle(), err.Error()) - } - - if added { - logger.Infof("Added tag '%s' to scene '%s'", t.tag.Name, s.GetTitle()) + } + if t.studios { + if err := autotag.SceneStudios(t.scene, r.Scene(), r.Studio()); err != nil { + return err + } + } + if t.tags { + if err := autotag.SceneTags(t.scene, r.Scene(), r.Tag()); err != nil { + return err } } diff --git a/pkg/models/extension_find_filter.go b/pkg/models/extension_find_filter.go index f2e7e7ab1..8dc1ed515 100644 --- a/pkg/models/extension_find_filter.go +++ b/pkg/models/extension_find_filter.go @@ -1,5 +1,9 @@ package models +// PerPageAll is the value used for perPage to indicate all results should be +// returned. +const PerPageAll = -1 + func (ff FindFilterType) GetSort(defaultSort string) string { var sort string if ff.Sort == nil { diff --git a/pkg/models/mocks/PerformerReaderWriter.go b/pkg/models/mocks/PerformerReaderWriter.go index 60575ab3b..20629b3b5 100644 --- a/pkg/models/mocks/PerformerReaderWriter.go +++ b/pkg/models/mocks/PerformerReaderWriter.go @@ -388,6 +388,29 @@ func (_m *PerformerReaderWriter) Query(performerFilter *models.PerformerFilterTy return r0, r1, r2 } +// QueryForAutoTag provides a mock function with given fields: words +func (_m *PerformerReaderWriter) QueryForAutoTag(words []string) ([]*models.Performer, error) { + ret := _m.Called(words) + + var r0 []*models.Performer + if rf, ok := ret.Get(0).(func([]string) []*models.Performer); ok { + r0 = rf(words) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).([]*models.Performer) + } + } + + var r1 error + if rf, ok := ret.Get(1).(func([]string) error); ok { + r1 = rf(words) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + // Update provides a mock function with given fields: updatedPerformer func (_m *PerformerReaderWriter) Update(updatedPerformer models.PerformerPartial) (*models.Performer, error) { ret := _m.Called(updatedPerformer) diff --git a/pkg/models/mocks/StudioReaderWriter.go b/pkg/models/mocks/StudioReaderWriter.go index fb9c02d7d..fbd8a1936 100644 --- a/pkg/models/mocks/StudioReaderWriter.go +++ b/pkg/models/mocks/StudioReaderWriter.go @@ -296,6 +296,29 @@ func (_m *StudioReaderWriter) Query(studioFilter *models.StudioFilterType, findF return r0, r1, r2 } +// QueryForAutoTag provides a mock function with given fields: words +func (_m *StudioReaderWriter) QueryForAutoTag(words []string) ([]*models.Studio, error) { + ret := _m.Called(words) + + var r0 []*models.Studio + if rf, ok := ret.Get(0).(func([]string) []*models.Studio); ok { + r0 = rf(words) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).([]*models.Studio) + } + } + + var r1 error + if rf, ok := ret.Get(1).(func([]string) error); ok { + r1 = rf(words) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + // Update provides a mock function with given fields: updatedStudio func (_m *StudioReaderWriter) Update(updatedStudio models.StudioPartial) (*models.Studio, error) { ret := _m.Called(updatedStudio) diff --git a/pkg/models/mocks/TagReaderWriter.go b/pkg/models/mocks/TagReaderWriter.go index 65dcd8b89..e0d765577 100644 --- a/pkg/models/mocks/TagReaderWriter.go +++ b/pkg/models/mocks/TagReaderWriter.go @@ -367,6 +367,29 @@ func (_m *TagReaderWriter) Query(tagFilter *models.TagFilterType, findFilter *mo return r0, r1, r2 } +// QueryForAutoTag provides a mock function with given fields: words +func (_m *TagReaderWriter) QueryForAutoTag(words []string) ([]*models.Tag, error) { + ret := _m.Called(words) + + var r0 []*models.Tag + if rf, ok := ret.Get(0).(func([]string) []*models.Tag); ok { + r0 = rf(words) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).([]*models.Tag) + } + } + + var r1 error + if rf, ok := ret.Get(1).(func([]string) error); ok { + r1 = rf(words) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + // Update provides a mock function with given fields: updatedTag func (_m *TagReaderWriter) Update(updatedTag models.Tag) (*models.Tag, error) { ret := _m.Called(updatedTag) diff --git a/pkg/models/performer.go b/pkg/models/performer.go index 2c550b720..fabcff44a 100644 --- a/pkg/models/performer.go +++ b/pkg/models/performer.go @@ -11,6 +11,9 @@ type PerformerReader interface { CountByTagID(tagID int) (int, error) Count() (int, error) All() ([]*Performer, error) + // TODO - this interface is temporary until the filter schema can fully + // support the query needed + QueryForAutoTag(words []string) ([]*Performer, error) Query(performerFilter *PerformerFilterType, findFilter *FindFilterType) ([]*Performer, int, error) GetImage(performerID int) ([]byte, error) GetStashIDs(performerID int) ([]*StashID, error) diff --git a/pkg/models/studio.go b/pkg/models/studio.go index 358abf596..7aa2e87b8 100644 --- a/pkg/models/studio.go +++ b/pkg/models/studio.go @@ -7,6 +7,9 @@ type StudioReader interface { FindByName(name string, nocase bool) (*Studio, error) Count() (int, error) All() ([]*Studio, error) + // TODO - this interface is temporary until the filter schema can fully + // support the query needed + QueryForAutoTag(words []string) ([]*Studio, error) Query(studioFilter *StudioFilterType, findFilter *FindFilterType) ([]*Studio, int, error) GetImage(studioID int) ([]byte, error) HasImage(studioID int) (bool, error) diff --git a/pkg/models/tag.go b/pkg/models/tag.go index 5f03e33b5..a675bfbdf 100644 --- a/pkg/models/tag.go +++ b/pkg/models/tag.go @@ -12,6 +12,9 @@ type TagReader interface { FindByNames(names []string, nocase bool) ([]*Tag, error) Count() (int, error) All() ([]*Tag, error) + // TODO - this interface is temporary until the filter schema can fully + // support the query needed + QueryForAutoTag(words []string) ([]*Tag, error) Query(tagFilter *TagFilterType, findFilter *FindFilterType) ([]*Tag, int, error) GetImage(tagID int) ([]byte, error) } diff --git a/pkg/sqlite/performer.go b/pkg/sqlite/performer.go index 17406eb7c..a631f1ad1 100644 --- a/pkg/sqlite/performer.go +++ b/pkg/sqlite/performer.go @@ -4,6 +4,7 @@ import ( "database/sql" "fmt" "strconv" + "strings" "github.com/stashapp/stash/pkg/models" ) @@ -172,6 +173,25 @@ func (qb *performerQueryBuilder) All() ([]*models.Performer, error) { return qb.queryPerformers(selectAll("performers")+qb.getPerformerSort(nil), nil) } +func (qb *performerQueryBuilder) QueryForAutoTag(words []string) ([]*models.Performer, error) { + // TODO - Query needs to be changed to support queries of this type, and + // this method should be removed + query := selectAll(performerTable) + + var whereClauses []string + var args []interface{} + + for _, w := range words { + whereClauses = append(whereClauses, "name like ?") + args = append(args, "%"+w+"%") + whereClauses = append(whereClauses, "aliases like ?") + args = append(args, "%"+w+"%") + } + + where := strings.Join(whereClauses, " OR ") + return qb.queryPerformers(query+" WHERE "+where, args) +} + func (qb *performerQueryBuilder) Query(performerFilter *models.PerformerFilterType, findFilter *models.FindFilterType) ([]*models.Performer, int, error) { if performerFilter == nil { performerFilter = &models.PerformerFilterType{} diff --git a/pkg/sqlite/performer_test.go b/pkg/sqlite/performer_test.go index d2f32182b..71237831c 100644 --- a/pkg/sqlite/performer_test.go +++ b/pkg/sqlite/performer_test.go @@ -100,6 +100,26 @@ func TestPerformerFindByNames(t *testing.T) { }) } +func TestPerformerQueryForAutoTag(t *testing.T) { + withTxn(func(r models.Repository) error { + tqb := r.Performer() + + name := performerNames[performerIdxWithScene] // find a performer by name + + performers, err := tqb.QueryForAutoTag([]string{name}) + + if err != nil { + t.Errorf("Error finding performers: %s", err.Error()) + } + + assert.Len(t, performers, 2) + assert.Equal(t, strings.ToLower(performerNames[performerIdxWithScene]), strings.ToLower(performers[0].Name.String)) + assert.Equal(t, strings.ToLower(performerNames[performerIdxWithScene]), strings.ToLower(performers[1].Name.String)) + + return nil + }) +} + func TestPerformerUpdatePerformerImage(t *testing.T) { if err := withTxn(func(r models.Repository) error { qb := r.Performer() diff --git a/pkg/sqlite/studio.go b/pkg/sqlite/studio.go index cbfb57f4d..2ec5f82c2 100644 --- a/pkg/sqlite/studio.go +++ b/pkg/sqlite/studio.go @@ -3,6 +3,7 @@ package sqlite import ( "database/sql" "fmt" + "strings" "github.com/stashapp/stash/pkg/models" ) @@ -121,6 +122,23 @@ func (qb *studioQueryBuilder) All() ([]*models.Studio, error) { return qb.queryStudios(selectAll("studios")+qb.getStudioSort(nil), nil) } +func (qb *studioQueryBuilder) QueryForAutoTag(words []string) ([]*models.Studio, error) { + // TODO - Query needs to be changed to support queries of this type, and + // this method should be removed + query := selectAll(studioTable) + + var whereClauses []string + var args []interface{} + + for _, w := range words { + whereClauses = append(whereClauses, "name like ?") + args = append(args, "%"+w+"%") + } + + where := strings.Join(whereClauses, " OR ") + return qb.queryStudios(query+" WHERE "+where, args) +} + func (qb *studioQueryBuilder) Query(studioFilter *models.StudioFilterType, findFilter *models.FindFilterType) ([]*models.Studio, int, error) { if studioFilter == nil { studioFilter = &models.StudioFilterType{} diff --git a/pkg/sqlite/studio_test.go b/pkg/sqlite/studio_test.go index ae17c1cf7..de947ee82 100644 --- a/pkg/sqlite/studio_test.go +++ b/pkg/sqlite/studio_test.go @@ -45,6 +45,26 @@ func TestStudioFindByName(t *testing.T) { }) } +func TestStudioQueryForAutoTag(t *testing.T) { + withTxn(func(r models.Repository) error { + tqb := r.Studio() + + name := studioNames[studioIdxWithScene] // find a studio by name + + studios, err := tqb.QueryForAutoTag([]string{name}) + + if err != nil { + t.Errorf("Error finding studios: %s", err.Error()) + } + + assert.Len(t, studios, 2) + assert.Equal(t, strings.ToLower(studioNames[studioIdxWithScene]), strings.ToLower(studios[0].Name.String)) + assert.Equal(t, strings.ToLower(studioNames[studioIdxWithScene]), strings.ToLower(studios[1].Name.String)) + + return nil + }) +} + func TestStudioQueryParent(t *testing.T) { withTxn(func(r models.Repository) error { sqb := r.Studio() diff --git a/pkg/sqlite/tag.go b/pkg/sqlite/tag.go index acb0105d1..a4549acd9 100644 --- a/pkg/sqlite/tag.go +++ b/pkg/sqlite/tag.go @@ -4,6 +4,7 @@ import ( "database/sql" "errors" "fmt" + "strings" "github.com/stashapp/stash/pkg/models" ) @@ -192,6 +193,23 @@ func (qb *tagQueryBuilder) All() ([]*models.Tag, error) { return qb.queryTags(selectAll("tags")+qb.getDefaultTagSort(), nil) } +func (qb *tagQueryBuilder) QueryForAutoTag(words []string) ([]*models.Tag, error) { + // TODO - Query needs to be changed to support queries of this type, and + // this method should be removed + query := selectAll(tagTable) + + var whereClauses []string + var args []interface{} + + for _, w := range words { + whereClauses = append(whereClauses, "name like ?") + args = append(args, "%"+w+"%") + } + + where := strings.Join(whereClauses, " OR ") + return qb.queryTags(query+" WHERE "+where, args) +} + func (qb *tagQueryBuilder) validateFilter(tagFilter *models.TagFilterType) error { const and = "AND" const or = "OR" diff --git a/pkg/sqlite/tag_test.go b/pkg/sqlite/tag_test.go index 272006776..f6a29def2 100644 --- a/pkg/sqlite/tag_test.go +++ b/pkg/sqlite/tag_test.go @@ -70,6 +70,26 @@ func TestTagFindByName(t *testing.T) { }) } +func TestTagQueryForAutoTag(t *testing.T) { + withTxn(func(r models.Repository) error { + tqb := r.Tag() + + name := tagNames[tagIdxWithScene] // find a tag by name + + tags, err := tqb.QueryForAutoTag([]string{name}) + + if err != nil { + t.Errorf("Error finding tags: %s", err.Error()) + } + + assert.Len(t, tags, 2) + assert.Equal(t, strings.ToLower(tagNames[tagIdxWithScene]), strings.ToLower(tags[0].Name)) + assert.Equal(t, strings.ToLower(tagNames[tagIdxWithScene]), strings.ToLower(tags[1].Name)) + + return nil + }) +} + func TestTagFindByNames(t *testing.T) { var names []string diff --git a/ui/v2.5/src/components/Changelog/versions/v070.md b/ui/v2.5/src/components/Changelog/versions/v070.md index 356ef3a1b..ceae9c844 100644 --- a/ui/v2.5/src/components/Changelog/versions/v070.md +++ b/ui/v2.5/src/components/Changelog/versions/v070.md @@ -11,6 +11,7 @@ * Added scene queue. ### 🎨 Improvements +* Improved performance of the auto-tagger. * Clean generation artifacts after generating each scene. * Log message at startup when cleaning the `tmp` and `downloads` generated folders takes more than one second. * Sort movie scenes by scene number by default. @@ -27,6 +28,7 @@ * Change performer text query to search by name and alias only. ### 🐛 Bug fixes +* Fixed error when auto-tagging for performers/studios/tags with regex characters in the name. * Fix scraped performer image not updating after clearing the current image when creating a new performer. * Fix error preventing adding a new library path when an existing library path is missing. * Fix whitespace in query string returning all objects. From aedadc3857a324e64d01bf01a474307961e1205c Mon Sep 17 00:00:00 2001 From: bnkai <48220860+bnkai@users.noreply.github.com> Date: Mon, 26 Apr 2021 06:31:25 +0300 Subject: [PATCH 45/66] Add lbToKg pp action to the scraper (#1337) --- pkg/scraper/mapped.go | 21 +++++++++++++++++++ pkg/scraper/xpath_test.go | 16 +++++++++----- .../src/components/Changelog/versions/v070.md | 1 + ui/v2.5/src/docs/en/Scraping.md | 12 ++++++++++- 4 files changed, 44 insertions(+), 6 deletions(-) diff --git a/pkg/scraper/mapped.go b/pkg/scraper/mapped.go index f6c076e66..5945f6570 100644 --- a/pkg/scraper/mapped.go +++ b/pkg/scraper/mapped.go @@ -462,12 +462,25 @@ func (p *postProcessFeetToCm) Apply(value string, q mappedQuery) string { return strconv.Itoa(int(math.Round(centimeters))) } +type postProcessLbToKg bool + +func (p *postProcessLbToKg) Apply(value string, q mappedQuery) string { + const lb_in_kg = 0.45359237 + w, err := strconv.ParseFloat(value, 64) + if err == nil { + w = w * lb_in_kg + value = strconv.Itoa(int(math.Round(w))) + } + return value +} + type mappedPostProcessAction struct { ParseDate string `yaml:"parseDate"` Replace mappedRegexConfigs `yaml:"replace"` SubScraper *mappedScraperAttrConfig `yaml:"subScraper"` Map map[string]string `yaml:"map"` FeetToCm bool `yaml:"feetToCm"` + LbToKg bool `yaml:"lbToKg"` } func (a mappedPostProcessAction) ToPostProcessAction() (postProcessAction, error) { @@ -511,6 +524,14 @@ func (a mappedPostProcessAction) ToPostProcessAction() (postProcessAction, error action := postProcessFeetToCm(a.FeetToCm) ret = &action } + if a.LbToKg { + if found != "" { + return nil, fmt.Errorf("post-process actions must have a single field, found %s and %s", found, "lbToKg") + } + found = "lbToKg" + action := postProcessLbToKg(a.LbToKg) + ret = &action + } if ret == nil { return nil, errors.New("invalid post-process action") diff --git a/pkg/scraper/xpath_test.go b/pkg/scraper/xpath_test.go index d0883e16f..4a2d94ed4 100644 --- a/pkg/scraper/xpath_test.go +++ b/pkg/scraper/xpath_test.go @@ -105,7 +105,7 @@ const htmlDoc1 = ` Weight: - 57 + 126 @@ -212,7 +212,6 @@ func makeXPathConfig() mappedPerformerScraperConfig { config.mappedConfig["Piercings"] = makeSimpleAttrConfig(makeCommonXPath("Piercings:") + "/comment()") config.mappedConfig["Details"] = makeSimpleAttrConfig(makeCommonXPath("Details:")) config.mappedConfig["HairColor"] = makeSimpleAttrConfig(makeCommonXPath("Hair Color:")) - config.mappedConfig["Weight"] = makeSimpleAttrConfig(makeCommonXPath("Weight:")) // special handling for birthdate birthdateAttrConfig := makeSimpleAttrConfig(makeCommonXPath("Date of Birth:")) @@ -252,7 +251,7 @@ func makeXPathConfig() mappedPerformerScraperConfig { config.mappedConfig["Gender"] = genderConfig - // use fixed for height + // use fixed for Country config.mappedConfig["Country"] = mappedScraperAttrConfig{ Fixed: "United States", } @@ -264,6 +263,13 @@ func makeXPathConfig() mappedPerformerScraperConfig { } config.mappedConfig["Height"] = heightConfig + weightConfig := makeSimpleAttrConfig(makeCommonXPath("Weight:")) + weightConvAction := postProcessLbToKg(true) + weightConfig.postProcessActions = []postProcessAction{ + &weightConvAction, + } + config.mappedConfig["Weight"] = weightConfig + return config } @@ -317,10 +323,10 @@ func TestScrapePerformerXPath(t *testing.T) { const tattoos = "None" const piercings = "" const gender = "Female" - const height = "170" + const height = "170" // 5ft7 const details = "Some girls are so damn hot that they can get you bent out of shape, and you will not even be mad at them for doing so. Well, tawny blonde Mia Malkova can bend her body into any shape she pleases, and that’s sure to satisfy all of the horny cocks and wet pussies out there. This girl has acrobatic and contortionist abilities that could even twist a pretzel into a new knot, which can be very helpful in the ... arrow_drop_down Some girls are so damn hot that they can get you bent out of shape, and you will not even be mad at them for doing so. Well, tawny blonde Mia Malkova can bend her body into any shape she pleases, and that’s sure to satisfy all of the horny cocks and wet pussies out there. This girl has acrobatic and contortionist abilities that could even twist a pretzel into a new knot, which can be very helpful in the VR Porn movies – trust us. Ankles behind her neck and feet over her back so she can kiss her toes, turned, twisted and gyrating, she can fuck any which way she wants (and that ass!), will surely make you fall in love with this hot Virtual Reality Porn slut, as she is one of the finest of them all. Talking about perfection, maybe it’s all the acrobatic work that keeps it in such gorgeous shape? Who cares really, because you just want to take a big bite out of it and never let go. But it’s not all about the body. Mia’s also got a great smile, which might not sound kinky, but believe us, it is a smile that will heat up your innards and drop your pants. Is it her golden skin, her innocent pink lips or that heart-shaped face? There is just too much good stuff going on with Mia Malkova, which is maybe why these past few years have heaped awards upon awards on this Southern California native. Mia came to VR Bangers for her first VR Porn video, so you know she’s only going for top-notch scenes with top-game performers, men, and women. Better hit up that yoga studio if you ever dream of being able to bang a flexible and talented chick like lady Malkova." const hairColor = "Blonde" - const weight = "57" + const weight = "57" // 126 lb verifyField(t, performerName, performer.Name, "Name") verifyField(t, gender, performer.Gender, "Gender") diff --git a/ui/v2.5/src/components/Changelog/versions/v070.md b/ui/v2.5/src/components/Changelog/versions/v070.md index ceae9c844..18b534cff 100644 --- a/ui/v2.5/src/components/Changelog/versions/v070.md +++ b/ui/v2.5/src/components/Changelog/versions/v070.md @@ -1,6 +1,7 @@ ### ✨ New Features * Support serving UI from specific directory location. * Added details, death date, hair color, and weight to Performers. +* Added `lbToKg` post-process action for performer scrapers. * Added details to Studios. * Added [perceptual dupe checker](/sceneDuplicateChecker). * Add various `count` filter criteria and sort options. diff --git a/ui/v2.5/src/docs/en/Scraping.md b/ui/v2.5/src/docs/en/Scraping.md index 40171b62d..4d38b4fd9 100644 --- a/ui/v2.5/src/docs/en/Scraping.md +++ b/ui/v2.5/src/docs/en/Scraping.md @@ -346,19 +346,29 @@ The `Measurements` xpath string will replace `$infoPiece` with `//div[@class="in Post-processing operations are contained in the `postProcess` key. Post-processing operations are performed in the order they are specified. The following post-processing operations are available: * `feetToCm`: converts a string containing feet and inches numbers into centimetres. Looks for up to two separate integers and interprets the first as the number of feet, and the second as the number of inches. The numbers can be separated by any non-numeric character including the `.` character. It does not handle decimal numbers. For example `6.3` and `6ft3.3` would both be interpreted as 6 feet, 3 inches before converting into centimetres. +* `lbToKg`: converts a string containing lbs to kg. * `map`: contains a map of input values to output values. Where a value matches one of the input values, it is replaced with the matching output value. If no value is matched, then value is unmodified. Example: ```yaml performer: Gender: - selector: //div[class="example element"] + selector: //div[@class="example element"] postProcess: - map: F: Female M: Male + Height: + selector: //span[@id="height"] + postProcess: + - feetToCm: true + Weight: + selector: //span[@id="weight"] + postProcess: + - lbToKg: true ``` Gets the contents of the selected div element, and sets the returned value to `Female` if the scraped value is `F`; `Male` if the scraped value is `M`. +Height and weight are extracted from the selected spans and converted to `cm` and `kg`. * `parseDate`: if present, the value is the date format using go's reference date (2006-01-02). For example, if an example date was `14-Mar-2003`, then the date format would be `02-Jan-2006`. See the [time.Parse documentation](https://golang.org/pkg/time/#Parse) for details. When present, the scraper will convert the input string into a date, then convert it to the string format used by stash (`YYYY-MM-DD`). Strings "Today", "Yesterday" are matched (case insensitive) and converted by the scraper so you don't need to edit/replace them. From eefc628cf06d16025a89f8b01de821e768134f2f Mon Sep 17 00:00:00 2001 From: stg-annon <14135675+stg-annon@users.noreply.github.com> Date: Sun, 25 Apr 2021 23:37:31 -0400 Subject: [PATCH 46/66] update docs to match current functionality (#1339) was wondering why `per_page:0` was not working it seems to have been updated to `per_page:-1` to return all results --- graphql/schema/types/filters.graphql | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/graphql/schema/types/filters.graphql b/graphql/schema/types/filters.graphql index b0d124bbe..f62a61c52 100644 --- a/graphql/schema/types/filters.graphql +++ b/graphql/schema/types/filters.graphql @@ -6,7 +6,7 @@ enum SortDirectionEnum { input FindFilterType { q: String page: Int - """use per_page = 0 to indicate all results. Defaults to 25.""" + """use per_page = -1 to indicate all results. Defaults to 25.""" per_page: Int sort: String direction: SortDirectionEnum From 70b66d91a07b9166707b196db84e86362dd14927 Mon Sep 17 00:00:00 2001 From: julien0221 <68500525+julien0221@users.noreply.github.com> Date: Mon, 26 Apr 2021 04:48:32 +0100 Subject: [PATCH 47/66] Added rating to performers and studios (#1308) --- graphql/documents/data/performer-slim.graphql | 1 + graphql/documents/data/performer.graphql | 1 + graphql/documents/data/studio-slim.graphql | 1 + graphql/documents/data/studio.graphql | 1 + graphql/documents/mutations/studio.graphql | 8 +- graphql/schema/types/filters.graphql | 4 + graphql/schema/types/performer.graphql | 4 + graphql/schema/types/studio.graphql | 3 + pkg/api/resolver_model_performer.go | 8 ++ pkg/api/resolver_model_studio.go | 8 ++ pkg/api/resolver_mutation_performer.go | 7 ++ pkg/api/resolver_mutation_studio.go | 6 ++ pkg/database/database.go | 2 +- .../22_performers_studios_rating.up.sql | 2 + pkg/manager/jsonschema/performer.go | 1 + pkg/manager/jsonschema/studio.go | 1 + pkg/models/model_performer.go | 2 + pkg/models/model_studio.go | 2 + pkg/performer/export.go | 3 + pkg/performer/export_test.go | 3 + pkg/performer/import.go | 3 + pkg/sqlite/performer.go | 1 + pkg/sqlite/performer_test.go | 61 +++++++++++++++ pkg/sqlite/studio.go | 4 +- pkg/sqlite/studio_test.go | 76 ++++++++++++++++++- pkg/studio/export.go | 4 + pkg/studio/export_test.go | 10 ++- pkg/studio/import.go | 1 + .../src/components/Changelog/versions/v070.md | 1 + .../Performers/EditPerformersDialog.tsx | 55 +++++++++++++- .../components/Performers/PerformerCard.tsx | 25 ++++-- .../PerformerDetailsPanel.tsx | 17 +++++ .../PerformerDetails/PerformerEditPanel.tsx | 45 ++++++++++- ui/v2.5/src/components/Performers/styles.scss | 8 ++ ui/v2.5/src/components/Shared/Icon.tsx | 6 +- ui/v2.5/src/components/Studios/StudioCard.tsx | 16 ++++ .../Studios/StudioDetails/Studio.tsx | 39 ++++++++++ ui/v2.5/src/docs/en/JSONSpec.md | 2 + ui/v2.5/src/models/list-filter/filter.ts | 21 +++++ 39 files changed, 438 insertions(+), 25 deletions(-) create mode 100644 pkg/database/migrations/22_performers_studios_rating.up.sql diff --git a/graphql/documents/data/performer-slim.graphql b/graphql/documents/data/performer-slim.graphql index 603744d33..1420d15c4 100644 --- a/graphql/documents/data/performer-slim.graphql +++ b/graphql/documents/data/performer-slim.graphql @@ -12,4 +12,5 @@ fragment SlimPerformerData on Performer { endpoint stash_id } + rating } diff --git a/graphql/documents/data/performer.graphql b/graphql/documents/data/performer.graphql index 09b9e1e68..ef0dde256 100644 --- a/graphql/documents/data/performer.graphql +++ b/graphql/documents/data/performer.graphql @@ -31,6 +31,7 @@ fragment PerformerData on Performer { stash_id endpoint } + rating details death_date hair_color diff --git a/graphql/documents/data/studio-slim.graphql b/graphql/documents/data/studio-slim.graphql index 563375e78..f840ad2fb 100644 --- a/graphql/documents/data/studio-slim.graphql +++ b/graphql/documents/data/studio-slim.graphql @@ -10,4 +10,5 @@ fragment SlimStudioData on Studio { id } details + rating } diff --git a/graphql/documents/data/studio.graphql b/graphql/documents/data/studio.graphql index ae8d1d0d8..a9515c32d 100644 --- a/graphql/documents/data/studio.graphql +++ b/graphql/documents/data/studio.graphql @@ -32,4 +32,5 @@ fragment StudioData on Studio { endpoint } details + rating } diff --git a/graphql/documents/mutations/studio.graphql b/graphql/documents/mutations/studio.graphql index e4cffae84..6d1944dc1 100644 --- a/graphql/documents/mutations/studio.graphql +++ b/graphql/documents/mutations/studio.graphql @@ -1,14 +1,10 @@ -mutation StudioCreate( - $input: StudioCreateInput!) { - +mutation StudioCreate($input: StudioCreateInput!) { studioCreate(input: $input) { ...StudioData } } -mutation StudioUpdate( - $input: StudioUpdateInput!) { - +mutation StudioUpdate($input: StudioUpdateInput!) { studioUpdate(input: $input) { ...StudioData } diff --git a/graphql/schema/types/filters.graphql b/graphql/schema/types/filters.graphql index f62a61c52..8d8eb06a3 100644 --- a/graphql/schema/types/filters.graphql +++ b/graphql/schema/types/filters.graphql @@ -71,6 +71,8 @@ input PerformerFilterType { gallery_count: IntCriterionInput """Filter by StashID""" stash_id: String + """Filter by rating""" + rating: IntCriterionInput """Filter by url""" url: StringCriterionInput """Filter by hair color""" @@ -149,6 +151,8 @@ input StudioFilterType { stash_id: String """Filter to only include studios missing this property""" is_missing: String + """Filter by rating""" + rating: IntCriterionInput """Filter by scene count""" scene_count: IntCriterionInput """Filter by image count""" diff --git a/graphql/schema/types/performer.graphql b/graphql/schema/types/performer.graphql index e85bb5d9c..1e1fe3c03 100644 --- a/graphql/schema/types/performer.graphql +++ b/graphql/schema/types/performer.graphql @@ -35,6 +35,7 @@ type Performer { gallery_count: Int # Resolver scenes: [Scene!]! stash_ids: [StashID!]! + rating: Int details: String death_date: String hair_color: String @@ -63,6 +64,7 @@ input PerformerCreateInput { """This should be a URL or a base64 encoded data URL""" image: String stash_ids: [StashIDInput!] + rating: Int details: String death_date: String hair_color: String @@ -92,6 +94,7 @@ input PerformerUpdateInput { """This should be a URL or a base64 encoded data URL""" image: String stash_ids: [StashIDInput!] + rating: Int details: String death_date: String hair_color: String @@ -118,6 +121,7 @@ input BulkPerformerUpdateInput { instagram: String favorite: Boolean tag_ids: BulkUpdateIds + rating: Int details: String death_date: String hair_color: String diff --git a/graphql/schema/types/studio.graphql b/graphql/schema/types/studio.graphql index 25145b823..26d280f06 100644 --- a/graphql/schema/types/studio.graphql +++ b/graphql/schema/types/studio.graphql @@ -11,6 +11,7 @@ type Studio { image_count: Int # Resolver gallery_count: Int # Resolver stash_ids: [StashID!]! + rating: Int details: String } @@ -21,6 +22,7 @@ input StudioCreateInput { """This should be a URL or a base64 encoded data URL""" image: String stash_ids: [StashIDInput!] + rating: Int details: String } @@ -32,6 +34,7 @@ input StudioUpdateInput { """This should be a URL or a base64 encoded data URL""" image: String stash_ids: [StashIDInput!] + rating: Int details: String } diff --git a/pkg/api/resolver_model_performer.go b/pkg/api/resolver_model_performer.go index c74ffe95d..a5f8e4811 100644 --- a/pkg/api/resolver_model_performer.go +++ b/pkg/api/resolver_model_performer.go @@ -209,6 +209,14 @@ func (r *performerResolver) StashIds(ctx context.Context, obj *models.Performer) return ret, nil } +func (r *performerResolver) Rating(ctx context.Context, obj *models.Performer) (*int, error) { + if obj.Rating.Valid { + rating := int(obj.Rating.Int64) + return &rating, nil + } + return nil, nil +} + func (r *performerResolver) Details(ctx context.Context, obj *models.Performer) (*string, error) { if obj.Details.Valid { return &obj.Details.String, nil diff --git a/pkg/api/resolver_model_studio.go b/pkg/api/resolver_model_studio.go index da5a1b8b7..89d2c2bea 100644 --- a/pkg/api/resolver_model_studio.go +++ b/pkg/api/resolver_model_studio.go @@ -117,6 +117,14 @@ func (r *studioResolver) StashIds(ctx context.Context, obj *models.Studio) (ret return ret, nil } +func (r *studioResolver) Rating(ctx context.Context, obj *models.Studio) (*int, error) { + if obj.Rating.Valid { + rating := int(obj.Rating.Int64) + return &rating, nil + } + return nil, nil +} + func (r *studioResolver) Details(ctx context.Context, obj *models.Studio) (*string, error) { if obj.Details.Valid { return &obj.Details.String, nil diff --git a/pkg/api/resolver_mutation_performer.go b/pkg/api/resolver_mutation_performer.go index 9b7feee5c..60af02780 100644 --- a/pkg/api/resolver_mutation_performer.go +++ b/pkg/api/resolver_mutation_performer.go @@ -85,6 +85,11 @@ func (r *mutationResolver) PerformerCreate(ctx context.Context, input models.Per } else { newPerformer.Favorite = sql.NullBool{Bool: false, Valid: true} } + if input.Rating != nil { + newPerformer.Rating = sql.NullInt64{Int64: int64(*input.Rating), Valid: true} + } else { + newPerformer.Rating = sql.NullInt64{Valid: false} + } if input.Details != nil { newPerformer.Details = sql.NullString{String: *input.Details, Valid: true} } @@ -198,6 +203,7 @@ func (r *mutationResolver) PerformerUpdate(ctx context.Context, input models.Per updatedPerformer.Twitter = translator.nullString(input.Twitter, "twitter") updatedPerformer.Instagram = translator.nullString(input.Instagram, "instagram") updatedPerformer.Favorite = translator.nullBool(input.Favorite, "favorite") + updatedPerformer.Rating = translator.nullInt64(input.Rating, "rating") updatedPerformer.Details = translator.nullString(input.Details, "details") updatedPerformer.DeathDate = translator.sqliteDate(input.DeathDate, "death_date") updatedPerformer.HairColor = translator.nullString(input.HairColor, "hair_color") @@ -304,6 +310,7 @@ func (r *mutationResolver) BulkPerformerUpdate(ctx context.Context, input models updatedPerformer.Twitter = translator.nullString(input.Twitter, "twitter") updatedPerformer.Instagram = translator.nullString(input.Instagram, "instagram") updatedPerformer.Favorite = translator.nullBool(input.Favorite, "favorite") + updatedPerformer.Rating = translator.nullInt64(input.Rating, "rating") updatedPerformer.Details = translator.nullString(input.Details, "details") updatedPerformer.DeathDate = translator.sqliteDate(input.DeathDate, "death_date") updatedPerformer.HairColor = translator.nullString(input.HairColor, "hair_color") diff --git a/pkg/api/resolver_mutation_studio.go b/pkg/api/resolver_mutation_studio.go index 8ec804765..7b06485b4 100644 --- a/pkg/api/resolver_mutation_studio.go +++ b/pkg/api/resolver_mutation_studio.go @@ -42,6 +42,11 @@ func (r *mutationResolver) StudioCreate(ctx context.Context, input models.Studio newStudio.ParentID = sql.NullInt64{Int64: parentID, Valid: true} } + if input.Rating != nil { + newStudio.Rating = sql.NullInt64{Int64: int64(*input.Rating), Valid: true} + } else { + newStudio.Rating = sql.NullInt64{Valid: false} + } if input.Details != nil { newStudio.Details = sql.NullString{String: *input.Details, Valid: true} } @@ -115,6 +120,7 @@ func (r *mutationResolver) StudioUpdate(ctx context.Context, input models.Studio updatedStudio.URL = translator.nullString(input.URL, "url") updatedStudio.Details = translator.nullString(input.Details, "details") updatedStudio.ParentID = translator.nullInt64FromString(input.ParentID, "parent_id") + updatedStudio.Rating = translator.nullInt64(input.Rating, "rating") // Start the transaction and save the studio var studio *models.Studio diff --git a/pkg/database/database.go b/pkg/database/database.go index d210e1215..e3ddff607 100644 --- a/pkg/database/database.go +++ b/pkg/database/database.go @@ -23,7 +23,7 @@ import ( var DB *sqlx.DB var WriteMu *sync.Mutex var dbPath string -var appSchemaVersion uint = 21 +var appSchemaVersion uint = 22 var databaseSchemaVersion uint var ( diff --git a/pkg/database/migrations/22_performers_studios_rating.up.sql b/pkg/database/migrations/22_performers_studios_rating.up.sql new file mode 100644 index 000000000..d87d08f65 --- /dev/null +++ b/pkg/database/migrations/22_performers_studios_rating.up.sql @@ -0,0 +1,2 @@ +ALTER TABLE `performers` ADD COLUMN `rating` tinyint; +ALTER TABLE `studios` ADD COLUMN `rating` tinyint; diff --git a/pkg/manager/jsonschema/performer.go b/pkg/manager/jsonschema/performer.go index a12a617db..0ff38bff6 100644 --- a/pkg/manager/jsonschema/performer.go +++ b/pkg/manager/jsonschema/performer.go @@ -30,6 +30,7 @@ type Performer struct { Image string `json:"image,omitempty"` CreatedAt models.JSONTime `json:"created_at,omitempty"` UpdatedAt models.JSONTime `json:"updated_at,omitempty"` + Rating int `json:"rating,omitempty"` Details string `json:"details,omitempty"` DeathDate string `json:"death_date,omitempty"` HairColor string `json:"hair_color,omitempty"` diff --git a/pkg/manager/jsonschema/studio.go b/pkg/manager/jsonschema/studio.go index d3e55cb08..82a7e740a 100644 --- a/pkg/manager/jsonschema/studio.go +++ b/pkg/manager/jsonschema/studio.go @@ -15,6 +15,7 @@ type Studio struct { Image string `json:"image,omitempty"` CreatedAt models.JSONTime `json:"created_at,omitempty"` UpdatedAt models.JSONTime `json:"updated_at,omitempty"` + Rating int `json:"rating,omitempty"` Details string `json:"details,omitempty"` } diff --git a/pkg/models/model_performer.go b/pkg/models/model_performer.go index 0a0ce3f09..eefce07de 100644 --- a/pkg/models/model_performer.go +++ b/pkg/models/model_performer.go @@ -29,6 +29,7 @@ type Performer struct { Favorite sql.NullBool `db:"favorite" json:"favorite"` CreatedAt SQLiteTimestamp `db:"created_at" json:"created_at"` UpdatedAt SQLiteTimestamp `db:"updated_at" json:"updated_at"` + Rating sql.NullInt64 `db:"rating" json:"rating"` Details sql.NullString `db:"details" json:"details"` DeathDate SQLiteDate `db:"death_date" json:"death_date"` HairColor sql.NullString `db:"hair_color" json:"hair_color"` @@ -57,6 +58,7 @@ type PerformerPartial struct { Favorite *sql.NullBool `db:"favorite" json:"favorite"` CreatedAt *SQLiteTimestamp `db:"created_at" json:"created_at"` UpdatedAt *SQLiteTimestamp `db:"updated_at" json:"updated_at"` + Rating *sql.NullInt64 `db:"rating" json:"rating"` Details *sql.NullString `db:"details" json:"details"` DeathDate *SQLiteDate `db:"death_date" json:"death_date"` HairColor *sql.NullString `db:"hair_color" json:"hair_color"` diff --git a/pkg/models/model_studio.go b/pkg/models/model_studio.go index 6336d9163..769acb8e2 100644 --- a/pkg/models/model_studio.go +++ b/pkg/models/model_studio.go @@ -15,6 +15,7 @@ type Studio struct { ParentID sql.NullInt64 `db:"parent_id,omitempty" json:"parent_id"` CreatedAt SQLiteTimestamp `db:"created_at" json:"created_at"` UpdatedAt SQLiteTimestamp `db:"updated_at" json:"updated_at"` + Rating sql.NullInt64 `db:"rating" json:"rating"` Details sql.NullString `db:"details" json:"details"` } @@ -26,6 +27,7 @@ type StudioPartial struct { ParentID *sql.NullInt64 `db:"parent_id,omitempty" json:"parent_id"` CreatedAt *SQLiteTimestamp `db:"created_at" json:"created_at"` UpdatedAt *SQLiteTimestamp `db:"updated_at" json:"updated_at"` + Rating *sql.NullInt64 `db:"rating" json:"rating"` Details *sql.NullString `db:"details" json:"details"` } diff --git a/pkg/performer/export.go b/pkg/performer/export.go index 8433353a7..555abe58d 100644 --- a/pkg/performer/export.go +++ b/pkg/performer/export.go @@ -66,6 +66,9 @@ func ToJSON(reader models.PerformerReader, performer *models.Performer) (*jsonsc if performer.Favorite.Valid { newPerformerJSON.Favorite = performer.Favorite.Bool } + if performer.Rating.Valid { + newPerformerJSON.Rating = int(performer.Rating.Int64) + } if performer.Details.Valid { newPerformerJSON.Details = performer.Details.String } diff --git a/pkg/performer/export_test.go b/pkg/performer/export_test.go index 0f082163e..0d143b2d5 100644 --- a/pkg/performer/export_test.go +++ b/pkg/performer/export_test.go @@ -36,6 +36,7 @@ const ( piercings = "piercings" tattoos = "tattoos" twitter = "twitter" + rating = 5 details = "details" hairColor = "hairColor" weight = 60 @@ -86,6 +87,7 @@ func createFullPerformer(id int, name string) *models.Performer { UpdatedAt: models.SQLiteTimestamp{ Timestamp: updateTime, }, + Rating: models.NullInt64(rating), Details: models.NullString(details), DeathDate: deathDate, HairColor: models.NullString(hairColor), @@ -133,6 +135,7 @@ func createFullJSONPerformer(name string, image string) *jsonschema.Performer { UpdatedAt: models.JSONTime{ Time: updateTime, }, + Rating: rating, Image: image, Details: details, DeathDate: deathDate.String, diff --git a/pkg/performer/import.go b/pkg/performer/import.go index 09e0a56a2..db32e1286 100644 --- a/pkg/performer/import.go +++ b/pkg/performer/import.go @@ -224,6 +224,9 @@ func performerJSONToPerformer(performerJSON jsonschema.Performer) models.Perform if performerJSON.Instagram != "" { newPerformer.Instagram = sql.NullString{String: performerJSON.Instagram, Valid: true} } + if performerJSON.Rating != 0 { + newPerformer.Rating = sql.NullInt64{Int64: int64(performerJSON.Rating), Valid: true} + } if performerJSON.Details != "" { newPerformer.Details = sql.NullString{String: performerJSON.Details, Valid: true} } diff --git a/pkg/sqlite/performer.go b/pkg/sqlite/performer.go index a631f1ad1..1fbf8a87b 100644 --- a/pkg/sqlite/performer.go +++ b/pkg/sqlite/performer.go @@ -279,6 +279,7 @@ func (qb *performerQueryBuilder) Query(performerFilter *models.PerformerFilterTy query.handleStringCriterionInput(performerFilter.CareerLength, tableName+".career_length") query.handleStringCriterionInput(performerFilter.Tattoos, tableName+".tattoos") query.handleStringCriterionInput(performerFilter.Piercings, tableName+".piercings") + query.handleIntCriterionInput(performerFilter.Rating, tableName+".rating") query.handleStringCriterionInput(performerFilter.HairColor, tableName+".hair_color") query.handleStringCriterionInput(performerFilter.URL, tableName+".url") query.handleIntCriterionInput(performerFilter.Weight, tableName+".weight") diff --git a/pkg/sqlite/performer_test.go b/pkg/sqlite/performer_test.go index 71237831c..4d7833b3d 100644 --- a/pkg/sqlite/performer_test.go +++ b/pkg/sqlite/performer_test.go @@ -617,6 +617,67 @@ func TestPerformerStashIDs(t *testing.T) { t.Error(err.Error()) } } +func TestPerformerQueryRating(t *testing.T) { + const rating = 3 + ratingCriterion := models.IntCriterionInput{ + Value: rating, + Modifier: models.CriterionModifierEquals, + } + + verifyPerformersRating(t, ratingCriterion) + + ratingCriterion.Modifier = models.CriterionModifierNotEquals + verifyPerformersRating(t, ratingCriterion) + + ratingCriterion.Modifier = models.CriterionModifierGreaterThan + verifyPerformersRating(t, ratingCriterion) + + ratingCriterion.Modifier = models.CriterionModifierLessThan + verifyPerformersRating(t, ratingCriterion) + + ratingCriterion.Modifier = models.CriterionModifierIsNull + verifyPerformersRating(t, ratingCriterion) + + ratingCriterion.Modifier = models.CriterionModifierNotNull + verifyPerformersRating(t, ratingCriterion) +} + +func verifyPerformersRating(t *testing.T, ratingCriterion models.IntCriterionInput) { + withTxn(func(r models.Repository) error { + sqb := r.Performer() + performerFilter := models.PerformerFilterType{ + Rating: &ratingCriterion, + } + + performers := queryPerformers(t, sqb, &performerFilter, nil) + + for _, performer := range performers { + verifyInt64(t, performer.Rating, ratingCriterion) + } + + return nil + }) +} + +func TestPerformerQueryIsMissingRating(t *testing.T) { + withTxn(func(r models.Repository) error { + sqb := r.Performer() + isMissing := "rating" + performerFilter := models.PerformerFilterType{ + IsMissing: &isMissing, + } + + performers := queryPerformers(t, sqb, &performerFilter, nil) + + assert.True(t, len(performers) > 0) + + for _, performer := range performers { + assert.True(t, !performer.Rating.Valid) + } + + return nil + }) +} // TODO Update // TODO Destroy diff --git a/pkg/sqlite/studio.go b/pkg/sqlite/studio.go index 2ec5f82c2..be9c6eca1 100644 --- a/pkg/sqlite/studio.go +++ b/pkg/sqlite/studio.go @@ -183,10 +183,12 @@ func (qb *studioQueryBuilder) Query(studioFilter *models.StudioFilterType, findF query.addArg(stashIDFilter) } + if rating := studioFilter.Rating; rating != nil { + query.handleIntCriterionInput(studioFilter.Rating, "studios.rating") + } query.handleCountCriterion(studioFilter.SceneCount, studioTable, sceneTable, studioIDColumn) query.handleCountCriterion(studioFilter.ImageCount, studioTable, imageTable, studioIDColumn) query.handleCountCriterion(studioFilter.GalleryCount, studioTable, galleryTable, studioIDColumn) - query.handleStringCriterionInput(studioFilter.URL, "studios.url") if isMissingFilter := studioFilter.IsMissing; isMissingFilter != nil && *isMissingFilter != "" { diff --git a/pkg/sqlite/studio_test.go b/pkg/sqlite/studio_test.go index de947ee82..cf0fc5096 100644 --- a/pkg/sqlite/studio_test.go +++ b/pkg/sqlite/studio_test.go @@ -482,17 +482,42 @@ func TestStudioQueryURL(t *testing.T) { verifyStudioQuery(t, filter, verifyFn) } +func TestStudioQueryRating(t *testing.T) { + const rating = 3 + ratingCriterion := models.IntCriterionInput{ + Value: rating, + Modifier: models.CriterionModifierEquals, + } + + verifyStudiosRating(t, ratingCriterion) + + ratingCriterion.Modifier = models.CriterionModifierNotEquals + verifyStudiosRating(t, ratingCriterion) + + ratingCriterion.Modifier = models.CriterionModifierGreaterThan + verifyStudiosRating(t, ratingCriterion) + + ratingCriterion.Modifier = models.CriterionModifierLessThan + verifyStudiosRating(t, ratingCriterion) + + ratingCriterion.Modifier = models.CriterionModifierIsNull + verifyStudiosRating(t, ratingCriterion) + + ratingCriterion.Modifier = models.CriterionModifierNotNull + verifyStudiosRating(t, ratingCriterion) +} + func verifyStudioQuery(t *testing.T, filter models.StudioFilterType, verifyFn func(s *models.Studio)) { withTxn(func(r models.Repository) error { t.Helper() sqb := r.Studio() - galleries := queryStudio(t, sqb, &filter, nil) + studios := queryStudio(t, sqb, &filter, nil) // assume it should find at least one - assert.Greater(t, len(galleries), 0) + assert.Greater(t, len(studios), 0) - for _, studio := range galleries { + for _, studio := range studios { verifyFn(studio) } @@ -500,6 +525,51 @@ func verifyStudioQuery(t *testing.T, filter models.StudioFilterType, verifyFn fu }) } +func verifyStudiosRating(t *testing.T, ratingCriterion models.IntCriterionInput) { + withTxn(func(r models.Repository) error { + sqb := r.Studio() + studioFilter := models.StudioFilterType{ + Rating: &ratingCriterion, + } + + studios, _, err := sqb.Query(&studioFilter, nil) + + if err != nil { + t.Errorf("Error querying studio: %s", err.Error()) + } + + for _, studio := range studios { + verifyInt64(t, studio.Rating, ratingCriterion) + } + + return nil + }) +} + +func TestStudioQueryIsMissingRating(t *testing.T) { + withTxn(func(r models.Repository) error { + sqb := r.Studio() + isMissing := "rating" + studioFilter := models.StudioFilterType{ + IsMissing: &isMissing, + } + + studios, _, err := sqb.Query(&studioFilter, nil) + + if err != nil { + t.Errorf("Error querying studio: %s", err.Error()) + } + + assert.True(t, len(studios) > 0) + + for _, studio := range studios { + assert.True(t, !studio.Rating.Valid) + } + + return nil + }) +} + func queryStudio(t *testing.T, sqb models.StudioReader, studioFilter *models.StudioFilterType, findFilter *models.FindFilterType) []*models.Studio { studios, _, err := sqb.Query(studioFilter, findFilter) if err != nil { diff --git a/pkg/studio/export.go b/pkg/studio/export.go index 5f1e3008f..dc71fd915 100644 --- a/pkg/studio/export.go +++ b/pkg/studio/export.go @@ -38,6 +38,10 @@ func ToJSON(reader models.StudioReader, studio *models.Studio) (*jsonschema.Stud } } + if studio.Rating.Valid { + newStudioJSON.Rating = int(studio.Rating.Int64) + } + image, err := reader.GetImage(studio.ID) if err != nil { return nil, fmt.Errorf("error getting studio image: %s", err.Error()) diff --git a/pkg/studio/export_test.go b/pkg/studio/export_test.go index 1a453ec2d..516c3714e 100644 --- a/pkg/studio/export_test.go +++ b/pkg/studio/export_test.go @@ -25,10 +25,10 @@ const ( ) const ( - studioName = "testStudio" - url = "url" - details = "details" - + studioName = "testStudio" + url = "url" + details = "details" + rating = 5 parentStudioName = "parentStudio" ) @@ -55,6 +55,7 @@ func createFullStudio(id int, parentID int) models.Studio { UpdatedAt: models.SQLiteTimestamp{ Timestamp: updateTime, }, + Rating: models.NullInt64(rating), } if parentID != 0 { @@ -89,6 +90,7 @@ func createFullJSONStudio(parentStudio, image string) *jsonschema.Studio { }, ParentStudio: parentStudio, Image: image, + Rating: rating, } } diff --git a/pkg/studio/import.go b/pkg/studio/import.go index 6e38290f6..f509c0626 100644 --- a/pkg/studio/import.go +++ b/pkg/studio/import.go @@ -31,6 +31,7 @@ func (i *Importer) PreImport() error { Details: sql.NullString{String: i.Input.Details, Valid: true}, CreatedAt: models.SQLiteTimestamp{Timestamp: i.Input.CreatedAt.GetTime()}, UpdatedAt: models.SQLiteTimestamp{Timestamp: i.Input.UpdatedAt.GetTime()}, + Rating: sql.NullInt64{Int64: int64(i.Input.Rating), Valid: true}, } if err := i.populateParentStudio(); err != nil { diff --git a/ui/v2.5/src/components/Changelog/versions/v070.md b/ui/v2.5/src/components/Changelog/versions/v070.md index 18b534cff..f4c0aa656 100644 --- a/ui/v2.5/src/components/Changelog/versions/v070.md +++ b/ui/v2.5/src/components/Changelog/versions/v070.md @@ -1,4 +1,5 @@ ### ✨ New Features +* Added rating field to performers and studios. * Support serving UI from specific directory location. * Added details, death date, hair color, and weight to Performers. * Added `lbToKg` post-process action for performer scrapers. diff --git a/ui/v2.5/src/components/Performers/EditPerformersDialog.tsx b/ui/v2.5/src/components/Performers/EditPerformersDialog.tsx index e0f6d03a3..aecde2db2 100644 --- a/ui/v2.5/src/components/Performers/EditPerformersDialog.tsx +++ b/ui/v2.5/src/components/Performers/EditPerformersDialog.tsx @@ -1,11 +1,13 @@ import React, { useEffect, useState } from "react"; -import { Form } from "react-bootstrap"; +import { Form, Col, Row } from "react-bootstrap"; import _ from "lodash"; import { useBulkPerformerUpdate } from "src/core/StashService"; import * as GQL from "src/core/generated-graphql"; import { Modal } from "src/components/Shared"; import { useToast } from "src/hooks"; +import { FormUtils } from "src/utils"; import MultiSet from "../Shared/MultiSet"; +import { RatingStars } from "../Scenes/SceneDetails/RatingStars"; interface IListOperationProps { selected: GQL.SlimPerformerDataFragment[]; @@ -16,7 +18,7 @@ export const EditPerformersDialog: React.FC = ( props: IListOperationProps ) => { const Toast = useToast(); - + const [rating, setRating] = useState(); const [tagMode, setTagMode] = React.useState( GQL.BulkUpdateIdMode.Add ); @@ -43,6 +45,7 @@ export const EditPerformersDialog: React.FC = ( function getPerformerInput(): GQL.BulkPerformerUpdateInput { // need to determine what we are actually setting on each performer const aggregateTagIds = getTagIds(props.selected); + const aggregateRating = getRating(props.selected); const performerInput: GQL.BulkPerformerUpdateInput = { ids: props.selected.map((performer) => { @@ -50,6 +53,19 @@ export const EditPerformersDialog: React.FC = ( }), }; + // if rating is undefined + if (rating === undefined) { + // and all galleries have the same rating, then we are unsetting the rating. + if (aggregateRating) { + // null to unset rating + performerInput.rating = null; + } + // otherwise not setting the rating + } else { + // if rating is set, then we are setting the rating for all + performerInput.rating = rating; + } + // if tagIds non-empty, then we are setting them if ( tagMode === GQL.BulkUpdateIdMode.Set && @@ -106,19 +122,38 @@ export const EditPerformersDialog: React.FC = ( return ret; } + function getRating(state: GQL.SlimPerformerDataFragment[]) { + let ret: number | undefined; + let first = true; + + state.forEach((performer) => { + if (first) { + ret = performer.rating ?? undefined; + first = false; + } else if (ret !== performer.rating) { + ret = undefined; + } + }); + + return ret; + } + useEffect(() => { const state = props.selected; let updateTagIds: string[] = []; let updateFavorite: boolean | undefined; + let updateRating: number | undefined; let first = true; state.forEach((performer: GQL.SlimPerformerDataFragment) => { const performerTagIDs = (performer.tags ?? []).map((p) => p.id).sort(); + const performerRating = performer.rating; if (first) { updateTagIds = performerTagIDs; first = false; updateFavorite = performer.favorite; + updateRating = performerRating ?? undefined; } else { if (!_.isEqual(performerTagIDs, updateTagIds)) { updateTagIds = []; @@ -126,6 +161,9 @@ export const EditPerformersDialog: React.FC = ( if (performer.favorite !== updateFavorite) { updateFavorite = undefined; } + if (performerRating !== updateRating) { + updateRating = undefined; + } } }); @@ -133,6 +171,7 @@ export const EditPerformersDialog: React.FC = ( setTagIds(updateTagIds); } setFavorite(updateFavorite); + setRating(updateRating); }, [props.selected, tagMode]); useEffect(() => { @@ -201,6 +240,18 @@ export const EditPerformersDialog: React.FC = ( }} isRunning={isUpdating} > + + {FormUtils.renderLabel({ + title: "Rating", + })} + + setRating(value)} + disabled={isUpdating} + /> + +
    Tags diff --git a/ui/v2.5/src/components/Performers/PerformerCard.tsx b/ui/v2.5/src/components/Performers/PerformerCard.tsx index 298f5c0d6..add2ee836 100644 --- a/ui/v2.5/src/components/Performers/PerformerCard.tsx +++ b/ui/v2.5/src/components/Performers/PerformerCard.tsx @@ -1,6 +1,5 @@ import React from "react"; import { Link } from "react-router-dom"; -import { FormattedMessage } from "react-intl"; import * as GQL from "src/core/generated-graphql"; import { NavUtils, TextUtils } from "src/utils"; import { @@ -35,13 +34,13 @@ export const PerformerCard: React.FC = ({ ); const ageString = `${age} years old${ageFromDate ? " in this scene." : "."}`; - function maybeRenderFavoriteBanner() { + function maybeRenderFavoriteIcon() { if (performer.favorite === false) { return; } return ( -
    - +
    +
    ); } @@ -120,6 +119,21 @@ export const PerformerCard: React.FC = ({ } } + function maybeRenderRatingBanner() { + if (!performer.rating) { + return; + } + return ( +
    + RATING: {performer.rating} +
    + ); + } + return ( = ({ alt={performer.name ?? ""} src={performer.image_path ?? ""} /> - {maybeRenderFavoriteBanner()} + {maybeRenderFavoriteIcon()} + {maybeRenderRatingBanner()} } details={ diff --git a/ui/v2.5/src/components/Performers/PerformerDetails/PerformerDetailsPanel.tsx b/ui/v2.5/src/components/Performers/PerformerDetails/PerformerDetailsPanel.tsx index 79980287c..0218700cf 100644 --- a/ui/v2.5/src/components/Performers/PerformerDetails/PerformerDetailsPanel.tsx +++ b/ui/v2.5/src/components/Performers/PerformerDetails/PerformerDetailsPanel.tsx @@ -5,6 +5,7 @@ import * as GQL from "src/core/generated-graphql"; import { genderToString } from "src/core/StashService"; import { TextUtils } from "src/utils"; import { TextField, URLField } from "src/utils/field"; +import { RatingStars } from "src/components/Scenes/SceneDetails/RatingStars"; interface IPerformerDetails { performer: Partial; @@ -35,6 +36,21 @@ export const PerformerDetailsPanel: React.FC = ({ ); } + function renderRating() { + if (!performer.rating) { + return null; + } + + return ( +
    +
    Rating:
    +
    + +
    +
    + ); + } + function renderStashIDs() { if (!performer.stash_ids?.length) { return; @@ -139,6 +155,7 @@ export const PerformerDetailsPanel: React.FC = ({ TextUtils.instagramURL )} /> + {renderRating()} {renderTagsField()} {renderStashIDs()} diff --git a/ui/v2.5/src/components/Performers/PerformerDetails/PerformerEditPanel.tsx b/ui/v2.5/src/components/Performers/PerformerDetails/PerformerEditPanel.tsx index cf5916340..4e4e78484 100644 --- a/ui/v2.5/src/components/Performers/PerformerDetails/PerformerEditPanel.tsx +++ b/ui/v2.5/src/components/Performers/PerformerDetails/PerformerEditPanel.tsx @@ -36,6 +36,7 @@ import { ImageUtils } from "src/utils"; import { useToast } from "src/hooks"; import { Prompt, useHistory } from "react-router-dom"; import { useFormik } from "formik"; +import { RatingStars } from "src/components/Scenes/SceneDetails/RatingStars"; import { PerformerScrapeDialog } from "./PerformerScrapeDialog"; import PerformerScrapeModal from "./PerformerScrapeModal"; @@ -62,7 +63,6 @@ export const PerformerEditPanel: React.FC = ({ // Editing state const [scraper, setScraper] = useState(); const [newTags, setNewTags] = useState(); - const [isDeleteAlertOpen, setIsDeleteAlertOpen] = useState(false); // Network state @@ -109,6 +109,7 @@ export const PerformerEditPanel: React.FC = ({ tag_ids: yup.array(yup.string().required()).optional(), stash_ids: yup.mixed().optional(), image: yup.string().optional().nullable(), + rating: yup.number().optional().nullable(), details: yup.string().optional(), death_date: yup.string().optional(), hair_color: yup.string().optional(), @@ -135,6 +136,7 @@ export const PerformerEditPanel: React.FC = ({ tag_ids: (performer.tags ?? []).map((t) => t.id), stash_ids: performer.stash_ids ?? undefined, image: undefined, + rating: performer.rating ?? undefined, details: performer.details ?? "", death_date: performer.death_date ?? "", hair_color: performer.hair_color ?? "", @@ -149,6 +151,10 @@ export const PerformerEditPanel: React.FC = ({ onSubmit: (values) => onSave(values), }); + function setRating(v: number) { + formik.setFieldValue("rating", v); + } + function translateScrapedGender(scrapedGender?: string) { if (!scrapedGender) { return; @@ -386,6 +392,30 @@ export const PerformerEditPanel: React.FC = ({ }); } + // numeric keypresses get caught by jwplayer, so blur the element + // if the rating sequence is started + Mousetrap.bind("r", () => { + if (document.activeElement instanceof HTMLElement) { + document.activeElement.blur(); + } + + Mousetrap.bind("0", () => setRating(NaN)); + Mousetrap.bind("1", () => setRating(1)); + Mousetrap.bind("2", () => setRating(2)); + Mousetrap.bind("3", () => setRating(3)); + Mousetrap.bind("4", () => setRating(4)); + Mousetrap.bind("5", () => setRating(5)); + + setTimeout(() => { + Mousetrap.unbind("0"); + Mousetrap.unbind("1"); + Mousetrap.unbind("2"); + Mousetrap.unbind("3"); + Mousetrap.unbind("4"); + Mousetrap.unbind("5"); + }, 1000); + }); + return () => { Mousetrap.unbind("s s"); @@ -424,6 +454,7 @@ export const PerformerEditPanel: React.FC = ({ return { ...values, gender: stringToGender(values.gender), + rating: values.rating ?? null, weight: Number(values.weight), id: performer.id ?? "", }; @@ -909,6 +940,18 @@ export const PerformerEditPanel: React.FC = ({ {renderTagsField()} + + + + Rating + + + formik.setFieldValue("rating", value)} + /> + + {renderStashIDs()} {renderButtons()} diff --git a/ui/v2.5/src/components/Performers/styles.scss b/ui/v2.5/src/components/Performers/styles.scss index 47f58d437..8c9c797d3 100644 --- a/ui/v2.5/src/components/Performers/styles.scss +++ b/ui/v2.5/src/components/Performers/styles.scss @@ -72,6 +72,14 @@ right: 1rem; width: 3rem; } + + .favorite { + color: #ff7373; + filter: drop-shadow(0 0 2px rgba(0, 0, 0, 0.9)); + position: absolute; + right: 5px; + top: 10px; + } } .card { diff --git a/ui/v2.5/src/components/Shared/Icon.tsx b/ui/v2.5/src/components/Shared/Icon.tsx index ca822d9e0..584b37447 100644 --- a/ui/v2.5/src/components/Shared/Icon.tsx +++ b/ui/v2.5/src/components/Shared/Icon.tsx @@ -1,6 +1,6 @@ import React from "react"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; -import { IconProp, library } from "@fortawesome/fontawesome-svg-core"; +import { IconProp, SizeProp, library } from "@fortawesome/fontawesome-svg-core"; import { faStar as fasStar } from "@fortawesome/free-solid-svg-icons"; import { faStar as farStar } from "@fortawesome/free-regular-svg-icons"; @@ -11,13 +11,15 @@ interface IIcon { icon: IconProp; className?: string; color?: string; + size?: SizeProp; } -const Icon: React.FC = ({ icon, className, color }) => ( +const Icon: React.FC = ({ icon, className, color, size }) => ( ); diff --git a/ui/v2.5/src/components/Studios/StudioCard.tsx b/ui/v2.5/src/components/Studios/StudioCard.tsx index da6cb03ee..6a36b9c9f 100644 --- a/ui/v2.5/src/components/Studios/StudioCard.tsx +++ b/ui/v2.5/src/components/Studios/StudioCard.tsx @@ -45,6 +45,21 @@ function maybeRenderChildren(studio: GQL.StudioDataFragment) { } } +function maybeRenderRatingBanner(studio: GQL.StudioDataFragment) { + if (!studio.rating) { + return; + } + return ( +
    + RATING: {studio.rating} +
    + ); +} + export const StudioCard: React.FC = ({ studio, hideParent, @@ -122,6 +137,7 @@ export const StudioCard: React.FC = ({ {maybeRenderParent(studio, hideParent)} {maybeRenderChildren(studio)} + {maybeRenderRatingBanner(studio)} {maybeRenderPopoverButtonGroup()} } diff --git a/ui/v2.5/src/components/Studios/StudioDetails/Studio.tsx b/ui/v2.5/src/components/Studios/StudioDetails/Studio.tsx index 0cd7c3813..c232df6a9 100644 --- a/ui/v2.5/src/components/Studios/StudioDetails/Studio.tsx +++ b/ui/v2.5/src/components/Studios/StudioDetails/Studio.tsx @@ -20,6 +20,7 @@ import { StudioSelect, } from "src/components/Shared"; import { useToast } from "src/hooks"; +import { RatingStars } from "src/components/Scenes/SceneDetails/RatingStars"; import { StudioScenesPanel } from "./StudioScenesPanel"; import { StudioGalleriesPanel } from "./StudioGalleriesPanel"; import { StudioImagesPanel } from "./StudioImagesPanel"; @@ -45,6 +46,7 @@ export const Studio: React.FC = () => { const [name, setName] = useState(); const [url, setUrl] = useState(); const [parentStudioId, setParentStudioId] = useState(); + const [rating, setRating] = useState(undefined); const [details, setDetails] = useState(); // Studio state @@ -64,6 +66,7 @@ export const Studio: React.FC = () => { setName(state.name); setUrl(state.url ?? undefined); setParentStudioId(state?.parent_studio?.id ?? undefined); + setRating(state.rating ?? undefined); setDetails(state.details ?? undefined); } @@ -72,6 +75,7 @@ export const Studio: React.FC = () => { updateStudioEditState(studioData); setImagePreview(studioData.image_path ?? undefined); setStudio(studioData); + setRating(studioData.rating ?? undefined); } // set up hotkeys @@ -83,6 +87,30 @@ export const Studio: React.FC = () => { Mousetrap.bind("e", () => setIsEditing(true)); Mousetrap.bind("d d", () => onDelete()); + // numeric keypresses get caught by jwplayer, so blur the element + // if the rating sequence is started + Mousetrap.bind("r", () => { + if (document.activeElement instanceof HTMLElement) { + document.activeElement.blur(); + } + + Mousetrap.bind("0", () => setRating(NaN)); + Mousetrap.bind("1", () => setRating(1)); + Mousetrap.bind("2", () => setRating(2)); + Mousetrap.bind("3", () => setRating(3)); + Mousetrap.bind("4", () => setRating(4)); + Mousetrap.bind("5", () => setRating(5)); + + setTimeout(() => { + Mousetrap.unbind("0"); + Mousetrap.unbind("1"); + Mousetrap.unbind("2"); + Mousetrap.unbind("3"); + Mousetrap.unbind("4"); + Mousetrap.unbind("5"); + }, 1000); + }); + return () => { if (isEditing) { Mousetrap.unbind("s s"); @@ -121,6 +149,7 @@ export const Studio: React.FC = () => { image, details, parent_id: parentStudioId ?? null, + rating: rating ?? null, }; if (!isNew) { @@ -314,6 +343,16 @@ export const Studio: React.FC = () => { Parent Studio {renderStudio()} + + Rating: + + setRating(value ?? NaN)} + /> + + {!isEditing && renderStashIDs()} diff --git a/ui/v2.5/src/docs/en/JSONSpec.md b/ui/v2.5/src/docs/en/JSONSpec.md index e83141891..9d65970fe 100644 --- a/ui/v2.5/src/docs/en/JSONSpec.md +++ b/ui/v2.5/src/docs/en/JSONSpec.md @@ -67,6 +67,7 @@ piercings image (base64 encoding of the image file) created_at updated_at +rating (integer) details ``` @@ -77,6 +78,7 @@ url image (base64 encoding of the image file) created_at updated_at +rating (integer) details ``` diff --git a/ui/v2.5/src/models/list-filter/filter.ts b/ui/v2.5/src/models/list-filter/filter.ts index 70dc2fee1..0d3fe79b6 100644 --- a/ui/v2.5/src/models/list-filter/filter.ts +++ b/ui/v2.5/src/models/list-filter/filter.ts @@ -202,6 +202,7 @@ export class ListFilterModel { "scenes_count", "tag_count", "random", + "rating", ]; this.displayModeOptions = [DisplayMode.Grid, DisplayMode.List]; @@ -231,6 +232,7 @@ export class ListFilterModel { new GenderCriterionOption(), new PerformerIsMissingCriterionOption(), new TagsCriterionOption(), + new RatingCriterionOption(), ListFilterModel.createCriterionOption("url"), ListFilterModel.createCriterionOption("tag_count"), ListFilterModel.createCriterionOption("scene_count"), @@ -244,6 +246,8 @@ export class ListFilterModel { break; } case FilterMode.Studios: + this.sortBy = "name"; + this.sortByOptions = ["name", "random", "rating", "scenes_count"]; this.sortBy = defaultSort ?? "name"; this.sortByOptions = [ "name", @@ -257,6 +261,7 @@ export class ListFilterModel { new NoneCriterionOption(), new ParentStudiosCriterionOption(), new StudioIsMissingCriterionOption(), + new RatingCriterionOption(), ListFilterModel.createCriterionOption("scene_count"), ListFilterModel.createCriterionOption("image_count"), ListFilterModel.createCriterionOption("gallery_count"), @@ -777,6 +782,14 @@ export class ListFilterModel { }; break; } + case "rating": { + const ratingCrit = criterion as RatingCriterion; + result.rating = { + value: ratingCrit.value, + modifier: ratingCrit.modifier, + }; + break; + } case "url": { const urlCrit = criterion as StringCriterion; result.url = { @@ -1040,6 +1053,14 @@ export class ListFilterModel { }; break; } + case "rating": { + const ratingCrit = criterion as RatingCriterion; + result.rating = { + value: ratingCrit.value, + modifier: ratingCrit.modifier, + }; + break; + } case "url": { const urlCrit = criterion as StringCriterion; result.url = { From fe0c5615a66156b861b0810259afe7edfe501e7b Mon Sep 17 00:00:00 2001 From: WithoutPants <53250216+WithoutPants@users.noreply.github.com> Date: Wed, 28 Apr 2021 09:12:35 +1000 Subject: [PATCH 48/66] Memo-ise list hook functions (#1329) --- ui/v2.5/src/hooks/ListHook.tsx | 52 +++++++++++++++++++++------------- 1 file changed, 33 insertions(+), 19 deletions(-) diff --git a/ui/v2.5/src/hooks/ListHook.tsx b/ui/v2.5/src/hooks/ListHook.tsx index 5aec643ff..c2891c4ff 100644 --- a/ui/v2.5/src/hooks/ListHook.tsx +++ b/ui/v2.5/src/hooks/ListHook.tsx @@ -1,6 +1,12 @@ import _ from "lodash"; import queryString from "query-string"; -import React, { useCallback, useRef, useState, useEffect } from "react"; +import React, { + useCallback, + useRef, + useState, + useEffect, + useMemo, +} from "react"; import { ApolloError } from "@apollo/client"; import { useHistory, useLocation } from "react-router-dom"; import Mousetrap from "mousetrap"; @@ -525,26 +531,34 @@ const useList = ( options.persistState, ]); - function updateQueryParams(listFilter: ListFilterModel) { - setFilter(listFilter); - const newLocation = { ...location }; - newLocation.search = listFilter.makeQueryParameters(); - history.replace(newLocation); - if (options.persistState) { - updateInterfaceConfig(listFilter, options.persistState); - } - } + const updateQueryParams = useCallback( + (listFilter: ListFilterModel) => { + setFilter(listFilter); + const newLocation = { ...location }; + newLocation.search = listFilter.makeQueryParameters(); + history.replace(newLocation); + if (options.persistState) { + updateInterfaceConfig(listFilter, options.persistState); + } + }, + [setFilter, history, location, options.persistState, updateInterfaceConfig] + ); - const onChangePage = (page: number) => { - const newFilter = _.cloneDeep(filter); - newFilter.currentPage = page; - updateQueryParams(newFilter); - window.scrollTo(0, 0); - }; + const onChangePage = useCallback( + (page: number) => { + const newFilter = _.cloneDeep(filter); + newFilter.currentPage = page; + updateQueryParams(newFilter); + window.scrollTo(0, 0); + }, + [filter, updateQueryParams] + ); - const renderFilter = !options.filterHook - ? filter - : options.filterHook(_.cloneDeep(filter)); + const renderFilter = useMemo(() => { + return !options.filterHook + ? filter + : options.filterHook(_.cloneDeep(filter)); + }, [filter, options]); const { contentTemplate, onSelectChange } = RenderList({ ...options, From 210feb4034e1ba5c80c84ed48be63e70801f2b3d Mon Sep 17 00:00:00 2001 From: WithoutPants <53250216+WithoutPants@users.noreply.github.com> Date: Wed, 28 Apr 2021 09:27:47 +1000 Subject: [PATCH 49/66] Apply scene queuing for all scene listviews (#1332) * Apply queue population for all scene list views * Add missing localisation strings --- ui/v2.5/src/components/Scenes/SceneCard.tsx | 13 ++- .../src/components/Scenes/SceneCardsGrid.tsx | 14 +-- ui/v2.5/src/components/Scenes/SceneList.tsx | 28 +++--- .../src/components/Scenes/SceneListTable.tsx | 93 ++++++++++--------- ui/v2.5/src/components/Tagger/Tagger.tsx | 25 +++-- ui/v2.5/src/components/Wall/WallItem.tsx | 7 +- ui/v2.5/src/components/Wall/WallPanel.tsx | 4 + ui/v2.5/src/locale/en-GB.json | 2 + ui/v2.5/src/locale/en-US.json | 2 + ui/v2.5/src/models/sceneQueue.ts | 41 +++++--- 10 files changed, 134 insertions(+), 95 deletions(-) diff --git a/ui/v2.5/src/components/Scenes/SceneCard.tsx b/ui/v2.5/src/components/Scenes/SceneCard.tsx index 49bee3148..d09697cac 100644 --- a/ui/v2.5/src/components/Scenes/SceneCard.tsx +++ b/ui/v2.5/src/components/Scenes/SceneCard.tsx @@ -12,6 +12,7 @@ import { TruncatedText, } from "src/components/Shared"; import { TextUtils } from "src/utils"; +import { SceneQueue } from "src/models/sceneQueue"; import { PerformerPopoverButton } from "../Shared/PerformerPopoverButton"; interface IScenePreviewProps { @@ -65,12 +66,13 @@ export const ScenePreview: React.FC = ({ interface ISceneCardProps { scene: GQL.SlimSceneDataFragment; + index?: number; + queue?: SceneQueue; compact?: boolean; selecting?: boolean; selected?: boolean | undefined; zoomIndex?: number; onSelectedChanged?: (selected: boolean, shiftKey: boolean) => void; - onSceneClicked?: () => void; } export const SceneCard: React.FC = ( @@ -300,9 +302,6 @@ export const SceneCard: React.FC = ( if (props.selecting && props.onSelectedChanged) { props.onSelectedChanged(!props.selected, shiftKey); event.preventDefault(); - } else if (props.onSceneClicked) { - props.onSceneClicked(); - event.preventDefault(); } } @@ -340,6 +339,10 @@ export const SceneCard: React.FC = ( let shiftKey = false; + const sceneLink = props.queue + ? props.queue.makeLink(props.scene.id, { sceneIndex: props.index }) + : `/scenes/${props.scene.id}`; + return ( = (
    ; zoomIndex: number; onSelectChange: (id: string, selected: boolean, shiftKey: boolean) => void; - onSceneClick?: (id: string, index: number) => void; } export const SceneCardsGrid: React.FC = ({ scenes, + queue, selectedIds, zoomIndex, onSelectChange, - onSceneClick, }) => { - function sceneClicked(sceneID: string, index: number) { - if (onSceneClick) { - onSceneClick(sceneID, index); - } - } - return (
    {scenes.map((scene, index) => ( 0} selected={selectedIds.has(scene.id)} onSelectedChanged={(selected: boolean, shiftKey: boolean) => onSelectChange(scene.id, selected, shiftKey) } - onSceneClicked={() => sceneClicked(scene.id, index)} /> ))}
    diff --git a/ui/v2.5/src/components/Scenes/SceneList.tsx b/ui/v2.5/src/components/Scenes/SceneList.tsx index 22b8a18e0..1fcd9b345 100644 --- a/ui/v2.5/src/components/Scenes/SceneList.tsx +++ b/ui/v2.5/src/components/Scenes/SceneList.tsx @@ -123,8 +123,8 @@ export const SceneList: React.FC = ({ if (queryResults.data.findScenes.scenes.length > index) { const { id } = queryResults!.data!.findScenes!.scenes[index]; // navigate to the image player page - const queue = SceneQueue.fromListFilterModel(filterCopy, index); - queue.playScene(history, id, { autoPlay: true }); + const queue = SceneQueue.fromListFilterModel(filterCopy); + queue.playScene(history, id, { sceneIndex: index, autoPlay: true }); } } } @@ -143,15 +143,6 @@ export const SceneList: React.FC = ({ setIsExportDialogOpen(true); } - async function sceneClicked( - sceneId: string, - sceneIndex: number, - filter: ListFilterModel - ) { - const queue = SceneQueue.fromListFilterModel(filter, sceneIndex); - queue.playScene(history, sceneId); - } - function maybeRenderSceneGenerateDialog(selectedIds: Set) { if (isGenerateDialogOpen) { return ( @@ -207,27 +198,34 @@ export const SceneList: React.FC = ({ if (!result.data || !result.data.findScenes) { return; } + + const queue = SceneQueue.fromListFilterModel(filter); + if (filter.displayMode === DisplayMode.Grid) { return ( listData.onSelectChange(id, selected, shiftKey) } - onSceneClick={(id, index) => sceneClicked(id, index, filter)} /> ); } if (filter.displayMode === DisplayMode.List) { - return ; + return ( + + ); } if (filter.displayMode === DisplayMode.Wall) { - return ; + return ( + + ); } if (filter.displayMode === DisplayMode.Tagger) { - return ; + return ; } } diff --git a/ui/v2.5/src/components/Scenes/SceneListTable.tsx b/ui/v2.5/src/components/Scenes/SceneListTable.tsx index 518e7f2a5..97496b440 100644 --- a/ui/v2.5/src/components/Scenes/SceneListTable.tsx +++ b/ui/v2.5/src/components/Scenes/SceneListTable.tsx @@ -9,6 +9,7 @@ import { Icon, TruncatedText } from "src/components/Shared"; interface ISceneListTableProps { scenes: GQL.SlimSceneDataFragment[]; + queue?: SceneQueue; } export const SceneListTable: React.FC = ( @@ -37,52 +38,58 @@ export const SceneListTable: React.FC = ( ) ); - const renderSceneRow = (scene: GQL.SlimSceneDataFragment) => ( - - - - {scene.title - - - - -
    - { + const sceneLink = props.queue + ? props.queue.makeLink(scene.id, { sceneIndex: index }) + : `/scenes/${scene.id}`; + + return ( + + + + {scene.title -
    - - - {scene.rating ? scene.rating : ""} - - {scene.file.duration && - TextUtils.secondsToTimestamp(scene.file.duration)} - - {renderTags(scene.tags)} - {renderPerformers(scene.performers)} - - {scene.studio && ( - -
    {scene.studio.name}
    - )} - - {renderMovies(scene)} - - {scene.gallery && ( - - )} - - - ); + )} + + {renderMovies(scene)} + + {scene.gallery && ( + + )} + + + ); + }; return (
    diff --git a/ui/v2.5/src/components/Tagger/Tagger.tsx b/ui/v2.5/src/components/Tagger/Tagger.tsx index d18827b1e..ed9aa0a17 100755 --- a/ui/v2.5/src/components/Tagger/Tagger.tsx +++ b/ui/v2.5/src/components/Tagger/Tagger.tsx @@ -14,6 +14,7 @@ import { } from "src/core/StashService"; import { Manual } from "src/components/Help/Manual"; +import { SceneQueue } from "src/models/sceneQueue"; import StashSearchResult from "./StashSearchResult"; import Config from "./Config"; import { @@ -141,6 +142,7 @@ function prepareQueryString( interface ITaggerListProps { scenes: GQL.SlimSceneDataFragment[]; + queue?: SceneQueue; selectedEndpoint: { endpoint: string; index: number }; config: ITaggerConfig; queueFingerprintSubmission: (sceneId: string, endpoint: string) => void; @@ -149,6 +151,7 @@ interface ITaggerListProps { const TaggerList: React.FC = ({ scenes, + queue, selectedEndpoint, config, queueFingerprintSubmission, @@ -304,8 +307,15 @@ const TaggerList: React.FC = ({ setHideUnmatched(!hideUnmatched); }; + function generateSceneLink(scene: GQL.SlimSceneDataFragment, index: number) { + return queue + ? queue.makeLink(scene.id, { sceneIndex: index }) + : `/scenes/${scene.id}`; + } + const renderScenes = () => - scenes.map((scene) => { + scenes.map((scene, index) => { + const sceneLink = generateSceneLink(scene, index); const { paths, file, ext } = parsePath(scene.path); const originalDir = scene.path.slice( 0, @@ -376,7 +386,7 @@ const TaggerList: React.FC = ({
    Scene successfully tagged:
    - + {taggedScenes[scene.id].title}
    @@ -476,7 +486,7 @@ const TaggerList: React.FC = ({
    - + = ({ />
    - + = ({ interface ITaggerProps { scenes: GQL.SlimSceneDataFragment[]; + queue?: SceneQueue; } -export const Tagger: React.FC = ({ scenes }) => { +export const Tagger: React.FC = ({ scenes, queue }) => { const stashConfig = useConfiguration(); const [{ data: config }, setConfig] = useLocalForage( LOCAL_FORAGE_KEY, @@ -620,6 +628,7 @@ export const Tagger: React.FC = ({ scenes }) => { = (props: IWallItemProps) => { let linkSrc: string = "#"; if (!props.clickHandler) { if (props.scene) { - linkSrc = `/scenes/${props.scene.id}`; + linkSrc = props.sceneQueue + ? props.sceneQueue.makeLink(props.scene.id, { sceneIndex: props.index }) + : `/scenes/${props.scene.id}`; } else if (props.sceneMarker) { linkSrc = NavUtils.makeSceneMarkerUrl(props.sceneMarker); } else if (props.image) { diff --git a/ui/v2.5/src/components/Wall/WallPanel.tsx b/ui/v2.5/src/components/Wall/WallPanel.tsx index b3f2a3f2d..a50ce1414 100644 --- a/ui/v2.5/src/components/Wall/WallPanel.tsx +++ b/ui/v2.5/src/components/Wall/WallPanel.tsx @@ -1,9 +1,11 @@ import React from "react"; import * as GQL from "src/core/generated-graphql"; +import { SceneQueue } from "src/models/sceneQueue"; import { WallItem } from "./WallItem"; interface IWallPanelProps { scenes?: GQL.SlimSceneDataFragment[]; + sceneQueue?: SceneQueue; sceneMarkers?: GQL.SceneMarkerDataFragment[]; images?: GQL.SlimImageDataFragment[]; clickHandler?: ( @@ -43,7 +45,9 @@ export const WallPanel: React.FC = ( const scenes = (props.scenes ?? []).map((scene, index, sceneArray) => ( diff --git a/ui/v2.5/src/locale/en-GB.json b/ui/v2.5/src/locale/en-GB.json index 0e09f413c..5295121cf 100644 --- a/ui/v2.5/src/locale/en-GB.json +++ b/ui/v2.5/src/locale/en-GB.json @@ -1,6 +1,7 @@ { "developmentVersion": "Development Version", "images": "Images", + "images-size": "Images size", "galleries": "Galleries", "library-size": "Library size", "markers": "Markers", @@ -9,6 +10,7 @@ "organized": "Organised", "performers": "Performers", "scenes": "Scenes", + "scenes-size": "Scenes size", "studios": "Studios", "tags": "Tags", "up-dir": "Up a directory", diff --git a/ui/v2.5/src/locale/en-US.json b/ui/v2.5/src/locale/en-US.json index d6ac76584..113c8a42a 100644 --- a/ui/v2.5/src/locale/en-US.json +++ b/ui/v2.5/src/locale/en-US.json @@ -1,6 +1,7 @@ { "developmentVersion": "Development Version", "images": "Images", + "images-size": "Images size", "galleries": "Galleries", "library-size": "Library size", "markers": "Markers", @@ -9,6 +10,7 @@ "organized": "Organized", "performers": "Performers", "scenes": "Scenes", + "scenes-size": "Scenes size", "studios": "Studios", "tags": "Tags", "up-dir": "Up a directory", diff --git a/ui/v2.5/src/models/sceneQueue.ts b/ui/v2.5/src/models/sceneQueue.ts index feb0f9569..3631470f4 100644 --- a/ui/v2.5/src/models/sceneQueue.ts +++ b/ui/v2.5/src/models/sceneQueue.ts @@ -13,6 +13,7 @@ interface IQueryParameters { } export interface IPlaySceneOptions { + sceneIndex?: number; newPage?: number; autoPlay?: boolean; } @@ -20,11 +21,10 @@ export interface IPlaySceneOptions { export class SceneQueue { public query?: ListFilterModel; public sceneIDs?: number[]; + private originalQueryPage?: number; + private originalQueryPageSize?: number; - public static fromListFilterModel( - filter: ListFilterModel, - currentSceneIndex?: number - ) { + public static fromListFilterModel(filter: ListFilterModel) { const ret = new SceneQueue(); const filterCopy = Object.assign( @@ -33,13 +33,8 @@ export class SceneQueue { ); filterCopy.itemsPerPage = 40; - // adjust page to be correct for the index - const filterIndex = - currentSceneIndex !== undefined - ? currentSceneIndex + (filter.currentPage - 1) * filter.itemsPerPage - : 0; - const newPage = Math.floor(filterIndex / filterCopy.itemsPerPage) + 1; - filterCopy.currentPage = newPage; + ret.originalQueryPage = filter.currentPage; + ret.originalQueryPageSize = filter.itemsPerPage; ret.query = filterCopy; return ret; @@ -51,7 +46,7 @@ export class SceneQueue { return ret; } - private makeQueryParameters(page?: number) { + private makeQueryParameters(sceneIndex?: number, page?: number) { if (this.query) { const queryParams = this.query.getQueryParameters(); const translatedParams = { @@ -64,6 +59,17 @@ export class SceneQueue { if (page !== undefined) { translatedParams.qfp = page; + } else if ( + sceneIndex !== undefined && + this.originalQueryPage !== undefined && + this.originalQueryPageSize !== undefined + ) { + // adjust page to be correct for the index + const filterIndex = + sceneIndex + + (this.originalQueryPage - 1) * this.originalQueryPageSize; + const newPage = Math.floor(filterIndex / this.query.itemsPerPage) + 1; + translatedParams.qfp = newPage; } return queryString.stringify(translatedParams, { encode: false }); @@ -109,8 +115,15 @@ export class SceneQueue { sceneID: string, options?: IPlaySceneOptions ) { - const paramStr = this.makeQueryParameters(options?.newPage); + history.push(this.makeLink(sceneID, options)); + } + + public makeLink(sceneID: string, options?: IPlaySceneOptions) { + const paramStr = this.makeQueryParameters( + options?.sceneIndex, + options?.newPage + ); const autoplayParam = options?.autoPlay ? "&autoplay=true" : ""; - history.push(`/scenes/${sceneID}?${paramStr}${autoplayParam}`); + return `/scenes/${sceneID}?${paramStr}${autoplayParam}`; } } From 4d13e8d7f7b44153587a7f314dd03c819eda1097 Mon Sep 17 00:00:00 2001 From: julien0221 <68500525+julien0221@users.noreply.github.com> Date: Thu, 29 Apr 2021 02:20:59 +0100 Subject: [PATCH 50/66] Fixed rating filter on studios (#1342) --- ui/v2.5/src/models/list-filter/filter.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/ui/v2.5/src/models/list-filter/filter.ts b/ui/v2.5/src/models/list-filter/filter.ts index 0d3fe79b6..acb6b4b5d 100644 --- a/ui/v2.5/src/models/list-filter/filter.ts +++ b/ui/v2.5/src/models/list-filter/filter.ts @@ -246,8 +246,6 @@ export class ListFilterModel { break; } case FilterMode.Studios: - this.sortBy = "name"; - this.sortByOptions = ["name", "random", "rating", "scenes_count"]; this.sortBy = defaultSort ?? "name"; this.sortByOptions = [ "name", @@ -255,6 +253,7 @@ export class ListFilterModel { "images_count", "galleries_count", "random", + "rating", ]; this.displayModeOptions = [DisplayMode.Grid]; this.criterionOptions = [ From 502d99de1b08e51cd2aeca0b9d4cc817be4f92c4 Mon Sep 17 00:00:00 2001 From: julien0221 <68500525+julien0221@users.noreply.github.com> Date: Thu, 29 Apr 2021 02:31:51 +0100 Subject: [PATCH 51/66] Added new filters (date and title) to galleries (#1344) * Added new filters (date and title) to galleries * Added image_count on filter for galleries --- pkg/sqlite/gallery.go | 1 + pkg/sqlite/gallery_test.go | 50 ++++++++++++++++++++++++ ui/v2.5/src/models/list-filter/filter.ts | 13 +++++- 3 files changed, 63 insertions(+), 1 deletion(-) diff --git a/pkg/sqlite/gallery.go b/pkg/sqlite/gallery.go index d2e475ffe..445baa46a 100644 --- a/pkg/sqlite/gallery.go +++ b/pkg/sqlite/gallery.go @@ -199,6 +199,7 @@ func (qb *galleryQueryBuilder) makeQuery(galleryFilter *models.GalleryFilterType query.handleStringCriterionInput(galleryFilter.Path, "galleries.path") query.handleIntCriterionInput(galleryFilter.Rating, "galleries.rating") query.handleStringCriterionInput(galleryFilter.URL, "galleries.url") + query.handleCountCriterion(galleryFilter.ImageCount, galleryTable, galleriesImagesTable, galleryIDColumn) qb.handleAverageResolutionFilter(&query, galleryFilter.AverageResolution) if Organized := galleryFilter.Organized; Organized != nil { diff --git a/pkg/sqlite/gallery_test.go b/pkg/sqlite/gallery_test.go index 999224bdc..7b42133a4 100644 --- a/pkg/sqlite/gallery_test.go +++ b/pkg/sqlite/gallery_test.go @@ -712,6 +712,56 @@ func verifyGalleriesPerformerCount(t *testing.T, performerCountCriterion models. }) } +func TestGalleryQueryImageCount(t *testing.T) { + const imageCount = 0 + imageCountCriterion := models.IntCriterionInput{ + Value: imageCount, + Modifier: models.CriterionModifierEquals, + } + + verifyGalleriesImageCount(t, imageCountCriterion) + + imageCountCriterion.Modifier = models.CriterionModifierNotEquals + verifyGalleriesImageCount(t, imageCountCriterion) + + imageCountCriterion.Modifier = models.CriterionModifierGreaterThan + verifyGalleriesImageCount(t, imageCountCriterion) + + imageCountCriterion.Modifier = models.CriterionModifierLessThan + verifyGalleriesImageCount(t, imageCountCriterion) +} + +func verifyGalleriesImageCount(t *testing.T, imageCountCriterion models.IntCriterionInput) { + withTxn(func(r models.Repository) error { + sqb := r.Gallery() + galleryFilter := models.GalleryFilterType{ + ImageCount: &imageCountCriterion, + } + + galleries := queryGallery(t, sqb, &galleryFilter, nil) + assert.Greater(t, len(galleries), -1) + + for _, gallery := range galleries { + pp := 0 + + _, count, err := r.Image().Query(&models.ImageFilterType{ + Galleries: &models.MultiCriterionInput{ + Value: []string{strconv.Itoa(gallery.ID)}, + Modifier: models.CriterionModifierIncludes, + }, + }, &models.FindFilterType{ + PerPage: &pp, + }) + if err != nil { + return err + } + verifyInt(t, count, imageCountCriterion) + } + + return nil + }) +} + // TODO Count // TODO All // TODO Query diff --git a/ui/v2.5/src/models/list-filter/filter.ts b/ui/v2.5/src/models/list-filter/filter.ts index acb6b4b5d..64b282b3e 100644 --- a/ui/v2.5/src/models/list-filter/filter.ts +++ b/ui/v2.5/src/models/list-filter/filter.ts @@ -281,11 +281,13 @@ export class ListFilterModel { case FilterMode.Galleries: this.sortBy = defaultSort ?? "path"; this.sortByOptions = [ + "date", "path", "file_mod_time", "images_count", "tag_count", "performer_count", + "title", "random", ]; this.displayModeOptions = [DisplayMode.Grid, DisplayMode.List]; @@ -301,6 +303,7 @@ export class ListFilterModel { new PerformerTagsCriterionOption(), new PerformersCriterionOption(), ListFilterModel.createCriterionOption("performer_count"), + ListFilterModel.createCriterionOption("image_count"), new StudiosCriterionOption(), ListFilterModel.createCriterionOption("url"), ]; @@ -463,7 +466,7 @@ export class ListFilterModel { this.itemsPerPage !== DEFAULT_PARAMS.itemsPerPage ? this.itemsPerPage : undefined, - sortby: this.sortBy !== "date" ? this.getSortBy() : undefined, + sortby: this.getSortBy() ?? undefined, sortdir: this.sortDirection === SortDirectionEnum.Desc ? "desc" : undefined, disp: @@ -1215,6 +1218,14 @@ export class ListFilterModel { }; break; } + case "image_count": { + const countCrit = criterion as NumberCriterion; + result.image_count = { + value: countCrit.value, + modifier: countCrit.modifier, + }; + break; + } case "studios": { const studCrit = criterion as StudiosCriterion; result.studios = { From 597576f5e671d6b1cffc07af4e3f4bb88e004b3a Mon Sep 17 00:00:00 2001 From: bnkai <48220860+bnkai@users.noreply.github.com> Date: Thu, 29 Apr 2021 04:38:55 +0300 Subject: [PATCH 52/66] Get distinct values from scraper (#1338) Co-authored-by: WithoutPants <53250216+WithoutPants@users.noreply.github.com> --- pkg/scraper/mapped.go | 10 +++++- pkg/scraper/xpath_test.go | 18 ++++++++++ pkg/utils/string_collections.go | 34 +++++++++++++++++++ .../src/components/Changelog/versions/v070.md | 1 + ui/v2.5/src/docs/en/Scraping.md | 12 ++++++- 5 files changed, 73 insertions(+), 2 deletions(-) diff --git a/pkg/scraper/mapped.go b/pkg/scraper/mapped.go index 5945f6570..87a040141 100644 --- a/pkg/scraper/mapped.go +++ b/pkg/scraper/mapped.go @@ -12,6 +12,7 @@ import ( "github.com/stashapp/stash/pkg/logger" "github.com/stashapp/stash/pkg/models" + "github.com/stashapp/stash/pkg/utils" "gopkg.in/yaml.v2" ) @@ -73,7 +74,9 @@ func (s mappedConfig) postProcess(q mappedQuery, attrConfig mappedScraperAttrCon result := attrConfig.concatenateResults(found) result = attrConfig.postProcess(result, q) if attrConfig.hasSplit() { - return attrConfig.splitString(result) + results := attrConfig.splitString(result) + results = attrConfig.distinctResults(results) + return results } ret = []string{result} @@ -86,6 +89,7 @@ func (s mappedConfig) postProcess(q mappedQuery, attrConfig mappedScraperAttrCon ret = append(ret, text) } + ret = attrConfig.distinctResults(ret) } return ret @@ -639,6 +643,10 @@ func (c mappedScraperAttrConfig) concatenateResults(nodes []string) string { return strings.Join(result, separator) } +func (c mappedScraperAttrConfig) distinctResults(nodes []string) []string { + return utils.StrUnique(nodes) +} + func (c mappedScraperAttrConfig) splitString(value string) []string { separator := c.Split var res []string diff --git a/pkg/scraper/xpath_test.go b/pkg/scraper/xpath_test.go index 4a2d94ed4..5983bd7a0 100644 --- a/pkg/scraper/xpath_test.go +++ b/pkg/scraper/xpath_test.go @@ -163,6 +163,8 @@ const htmlDoc1 = `
      + +
    • YouTube
    • @@ -270,6 +272,12 @@ func makeXPathConfig() mappedPerformerScraperConfig { } config.mappedConfig["Weight"] = weightConfig + tagConfig := mappedScraperAttrConfig{ + Selector: `//ul[@id="socialmedia"]//a`, + } + config.Tags = make(mappedConfig) + config.Tags["Name"] = tagConfig + return config } @@ -348,6 +356,16 @@ func TestScrapePerformerXPath(t *testing.T) { verifyField(t, details, performer.Details, "Details") verifyField(t, hairColor, performer.HairColor, "HairColor") verifyField(t, weight, performer.Weight, "Weight") + + expectedTagNames := []string{ + "Twitter", + "Facebook", + "YouTube", + "Instagram", + } + for i, expected := range expectedTagNames { + verifyField(t, expected, &performer.Tags[i].Name, "TagName") + } } func TestConcatXPath(t *testing.T) { diff --git a/pkg/utils/string_collections.go b/pkg/utils/string_collections.go index af135eb71..ae2f52991 100644 --- a/pkg/utils/string_collections.go +++ b/pkg/utils/string_collections.go @@ -35,6 +35,40 @@ func StrMap(vs []string, f func(string) string) []string { return vsm } +// StrAppendUnique appends toAdd to the vs string slice if toAdd does not already +// exist in the slice. It returns the new or unchanged string slice. +func StrAppendUnique(vs []string, toAdd string) []string { + if StrInclude(vs, toAdd) { + return vs + } + + return append(vs, toAdd) +} + +// StrAppendUniques appends a slice of string values to the vs string slice. It only +// appends values that do not already exist in the slice. It returns the new or +// unchanged string slice. +func StrAppendUniques(vs []string, toAdd []string) []string { + for _, v := range toAdd { + vs = StrAppendUnique(vs, v) + } + + return vs +} + +// StrUnique returns the vs string slice with non-unique values removed. +func StrUnique(vs []string) []string { + distinctValues := make(map[string]struct{}) + var ret []string + for _, v := range vs { + if _, exists := distinctValues[v]; !exists { + distinctValues[v] = struct{}{} + ret = append(ret, v) + } + } + return ret +} + // StringSliceToIntSlice converts a slice of strings to a slice of ints. // Returns an error if any values cannot be parsed. func StringSliceToIntSlice(ss []string) ([]int, error) { diff --git a/ui/v2.5/src/components/Changelog/versions/v070.md b/ui/v2.5/src/components/Changelog/versions/v070.md index f4c0aa656..f79e9775c 100644 --- a/ui/v2.5/src/components/Changelog/versions/v070.md +++ b/ui/v2.5/src/components/Changelog/versions/v070.md @@ -13,6 +13,7 @@ * Added scene queue. ### 🎨 Improvements +* Remove duplicate values when scraping lists of elements. * Improved performance of the auto-tagger. * Clean generation artifacts after generating each scene. * Log message at startup when cleaning the `tmp` and `downloads` generated folders takes more than one second. diff --git a/ui/v2.5/src/docs/en/Scraping.md b/ui/v2.5/src/docs/en/Scraping.md index 4d38b4fd9..e537bfb2a 100644 --- a/ui/v2.5/src/docs/en/Scraping.md +++ b/ui/v2.5/src/docs/en/Scraping.md @@ -389,7 +389,17 @@ Replaces `2001 to 2003` with `2001-2003`. Additionally, there are a number of fixed post-processing fields that are specified at the attribute level (not in `postProcess`) that are performed after the `postProcess` operations: * `concat`: if an xpath matches multiple elements, and `concat` is present, then all of the elements will be concatenated together -* `split`: Its the inverse of `concat`. Splits a string to more elements using the separator given. For more info and examples have a look at PR [#579](https://github.com/stashapp/stash/pull/579) +* `split`: the inverse of `concat`. Splits a string to more elements using the separator given. For more info and examples have a look at PR [#579](https://github.com/stashapp/stash/pull/579) + +Example: +```yaml +Tags: + Name: + selector: //span[@class="list_attributes"] + split: "," +``` +Splits a comma separated list of tags located in the span and returns the tags. + For backwards compatibility, `replace`, `subscraper` and `parseDate` are also allowed as keys for the attribute. From 4a04dfe4a2e4dda26a78f3f9e25388d5c2c43fc3 Mon Sep 17 00:00:00 2001 From: InfiniteTF Date: Fri, 30 Apr 2021 06:55:18 +0200 Subject: [PATCH 53/66] Fix scene tagger bugs (#1357) --- .../src/components/Changelog/versions/v070.md | 1 + ui/v2.5/src/components/Tagger/Config.tsx | 2 + .../components/Tagger/StashSearchResult.tsx | 45 +++++++++++++------ ui/v2.5/src/components/Tagger/Tagger.tsx | 28 +++++++++--- ui/v2.5/src/components/Tagger/utils.ts | 24 +++++----- 5 files changed, 69 insertions(+), 31 deletions(-) diff --git a/ui/v2.5/src/components/Changelog/versions/v070.md b/ui/v2.5/src/components/Changelog/versions/v070.md index f79e9775c..404202b85 100644 --- a/ui/v2.5/src/components/Changelog/versions/v070.md +++ b/ui/v2.5/src/components/Changelog/versions/v070.md @@ -31,6 +31,7 @@ * Change performer text query to search by name and alias only. ### 🐛 Bug fixes +* Fix performer/studio being cleared when skipped in scene tagger. * Fixed error when auto-tagging for performers/studios/tags with regex characters in the name. * Fix scraped performer image not updating after clearing the current image when creating a new performer. * Fix error preventing adding a new library path when an existing library path is missing. diff --git a/ui/v2.5/src/components/Tagger/Config.tsx b/ui/v2.5/src/components/Tagger/Config.tsx index f320567af..cce04bbde 100644 --- a/ui/v2.5/src/components/Tagger/Config.tsx +++ b/ui/v2.5/src/components/Tagger/Config.tsx @@ -44,6 +44,8 @@ const Config: React.FC = ({ show, config, setConfig }) => { if (!blacklistRef.current) return; const input = blacklistRef.current.value; + if (input.length === 0) return; + setConfig({ ...config, blacklist: [...config.blacklist, input], diff --git a/ui/v2.5/src/components/Tagger/StashSearchResult.tsx b/ui/v2.5/src/components/Tagger/StashSearchResult.tsx index bb121e612..a8a06e6bf 100755 --- a/ui/v2.5/src/components/Tagger/StashSearchResult.tsx +++ b/ui/v2.5/src/components/Tagger/StashSearchResult.tsx @@ -25,28 +25,38 @@ const getDurationStatus = ( scene: IStashBoxScene, stashDuration: number | undefined | null ) => { - const fingerprintDuration = - scene.fingerprints.map((f) => f.duration)?.[0] ?? null; - const sceneDuration = scene.duration || fingerprintDuration; - if (!sceneDuration || !stashDuration) return ""; - const diff = Math.abs(sceneDuration - stashDuration); - if (diff < 5) { + if (!stashDuration) return ""; + + const durations = scene.fingerprints + .map((f) => f.duration) + .map((d) => Math.abs(d - stashDuration)); + const matchCount = durations.filter((duration) => duration <= 5).length; + + let match; + if (matchCount > 0) + match = `Duration matches ${matchCount}/${durations.length} fingerprints`; + else if (Math.abs(scene.duration - stashDuration) < 5) + match = "Duration is a match"; + + if (match) return (
      - Duration is a match + {match}
      ); - } - return
      Duration off by {Math.floor(diff)}s
      ; + + const minDiff = Math.min(scene.duration, ...durations); + return
      Duration off by at least {Math.floor(minDiff)}s
      ; }; const getFingerprintStatus = ( scene: IStashBoxScene, stashScene: GQL.SlimSceneDataFragment ) => { - const checksum = stashScene.checksum ?? stashScene.oshash ?? undefined; - const checksumMatch = scene.fingerprints.some((f) => f.hash === checksum); + const checksumMatch = scene.fingerprints.some( + (f) => f.hash === stashScene.checksum || f.hash === stashScene.oshash + ); const phashMatch = scene.fingerprints.some( (f) => f.hash === stashScene.phash ); @@ -176,6 +186,8 @@ const StashSearchResult: React.FC = ({ studioID = res.data.studioUpdate.id; } else if (studio.type === "existing") { studioID = studio.data.id; + } else if (studio.type === "skip") { + studioID = stashScene.studio?.id; } setSaveState("Saving performers"); @@ -296,6 +308,10 @@ const StashSearchResult: React.FC = ({ updatedTags = uniq(newTagIDs); } + const performer_ids = performerIDs.filter( + (id) => id !== "Skip" + ) as string[]; + const sceneUpdateResult = await updateScene({ variables: { input: { @@ -303,9 +319,10 @@ const StashSearchResult: React.FC = ({ title: scene.title, details: scene.details, date: scene.date, - performer_ids: performerIDs.filter( - (id) => id !== "Skip" - ) as string[], + performer_ids: + performer_ids.length === 0 + ? stashScene.performers.map((p) => p.id) + : performer_ids, studio_id: studioID, cover_image: imgData, url: scene.url, diff --git a/ui/v2.5/src/components/Tagger/Tagger.tsx b/ui/v2.5/src/components/Tagger/Tagger.tsx index ed9aa0a17..35bbb4a58 100755 --- a/ui/v2.5/src/components/Tagger/Tagger.tsx +++ b/ui/v2.5/src/components/Tagger/Tagger.tsx @@ -171,12 +171,15 @@ const TaggerList: React.FC = ({ const [selectedResult, setSelectedResult] = useState< Record >(); + const [selectedFingerprintResult, setSelectedFingerprintResult] = useState< + Record + >(); const [taggedScenes, setTaggedScenes] = useState< Record> >({}); const [loadingFingerprints, setLoadingFingerprints] = useState(false); const [fingerprints, setFingerprints] = useState< - Record + Record >({}); const [hideUnmatched, setHideUnmatched] = useState(false); const fingerprintQueue = @@ -269,7 +272,9 @@ const TaggerList: React.FC = ({ selectScenes(results.data?.queryStashBoxScene).forEach((scene) => { scene.fingerprints?.forEach((f) => { - newFingerprints[f.hash] = scene; + newFingerprints[f.hash] = newFingerprints[f.hash] + ? [...newFingerprints[f.hash], scene] + : [scene]; }); }); @@ -428,21 +433,30 @@ const TaggerList: React.FC = ({ let searchResult; if (fingerprintMatch && !isTagged && !hasStashIDs) { - searchResult = ( + searchResult = sortScenesByDuration( + fingerprintMatch, + scene.file.duration ?? 0 + ).map((match, i) => ( {}} + isActive={(selectedFingerprintResult?.[scene.id] ?? 0) === i} + setActive={() => + setSelectedFingerprintResult({ + ...selectedFingerprintResult, + [scene.id]: i, + }) + } setScene={handleTaggedScene} - scene={fingerprintMatch} + scene={match} setCoverImage={config.setCoverImage} setTags={config.setTags} tagOperation={config.tagOperation} endpoint={selectedEndpoint.endpoint} queueFingerprintSubmission={queueFingerprintSubmission} + key={match.stash_id} /> - ); + )); } else if ( searchResults[scene.id]?.length > 0 && !isTagged && diff --git a/ui/v2.5/src/components/Tagger/utils.ts b/ui/v2.5/src/components/Tagger/utils.ts index b5666bcf0..6222cde6d 100644 --- a/ui/v2.5/src/components/Tagger/utils.ts +++ b/ui/v2.5/src/components/Tagger/utils.ts @@ -165,18 +165,22 @@ export const sortScenesByDuration = ( targetDuration?: number ) => scenes.sort((a, b) => { - const adur = - a?.duration || (a?.fingerprints.map((f) => f.duration)?.[0] ?? null); - const bdur = - b?.duration || (b?.fingerprints.map((f) => f.duration)?.[0] ?? null); - if (!adur && !bdur) return 0; - if (adur && !bdur) return -1; - if (!adur && bdur) return 1; - if (!targetDuration) return 0; - const aDiff = Math.abs((adur ?? 0) - targetDuration); - const bDiff = Math.abs((bdur ?? 0) - targetDuration); + const aDur = [ + a.duration, + ...a.fingerprints.map((f) => f.duration), + ].map((d) => Math.abs(d - targetDuration)); + const bDur = [ + b.duration, + ...b.fingerprints.map((f) => f.duration), + ].map((d) => Math.abs(d - targetDuration)); + + if (aDur.length > 0 && bDur.length === 0) return -1; + if (aDur.length === 0 && bDur.length > 0) return 1; + + const aDiff = Math.min(...aDur); + const bDiff = Math.min(...bDur); if (aDiff < bDiff) return -1; if (aDiff > bDiff) return 1; From 3f0c9654001b9a3ab4ad4c9900ce172f8850d4f6 Mon Sep 17 00:00:00 2001 From: WithoutPants <53250216+WithoutPants@users.noreply.github.com> Date: Sat, 1 May 2021 11:19:21 +1000 Subject: [PATCH 54/66] Fix development releases --- .github/workflows/build.yml | 13 +++++-------- 1 file changed, 5 insertions(+), 8 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 7a90be6f4..3aff928c2 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -84,14 +84,12 @@ jobs: - name: Development Release if: ${{ github.event_name == 'push' && github.ref == 'refs/heads/develop' }} - uses: meeDamian/github-release@2.0 + uses: marvinpinto/action-automatic-releases@v1.1.2 with: - token: "${{ secrets.GITHUB_TOKEN }}" + repo_token: "${{ secrets.GITHUB_TOKEN }}" prerelease: true - allow_override: true - tag: latest_develop - name: "${{ env.STASH_VERSION }}: Latest development build" - body: "**${{ env.RELEASE_DATE }}**\n This is always the latest committed version on the develop branch. Use as your own risk!" + automatic_release_tag: latest_develop + title: "${{ env.STASH_VERSION }}: Latest development build" files: | dist/stash-osx dist/stash-win.exe @@ -100,8 +98,7 @@ jobs: dist/stash-linux-arm32v7 dist/stash-pi CHECKSUMS_SHA1 - gzip: false - + - name: Master release if: ${{ github.event_name == 'release' && github.ref != 'refs/tags/latest_develop' }} uses: meeDamian/github-release@2.0 From d7a04ced003fc62eb5003b5fdc97081d35518669 Mon Sep 17 00:00:00 2001 From: WithoutPants <53250216+WithoutPants@users.noreply.github.com> Date: Mon, 3 May 2021 08:23:19 +1000 Subject: [PATCH 55/66] Revert always show preview videos on small devices (#1340) --- .../src/components/Changelog/versions/v070.md | 1 + ui/v2.5/src/components/Scenes/styles.scss | 39 +++++-------------- 2 files changed, 11 insertions(+), 29 deletions(-) diff --git a/ui/v2.5/src/components/Changelog/versions/v070.md b/ui/v2.5/src/components/Changelog/versions/v070.md index 404202b85..a38326c2d 100644 --- a/ui/v2.5/src/components/Changelog/versions/v070.md +++ b/ui/v2.5/src/components/Changelog/versions/v070.md @@ -31,6 +31,7 @@ * Change performer text query to search by name and alias only. ### 🐛 Bug fixes +* Reverted video previews always playing on small devices. * Fix performer/studio being cleared when skipped in scene tagger. * Fixed error when auto-tagging for performers/studios/tags with regex characters in the name. * Fix scraped performer image not updating after clearing the current image when creating a new performer. diff --git a/ui/v2.5/src/components/Scenes/styles.scss b/ui/v2.5/src/components/Scenes/styles.scss index cb9c06619..0fa948a48 100644 --- a/ui/v2.5/src/components/Scenes/styles.scss +++ b/ui/v2.5/src/components/Scenes/styles.scss @@ -214,36 +214,17 @@ textarea.scene-description { } } - @media (pointer: fine) { - &:hover { - .scene-specs-overlay, - .rating-banner, - .scene-studio-overlay { - opacity: 0; - transition: opacity 0.5s; - } - - .scene-studio-overlay:hover { - opacity: 0.75; - transition: opacity 0.5s; - } - - .scene-card-check { - opacity: 0.75; - transition: opacity 0.5s; - } - - .scene-card-preview-video { - top: 0; - transition-delay: 0.2s; - } - } - } - - /* replicate hover for non-hoverable interfaces */ - @media (hover: none), (pointer: coarse), (pointer: none) { - /* don't hide overlays */ + &:hover, + &:active { + .scene-specs-overlay, + .rating-banner, .scene-studio-overlay { + opacity: 0; + transition: opacity 0.5s; + } + + .scene-studio-overlay:hover, + .scene-studio-overlay:active { opacity: 0.75; transition: opacity 0.5s; } From 2c52fd711bd61afcb4edda82a860e1bdc8e7f070 Mon Sep 17 00:00:00 2001 From: Jeremy Meyers Date: Sun, 2 May 2021 22:49:15 -0400 Subject: [PATCH 56/66] Several syntactical and content changes (#1347) --- README.md | 39 +++++++++++++++++++-------------------- 1 file changed, 19 insertions(+), 20 deletions(-) diff --git a/README.md b/README.md index ff723d81b..e81be3020 100644 --- a/README.md +++ b/README.md @@ -8,28 +8,28 @@ https://stashapp.cc **Stash is a locally hosted web-based app written in Go which organizes and serves your porn.** -* It can gather information about videos in your collection from the internet, and is extensible through the use of community-built plugins. -* It supports a wide variety of both video and image formats +* It can gather information about videos in your collection from the internet, and is extensible through the use of community-built plugins for a large number of content producers. +* It supports a wide variety of both video and image formats. * You can tag videos and find them later. * It provides statistics about performers, tags, studios and other things. You can [watch a demo video](https://vimeo.com/275537038) to see it in action (password is stashapp). -For further information you can [read the in-app manual](https://github.com/stashapp/stash/tree/develop/ui/v2.5/src/docs/en). +For further information you can [read the in-app manual](ui/v2.5/src/docs/en). # Installing stash -## Docker install +## via Docker Follow [this README.md in the docker directory.](docker/production/README.md) ## Pre-Compiled Binaries -Stash supports macOS, Windows, and Linux. Download the [latest release here](https://github.com/stashapp/stash/releases). +The Stash server runs on macOS, Windows, and Linux. Download the [latest release here](https://github.com/stashapp/stash/releases). Run the executable (double click the exe on windows or run `./stash-osx` / `./stash-linux` from the terminal on macOS / Linux) and navigate to either https://localhost:9999 or http://localhost:9999 to get started. -*Note for Windows users:* Running the app might present a security prompt since the binary isn't signed yet. Just click more info and then the "run anyway" button. +*Note for Windows users:* Running the app might present a security prompt since the binary isn't yet signed. Bypass this by clicking "more info" and then the "run anyway" button. #### FFMPEG @@ -48,9 +48,9 @@ The `ffmpeg(.exe)` and `ffprobe(.exe)` files should be placed in `~/.stash` on m 2) Run Stash. It will prompt you for some configuration options and a directory to index (you can also do this step afterward) 3) After configuration, launch your web browser and navigate to the URL shown within the Stash app. -**Note that Stash does not currently retrieve and organize information about your entire library automatically.** You will need to help it along through the use of [scrapers](https://github.com/stashapp/stash/blob/develop/ui/v2.5/src/docs/en/Scraping.md). The Stash community has developed scrapers for many popular data sources which can be downloaded and installed from [this repository](https://github.com/stashapp/CommunityScrapers). +**Note that Stash does not currently retrieve and organize information about your entire library automatically.** You will need to help it along through the use of [scrapers](blob/develop/ui/v2.5/src/docs/en/Scraping.md). The Stash community has developed scrapers for many popular data sources which can be downloaded and installed from [this repository](https://github.com/stashapp/CommunityScrapers). -The simplest way to tag a large number of files is by using the [Tagger](https://github.com/stashapp/stash/blob/develop/ui/v2.5/src/docs/en/Tagger.md) which uses filename keywords to help identify the file and pull in scene and performer information from our database. Note that this information is not comprehensive and you may need to use the scrapers to identify some of your media. +The simplest way to tag a large number of files is by using the [Tagger](https://github.com/stashapp/stash/blob/develop/ui/v2.5/src/docs/en/Tagger.md) which uses filename keywords to help identify the file and pull in scene and performer information from our stash-box database. Note that this data source is not comprehensive and you may need to use the scrapers to identify some of your media. ## CLI @@ -60,7 +60,7 @@ For example, to run stash locally on port 80 run it like this (OSX / Linux) `sta ## SSL (HTTPS) -Stash supports HTTPS with some additional work. First you must generate a SSL certificate and key combo. Here is an example using openssl: +Stash can run over HTTPS with some additional work. First you must generate a SSL certificate and key combo. Here is an example using openssl: `openssl req -x509 -newkey rsa:4096 -sha256 -days 7300 -nodes -keyout stash.key -out stash.crt -extensions san -config <(echo "[req]"; echo distinguished_name=req; echo "[san]"; echo subjectAltName=DNS:stash.server,IP:127.0.0.1) -subj /CN=stash.server` @@ -70,31 +70,30 @@ Once you have a certificate and key file name them `stash.crt` and `stash.key` a # Customization -## Themes -There is a [directory of themes](https://github.com/stashapp/stash/wiki/Themes) on our Wiki, along with instructions on how to install them.. +## Themes and CSS Customization +There is a [directory of community-created themes](https://github.com/stashapp/stash/wiki/Themes) on our Wiki, along with instructions on how to install them. -## CSS Customization -You can make Stash interface fit your desired style with [Custom CSS snippets](https://github.com/stashapp/stash/wiki/Custom-CSS-snippets) and [CSS Tweaks](https://github.com/stashapp/stash/wiki/CSS-Tweaks). +You can also make Stash interface fit your desired style with [Custom CSS snippets](https://github.com/stashapp/stash/wiki/Custom-CSS-snippets) and [CSS Tweaks](https://github.com/stashapp/stash/wiki/CSS-Tweaks). # Support (FAQ) -Answers to frequently asked questions can be found [on our Wiki](https://github.com/stashapp/stash/wiki/FAQ) +Answers to other Frequently Asked Questions can be found [on our Wiki](https://github.com/stashapp/stash/wiki/FAQ) For issues not addressed there, there are a few options. * Read the [Wiki](https://github.com/stashapp/stash/wiki) * Check the in-app documentation (also available [here](https://github.com/stashapp/stash/tree/develop/ui/v2.5/src/docs/en) -* Join the [Discord server](https://discord.gg/2TsNFKt). +* Join the [Discord server](https://discord.gg/2TsNFKt), where the community can offer support. -# Building From Source Code +# Compiling From Source Code -## Install +## Pre-requisites * [Go](https://golang.org/dl/) * [Revive](https://github.com/mgechev/revive) - Configurable linter * Go Install: `go get github.com/mgechev/revive` -* [Packr2](https://github.com/gobuffalo/packr/tree/v2.0.2/v2) - Static asset bundler - * Go Install: `go get github.com/gobuffalo/packr/v2/packr2@v2.0.2` +* [Packr2](https://github.com/gobuffalo/packr/) - Static asset bundler + * Go Install: `go get github.com/gobuffalo/packr/v2/packr2` * [Binary Download](https://github.com/gobuffalo/packr/releases) * [Yarn](https://yarnpkg.com/en/docs/install) - Yarn package manager * Run `yarn install --frozen-lockfile` in the `stash/ui/v2.5` folder (before running make generate for first time). @@ -141,7 +140,7 @@ NOTE: The `make` command in Windows will be `mingw32-make` with MingW. ## Cross compiling -This project uses a modification of [this](https://github.com/bep/dockerfiles/tree/master/ci-goreleaser) docker container to create an environment +This project uses a modification of the [CI-GoReleaser](https://github.com/bep/dockerfiles/tree/master/ci-goreleaser) docker container to create an environment where the app can be cross-compiled. This process is kicked off by CI via the `scripts/cross-compile.sh` script. Run the following command to open a bash shell to the container to poke around: From a3609079bbc074b581fec8b66ae3909801a8676a Mon Sep 17 00:00:00 2001 From: WithoutPants <53250216+WithoutPants@users.noreply.github.com> Date: Mon, 3 May 2021 13:09:46 +1000 Subject: [PATCH 57/66] Autotag support for images and galleries (#1345) * Add compound queries for images and galleries * Implement image and gallery auto tagging --- graphql/schema/types/filters.graphql | 8 + pkg/autotag/gallery.go | 117 +++++ pkg/autotag/gallery_test.go | 145 ++++++ pkg/autotag/image.go | 117 +++++ pkg/autotag/image_test.go | 145 ++++++ pkg/autotag/integration_test.go | 434 +++++++++++++++++- pkg/autotag/performer.go | 20 + pkg/autotag/performer_test.go | 146 +++++- pkg/autotag/scene.go | 4 +- pkg/autotag/scene_test.go | 86 ++-- pkg/autotag/studio.go | 66 +++ pkg/autotag/studio_test.go | 154 ++++++- pkg/autotag/tag.go | 20 + pkg/autotag/tag_test.go | 146 +++++- pkg/autotag/tagger.go | 42 ++ pkg/gallery/update.go | 40 ++ pkg/image/update.go | 45 +- pkg/manager/manager_tasks.go | 131 ++---- pkg/manager/task_autotag.go | 381 +++++++++++++++ pkg/models/model_gallery.go | 15 + pkg/models/model_image.go | 11 + pkg/sqlite/gallery.go | 398 ++++++++-------- pkg/sqlite/gallery_test.go | 153 ++++++ pkg/sqlite/image.go | 385 ++++++++-------- pkg/sqlite/image_test.go | 201 ++++++++ pkg/sqlite/setup_test.go | 20 + .../src/components/Changelog/versions/v070.md | 1 + 27 files changed, 2910 insertions(+), 521 deletions(-) create mode 100644 pkg/autotag/gallery.go create mode 100644 pkg/autotag/gallery_test.go create mode 100644 pkg/autotag/image.go create mode 100644 pkg/autotag/image_test.go diff --git a/graphql/schema/types/filters.graphql b/graphql/schema/types/filters.graphql index 8d8eb06a3..9f5f3c91e 100644 --- a/graphql/schema/types/filters.graphql +++ b/graphql/schema/types/filters.graphql @@ -164,6 +164,10 @@ input StudioFilterType { } input GalleryFilterType { + AND: GalleryFilterType + OR: GalleryFilterType + NOT: GalleryFilterType + """Filter by path""" path: StringCriterionInput """Filter to only include galleries missing this property""" @@ -219,6 +223,10 @@ input TagFilterType { } input ImageFilterType { + AND: ImageFilterType + OR: ImageFilterType + NOT: ImageFilterType + """Filter by path""" path: StringCriterionInput """Filter by rating""" diff --git a/pkg/autotag/gallery.go b/pkg/autotag/gallery.go new file mode 100644 index 000000000..fa3ab3a84 --- /dev/null +++ b/pkg/autotag/gallery.go @@ -0,0 +1,117 @@ +package autotag + +import ( + "fmt" + "path/filepath" + "strings" + + "github.com/stashapp/stash/pkg/gallery" + "github.com/stashapp/stash/pkg/models" +) + +func galleryPathsFilter(paths []string) *models.GalleryFilterType { + if paths == nil { + return nil + } + + sep := string(filepath.Separator) + + var ret *models.GalleryFilterType + var or *models.GalleryFilterType + for _, p := range paths { + newOr := &models.GalleryFilterType{} + if or != nil { + or.Or = newOr + } else { + ret = newOr + } + + or = newOr + + if !strings.HasSuffix(p, sep) { + p = p + sep + } + + or.Path = &models.StringCriterionInput{ + Modifier: models.CriterionModifierEquals, + Value: p + "%", + } + } + + return ret +} + +func getMatchingGalleries(name string, paths []string, galleryReader models.GalleryReader) ([]*models.Gallery, error) { + regex := getPathQueryRegex(name) + organized := false + filter := models.GalleryFilterType{ + Path: &models.StringCriterionInput{ + Value: "(?i)" + regex, + Modifier: models.CriterionModifierMatchesRegex, + }, + Organized: &organized, + } + + filter.And = galleryPathsFilter(paths) + + pp := models.PerPageAll + gallerys, _, err := galleryReader.Query(&filter, &models.FindFilterType{ + PerPage: &pp, + }) + + if err != nil { + return nil, fmt.Errorf("error querying gallerys with regex '%s': %s", regex, err.Error()) + } + + var ret []*models.Gallery + for _, p := range gallerys { + if nameMatchesPath(name, p.Path.String) { + ret = append(ret, p) + } + } + + return ret, nil +} + +func getGalleryFileTagger(s *models.Gallery) tagger { + return tagger{ + ID: s.ID, + Type: "gallery", + Name: s.GetTitle(), + Path: s.Path.String, + } +} + +// GalleryPerformers tags the provided gallery with performers whose name matches the gallery's path. +func GalleryPerformers(s *models.Gallery, rw models.GalleryReaderWriter, performerReader models.PerformerReader) error { + t := getGalleryFileTagger(s) + + return t.tagPerformers(performerReader, func(subjectID, otherID int) (bool, error) { + return gallery.AddPerformer(rw, subjectID, otherID) + }) +} + +// GalleryStudios tags the provided gallery with the first studio whose name matches the gallery's path. +// +// Gallerys will not be tagged if studio is already set. +func GalleryStudios(s *models.Gallery, rw models.GalleryReaderWriter, studioReader models.StudioReader) error { + if s.StudioID.Valid { + // don't modify + return nil + } + + t := getGalleryFileTagger(s) + + return t.tagStudios(studioReader, func(subjectID, otherID int) (bool, error) { + return addGalleryStudio(rw, subjectID, otherID) + }) +} + +// GalleryTags tags the provided gallery with tags whose name matches the gallery's path. +func GalleryTags(s *models.Gallery, rw models.GalleryReaderWriter, tagReader models.TagReader) error { + t := getGalleryFileTagger(s) + + return t.tagTags(tagReader, func(subjectID, otherID int) (bool, error) { + return gallery.AddTag(rw, subjectID, otherID) + }) +} diff --git a/pkg/autotag/gallery_test.go b/pkg/autotag/gallery_test.go new file mode 100644 index 000000000..ff47f20c1 --- /dev/null +++ b/pkg/autotag/gallery_test.go @@ -0,0 +1,145 @@ +package autotag + +import ( + "testing" + + "github.com/stashapp/stash/pkg/models" + "github.com/stashapp/stash/pkg/models/mocks" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" +) + +const galleryExt = "zip" + +func TestGalleryPerformers(t *testing.T) { + const galleryID = 1 + const performerName = "performer name" + const performerID = 2 + performer := models.Performer{ + ID: performerID, + Name: models.NullString(performerName), + } + + const reversedPerformerName = "name performer" + const reversedPerformerID = 3 + reversedPerformer := models.Performer{ + ID: reversedPerformerID, + Name: models.NullString(reversedPerformerName), + } + + testTables := generateTestTable(performerName, galleryExt) + + assert := assert.New(t) + + for _, test := range testTables { + mockPerformerReader := &mocks.PerformerReaderWriter{} + mockGalleryReader := &mocks.GalleryReaderWriter{} + + mockPerformerReader.On("QueryForAutoTag", mock.Anything).Return([]*models.Performer{&performer, &reversedPerformer}, nil).Once() + + if test.Matches { + mockGalleryReader.On("GetPerformerIDs", galleryID).Return(nil, nil).Once() + mockGalleryReader.On("UpdatePerformers", galleryID, []int{performerID}).Return(nil).Once() + } + + gallery := models.Gallery{ + ID: galleryID, + Path: models.NullString(test.Path), + } + err := GalleryPerformers(&gallery, mockGalleryReader, mockPerformerReader) + + assert.Nil(err) + mockPerformerReader.AssertExpectations(t) + mockGalleryReader.AssertExpectations(t) + } +} + +func TestGalleryStudios(t *testing.T) { + const galleryID = 1 + const studioName = "studio name" + const studioID = 2 + studio := models.Studio{ + ID: studioID, + Name: models.NullString(studioName), + } + + const reversedStudioName = "name studio" + const reversedStudioID = 3 + reversedStudio := models.Studio{ + ID: reversedStudioID, + Name: models.NullString(reversedStudioName), + } + + testTables := generateTestTable(studioName, galleryExt) + + assert := assert.New(t) + + for _, test := range testTables { + mockStudioReader := &mocks.StudioReaderWriter{} + mockGalleryReader := &mocks.GalleryReaderWriter{} + + mockStudioReader.On("QueryForAutoTag", mock.Anything).Return([]*models.Studio{&studio, &reversedStudio}, nil).Once() + + if test.Matches { + mockGalleryReader.On("Find", galleryID).Return(&models.Gallery{}, nil).Once() + expectedStudioID := models.NullInt64(studioID) + mockGalleryReader.On("UpdatePartial", models.GalleryPartial{ + ID: galleryID, + StudioID: &expectedStudioID, + }).Return(nil, nil).Once() + } + + gallery := models.Gallery{ + ID: galleryID, + Path: models.NullString(test.Path), + } + err := GalleryStudios(&gallery, mockGalleryReader, mockStudioReader) + + assert.Nil(err) + mockStudioReader.AssertExpectations(t) + mockGalleryReader.AssertExpectations(t) + } +} + +func TestGalleryTags(t *testing.T) { + const galleryID = 1 + const tagName = "tag name" + const tagID = 2 + tag := models.Tag{ + ID: tagID, + Name: tagName, + } + + const reversedTagName = "name tag" + const reversedTagID = 3 + reversedTag := models.Tag{ + ID: reversedTagID, + Name: reversedTagName, + } + + testTables := generateTestTable(tagName, galleryExt) + + assert := assert.New(t) + + for _, test := range testTables { + mockTagReader := &mocks.TagReaderWriter{} + mockGalleryReader := &mocks.GalleryReaderWriter{} + + mockTagReader.On("QueryForAutoTag", mock.Anything).Return([]*models.Tag{&tag, &reversedTag}, nil).Once() + + if test.Matches { + mockGalleryReader.On("GetTagIDs", galleryID).Return(nil, nil).Once() + mockGalleryReader.On("UpdateTags", galleryID, []int{tagID}).Return(nil).Once() + } + + gallery := models.Gallery{ + ID: galleryID, + Path: models.NullString(test.Path), + } + err := GalleryTags(&gallery, mockGalleryReader, mockTagReader) + + assert.Nil(err) + mockTagReader.AssertExpectations(t) + mockGalleryReader.AssertExpectations(t) + } +} diff --git a/pkg/autotag/image.go b/pkg/autotag/image.go new file mode 100644 index 000000000..ff5816c6f --- /dev/null +++ b/pkg/autotag/image.go @@ -0,0 +1,117 @@ +package autotag + +import ( + "fmt" + "path/filepath" + "strings" + + "github.com/stashapp/stash/pkg/image" + "github.com/stashapp/stash/pkg/models" +) + +func imagePathsFilter(paths []string) *models.ImageFilterType { + if paths == nil { + return nil + } + + sep := string(filepath.Separator) + + var ret *models.ImageFilterType + var or *models.ImageFilterType + for _, p := range paths { + newOr := &models.ImageFilterType{} + if or != nil { + or.Or = newOr + } else { + ret = newOr + } + + or = newOr + + if !strings.HasSuffix(p, sep) { + p = p + sep + } + + or.Path = &models.StringCriterionInput{ + Modifier: models.CriterionModifierEquals, + Value: p + "%", + } + } + + return ret +} + +func getMatchingImages(name string, paths []string, imageReader models.ImageReader) ([]*models.Image, error) { + regex := getPathQueryRegex(name) + organized := false + filter := models.ImageFilterType{ + Path: &models.StringCriterionInput{ + Value: "(?i)" + regex, + Modifier: models.CriterionModifierMatchesRegex, + }, + Organized: &organized, + } + + filter.And = imagePathsFilter(paths) + + pp := models.PerPageAll + images, _, err := imageReader.Query(&filter, &models.FindFilterType{ + PerPage: &pp, + }) + + if err != nil { + return nil, fmt.Errorf("error querying images with regex '%s': %s", regex, err.Error()) + } + + var ret []*models.Image + for _, p := range images { + if nameMatchesPath(name, p.Path) { + ret = append(ret, p) + } + } + + return ret, nil +} + +func getImageFileTagger(s *models.Image) tagger { + return tagger{ + ID: s.ID, + Type: "image", + Name: s.GetTitle(), + Path: s.Path, + } +} + +// ImagePerformers tags the provided image with performers whose name matches the image's path. +func ImagePerformers(s *models.Image, rw models.ImageReaderWriter, performerReader models.PerformerReader) error { + t := getImageFileTagger(s) + + return t.tagPerformers(performerReader, func(subjectID, otherID int) (bool, error) { + return image.AddPerformer(rw, subjectID, otherID) + }) +} + +// ImageStudios tags the provided image with the first studio whose name matches the image's path. +// +// Images will not be tagged if studio is already set. +func ImageStudios(s *models.Image, rw models.ImageReaderWriter, studioReader models.StudioReader) error { + if s.StudioID.Valid { + // don't modify + return nil + } + + t := getImageFileTagger(s) + + return t.tagStudios(studioReader, func(subjectID, otherID int) (bool, error) { + return addImageStudio(rw, subjectID, otherID) + }) +} + +// ImageTags tags the provided image with tags whose name matches the image's path. +func ImageTags(s *models.Image, rw models.ImageReaderWriter, tagReader models.TagReader) error { + t := getImageFileTagger(s) + + return t.tagTags(tagReader, func(subjectID, otherID int) (bool, error) { + return image.AddTag(rw, subjectID, otherID) + }) +} diff --git a/pkg/autotag/image_test.go b/pkg/autotag/image_test.go new file mode 100644 index 000000000..8dba6b6e2 --- /dev/null +++ b/pkg/autotag/image_test.go @@ -0,0 +1,145 @@ +package autotag + +import ( + "testing" + + "github.com/stashapp/stash/pkg/models" + "github.com/stashapp/stash/pkg/models/mocks" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" +) + +const imageExt = "jpg" + +func TestImagePerformers(t *testing.T) { + const imageID = 1 + const performerName = "performer name" + const performerID = 2 + performer := models.Performer{ + ID: performerID, + Name: models.NullString(performerName), + } + + const reversedPerformerName = "name performer" + const reversedPerformerID = 3 + reversedPerformer := models.Performer{ + ID: reversedPerformerID, + Name: models.NullString(reversedPerformerName), + } + + testTables := generateTestTable(performerName, imageExt) + + assert := assert.New(t) + + for _, test := range testTables { + mockPerformerReader := &mocks.PerformerReaderWriter{} + mockImageReader := &mocks.ImageReaderWriter{} + + mockPerformerReader.On("QueryForAutoTag", mock.Anything).Return([]*models.Performer{&performer, &reversedPerformer}, nil).Once() + + if test.Matches { + mockImageReader.On("GetPerformerIDs", imageID).Return(nil, nil).Once() + mockImageReader.On("UpdatePerformers", imageID, []int{performerID}).Return(nil).Once() + } + + image := models.Image{ + ID: imageID, + Path: test.Path, + } + err := ImagePerformers(&image, mockImageReader, mockPerformerReader) + + assert.Nil(err) + mockPerformerReader.AssertExpectations(t) + mockImageReader.AssertExpectations(t) + } +} + +func TestImageStudios(t *testing.T) { + const imageID = 1 + const studioName = "studio name" + const studioID = 2 + studio := models.Studio{ + ID: studioID, + Name: models.NullString(studioName), + } + + const reversedStudioName = "name studio" + const reversedStudioID = 3 + reversedStudio := models.Studio{ + ID: reversedStudioID, + Name: models.NullString(reversedStudioName), + } + + testTables := generateTestTable(studioName, imageExt) + + assert := assert.New(t) + + for _, test := range testTables { + mockStudioReader := &mocks.StudioReaderWriter{} + mockImageReader := &mocks.ImageReaderWriter{} + + mockStudioReader.On("QueryForAutoTag", mock.Anything).Return([]*models.Studio{&studio, &reversedStudio}, nil).Once() + + if test.Matches { + mockImageReader.On("Find", imageID).Return(&models.Image{}, nil).Once() + expectedStudioID := models.NullInt64(studioID) + mockImageReader.On("Update", models.ImagePartial{ + ID: imageID, + StudioID: &expectedStudioID, + }).Return(nil, nil).Once() + } + + image := models.Image{ + ID: imageID, + Path: test.Path, + } + err := ImageStudios(&image, mockImageReader, mockStudioReader) + + assert.Nil(err) + mockStudioReader.AssertExpectations(t) + mockImageReader.AssertExpectations(t) + } +} + +func TestImageTags(t *testing.T) { + const imageID = 1 + const tagName = "tag name" + const tagID = 2 + tag := models.Tag{ + ID: tagID, + Name: tagName, + } + + const reversedTagName = "name tag" + const reversedTagID = 3 + reversedTag := models.Tag{ + ID: reversedTagID, + Name: reversedTagName, + } + + testTables := generateTestTable(tagName, imageExt) + + assert := assert.New(t) + + for _, test := range testTables { + mockTagReader := &mocks.TagReaderWriter{} + mockImageReader := &mocks.ImageReaderWriter{} + + mockTagReader.On("QueryForAutoTag", mock.Anything).Return([]*models.Tag{&tag, &reversedTag}, nil).Once() + + if test.Matches { + mockImageReader.On("GetTagIDs", imageID).Return(nil, nil).Once() + mockImageReader.On("UpdateTags", imageID, []int{tagID}).Return(nil).Once() + } + + image := models.Image{ + ID: imageID, + Path: test.Path, + } + err := ImageTags(&image, mockImageReader, mockTagReader) + + assert.Nil(err) + mockTagReader.AssertExpectations(t) + mockImageReader.AssertExpectations(t) + } +} diff --git a/pkg/autotag/integration_test.go b/pkg/autotag/integration_test.go index 32a0e7501..6c890c359 100644 --- a/pkg/autotag/integration_test.go +++ b/pkg/autotag/integration_test.go @@ -23,6 +23,8 @@ const testName = "Foo's Bar" const existingStudioName = "ExistingStudio" const existingStudioSceneName = testName + ".dontChangeStudio.mp4" +const existingStudioImageName = testName + ".dontChangeStudio.mp4" +const existingStudioGalleryName = testName + ".dontChangeStudio.mp4" var existingStudioID int @@ -109,7 +111,7 @@ func createTag(qb models.TagWriter) error { func createScenes(sqb models.SceneReaderWriter) error { // create the scenes - scenePatterns, falseScenePatterns := generateScenePaths(testName) + scenePatterns, falseScenePatterns := generateTestPaths(testName, sceneExt) for _, fn := range scenePatterns { err := createScene(sqb, makeScene(fn, true)) @@ -169,6 +171,130 @@ func createScene(sqb models.SceneWriter, scene *models.Scene) error { return nil } +func createImages(sqb models.ImageReaderWriter) error { + // create the images + imagePatterns, falseImagePatterns := generateTestPaths(testName, imageExt) + + for _, fn := range imagePatterns { + err := createImage(sqb, makeImage(fn, true)) + if err != nil { + return err + } + } + for _, fn := range falseImagePatterns { + err := createImage(sqb, makeImage(fn, false)) + if err != nil { + return err + } + } + + // add organized images + for _, fn := range imagePatterns { + s := makeImage("organized"+fn, false) + s.Organized = true + err := createImage(sqb, s) + if err != nil { + return err + } + } + + // create image with existing studio io + studioImage := makeImage(existingStudioImageName, true) + studioImage.StudioID = sql.NullInt64{Valid: true, Int64: int64(existingStudioID)} + err := createImage(sqb, studioImage) + if err != nil { + return err + } + + return nil +} + +func makeImage(name string, expectedResult bool) *models.Image { + image := &models.Image{ + Checksum: utils.MD5FromString(name), + Path: name, + } + + // if expectedResult is true then we expect it to match, set the title accordingly + if expectedResult { + image.Title = sql.NullString{Valid: true, String: name} + } + + return image +} + +func createImage(sqb models.ImageWriter, image *models.Image) error { + _, err := sqb.Create(*image) + + if err != nil { + return fmt.Errorf("Failed to create image with name '%s': %s", image.Path, err.Error()) + } + + return nil +} + +func createGalleries(sqb models.GalleryReaderWriter) error { + // create the galleries + galleryPatterns, falseGalleryPatterns := generateTestPaths(testName, galleryExt) + + for _, fn := range galleryPatterns { + err := createGallery(sqb, makeGallery(fn, true)) + if err != nil { + return err + } + } + for _, fn := range falseGalleryPatterns { + err := createGallery(sqb, makeGallery(fn, false)) + if err != nil { + return err + } + } + + // add organized galleries + for _, fn := range galleryPatterns { + s := makeGallery("organized"+fn, false) + s.Organized = true + err := createGallery(sqb, s) + if err != nil { + return err + } + } + + // create gallery with existing studio io + studioGallery := makeGallery(existingStudioGalleryName, true) + studioGallery.StudioID = sql.NullInt64{Valid: true, Int64: int64(existingStudioID)} + err := createGallery(sqb, studioGallery) + if err != nil { + return err + } + + return nil +} + +func makeGallery(name string, expectedResult bool) *models.Gallery { + gallery := &models.Gallery{ + Checksum: utils.MD5FromString(name), + Path: models.NullString(name), + } + + // if expectedResult is true then we expect it to match, set the title accordingly + if expectedResult { + gallery.Title = sql.NullString{Valid: true, String: name} + } + + return gallery +} + +func createGallery(sqb models.GalleryWriter, gallery *models.Gallery) error { + _, err := sqb.Create(*gallery) + + if err != nil { + return fmt.Errorf("Failed to create gallery with name '%s': %s", gallery.Path.String, err.Error()) + } + + return nil +} + func withTxn(f func(r models.Repository) error) error { t := sqlite.NewTransactionManager() return t.WithTxn(context.TODO(), f) @@ -204,6 +330,16 @@ func populateDB() error { return err } + err = createImages(r.Image()) + if err != nil { + return err + } + + err = createGalleries(r.Gallery()) + if err != nil { + return err + } + return nil }); err != nil { return err @@ -212,7 +348,7 @@ func populateDB() error { return nil } -func TestParsePerformers(t *testing.T) { +func TestParsePerformerScenes(t *testing.T) { var performers []*models.Performer if err := withTxn(func(r models.Repository) error { var err error @@ -259,7 +395,7 @@ func TestParsePerformers(t *testing.T) { }) } -func TestParseStudios(t *testing.T) { +func TestParseStudioScenes(t *testing.T) { var studios []*models.Studio if err := withTxn(func(r models.Repository) error { var err error @@ -310,7 +446,7 @@ func TestParseStudios(t *testing.T) { }) } -func TestParseTags(t *testing.T) { +func TestParseTagScenes(t *testing.T) { var tags []*models.Tag if err := withTxn(func(r models.Repository) error { var err error @@ -356,3 +492,293 @@ func TestParseTags(t *testing.T) { return nil }) } + +func TestParsePerformerImages(t *testing.T) { + var performers []*models.Performer + if err := withTxn(func(r models.Repository) error { + var err error + performers, err = r.Performer().All() + return err + }); err != nil { + t.Errorf("Error getting performer: %s", err) + return + } + + for _, p := range performers { + if err := withTxn(func(r models.Repository) error { + return PerformerImages(p, nil, r.Image()) + }); err != nil { + t.Errorf("Error auto-tagging performers: %s", err) + } + } + + // verify that images were tagged correctly + withTxn(func(r models.Repository) error { + pqb := r.Performer() + + images, err := r.Image().All() + if err != nil { + t.Error(err.Error()) + } + + for _, image := range images { + performers, err := pqb.FindByImageID(image.ID) + + if err != nil { + t.Errorf("Error getting image performers: %s", err.Error()) + } + + // title is only set on images where we expect performer to be set + if image.Title.String == image.Path && len(performers) == 0 { + t.Errorf("Did not set performer '%s' for path '%s'", testName, image.Path) + } else if image.Title.String != image.Path && len(performers) > 0 { + t.Errorf("Incorrectly set performer '%s' for path '%s'", testName, image.Path) + } + } + + return nil + }) +} + +func TestParseStudioImages(t *testing.T) { + var studios []*models.Studio + if err := withTxn(func(r models.Repository) error { + var err error + studios, err = r.Studio().All() + return err + }); err != nil { + t.Errorf("Error getting studio: %s", err) + return + } + + for _, s := range studios { + if err := withTxn(func(r models.Repository) error { + return StudioImages(s, nil, r.Image()) + }); err != nil { + t.Errorf("Error auto-tagging performers: %s", err) + } + } + + // verify that images were tagged correctly + withTxn(func(r models.Repository) error { + images, err := r.Image().All() + if err != nil { + t.Error(err.Error()) + } + + for _, image := range images { + // check for existing studio id image first + if image.Path == existingStudioImageName { + if image.StudioID.Int64 != int64(existingStudioID) { + t.Error("Incorrectly overwrote studio ID for image with existing studio ID") + } + } else { + // title is only set on images where we expect studio to be set + if image.Title.String == image.Path { + if !image.StudioID.Valid { + t.Errorf("Did not set studio '%s' for path '%s'", testName, image.Path) + } else if image.StudioID.Int64 != int64(studios[1].ID) { + t.Errorf("Incorrect studio id %d set for path '%s'", image.StudioID.Int64, image.Path) + } + + } else if image.Title.String != image.Path && image.StudioID.Int64 == int64(studios[1].ID) { + t.Errorf("Incorrectly set studio '%s' for path '%s'", testName, image.Path) + } + } + } + + return nil + }) +} + +func TestParseTagImages(t *testing.T) { + var tags []*models.Tag + if err := withTxn(func(r models.Repository) error { + var err error + tags, err = r.Tag().All() + return err + }); err != nil { + t.Errorf("Error getting performer: %s", err) + return + } + + for _, s := range tags { + if err := withTxn(func(r models.Repository) error { + return TagImages(s, nil, r.Image()) + }); err != nil { + t.Errorf("Error auto-tagging performers: %s", err) + } + } + + // verify that images were tagged correctly + withTxn(func(r models.Repository) error { + images, err := r.Image().All() + if err != nil { + t.Error(err.Error()) + } + + tqb := r.Tag() + + for _, image := range images { + tags, err := tqb.FindByImageID(image.ID) + + if err != nil { + t.Errorf("Error getting image tags: %s", err.Error()) + } + + // title is only set on images where we expect performer to be set + if image.Title.String == image.Path && len(tags) == 0 { + t.Errorf("Did not set tag '%s' for path '%s'", testName, image.Path) + } else if image.Title.String != image.Path && len(tags) > 0 { + t.Errorf("Incorrectly set tag '%s' for path '%s'", testName, image.Path) + } + } + + return nil + }) +} + +func TestParsePerformerGalleries(t *testing.T) { + var performers []*models.Performer + if err := withTxn(func(r models.Repository) error { + var err error + performers, err = r.Performer().All() + return err + }); err != nil { + t.Errorf("Error getting performer: %s", err) + return + } + + for _, p := range performers { + if err := withTxn(func(r models.Repository) error { + return PerformerGalleries(p, nil, r.Gallery()) + }); err != nil { + t.Errorf("Error auto-tagging performers: %s", err) + } + } + + // verify that galleries were tagged correctly + withTxn(func(r models.Repository) error { + pqb := r.Performer() + + galleries, err := r.Gallery().All() + if err != nil { + t.Error(err.Error()) + } + + for _, gallery := range galleries { + performers, err := pqb.FindByGalleryID(gallery.ID) + + if err != nil { + t.Errorf("Error getting gallery performers: %s", err.Error()) + } + + // title is only set on galleries where we expect performer to be set + if gallery.Title.String == gallery.Path.String && len(performers) == 0 { + t.Errorf("Did not set performer '%s' for path '%s'", testName, gallery.Path.String) + } else if gallery.Title.String != gallery.Path.String && len(performers) > 0 { + t.Errorf("Incorrectly set performer '%s' for path '%s'", testName, gallery.Path.String) + } + } + + return nil + }) +} + +func TestParseStudioGalleries(t *testing.T) { + var studios []*models.Studio + if err := withTxn(func(r models.Repository) error { + var err error + studios, err = r.Studio().All() + return err + }); err != nil { + t.Errorf("Error getting studio: %s", err) + return + } + + for _, s := range studios { + if err := withTxn(func(r models.Repository) error { + return StudioGalleries(s, nil, r.Gallery()) + }); err != nil { + t.Errorf("Error auto-tagging performers: %s", err) + } + } + + // verify that galleries were tagged correctly + withTxn(func(r models.Repository) error { + galleries, err := r.Gallery().All() + if err != nil { + t.Error(err.Error()) + } + + for _, gallery := range galleries { + // check for existing studio id gallery first + if gallery.Path.String == existingStudioGalleryName { + if gallery.StudioID.Int64 != int64(existingStudioID) { + t.Error("Incorrectly overwrote studio ID for gallery with existing studio ID") + } + } else { + // title is only set on galleries where we expect studio to be set + if gallery.Title.String == gallery.Path.String { + if !gallery.StudioID.Valid { + t.Errorf("Did not set studio '%s' for path '%s'", testName, gallery.Path.String) + } else if gallery.StudioID.Int64 != int64(studios[1].ID) { + t.Errorf("Incorrect studio id %d set for path '%s'", gallery.StudioID.Int64, gallery.Path.String) + } + + } else if gallery.Title.String != gallery.Path.String && gallery.StudioID.Int64 == int64(studios[1].ID) { + t.Errorf("Incorrectly set studio '%s' for path '%s'", testName, gallery.Path.String) + } + } + } + + return nil + }) +} + +func TestParseTagGalleries(t *testing.T) { + var tags []*models.Tag + if err := withTxn(func(r models.Repository) error { + var err error + tags, err = r.Tag().All() + return err + }); err != nil { + t.Errorf("Error getting performer: %s", err) + return + } + + for _, s := range tags { + if err := withTxn(func(r models.Repository) error { + return TagGalleries(s, nil, r.Gallery()) + }); err != nil { + t.Errorf("Error auto-tagging performers: %s", err) + } + } + + // verify that galleries were tagged correctly + withTxn(func(r models.Repository) error { + galleries, err := r.Gallery().All() + if err != nil { + t.Error(err.Error()) + } + + tqb := r.Tag() + + for _, gallery := range galleries { + tags, err := tqb.FindByGalleryID(gallery.ID) + + if err != nil { + t.Errorf("Error getting gallery tags: %s", err.Error()) + } + + // title is only set on galleries where we expect performer to be set + if gallery.Title.String == gallery.Path.String && len(tags) == 0 { + t.Errorf("Did not set tag '%s' for path '%s'", testName, gallery.Path.String) + } else if gallery.Title.String != gallery.Path.String && len(tags) > 0 { + t.Errorf("Incorrectly set tag '%s' for path '%s'", testName, gallery.Path.String) + } + } + + return nil + }) +} diff --git a/pkg/autotag/performer.go b/pkg/autotag/performer.go index b6f40c1ad..bdbd497c3 100644 --- a/pkg/autotag/performer.go +++ b/pkg/autotag/performer.go @@ -1,6 +1,8 @@ package autotag import ( + "github.com/stashapp/stash/pkg/gallery" + "github.com/stashapp/stash/pkg/image" "github.com/stashapp/stash/pkg/models" "github.com/stashapp/stash/pkg/scene" ) @@ -40,3 +42,21 @@ func PerformerScenes(p *models.Performer, paths []string, rw models.SceneReaderW return scene.AddPerformer(rw, otherID, subjectID) }) } + +// PerformerImages searches for images whose path matches the provided performer name and tags the image with the performer. +func PerformerImages(p *models.Performer, paths []string, rw models.ImageReaderWriter) error { + t := getPerformerTagger(p) + + return t.tagImages(paths, rw, func(subjectID, otherID int) (bool, error) { + return image.AddPerformer(rw, otherID, subjectID) + }) +} + +// PerformerGalleries searches for galleries whose path matches the provided performer name and tags the gallery with the performer. +func PerformerGalleries(p *models.Performer, paths []string, rw models.GalleryReaderWriter) error { + t := getPerformerTagger(p) + + return t.tagGalleries(paths, rw, func(subjectID, otherID int) (bool, error) { + return gallery.AddPerformer(rw, otherID, subjectID) + }) +} diff --git a/pkg/autotag/performer_test.go b/pkg/autotag/performer_test.go index 1e935c9d5..7d78b9304 100644 --- a/pkg/autotag/performer_test.go +++ b/pkg/autotag/performer_test.go @@ -36,7 +36,7 @@ func testPerformerScenes(t *testing.T, performerName, expectedRegex string) { const performerID = 2 var scenes []*models.Scene - matchingPaths, falsePaths := generateScenePaths(performerName) + matchingPaths, falsePaths := generateTestPaths(performerName, "mp4") for i, p := range append(matchingPaths, falsePaths...) { scenes = append(scenes, &models.Scene{ ID: i + 1, @@ -79,3 +79,147 @@ func testPerformerScenes(t *testing.T, performerName, expectedRegex string) { assert.Nil(err) mockSceneReader.AssertExpectations(t) } + +func TestPerformerImages(t *testing.T) { + type test struct { + performerName string + expectedRegex string + } + + performerNames := []test{ + { + "performer name", + `(?i)(?:^|_|[^\w\d])performer[.\-_ ]*name(?:$|_|[^\w\d])`, + }, + { + "performer + name", + `(?i)(?:^|_|[^\w\d])performer[.\-_ ]*\+[.\-_ ]*name(?:$|_|[^\w\d])`, + }, + } + + for _, p := range performerNames { + testPerformerImages(t, p.performerName, p.expectedRegex) + } +} + +func testPerformerImages(t *testing.T, performerName, expectedRegex string) { + mockImageReader := &mocks.ImageReaderWriter{} + + const performerID = 2 + + var images []*models.Image + matchingPaths, falsePaths := generateTestPaths(performerName, imageExt) + for i, p := range append(matchingPaths, falsePaths...) { + images = append(images, &models.Image{ + ID: i + 1, + Path: p, + }) + } + + performer := models.Performer{ + ID: performerID, + Name: models.NullString(performerName), + } + + organized := false + perPage := models.PerPageAll + + expectedImageFilter := &models.ImageFilterType{ + Organized: &organized, + Path: &models.StringCriterionInput{ + Value: expectedRegex, + Modifier: models.CriterionModifierMatchesRegex, + }, + } + + expectedFindFilter := &models.FindFilterType{ + PerPage: &perPage, + } + + mockImageReader.On("Query", expectedImageFilter, expectedFindFilter).Return(images, len(images), nil).Once() + + for i := range matchingPaths { + imageID := i + 1 + mockImageReader.On("GetPerformerIDs", imageID).Return(nil, nil).Once() + mockImageReader.On("UpdatePerformers", imageID, []int{performerID}).Return(nil).Once() + } + + err := PerformerImages(&performer, nil, mockImageReader) + + assert := assert.New(t) + + assert.Nil(err) + mockImageReader.AssertExpectations(t) +} + +func TestPerformerGalleries(t *testing.T) { + type test struct { + performerName string + expectedRegex string + } + + performerNames := []test{ + { + "performer name", + `(?i)(?:^|_|[^\w\d])performer[.\-_ ]*name(?:$|_|[^\w\d])`, + }, + { + "performer + name", + `(?i)(?:^|_|[^\w\d])performer[.\-_ ]*\+[.\-_ ]*name(?:$|_|[^\w\d])`, + }, + } + + for _, p := range performerNames { + testPerformerGalleries(t, p.performerName, p.expectedRegex) + } +} + +func testPerformerGalleries(t *testing.T, performerName, expectedRegex string) { + mockGalleryReader := &mocks.GalleryReaderWriter{} + + const performerID = 2 + + var galleries []*models.Gallery + matchingPaths, falsePaths := generateTestPaths(performerName, galleryExt) + for i, p := range append(matchingPaths, falsePaths...) { + galleries = append(galleries, &models.Gallery{ + ID: i + 1, + Path: models.NullString(p), + }) + } + + performer := models.Performer{ + ID: performerID, + Name: models.NullString(performerName), + } + + organized := false + perPage := models.PerPageAll + + expectedGalleryFilter := &models.GalleryFilterType{ + Organized: &organized, + Path: &models.StringCriterionInput{ + Value: expectedRegex, + Modifier: models.CriterionModifierMatchesRegex, + }, + } + + expectedFindFilter := &models.FindFilterType{ + PerPage: &perPage, + } + + mockGalleryReader.On("Query", expectedGalleryFilter, expectedFindFilter).Return(galleries, len(galleries), nil).Once() + + for i := range matchingPaths { + galleryID := i + 1 + mockGalleryReader.On("GetPerformerIDs", galleryID).Return(nil, nil).Once() + mockGalleryReader.On("UpdatePerformers", galleryID, []int{performerID}).Return(nil).Once() + } + + err := PerformerGalleries(&performer, nil, mockGalleryReader) + + assert := assert.New(t) + + assert.Nil(err) + mockGalleryReader.AssertExpectations(t) +} diff --git a/pkg/autotag/scene.go b/pkg/autotag/scene.go index d9bffd630..272f5a9fe 100644 --- a/pkg/autotag/scene.go +++ b/pkg/autotag/scene.go @@ -9,7 +9,7 @@ import ( "github.com/stashapp/stash/pkg/scene" ) -func pathsFilter(paths []string) *models.SceneFilterType { +func scenePathsFilter(paths []string) *models.SceneFilterType { if paths == nil { return nil } @@ -52,7 +52,7 @@ func getMatchingScenes(name string, paths []string, sceneReader models.SceneRead Organized: &organized, } - filter.And = pathsFilter(paths) + filter.And = scenePathsFilter(paths) pp := models.PerPageAll scenes, _, err := sceneReader.Query(&filter, &models.FindFilterType{ diff --git a/pkg/autotag/scene_test.go b/pkg/autotag/scene_test.go index a71056885..d2326522c 100644 --- a/pkg/autotag/scene_test.go +++ b/pkg/autotag/scene_test.go @@ -11,6 +11,8 @@ import ( "github.com/stretchr/testify/mock" ) +const sceneExt = "mp4" + var testSeparators = []string{ ".", "-", @@ -26,88 +28,88 @@ var testEndSeparators = []string{ ",", } -func generateNamePatterns(name, separator string) []string { +func generateNamePatterns(name, separator, ext string) []string { var ret []string - ret = append(ret, fmt.Sprintf("%s%saaa.mp4", name, separator)) - ret = append(ret, fmt.Sprintf("aaa%s%s.mp4", separator, name)) - ret = append(ret, fmt.Sprintf("aaa%s%s%sbbb.mp4", separator, name, separator)) - ret = append(ret, fmt.Sprintf("dir/%s%saaa.mp4", name, separator)) - ret = append(ret, fmt.Sprintf("dir\\%s%saaa.mp4", name, separator)) - ret = append(ret, fmt.Sprintf("%s%saaa/dir/bbb.mp4", name, separator)) - ret = append(ret, fmt.Sprintf("%s%saaa\\dir\\bbb.mp4", name, separator)) - ret = append(ret, fmt.Sprintf("dir/%s%s/aaa.mp4", name, separator)) - ret = append(ret, fmt.Sprintf("dir\\%s%s\\aaa.mp4", name, separator)) + ret = append(ret, fmt.Sprintf("%s%saaa.%s", name, separator, ext)) + ret = append(ret, fmt.Sprintf("aaa%s%s.%s", separator, name, ext)) + ret = append(ret, fmt.Sprintf("aaa%s%s%sbbb.%s", separator, name, separator, ext)) + ret = append(ret, fmt.Sprintf("dir/%s%saaa.%s", name, separator, ext)) + ret = append(ret, fmt.Sprintf("dir\\%s%saaa.%s", name, separator, ext)) + ret = append(ret, fmt.Sprintf("%s%saaa/dir/bbb.%s", name, separator, ext)) + ret = append(ret, fmt.Sprintf("%s%saaa\\dir\\bbb.%s", name, separator, ext)) + ret = append(ret, fmt.Sprintf("dir/%s%s/aaa.%s", name, separator, ext)) + ret = append(ret, fmt.Sprintf("dir\\%s%s\\aaa.%s", name, separator, ext)) return ret } -func generateSplitNamePatterns(name, separator string) []string { +func generateSplitNamePatterns(name, separator, ext string) []string { var ret []string splitted := strings.Split(name, " ") // only do this for names that are split into two if len(splitted) == 2 { - ret = append(ret, fmt.Sprintf("%s%s%s.mp4", splitted[0], separator, splitted[1])) + ret = append(ret, fmt.Sprintf("%s%s%s.%s", splitted[0], separator, splitted[1], ext)) } return ret } -func generateFalseNamePatterns(name string, separator string) []string { +func generateFalseNamePatterns(name string, separator, ext string) []string { splitted := strings.Split(name, " ") var ret []string // only do this for names that are split into two if len(splitted) == 2 { - ret = append(ret, fmt.Sprintf("%s%saaa%s%s.mp4", splitted[0], separator, separator, splitted[1])) + ret = append(ret, fmt.Sprintf("%s%saaa%s%s.%s", splitted[0], separator, separator, splitted[1], ext)) } return ret } -func generateScenePaths(testName string) (scenePatterns []string, falseScenePatterns []string) { +func generateTestPaths(testName, ext string) (scenePatterns []string, falseScenePatterns []string) { separators := append(testSeparators, testEndSeparators...) for _, separator := range separators { - scenePatterns = append(scenePatterns, generateNamePatterns(testName, separator)...) - scenePatterns = append(scenePatterns, generateNamePatterns(strings.ToLower(testName), separator)...) - scenePatterns = append(scenePatterns, generateNamePatterns(strings.ReplaceAll(testName, " ", ""), separator)...) - falseScenePatterns = append(falseScenePatterns, generateFalseNamePatterns(testName, separator)...) + scenePatterns = append(scenePatterns, generateNamePatterns(testName, separator, ext)...) + scenePatterns = append(scenePatterns, generateNamePatterns(strings.ToLower(testName), separator, ext)...) + scenePatterns = append(scenePatterns, generateNamePatterns(strings.ReplaceAll(testName, " ", ""), separator, ext)...) + falseScenePatterns = append(falseScenePatterns, generateFalseNamePatterns(testName, separator, ext)...) } // add test cases for intra-name separators for _, separator := range testSeparators { if separator != " " { - scenePatterns = append(scenePatterns, generateNamePatterns(strings.Replace(testName, " ", separator, -1), separator)...) + scenePatterns = append(scenePatterns, generateNamePatterns(strings.Replace(testName, " ", separator, -1), separator, ext)...) } } // add basic false scenarios - falseScenePatterns = append(falseScenePatterns, fmt.Sprintf("aaa%s.mp4", testName)) - falseScenePatterns = append(falseScenePatterns, fmt.Sprintf("%saaa.mp4", testName)) + falseScenePatterns = append(falseScenePatterns, fmt.Sprintf("aaa%s.%s", testName, ext)) + falseScenePatterns = append(falseScenePatterns, fmt.Sprintf("%saaa.%s", testName, ext)) // add path separator false scenarios - falseScenePatterns = append(falseScenePatterns, generateFalseNamePatterns(testName, "/")...) - falseScenePatterns = append(falseScenePatterns, generateFalseNamePatterns(testName, "\\")...) + falseScenePatterns = append(falseScenePatterns, generateFalseNamePatterns(testName, "/", ext)...) + falseScenePatterns = append(falseScenePatterns, generateFalseNamePatterns(testName, "\\", ext)...) // split patterns only valid for ._- and whitespace for _, separator := range testSeparators { - scenePatterns = append(scenePatterns, generateSplitNamePatterns(testName, separator)...) + scenePatterns = append(scenePatterns, generateSplitNamePatterns(testName, separator, ext)...) } // false patterns for other separators for _, separator := range testEndSeparators { - falseScenePatterns = append(falseScenePatterns, generateSplitNamePatterns(testName, separator)...) + falseScenePatterns = append(falseScenePatterns, generateSplitNamePatterns(testName, separator, ext)...) } return } type pathTestTable struct { - ScenePath string - Matches bool + Path string + Matches bool } -func generateTestTable(testName string) []pathTestTable { +func generateTestTable(testName, ext string) []pathTestTable { var ret []pathTestTable var scenePatterns []string @@ -116,15 +118,15 @@ func generateTestTable(testName string) []pathTestTable { separators := append(testSeparators, testEndSeparators...) for _, separator := range separators { - scenePatterns = append(scenePatterns, generateNamePatterns(testName, separator)...) - scenePatterns = append(scenePatterns, generateNamePatterns(strings.ToLower(testName), separator)...) - falseScenePatterns = append(falseScenePatterns, generateFalseNamePatterns(testName, separator)...) + scenePatterns = append(scenePatterns, generateNamePatterns(testName, separator, ext)...) + scenePatterns = append(scenePatterns, generateNamePatterns(strings.ToLower(testName), separator, ext)...) + falseScenePatterns = append(falseScenePatterns, generateFalseNamePatterns(testName, separator, ext)...) } for _, p := range scenePatterns { t := pathTestTable{ - ScenePath: p, - Matches: true, + Path: p, + Matches: true, } ret = append(ret, t) @@ -132,8 +134,8 @@ func generateTestTable(testName string) []pathTestTable { for _, p := range falseScenePatterns { t := pathTestTable{ - ScenePath: p, - Matches: false, + Path: p, + Matches: false, } ret = append(ret, t) @@ -158,7 +160,7 @@ func TestScenePerformers(t *testing.T) { Name: models.NullString(reversedPerformerName), } - testTables := generateTestTable(performerName) + testTables := generateTestTable(performerName, sceneExt) assert := assert.New(t) @@ -175,7 +177,7 @@ func TestScenePerformers(t *testing.T) { scene := models.Scene{ ID: sceneID, - Path: test.ScenePath, + Path: test.Path, } err := ScenePerformers(&scene, mockSceneReader, mockPerformerReader) @@ -201,7 +203,7 @@ func TestSceneStudios(t *testing.T) { Name: models.NullString(reversedStudioName), } - testTables := generateTestTable(studioName) + testTables := generateTestTable(studioName, sceneExt) assert := assert.New(t) @@ -222,7 +224,7 @@ func TestSceneStudios(t *testing.T) { scene := models.Scene{ ID: sceneID, - Path: test.ScenePath, + Path: test.Path, } err := SceneStudios(&scene, mockSceneReader, mockStudioReader) @@ -248,7 +250,7 @@ func TestSceneTags(t *testing.T) { Name: reversedTagName, } - testTables := generateTestTable(tagName) + testTables := generateTestTable(tagName, sceneExt) assert := assert.New(t) @@ -265,7 +267,7 @@ func TestSceneTags(t *testing.T) { scene := models.Scene{ ID: sceneID, - Path: test.ScenePath, + Path: test.Path, } err := SceneTags(&scene, mockSceneReader, mockTagReader) diff --git a/pkg/autotag/studio.go b/pkg/autotag/studio.go index c01eedecc..ba6309c5a 100644 --- a/pkg/autotag/studio.go +++ b/pkg/autotag/studio.go @@ -48,6 +48,54 @@ func addSceneStudio(sceneWriter models.SceneReaderWriter, sceneID, studioID int) return true, nil } +func addImageStudio(imageWriter models.ImageReaderWriter, imageID, studioID int) (bool, error) { + // don't set if already set + image, err := imageWriter.Find(imageID) + if err != nil { + return false, err + } + + if image.StudioID.Valid { + return false, nil + } + + // set the studio id + s := sql.NullInt64{Int64: int64(studioID), Valid: true} + imagePartial := models.ImagePartial{ + ID: imageID, + StudioID: &s, + } + + if _, err := imageWriter.Update(imagePartial); err != nil { + return false, err + } + return true, nil +} + +func addGalleryStudio(galleryWriter models.GalleryReaderWriter, galleryID, studioID int) (bool, error) { + // don't set if already set + gallery, err := galleryWriter.Find(galleryID) + if err != nil { + return false, err + } + + if gallery.StudioID.Valid { + return false, nil + } + + // set the studio id + s := sql.NullInt64{Int64: int64(studioID), Valid: true} + galleryPartial := models.GalleryPartial{ + ID: galleryID, + StudioID: &s, + } + + if _, err := galleryWriter.UpdatePartial(galleryPartial); err != nil { + return false, err + } + return true, nil +} + func getStudioTagger(p *models.Studio) tagger { return tagger{ ID: p.ID, @@ -64,3 +112,21 @@ func StudioScenes(p *models.Studio, paths []string, rw models.SceneReaderWriter) return addSceneStudio(rw, otherID, subjectID) }) } + +// StudioImages searches for images whose path matches the provided studio name and tags the image with the studio, if studio is not already set on the image. +func StudioImages(p *models.Studio, paths []string, rw models.ImageReaderWriter) error { + t := getStudioTagger(p) + + return t.tagImages(paths, rw, func(subjectID, otherID int) (bool, error) { + return addImageStudio(rw, otherID, subjectID) + }) +} + +// StudioGalleries searches for galleries whose path matches the provided studio name and tags the gallery with the studio, if studio is not already set on the gallery. +func StudioGalleries(p *models.Studio, paths []string, rw models.GalleryReaderWriter) error { + t := getStudioTagger(p) + + return t.tagGalleries(paths, rw, func(subjectID, otherID int) (bool, error) { + return addGalleryStudio(rw, otherID, subjectID) + }) +} diff --git a/pkg/autotag/studio_test.go b/pkg/autotag/studio_test.go index d61ba7efb..886ea1361 100644 --- a/pkg/autotag/studio_test.go +++ b/pkg/autotag/studio_test.go @@ -36,7 +36,7 @@ func testStudioScenes(t *testing.T, studioName, expectedRegex string) { const studioID = 2 var scenes []*models.Scene - matchingPaths, falsePaths := generateScenePaths(studioName) + matchingPaths, falsePaths := generateTestPaths(studioName, sceneExt) for i, p := range append(matchingPaths, falsePaths...) { scenes = append(scenes, &models.Scene{ ID: i + 1, @@ -83,3 +83,155 @@ func testStudioScenes(t *testing.T, studioName, expectedRegex string) { assert.Nil(err) mockSceneReader.AssertExpectations(t) } + +func TestStudioImages(t *testing.T) { + type test struct { + studioName string + expectedRegex string + } + + studioNames := []test{ + { + "studio name", + `(?i)(?:^|_|[^\w\d])studio[.\-_ ]*name(?:$|_|[^\w\d])`, + }, + { + "studio + name", + `(?i)(?:^|_|[^\w\d])studio[.\-_ ]*\+[.\-_ ]*name(?:$|_|[^\w\d])`, + }, + } + + for _, p := range studioNames { + testStudioImages(t, p.studioName, p.expectedRegex) + } +} + +func testStudioImages(t *testing.T, studioName, expectedRegex string) { + mockImageReader := &mocks.ImageReaderWriter{} + + const studioID = 2 + + var images []*models.Image + matchingPaths, falsePaths := generateTestPaths(studioName, imageExt) + for i, p := range append(matchingPaths, falsePaths...) { + images = append(images, &models.Image{ + ID: i + 1, + Path: p, + }) + } + + studio := models.Studio{ + ID: studioID, + Name: models.NullString(studioName), + } + + organized := false + perPage := models.PerPageAll + + expectedImageFilter := &models.ImageFilterType{ + Organized: &organized, + Path: &models.StringCriterionInput{ + Value: expectedRegex, + Modifier: models.CriterionModifierMatchesRegex, + }, + } + + expectedFindFilter := &models.FindFilterType{ + PerPage: &perPage, + } + + mockImageReader.On("Query", expectedImageFilter, expectedFindFilter).Return(images, len(images), nil).Once() + + for i := range matchingPaths { + imageID := i + 1 + mockImageReader.On("Find", imageID).Return(&models.Image{}, nil).Once() + expectedStudioID := models.NullInt64(studioID) + mockImageReader.On("Update", models.ImagePartial{ + ID: imageID, + StudioID: &expectedStudioID, + }).Return(nil, nil).Once() + } + + err := StudioImages(&studio, nil, mockImageReader) + + assert := assert.New(t) + + assert.Nil(err) + mockImageReader.AssertExpectations(t) +} + +func TestStudioGalleries(t *testing.T) { + type test struct { + studioName string + expectedRegex string + } + + studioNames := []test{ + { + "studio name", + `(?i)(?:^|_|[^\w\d])studio[.\-_ ]*name(?:$|_|[^\w\d])`, + }, + { + "studio + name", + `(?i)(?:^|_|[^\w\d])studio[.\-_ ]*\+[.\-_ ]*name(?:$|_|[^\w\d])`, + }, + } + + for _, p := range studioNames { + testStudioGalleries(t, p.studioName, p.expectedRegex) + } +} + +func testStudioGalleries(t *testing.T, studioName, expectedRegex string) { + mockGalleryReader := &mocks.GalleryReaderWriter{} + + const studioID = 2 + + var galleries []*models.Gallery + matchingPaths, falsePaths := generateTestPaths(studioName, galleryExt) + for i, p := range append(matchingPaths, falsePaths...) { + galleries = append(galleries, &models.Gallery{ + ID: i + 1, + Path: models.NullString(p), + }) + } + + studio := models.Studio{ + ID: studioID, + Name: models.NullString(studioName), + } + + organized := false + perPage := models.PerPageAll + + expectedGalleryFilter := &models.GalleryFilterType{ + Organized: &organized, + Path: &models.StringCriterionInput{ + Value: expectedRegex, + Modifier: models.CriterionModifierMatchesRegex, + }, + } + + expectedFindFilter := &models.FindFilterType{ + PerPage: &perPage, + } + + mockGalleryReader.On("Query", expectedGalleryFilter, expectedFindFilter).Return(galleries, len(galleries), nil).Once() + + for i := range matchingPaths { + galleryID := i + 1 + mockGalleryReader.On("Find", galleryID).Return(&models.Gallery{}, nil).Once() + expectedStudioID := models.NullInt64(studioID) + mockGalleryReader.On("UpdatePartial", models.GalleryPartial{ + ID: galleryID, + StudioID: &expectedStudioID, + }).Return(nil, nil).Once() + } + + err := StudioGalleries(&studio, nil, mockGalleryReader) + + assert := assert.New(t) + + assert.Nil(err) + mockGalleryReader.AssertExpectations(t) +} diff --git a/pkg/autotag/tag.go b/pkg/autotag/tag.go index 4f08394cc..2f8f74841 100644 --- a/pkg/autotag/tag.go +++ b/pkg/autotag/tag.go @@ -1,6 +1,8 @@ package autotag import ( + "github.com/stashapp/stash/pkg/gallery" + "github.com/stashapp/stash/pkg/image" "github.com/stashapp/stash/pkg/models" "github.com/stashapp/stash/pkg/scene" ) @@ -39,3 +41,21 @@ func TagScenes(p *models.Tag, paths []string, rw models.SceneReaderWriter) error return scene.AddTag(rw, otherID, subjectID) }) } + +// TagImages searches for images whose path matches the provided tag name and tags the image with the tag. +func TagImages(p *models.Tag, paths []string, rw models.ImageReaderWriter) error { + t := getTagTagger(p) + + return t.tagImages(paths, rw, func(subjectID, otherID int) (bool, error) { + return image.AddTag(rw, otherID, subjectID) + }) +} + +// TagGalleries searches for galleries whose path matches the provided tag name and tags the gallery with the tag. +func TagGalleries(p *models.Tag, paths []string, rw models.GalleryReaderWriter) error { + t := getTagTagger(p) + + return t.tagGalleries(paths, rw, func(subjectID, otherID int) (bool, error) { + return gallery.AddTag(rw, otherID, subjectID) + }) +} diff --git a/pkg/autotag/tag_test.go b/pkg/autotag/tag_test.go index 47dd3356e..7e70926cb 100644 --- a/pkg/autotag/tag_test.go +++ b/pkg/autotag/tag_test.go @@ -36,7 +36,7 @@ func testTagScenes(t *testing.T, tagName, expectedRegex string) { const tagID = 2 var scenes []*models.Scene - matchingPaths, falsePaths := generateScenePaths(tagName) + matchingPaths, falsePaths := generateTestPaths(tagName, "mp4") for i, p := range append(matchingPaths, falsePaths...) { scenes = append(scenes, &models.Scene{ ID: i + 1, @@ -79,3 +79,147 @@ func testTagScenes(t *testing.T, tagName, expectedRegex string) { assert.Nil(err) mockSceneReader.AssertExpectations(t) } + +func TestTagImages(t *testing.T) { + type test struct { + tagName string + expectedRegex string + } + + tagNames := []test{ + { + "tag name", + `(?i)(?:^|_|[^\w\d])tag[.\-_ ]*name(?:$|_|[^\w\d])`, + }, + { + "tag + name", + `(?i)(?:^|_|[^\w\d])tag[.\-_ ]*\+[.\-_ ]*name(?:$|_|[^\w\d])`, + }, + } + + for _, p := range tagNames { + testTagImages(t, p.tagName, p.expectedRegex) + } +} + +func testTagImages(t *testing.T, tagName, expectedRegex string) { + mockImageReader := &mocks.ImageReaderWriter{} + + const tagID = 2 + + var images []*models.Image + matchingPaths, falsePaths := generateTestPaths(tagName, "mp4") + for i, p := range append(matchingPaths, falsePaths...) { + images = append(images, &models.Image{ + ID: i + 1, + Path: p, + }) + } + + tag := models.Tag{ + ID: tagID, + Name: tagName, + } + + organized := false + perPage := models.PerPageAll + + expectedImageFilter := &models.ImageFilterType{ + Organized: &organized, + Path: &models.StringCriterionInput{ + Value: expectedRegex, + Modifier: models.CriterionModifierMatchesRegex, + }, + } + + expectedFindFilter := &models.FindFilterType{ + PerPage: &perPage, + } + + mockImageReader.On("Query", expectedImageFilter, expectedFindFilter).Return(images, len(images), nil).Once() + + for i := range matchingPaths { + imageID := i + 1 + mockImageReader.On("GetTagIDs", imageID).Return(nil, nil).Once() + mockImageReader.On("UpdateTags", imageID, []int{tagID}).Return(nil).Once() + } + + err := TagImages(&tag, nil, mockImageReader) + + assert := assert.New(t) + + assert.Nil(err) + mockImageReader.AssertExpectations(t) +} + +func TestTagGalleries(t *testing.T) { + type test struct { + tagName string + expectedRegex string + } + + tagNames := []test{ + { + "tag name", + `(?i)(?:^|_|[^\w\d])tag[.\-_ ]*name(?:$|_|[^\w\d])`, + }, + { + "tag + name", + `(?i)(?:^|_|[^\w\d])tag[.\-_ ]*\+[.\-_ ]*name(?:$|_|[^\w\d])`, + }, + } + + for _, p := range tagNames { + testTagGalleries(t, p.tagName, p.expectedRegex) + } +} + +func testTagGalleries(t *testing.T, tagName, expectedRegex string) { + mockGalleryReader := &mocks.GalleryReaderWriter{} + + const tagID = 2 + + var galleries []*models.Gallery + matchingPaths, falsePaths := generateTestPaths(tagName, "mp4") + for i, p := range append(matchingPaths, falsePaths...) { + galleries = append(galleries, &models.Gallery{ + ID: i + 1, + Path: models.NullString(p), + }) + } + + tag := models.Tag{ + ID: tagID, + Name: tagName, + } + + organized := false + perPage := models.PerPageAll + + expectedGalleryFilter := &models.GalleryFilterType{ + Organized: &organized, + Path: &models.StringCriterionInput{ + Value: expectedRegex, + Modifier: models.CriterionModifierMatchesRegex, + }, + } + + expectedFindFilter := &models.FindFilterType{ + PerPage: &perPage, + } + + mockGalleryReader.On("Query", expectedGalleryFilter, expectedFindFilter).Return(galleries, len(galleries), nil).Once() + + for i := range matchingPaths { + galleryID := i + 1 + mockGalleryReader.On("GetTagIDs", galleryID).Return(nil, nil).Once() + mockGalleryReader.On("UpdateTags", galleryID, []int{tagID}).Return(nil).Once() + } + + err := TagGalleries(&tag, nil, mockGalleryReader) + + assert := assert.New(t) + + assert.Nil(err) + mockGalleryReader.AssertExpectations(t) +} diff --git a/pkg/autotag/tagger.go b/pkg/autotag/tagger.go index 8690ffc8b..9d2759f6c 100644 --- a/pkg/autotag/tagger.go +++ b/pkg/autotag/tagger.go @@ -196,3 +196,45 @@ func (t *tagger) tagScenes(paths []string, sceneReader models.SceneReader, addFu return nil } + +func (t *tagger) tagImages(paths []string, imageReader models.ImageReader, addFunc addLinkFunc) error { + others, err := getMatchingImages(t.Name, paths, imageReader) + if err != nil { + return err + } + + for _, p := range others { + added, err := addFunc(t.ID, p.ID) + + if err != nil { + return t.addError("image", p.GetTitle(), err) + } + + if added { + t.addLog("image", p.GetTitle()) + } + } + + return nil +} + +func (t *tagger) tagGalleries(paths []string, galleryReader models.GalleryReader, addFunc addLinkFunc) error { + others, err := getMatchingGalleries(t.Name, paths, galleryReader) + if err != nil { + return err + } + + for _, p := range others { + added, err := addFunc(t.ID, p.ID) + + if err != nil { + return t.addError("gallery", p.GetTitle(), err) + } + + if added { + t.addLog("gallery", p.GetTitle()) + } + } + + return nil +} diff --git a/pkg/gallery/update.go b/pkg/gallery/update.go index 6befc5b1c..9355282a1 100644 --- a/pkg/gallery/update.go +++ b/pkg/gallery/update.go @@ -21,3 +21,43 @@ func AddImage(qb models.GalleryReaderWriter, galleryID int, imageID int) error { imageIDs = utils.IntAppendUnique(imageIDs, imageID) return qb.UpdateImages(galleryID, imageIDs) } + +func AddPerformer(qb models.GalleryReaderWriter, id int, performerID int) (bool, error) { + performerIDs, err := qb.GetPerformerIDs(id) + if err != nil { + return false, err + } + + oldLen := len(performerIDs) + performerIDs = utils.IntAppendUnique(performerIDs, performerID) + + if len(performerIDs) != oldLen { + if err := qb.UpdatePerformers(id, performerIDs); err != nil { + return false, err + } + + return true, nil + } + + return false, nil +} + +func AddTag(qb models.GalleryReaderWriter, id int, tagID int) (bool, error) { + tagIDs, err := qb.GetTagIDs(id) + if err != nil { + return false, err + } + + oldLen := len(tagIDs) + tagIDs = utils.IntAppendUnique(tagIDs, tagID) + + if len(tagIDs) != oldLen { + if err := qb.UpdateTags(id, tagIDs); err != nil { + return false, err + } + + return true, nil + } + + return false, nil +} diff --git a/pkg/image/update.go b/pkg/image/update.go index 3728d3187..95b80d697 100644 --- a/pkg/image/update.go +++ b/pkg/image/update.go @@ -1,6 +1,9 @@ package image -import "github.com/stashapp/stash/pkg/models" +import ( + "github.com/stashapp/stash/pkg/models" + "github.com/stashapp/stash/pkg/utils" +) func UpdateFileModTime(qb models.ImageWriter, id int, modTime models.NullSQLiteTimestamp) (*models.Image, error) { return qb.Update(models.ImagePartial{ @@ -8,3 +11,43 @@ func UpdateFileModTime(qb models.ImageWriter, id int, modTime models.NullSQLiteT FileModTime: &modTime, }) } + +func AddPerformer(qb models.ImageReaderWriter, id int, performerID int) (bool, error) { + performerIDs, err := qb.GetPerformerIDs(id) + if err != nil { + return false, err + } + + oldLen := len(performerIDs) + performerIDs = utils.IntAppendUnique(performerIDs, performerID) + + if len(performerIDs) != oldLen { + if err := qb.UpdatePerformers(id, performerIDs); err != nil { + return false, err + } + + return true, nil + } + + return false, nil +} + +func AddTag(qb models.ImageReaderWriter, id int, tagID int) (bool, error) { + tagIDs, err := qb.GetTagIDs(id) + if err != nil { + return false, err + } + + oldLen := len(tagIDs) + tagIDs = utils.IntAppendUnique(tagIDs, tagID) + + if len(tagIDs) != oldLen { + if err := qb.UpdateTags(id, tagIDs); err != nil { + return false, err + } + + return true, nil + } + + return false, nil +} diff --git a/pkg/manager/manager_tasks.go b/pkg/manager/manager_tasks.go index ffa7f1722..fbc729174 100644 --- a/pkg/manager/manager_tasks.go +++ b/pkg/manager/manager_tasks.go @@ -5,9 +5,7 @@ import ( "errors" "fmt" "os" - "path/filepath" "strconv" - "strings" "sync" "time" @@ -650,7 +648,7 @@ func (s *singleton) AutoTag(input models.AutoTagMetadataInput) { if s.isFileBasedAutoTag(input) { // doing file-based auto-tag - s.autoTagScenes(input.Paths, len(input.Performers) > 0, len(input.Studios) > 0, len(input.Tags) > 0) + s.autoTagFiles(input.Paths, len(input.Performers) > 0, len(input.Studios) > 0, len(input.Tags) > 0) } else { // doing specific performer/studio/tag auto-tag s.autoTagSpecific(input) @@ -658,90 +656,17 @@ func (s *singleton) AutoTag(input models.AutoTagMetadataInput) { }() } -func (s *singleton) autoTagScenes(paths []string, performers, studios, tags bool) { - if err := s.TxnManager.WithReadTxn(context.TODO(), func(r models.ReaderRepository) error { - ret := &models.SceneFilterType{} - or := ret - sep := string(filepath.Separator) - - for _, p := range paths { - if !strings.HasSuffix(p, sep) { - p = p + sep - } - - if ret.Path == nil { - or = ret - } else { - newOr := &models.SceneFilterType{} - or.Or = newOr - or = newOr - } - - or.Path = &models.StringCriterionInput{ - Modifier: models.CriterionModifierEquals, - Value: p + "%", - } - } - - organized := false - ret.Organized = &organized - - // batch process scenes - batchSize := 1000 - page := 1 - findFilter := &models.FindFilterType{ - PerPage: &batchSize, - Page: &page, - } - - more := true - processed := 0 - for more { - scenes, total, err := r.Scene().Query(ret, findFilter) - if err != nil { - return err - } - - if processed == 0 { - logger.Infof("Starting autotag of %d scenes", total) - } - - for _, ss := range scenes { - if s.Status.stopping { - logger.Info("Stopping due to user request") - return nil - } - - t := autoTagSceneTask{ - txnManager: s.TxnManager, - scene: ss, - performers: performers, - studios: studios, - tags: tags, - } - - var wg sync.WaitGroup - wg.Add(1) - go t.Start(&wg) - wg.Wait() - - processed++ - s.Status.setProgress(processed, total) - } - - if len(scenes) != batchSize { - more = false - } else { - page++ - } - } - - return nil - }); err != nil { - logger.Error(err.Error()) +func (s *singleton) autoTagFiles(paths []string, performers, studios, tags bool) { + t := autoTagFilesTask{ + paths: paths, + performers: performers, + studios: studios, + tags: tags, + txnManager: s.TxnManager, + status: &s.Status, } - logger.Info("Finished autotag") + t.process() } func (s *singleton) autoTagSpecific(input models.AutoTagMetadataInput) { @@ -838,7 +763,17 @@ func (s *singleton) autoTagPerformers(paths []string, performerIds []string) { } if err := s.TxnManager.WithTxn(context.TODO(), func(r models.Repository) error { - return autotag.PerformerScenes(performer, paths, r.Scene()) + if err := autotag.PerformerScenes(performer, paths, r.Scene()); err != nil { + return err + } + if err := autotag.PerformerImages(performer, paths, r.Image()); err != nil { + return err + } + if err := autotag.PerformerGalleries(performer, paths, r.Gallery()); err != nil { + return err + } + + return nil }); err != nil { return fmt.Errorf("error auto-tagging performer '%s': %s", performer.Name.String, err.Error()) } @@ -895,7 +830,17 @@ func (s *singleton) autoTagStudios(paths []string, studioIds []string) { } if err := s.TxnManager.WithTxn(context.TODO(), func(r models.Repository) error { - return autotag.StudioScenes(studio, paths, r.Scene()) + if err := autotag.StudioScenes(studio, paths, r.Scene()); err != nil { + return err + } + if err := autotag.StudioImages(studio, paths, r.Image()); err != nil { + return err + } + if err := autotag.StudioGalleries(studio, paths, r.Gallery()); err != nil { + return err + } + + return nil }); err != nil { return fmt.Errorf("error auto-tagging studio '%s': %s", studio.Name.String, err.Error()) } @@ -946,7 +891,17 @@ func (s *singleton) autoTagTags(paths []string, tagIds []string) { } if err := s.TxnManager.WithTxn(context.TODO(), func(r models.Repository) error { - return autotag.TagScenes(tag, paths, r.Scene()) + if err := autotag.TagScenes(tag, paths, r.Scene()); err != nil { + return err + } + if err := autotag.TagImages(tag, paths, r.Image()); err != nil { + return err + } + if err := autotag.TagGalleries(tag, paths, r.Gallery()); err != nil { + return err + } + + return nil }); err != nil { return fmt.Errorf("error auto-tagging tag '%s': %s", tag.Name, err.Error()) } diff --git a/pkg/manager/task_autotag.go b/pkg/manager/task_autotag.go index 6fb0a08e0..8cb3f80cf 100644 --- a/pkg/manager/task_autotag.go +++ b/pkg/manager/task_autotag.go @@ -2,6 +2,8 @@ package manager import ( "context" + "path/filepath" + "strings" "sync" "github.com/stashapp/stash/pkg/autotag" @@ -9,6 +11,317 @@ import ( "github.com/stashapp/stash/pkg/models" ) +type autoTagFilesTask struct { + paths []string + performers bool + studios bool + tags bool + + txnManager models.TransactionManager + status *TaskStatus +} + +func (t *autoTagFilesTask) makeSceneFilter() *models.SceneFilterType { + ret := &models.SceneFilterType{} + or := ret + sep := string(filepath.Separator) + + for _, p := range t.paths { + if !strings.HasSuffix(p, sep) { + p = p + sep + } + + if ret.Path == nil { + or = ret + } else { + newOr := &models.SceneFilterType{} + or.Or = newOr + or = newOr + } + + or.Path = &models.StringCriterionInput{ + Modifier: models.CriterionModifierEquals, + Value: p + "%", + } + } + + organized := false + ret.Organized = &organized + + return ret +} + +func (t *autoTagFilesTask) makeImageFilter() *models.ImageFilterType { + ret := &models.ImageFilterType{} + or := ret + sep := string(filepath.Separator) + + for _, p := range t.paths { + if !strings.HasSuffix(p, sep) { + p = p + sep + } + + if ret.Path == nil { + or = ret + } else { + newOr := &models.ImageFilterType{} + or.Or = newOr + or = newOr + } + + or.Path = &models.StringCriterionInput{ + Modifier: models.CriterionModifierEquals, + Value: p + "%", + } + } + + organized := false + ret.Organized = &organized + + return ret +} + +func (t *autoTagFilesTask) makeGalleryFilter() *models.GalleryFilterType { + ret := &models.GalleryFilterType{} + or := ret + sep := string(filepath.Separator) + + for _, p := range t.paths { + if !strings.HasSuffix(p, sep) { + p = p + sep + } + + if ret.Path == nil { + or = ret + } else { + newOr := &models.GalleryFilterType{} + or.Or = newOr + or = newOr + } + + or.Path = &models.StringCriterionInput{ + Modifier: models.CriterionModifierEquals, + Value: p + "%", + } + } + + organized := false + ret.Organized = &organized + + return ret +} + +func (t *autoTagFilesTask) getCount(r models.ReaderRepository) (int, error) { + pp := 0 + findFilter := &models.FindFilterType{ + PerPage: &pp, + } + + _, sceneCount, err := r.Scene().Query(t.makeSceneFilter(), findFilter) + if err != nil { + return 0, err + } + + _, imageCount, err := r.Image().Query(t.makeImageFilter(), findFilter) + if err != nil { + return 0, err + } + + _, galleryCount, err := r.Gallery().Query(t.makeGalleryFilter(), findFilter) + if err != nil { + return 0, err + } + + return sceneCount + imageCount + galleryCount, nil +} + +func (t *autoTagFilesTask) batchFindFilter(batchSize int) *models.FindFilterType { + page := 1 + return &models.FindFilterType{ + PerPage: &batchSize, + Page: &page, + } +} + +func (t *autoTagFilesTask) processScenes(r models.ReaderRepository) error { + if t.status.stopping { + return nil + } + + batchSize := 1000 + + findFilter := t.batchFindFilter(batchSize) + sceneFilter := t.makeSceneFilter() + + more := true + for more { + scenes, _, err := r.Scene().Query(sceneFilter, findFilter) + if err != nil { + return err + } + + for _, ss := range scenes { + if t.status.stopping { + return nil + } + + tt := autoTagSceneTask{ + txnManager: t.txnManager, + scene: ss, + performers: t.performers, + studios: t.studios, + tags: t.tags, + } + + var wg sync.WaitGroup + wg.Add(1) + go tt.Start(&wg) + wg.Wait() + + t.status.incrementProgress() + } + + if len(scenes) != batchSize { + more = false + } else { + *findFilter.Page++ + } + } + + return nil +} + +func (t *autoTagFilesTask) processImages(r models.ReaderRepository) error { + if t.status.stopping { + return nil + } + + batchSize := 1000 + + findFilter := t.batchFindFilter(batchSize) + imageFilter := t.makeImageFilter() + + more := true + for more { + images, _, err := r.Image().Query(imageFilter, findFilter) + if err != nil { + return err + } + + for _, ss := range images { + if t.status.stopping { + return nil + } + + tt := autoTagImageTask{ + txnManager: t.txnManager, + image: ss, + performers: t.performers, + studios: t.studios, + tags: t.tags, + } + + var wg sync.WaitGroup + wg.Add(1) + go tt.Start(&wg) + wg.Wait() + + t.status.incrementProgress() + } + + if len(images) != batchSize { + more = false + } else { + *findFilter.Page++ + } + } + + return nil +} + +func (t *autoTagFilesTask) processGalleries(r models.ReaderRepository) error { + if t.status.stopping { + return nil + } + + batchSize := 1000 + + findFilter := t.batchFindFilter(batchSize) + galleryFilter := t.makeGalleryFilter() + + more := true + for more { + galleries, _, err := r.Gallery().Query(galleryFilter, findFilter) + if err != nil { + return err + } + + for _, ss := range galleries { + if t.status.stopping { + return nil + } + + tt := autoTagGalleryTask{ + txnManager: t.txnManager, + gallery: ss, + performers: t.performers, + studios: t.studios, + tags: t.tags, + } + + var wg sync.WaitGroup + wg.Add(1) + go tt.Start(&wg) + wg.Wait() + + t.status.incrementProgress() + } + + if len(galleries) != batchSize { + more = false + } else { + *findFilter.Page++ + } + } + + return nil +} + +func (t *autoTagFilesTask) process() { + if err := t.txnManager.WithReadTxn(context.TODO(), func(r models.ReaderRepository) error { + total, err := t.getCount(r) + if err != nil { + return err + } + + t.status.total = total + + logger.Infof("Starting autotag of %d files", total) + + if err := t.processScenes(r); err != nil { + return err + } + + if err := t.processImages(r); err != nil { + return err + } + + if err := t.processGalleries(r); err != nil { + return err + } + + if t.status.stopping { + logger.Info("Stopping due to user request") + } + + return nil + }); err != nil { + logger.Error(err.Error()) + } + + logger.Info("Finished autotag") +} + type autoTagSceneTask struct { txnManager models.TransactionManager scene *models.Scene @@ -42,3 +355,71 @@ func (t *autoTagSceneTask) Start(wg *sync.WaitGroup) { logger.Error(err.Error()) } } + +type autoTagImageTask struct { + txnManager models.TransactionManager + image *models.Image + + performers bool + studios bool + tags bool +} + +func (t *autoTagImageTask) Start(wg *sync.WaitGroup) { + defer wg.Done() + if err := t.txnManager.WithTxn(context.TODO(), func(r models.Repository) error { + if t.performers { + if err := autotag.ImagePerformers(t.image, r.Image(), r.Performer()); err != nil { + return err + } + } + if t.studios { + if err := autotag.ImageStudios(t.image, r.Image(), r.Studio()); err != nil { + return err + } + } + if t.tags { + if err := autotag.ImageTags(t.image, r.Image(), r.Tag()); err != nil { + return err + } + } + + return nil + }); err != nil { + logger.Error(err.Error()) + } +} + +type autoTagGalleryTask struct { + txnManager models.TransactionManager + gallery *models.Gallery + + performers bool + studios bool + tags bool +} + +func (t *autoTagGalleryTask) Start(wg *sync.WaitGroup) { + defer wg.Done() + if err := t.txnManager.WithTxn(context.TODO(), func(r models.Repository) error { + if t.performers { + if err := autotag.GalleryPerformers(t.gallery, r.Gallery(), r.Performer()); err != nil { + return err + } + } + if t.studios { + if err := autotag.GalleryStudios(t.gallery, r.Gallery(), r.Studio()); err != nil { + return err + } + } + if t.tags { + if err := autotag.GalleryTags(t.gallery, r.Gallery(), r.Tag()); err != nil { + return err + } + } + + return nil + }); err != nil { + logger.Error(err.Error()) + } +} diff --git a/pkg/models/model_gallery.go b/pkg/models/model_gallery.go index 061dbf7d2..977df7663 100644 --- a/pkg/models/model_gallery.go +++ b/pkg/models/model_gallery.go @@ -2,6 +2,7 @@ package models import ( "database/sql" + "path/filepath" ) type Gallery struct { @@ -39,6 +40,20 @@ type GalleryPartial struct { UpdatedAt *SQLiteTimestamp `db:"updated_at" json:"updated_at"` } +// GetTitle returns the title of the scene. If the Title field is empty, +// then the base filename is returned. +func (s Gallery) GetTitle() string { + if s.Title.String != "" { + return s.Title.String + } + + if s.Path.Valid { + return filepath.Base(s.Path.String) + } + + return "" +} + const DefaultGthumbWidth int = 640 type Galleries []*Gallery diff --git a/pkg/models/model_image.go b/pkg/models/model_image.go index 47c21fcd5..6470e619d 100644 --- a/pkg/models/model_image.go +++ b/pkg/models/model_image.go @@ -2,6 +2,7 @@ package models import ( "database/sql" + "path/filepath" ) // Image stores the metadata for a single image. @@ -40,6 +41,16 @@ type ImagePartial struct { UpdatedAt *SQLiteTimestamp `db:"updated_at" json:"updated_at"` } +// GetTitle returns the title of the image. If the Title field is empty, +// then the base filename is returned. +func (s Image) GetTitle() string { + if s.Title.String != "" { + return s.Title.String + } + + return filepath.Base(s.Path) +} + // ImageFileType represents the file metadata for an image. type ImageFileType struct { Size *int `graphql:"size" json:"size"` diff --git a/pkg/sqlite/gallery.go b/pkg/sqlite/gallery.go index 445baa46a..466238061 100644 --- a/pkg/sqlite/gallery.go +++ b/pkg/sqlite/gallery.go @@ -159,7 +159,69 @@ func (qb *galleryQueryBuilder) All() ([]*models.Gallery, error) { return qb.queryGalleries(selectAll("galleries")+qb.getGallerySort(nil), nil) } -func (qb *galleryQueryBuilder) makeQuery(galleryFilter *models.GalleryFilterType, findFilter *models.FindFilterType) queryBuilder { +func (qb *galleryQueryBuilder) validateFilter(galleryFilter *models.GalleryFilterType) error { + const and = "AND" + const or = "OR" + const not = "NOT" + + if galleryFilter.And != nil { + if galleryFilter.Or != nil { + return illegalFilterCombination(and, or) + } + if galleryFilter.Not != nil { + return illegalFilterCombination(and, not) + } + + return qb.validateFilter(galleryFilter.And) + } + + if galleryFilter.Or != nil { + if galleryFilter.Not != nil { + return illegalFilterCombination(or, not) + } + + return qb.validateFilter(galleryFilter.Or) + } + + if galleryFilter.Not != nil { + return qb.validateFilter(galleryFilter.Not) + } + + return nil +} + +func (qb *galleryQueryBuilder) makeFilter(galleryFilter *models.GalleryFilterType) *filterBuilder { + query := &filterBuilder{} + + if galleryFilter.And != nil { + query.and(qb.makeFilter(galleryFilter.And)) + } + if galleryFilter.Or != nil { + query.or(qb.makeFilter(galleryFilter.Or)) + } + if galleryFilter.Not != nil { + query.not(qb.makeFilter(galleryFilter.Not)) + } + + query.handleCriterionFunc(boolCriterionHandler(galleryFilter.IsZip, "galleries.zip")) + query.handleCriterionFunc(stringCriterionHandler(galleryFilter.Path, "galleries.path")) + query.handleCriterionFunc(intCriterionHandler(galleryFilter.Rating, "galleries.rating")) + query.handleCriterionFunc(stringCriterionHandler(galleryFilter.URL, "galleries.url")) + query.handleCriterionFunc(boolCriterionHandler(galleryFilter.Organized, "galleries.organized")) + query.handleCriterionFunc(galleryIsMissingCriterionHandler(qb, galleryFilter.IsMissing)) + query.handleCriterionFunc(galleryTagsCriterionHandler(qb, galleryFilter.Tags)) + query.handleCriterionFunc(galleryTagCountCriterionHandler(qb, galleryFilter.TagCount)) + query.handleCriterionFunc(galleryPerformersCriterionHandler(qb, galleryFilter.Performers)) + query.handleCriterionFunc(galleryPerformerCountCriterionHandler(qb, galleryFilter.PerformerCount)) + query.handleCriterionFunc(galleryStudioCriterionHandler(qb, galleryFilter.Studios)) + query.handleCriterionFunc(galleryPerformerTagsCriterionHandler(qb, galleryFilter.PerformerTags)) + query.handleCriterionFunc(galleryAverageResolutionCriterionHandler(qb, galleryFilter.AverageResolution)) + query.handleCriterionFunc(galleryImageCountCriterionHandler(qb, galleryFilter.ImageCount)) + + return query +} + +func (qb *galleryQueryBuilder) makeQuery(galleryFilter *models.GalleryFilterType, findFilter *models.FindFilterType) (*queryBuilder, error) { if galleryFilter == nil { galleryFilter = &models.GalleryFilterType{} } @@ -169,15 +231,7 @@ func (qb *galleryQueryBuilder) makeQuery(galleryFilter *models.GalleryFilterType query := qb.newQuery() - query.body = selectDistinctIDs("galleries") - query.body += ` - left join performers_galleries as performers_join on performers_join.gallery_id = galleries.id - left join scenes_galleries as scenes_join on scenes_join.gallery_id = galleries.id - left join studios as studio on studio.id = galleries.studio_id - left join galleries_tags as tags_join on tags_join.gallery_id = galleries.id - left join galleries_images as images_join on images_join.gallery_id = galleries.id - left join images on images_join.image_id = images.id - ` + query.body = selectDistinctIDs(galleryTable) if q := findFilter.Q; q != nil && *q != "" { searchColumns := []string{"galleries.title", "galleries.path", "galleries.checksum"} @@ -186,110 +240,23 @@ func (qb *galleryQueryBuilder) makeQuery(galleryFilter *models.GalleryFilterType query.addArg(thisArgs...) } - if zipFilter := galleryFilter.IsZip; zipFilter != nil { - var favStr string - if *zipFilter == true { - favStr = "1" - } else { - favStr = "0" - } - query.addWhere("galleries.zip = " + favStr) + if err := qb.validateFilter(galleryFilter); err != nil { + return nil, err } + filter := qb.makeFilter(galleryFilter) - query.handleStringCriterionInput(galleryFilter.Path, "galleries.path") - query.handleIntCriterionInput(galleryFilter.Rating, "galleries.rating") - query.handleStringCriterionInput(galleryFilter.URL, "galleries.url") - query.handleCountCriterion(galleryFilter.ImageCount, galleryTable, galleriesImagesTable, galleryIDColumn) - qb.handleAverageResolutionFilter(&query, galleryFilter.AverageResolution) - - if Organized := galleryFilter.Organized; Organized != nil { - var organized string - if *Organized == true { - organized = "1" - } else { - organized = "0" - } - query.addWhere("galleries.organized = " + organized) - } - - if isMissingFilter := galleryFilter.IsMissing; isMissingFilter != nil && *isMissingFilter != "" { - switch *isMissingFilter { - case "scenes": - query.addWhere("scenes_join.gallery_id IS NULL") - case "studio": - query.addWhere("galleries.studio_id IS NULL") - case "performers": - query.addWhere("performers_join.gallery_id IS NULL") - case "date": - query.addWhere("galleries.date IS \"\" OR galleries.date IS \"0001-01-01\"") - case "tags": - query.addWhere("tags_join.gallery_id IS NULL") - default: - query.addWhere("galleries." + *isMissingFilter + " IS NULL") - } - } - - if tagsFilter := galleryFilter.Tags; tagsFilter != nil && len(tagsFilter.Value) > 0 { - for _, tagID := range tagsFilter.Value { - query.addArg(tagID) - } - - query.body += " LEFT JOIN tags on tags_join.tag_id = tags.id" - whereClause, havingClause := getMultiCriterionClause("galleries", "tags", "galleries_tags", "gallery_id", "tag_id", tagsFilter) - query.addWhere(whereClause) - query.addHaving(havingClause) - } - - if tagCountFilter := galleryFilter.TagCount; tagCountFilter != nil { - clause, count := getCountCriterionClause(galleryTable, galleriesTagsTable, galleryIDColumn, *tagCountFilter) - - if count == 1 { - query.addArg(tagCountFilter.Value) - } - - query.addWhere(clause) - } - - if performersFilter := galleryFilter.Performers; performersFilter != nil && len(performersFilter.Value) > 0 { - for _, performerID := range performersFilter.Value { - query.addArg(performerID) - } - - query.body += " LEFT JOIN performers ON performers_join.performer_id = performers.id" - whereClause, havingClause := getMultiCriterionClause("galleries", "performers", "performers_galleries", "gallery_id", "performer_id", performersFilter) - query.addWhere(whereClause) - query.addHaving(havingClause) - } - - if performerCountFilter := galleryFilter.PerformerCount; performerCountFilter != nil { - clause, count := getCountCriterionClause(galleryTable, performersGalleriesTable, galleryIDColumn, *performerCountFilter) - - if count == 1 { - query.addArg(performerCountFilter.Value) - } - - query.addWhere(clause) - } - - if studiosFilter := galleryFilter.Studios; studiosFilter != nil && len(studiosFilter.Value) > 0 { - for _, studioID := range studiosFilter.Value { - query.addArg(studioID) - } - - whereClause, havingClause := getMultiCriterionClause("galleries", "studio", "", "", "studio_id", studiosFilter) - query.addWhere(whereClause) - query.addHaving(havingClause) - } - - handleGalleryPerformerTagsCriterion(&query, galleryFilter.PerformerTags) + query.addFilter(filter) query.sortAndPagination = qb.getGallerySort(findFilter) + getPagination(findFilter) - return query + return &query, nil } func (qb *galleryQueryBuilder) Query(galleryFilter *models.GalleryFilterType, findFilter *models.FindFilterType) ([]*models.Gallery, int, error) { - query := qb.makeQuery(galleryFilter, findFilter) + query, err := qb.makeQuery(galleryFilter, findFilter) + if err != nil { + return nil, 0, err + } idsResult, countResult, err := query.executeFind() if err != nil { @@ -310,98 +277,155 @@ func (qb *galleryQueryBuilder) Query(galleryFilter *models.GalleryFilterType, fi } func (qb *galleryQueryBuilder) QueryCount(galleryFilter *models.GalleryFilterType, findFilter *models.FindFilterType) (int, error) { - query := qb.makeQuery(galleryFilter, findFilter) + query, err := qb.makeQuery(galleryFilter, findFilter) + if err != nil { + return 0, err + } return query.executeCount() } -func (qb *galleryQueryBuilder) handleAverageResolutionFilter(query *queryBuilder, resolutionFilter *models.ResolutionEnum) { - if resolutionFilter == nil { - return - } - - if resolution := resolutionFilter.String(); resolutionFilter.IsValid() { - var low int - var high int - - switch resolution { - case "VERY_LOW": - high = 240 - case "LOW": - low = 240 - high = 360 - case "R360P": - low = 360 - high = 480 - case "STANDARD": - low = 480 - high = 540 - case "WEB_HD": - low = 540 - high = 720 - case "STANDARD_HD": - low = 720 - high = 1080 - case "FULL_HD": - low = 1080 - high = 1440 - case "QUAD_HD": - low = 1440 - high = 1920 - case "VR_HD": - low = 1920 - high = 2160 - case "FOUR_K": - low = 2160 - high = 2880 - case "FIVE_K": - low = 2880 - high = 3384 - case "SIX_K": - low = 3384 - high = 4320 - case "EIGHT_K": - low = 4320 - } - - havingClause := "" - if low != 0 { - havingClause = "avg(MIN(images.width, images.height)) >= " + strconv.Itoa(low) - } - if high != 0 { - if havingClause != "" { - havingClause += " AND " +func galleryIsMissingCriterionHandler(qb *galleryQueryBuilder, isMissing *string) criterionHandlerFunc { + return func(f *filterBuilder) { + if isMissing != nil && *isMissing != "" { + switch *isMissing { + case "scenes": + f.addJoin("scenes_galleries", "scenes_join", "scenes_join.gallery_id = galleries.id") + f.addWhere("scenes_join.gallery_id IS NULL") + case "studio": + f.addWhere("galleries.studio_id IS NULL") + case "performers": + qb.performersRepository().join(f, "performers_join", "galleries.id") + f.addWhere("performers_join.gallery_id IS NULL") + case "date": + f.addWhere("galleries.date IS \"\" OR galleries.date IS \"0001-01-01\"") + case "tags": + qb.tagsRepository().join(f, "tags_join", "galleries.id") + f.addWhere("tags_join.gallery_id IS NULL") + default: + f.addWhere("(galleries." + *isMissing + " IS NULL OR TRIM(galleries." + *isMissing + ") = '')") } - havingClause += "avg(MIN(images.width, images.height)) < " + strconv.Itoa(high) - } - - if havingClause != "" { - query.addHaving(havingClause) } } } -func handleGalleryPerformerTagsCriterion(query *queryBuilder, performerTagsFilter *models.MultiCriterionInput) { - if performerTagsFilter != nil && len(performerTagsFilter.Value) > 0 { - for _, tagID := range performerTagsFilter.Value { - query.addArg(tagID) +func (qb *galleryQueryBuilder) getMultiCriterionHandlerBuilder(foreignTable, joinTable, foreignFK string, addJoinsFunc func(f *filterBuilder)) multiCriterionHandlerBuilder { + return multiCriterionHandlerBuilder{ + primaryTable: galleryTable, + foreignTable: foreignTable, + joinTable: joinTable, + primaryFK: galleryIDColumn, + foreignFK: foreignFK, + addJoinsFunc: addJoinsFunc, + } +} + +func galleryTagsCriterionHandler(qb *galleryQueryBuilder, tags *models.MultiCriterionInput) criterionHandlerFunc { + addJoinsFunc := func(f *filterBuilder) { + qb.tagsRepository().join(f, "tags_join", "galleries.id") + f.addJoin(tagTable, "", "tags_join.tag_id = tags.id") + } + h := qb.getMultiCriterionHandlerBuilder(tagTable, galleriesTagsTable, tagIDColumn, addJoinsFunc) + + return h.handler(tags) +} + +func galleryTagCountCriterionHandler(qb *galleryQueryBuilder, tagCount *models.IntCriterionInput) criterionHandlerFunc { + h := countCriterionHandlerBuilder{ + primaryTable: galleryTable, + joinTable: galleriesTagsTable, + primaryFK: galleryIDColumn, + } + + return h.handler(tagCount) +} + +func galleryPerformersCriterionHandler(qb *galleryQueryBuilder, performers *models.MultiCriterionInput) criterionHandlerFunc { + addJoinsFunc := func(f *filterBuilder) { + qb.performersRepository().join(f, "performers_join", "galleries.id") + f.addJoin(performerTable, "", "performers_join.performer_id = performers.id") + } + h := qb.getMultiCriterionHandlerBuilder(performerTable, performersGalleriesTable, performerIDColumn, addJoinsFunc) + + return h.handler(performers) +} + +func galleryPerformerCountCriterionHandler(qb *galleryQueryBuilder, performerCount *models.IntCriterionInput) criterionHandlerFunc { + h := countCriterionHandlerBuilder{ + primaryTable: galleryTable, + joinTable: performersGalleriesTable, + primaryFK: galleryIDColumn, + } + + return h.handler(performerCount) +} + +func galleryImageCountCriterionHandler(qb *galleryQueryBuilder, imageCount *models.IntCriterionInput) criterionHandlerFunc { + h := countCriterionHandlerBuilder{ + primaryTable: galleryTable, + joinTable: galleriesImagesTable, + primaryFK: galleryIDColumn, + } + + return h.handler(imageCount) +} + +func galleryStudioCriterionHandler(qb *galleryQueryBuilder, studios *models.MultiCriterionInput) criterionHandlerFunc { + addJoinsFunc := func(f *filterBuilder) { + f.addJoin(studioTable, "studio", "studio.id = galleries.studio_id") + } + h := qb.getMultiCriterionHandlerBuilder("studio", "", studioIDColumn, addJoinsFunc) + + return h.handler(studios) +} + +func galleryPerformerTagsCriterionHandler(qb *galleryQueryBuilder, performerTagsFilter *models.MultiCriterionInput) criterionHandlerFunc { + return func(f *filterBuilder) { + if performerTagsFilter != nil && len(performerTagsFilter.Value) > 0 { + qb.performersRepository().join(f, "performers_join", "galleries.id") + f.addJoin("performers_tags", "performer_tags_join", "performers_join.performer_id = performer_tags_join.performer_id") + + var args []interface{} + for _, tagID := range performerTagsFilter.Value { + args = append(args, tagID) + } + + if performerTagsFilter.Modifier == models.CriterionModifierIncludes { + // includes any of the provided ids + f.addWhere("performer_tags_join.tag_id IN "+getInBinding(len(performerTagsFilter.Value)), args...) + } else if performerTagsFilter.Modifier == models.CriterionModifierIncludesAll { + // includes all of the provided ids + f.addWhere("performer_tags_join.tag_id IN "+getInBinding(len(performerTagsFilter.Value)), args...) + f.addHaving(fmt.Sprintf("count(distinct performer_tags_join.tag_id) IS %d", len(performerTagsFilter.Value))) + } else if performerTagsFilter.Modifier == models.CriterionModifierExcludes { + f.addWhere(fmt.Sprintf(`not exists + (select performers_galleries.performer_id from performers_galleries + left join performers_tags on performers_tags.performer_id = performers_galleries.performer_id where + performers_galleries.gallery_id = galleries.id AND + performers_tags.tag_id in %s)`, getInBinding(len(performerTagsFilter.Value))), args...) + } } + } +} - query.body += " LEFT JOIN performers_tags AS performer_tags_join on performers_join.performer_id = performer_tags_join.performer_id" +func galleryAverageResolutionCriterionHandler(qb *galleryQueryBuilder, resolution *models.ResolutionEnum) criterionHandlerFunc { + return func(f *filterBuilder) { + if resolution != nil && resolution.IsValid() { + qb.imagesRepository().join(f, "images_join", "galleries.id") + f.addJoin("images", "", "images_join.image_id = images.id") - if performerTagsFilter.Modifier == models.CriterionModifierIncludes { - // includes any of the provided ids - query.addWhere("performer_tags_join.tag_id IN " + getInBinding(len(performerTagsFilter.Value))) - } else if performerTagsFilter.Modifier == models.CriterionModifierIncludesAll { - // includes all of the provided ids - query.addWhere("performer_tags_join.tag_id IN " + getInBinding(len(performerTagsFilter.Value))) - query.addHaving(fmt.Sprintf("count(distinct performer_tags_join.tag_id) IS %d", len(performerTagsFilter.Value))) - } else if performerTagsFilter.Modifier == models.CriterionModifierExcludes { - query.addWhere(fmt.Sprintf(`not exists - (select performers_galleries.performer_id from performers_galleries - left join performers_tags on performers_tags.performer_id = performers_galleries.performer_id where - performers_galleries.gallery_id = galleries.id AND - performers_tags.tag_id in %s)`, getInBinding(len(performerTagsFilter.Value)))) + min := resolution.GetMinResolution() + max := resolution.GetMaxResolution() + + const widthHeight = "avg(MIN(images.width, images.height))" + + if min > 0 { + f.addHaving(widthHeight + " >= " + strconv.Itoa(min)) + } + + if max > 0 { + f.addHaving(widthHeight + " < " + strconv.Itoa(max)) + } } } } @@ -418,6 +442,8 @@ func (qb *galleryQueryBuilder) getGallerySort(findFilter *models.FindFilterType) } switch sort { + case "images_count": + return getCountSort(galleryTable, galleriesImagesTable, galleryIDColumn, direction) case "tag_count": return getCountSort(galleryTable, galleriesTagsTable, galleryIDColumn, direction) case "performer_count": diff --git a/pkg/sqlite/gallery_test.go b/pkg/sqlite/gallery_test.go index 7b42133a4..bcc149edf 100644 --- a/pkg/sqlite/gallery_test.go +++ b/pkg/sqlite/gallery_test.go @@ -193,6 +193,143 @@ func verifyGalleriesPath(t *testing.T, sqb models.GalleryReader, pathCriterion m } } +func TestGalleryQueryPathOr(t *testing.T) { + const gallery1Idx = 1 + const gallery2Idx = 2 + + gallery1Path := getGalleryStringValue(gallery1Idx, "Path") + gallery2Path := getGalleryStringValue(gallery2Idx, "Path") + + galleryFilter := models.GalleryFilterType{ + Path: &models.StringCriterionInput{ + Value: gallery1Path, + Modifier: models.CriterionModifierEquals, + }, + Or: &models.GalleryFilterType{ + Path: &models.StringCriterionInput{ + Value: gallery2Path, + Modifier: models.CriterionModifierEquals, + }, + }, + } + + withTxn(func(r models.Repository) error { + sqb := r.Gallery() + + galleries := queryGallery(t, sqb, &galleryFilter, nil) + + assert.Len(t, galleries, 2) + assert.Equal(t, gallery1Path, galleries[0].Path.String) + assert.Equal(t, gallery2Path, galleries[1].Path.String) + + return nil + }) +} + +func TestGalleryQueryPathAndRating(t *testing.T) { + const galleryIdx = 1 + galleryPath := getGalleryStringValue(galleryIdx, "Path") + galleryRating := getRating(galleryIdx) + + galleryFilter := models.GalleryFilterType{ + Path: &models.StringCriterionInput{ + Value: galleryPath, + Modifier: models.CriterionModifierEquals, + }, + And: &models.GalleryFilterType{ + Rating: &models.IntCriterionInput{ + Value: int(galleryRating.Int64), + Modifier: models.CriterionModifierEquals, + }, + }, + } + + withTxn(func(r models.Repository) error { + sqb := r.Gallery() + + galleries := queryGallery(t, sqb, &galleryFilter, nil) + + assert.Len(t, galleries, 1) + assert.Equal(t, galleryPath, galleries[0].Path.String) + assert.Equal(t, galleryRating.Int64, galleries[0].Rating.Int64) + + return nil + }) +} + +func TestGalleryQueryPathNotRating(t *testing.T) { + const galleryIdx = 1 + + galleryRating := getRating(galleryIdx) + + pathCriterion := models.StringCriterionInput{ + Value: "gallery_.*1_Path", + Modifier: models.CriterionModifierMatchesRegex, + } + + ratingCriterion := models.IntCriterionInput{ + Value: int(galleryRating.Int64), + Modifier: models.CriterionModifierEquals, + } + + galleryFilter := models.GalleryFilterType{ + Path: &pathCriterion, + Not: &models.GalleryFilterType{ + Rating: &ratingCriterion, + }, + } + + withTxn(func(r models.Repository) error { + sqb := r.Gallery() + + galleries := queryGallery(t, sqb, &galleryFilter, nil) + + for _, gallery := range galleries { + verifyNullString(t, gallery.Path, pathCriterion) + ratingCriterion.Modifier = models.CriterionModifierNotEquals + verifyInt64(t, gallery.Rating, ratingCriterion) + } + + return nil + }) +} + +func TestGalleryIllegalQuery(t *testing.T) { + assert := assert.New(t) + + const galleryIdx = 1 + subFilter := models.GalleryFilterType{ + Path: &models.StringCriterionInput{ + Value: getGalleryStringValue(galleryIdx, "Path"), + Modifier: models.CriterionModifierEquals, + }, + } + + galleryFilter := &models.GalleryFilterType{ + And: &subFilter, + Or: &subFilter, + } + + withTxn(func(r models.Repository) error { + sqb := r.Gallery() + + _, _, err := sqb.Query(galleryFilter, nil) + assert.NotNil(err) + + galleryFilter.Or = nil + galleryFilter.Not = &subFilter + _, _, err = sqb.Query(galleryFilter, nil) + assert.NotNil(err) + + galleryFilter.And = nil + galleryFilter.Or = &subFilter + _, _, err = sqb.Query(galleryFilter, nil) + assert.NotNil(err) + + return nil + }) +} + func TestGalleryQueryURL(t *testing.T) { const sceneIdx = 1 galleryURL := getGalleryStringValue(sceneIdx, urlField) @@ -712,6 +849,22 @@ func verifyGalleriesPerformerCount(t *testing.T, performerCountCriterion models. }) } +func TestGalleryQueryAverageResolution(t *testing.T) { + withTxn(func(r models.Repository) error { + qb := r.Gallery() + resolution := models.ResolutionEnumLow + galleryFilter := models.GalleryFilterType{ + AverageResolution: &resolution, + } + + // not verifying average - just ensure we get at least one + galleries := queryGallery(t, qb, &galleryFilter, nil) + assert.Greater(t, len(galleries), 0) + + return nil + }) +} + func TestGalleryQueryImageCount(t *testing.T) { const imageCount = 0 imageCountCriterion := models.IntCriterionInput{ diff --git a/pkg/sqlite/image.go b/pkg/sqlite/image.go index 261fedd95..61ece04c6 100644 --- a/pkg/sqlite/image.go +++ b/pkg/sqlite/image.go @@ -12,35 +12,6 @@ const imageIDColumn = "image_id" const performersImagesTable = "performers_images" const imagesTagsTable = "images_tags" -var imagesForPerformerQuery = selectAll(imageTable) + ` -LEFT JOIN performers_images as performers_join on performers_join.image_id = images.id -WHERE performers_join.performer_id = ? -GROUP BY images.id -` - -var countImagesForPerformerQuery = ` -SELECT performer_id FROM performers_images as performers_join -WHERE performer_id = ? -GROUP BY image_id -` - -var imagesForStudioQuery = selectAll(imageTable) + ` -JOIN studios ON studios.id = images.studio_id -WHERE studios.id = ? -GROUP BY images.id -` -var imagesForMovieQuery = selectAll(imageTable) + ` -LEFT JOIN movies_images as movies_join on movies_join.image_id = images.id -WHERE movies_join.movie_id = ? -GROUP BY images.id -` - -var countImagesForTagQuery = ` -SELECT tag_id AS id FROM images_tags -WHERE images_tags.tag_id = ? -GROUP BY images_tags.image_id -` - var imagesForGalleryQuery = selectAll(imageTable) + ` LEFT JOIN galleries_images as galleries_join on galleries_join.image_id = images.id WHERE galleries_join.gallery_id = ? @@ -216,7 +187,69 @@ func (qb *imageQueryBuilder) All() ([]*models.Image, error) { return qb.queryImages(selectAll(imageTable)+qb.getImageSort(nil), nil) } -func (qb *imageQueryBuilder) makeQuery(imageFilter *models.ImageFilterType, findFilter *models.FindFilterType) queryBuilder { +func (qb *imageQueryBuilder) validateFilter(imageFilter *models.ImageFilterType) error { + const and = "AND" + const or = "OR" + const not = "NOT" + + if imageFilter.And != nil { + if imageFilter.Or != nil { + return illegalFilterCombination(and, or) + } + if imageFilter.Not != nil { + return illegalFilterCombination(and, not) + } + + return qb.validateFilter(imageFilter.And) + } + + if imageFilter.Or != nil { + if imageFilter.Not != nil { + return illegalFilterCombination(or, not) + } + + return qb.validateFilter(imageFilter.Or) + } + + if imageFilter.Not != nil { + return qb.validateFilter(imageFilter.Not) + } + + return nil +} + +func (qb *imageQueryBuilder) makeFilter(imageFilter *models.ImageFilterType) *filterBuilder { + query := &filterBuilder{} + + if imageFilter.And != nil { + query.and(qb.makeFilter(imageFilter.And)) + } + if imageFilter.Or != nil { + query.or(qb.makeFilter(imageFilter.Or)) + } + if imageFilter.Not != nil { + query.not(qb.makeFilter(imageFilter.Not)) + } + + query.handleCriterionFunc(stringCriterionHandler(imageFilter.Path, "images.path")) + query.handleCriterionFunc(intCriterionHandler(imageFilter.Rating, "images.rating")) + query.handleCriterionFunc(intCriterionHandler(imageFilter.OCounter, "images.o_counter")) + query.handleCriterionFunc(boolCriterionHandler(imageFilter.Organized, "images.organized")) + query.handleCriterionFunc(resolutionCriterionHandler(imageFilter.Resolution, "images.height", "images.width")) + query.handleCriterionFunc(imageIsMissingCriterionHandler(qb, imageFilter.IsMissing)) + + query.handleCriterionFunc(imageTagsCriterionHandler(qb, imageFilter.Tags)) + query.handleCriterionFunc(imageTagCountCriterionHandler(qb, imageFilter.TagCount)) + query.handleCriterionFunc(imageGalleriesCriterionHandler(qb, imageFilter.Galleries)) + query.handleCriterionFunc(imagePerformersCriterionHandler(qb, imageFilter.Performers)) + query.handleCriterionFunc(imagePerformerCountCriterionHandler(qb, imageFilter.PerformerCount)) + query.handleCriterionFunc(imageStudioCriterionHandler(qb, imageFilter.Studios)) + query.handleCriterionFunc(imagePerformerTagsCriterionHandler(qb, imageFilter.PerformerTags)) + + return query +} + +func (qb *imageQueryBuilder) makeQuery(imageFilter *models.ImageFilterType, findFilter *models.FindFilterType) (*queryBuilder, error) { if imageFilter == nil { imageFilter = &models.ImageFilterType{} } @@ -227,12 +260,6 @@ func (qb *imageQueryBuilder) makeQuery(imageFilter *models.ImageFilterType, find query := qb.newQuery() query.body = selectDistinctIDs(imageTable) - query.body += ` - left join performers_images as performers_join on performers_join.image_id = images.id - left join studios as studio on studio.id = images.studio_id - left join images_tags as tags_join on tags_join.image_id = images.id - left join galleries_images as galleries_join on galleries_join.image_id = images.id - ` if q := findFilter.Q; q != nil && *q != "" { searchColumns := []string{"images.title", "images.path", "images.checksum"} @@ -241,154 +268,23 @@ func (qb *imageQueryBuilder) makeQuery(imageFilter *models.ImageFilterType, find query.addArg(thisArgs...) } - query.handleStringCriterionInput(imageFilter.Path, "images.path") - - if rating := imageFilter.Rating; rating != nil { - clause, count := getIntCriterionWhereClause("images.rating", *imageFilter.Rating) - query.addWhere(clause) - if count == 1 { - query.addArg(imageFilter.Rating.Value) - } + if err := qb.validateFilter(imageFilter); err != nil { + return nil, err } + filter := qb.makeFilter(imageFilter) - if oCounter := imageFilter.OCounter; oCounter != nil { - clause, count := getIntCriterionWhereClause("images.o_counter", *imageFilter.OCounter) - query.addWhere(clause) - if count == 1 { - query.addArg(imageFilter.OCounter.Value) - } - } - - if Organized := imageFilter.Organized; Organized != nil { - var organized string - if *Organized == true { - organized = "1" - } else { - organized = "0" - } - query.addWhere("images.organized = " + organized) - } - - if resolutionFilter := imageFilter.Resolution; resolutionFilter != nil { - if resolution := resolutionFilter.String(); resolutionFilter.IsValid() { - switch resolution { - case "VERY_LOW": - query.addWhere("MIN(images.height, images.width) < 240") - case "LOW": - query.addWhere("(MIN(images.height, images.width) >= 240 AND MIN(images.height, images.width) < 360)") - case "R360P": - query.addWhere("(MIN(images.height, images.width) >= 360 AND MIN(images.height, images.width) < 480)") - case "STANDARD": - query.addWhere("(MIN(images.height, images.width) >= 480 AND MIN(images.height, images.width) < 540)") - case "WEB_HD": - query.addWhere("(MIN(images.height, images.width) >= 540 AND MIN(images.height, images.width) < 720)") - case "STANDARD_HD": - query.addWhere("(MIN(images.height, images.width) >= 720 AND MIN(images.height, images.width) < 1080)") - case "FULL_HD": - query.addWhere("(MIN(images.height, images.width) >= 1080 AND MIN(images.height, images.width) < 1440)") - case "QUAD_HD": - query.addWhere("(MIN(images.height, images.width) >= 1440 AND MIN(images.height, images.width) < 1920)") - case "VR_HD": - query.addWhere("(MIN(images.height, images.width) >= 1920 AND MIN(images.height, images.width) < 2160)") - case "FOUR_K": - query.addWhere("(MIN(images.height, images.width) >= 2160 AND MIN(images.height, images.width) < 2880)") - case "FIVE_K": - query.addWhere("(MIN(images.height, images.width) >= 2880 AND MIN(images.height, images.width) < 3384)") - case "SIX_K": - query.addWhere("(MIN(images.height, images.width) >= 3384 AND MIN(images.height, images.width) < 4320)") - case "EIGHT_K": - query.addWhere("MIN(images.height, images.width) >= 4320") - } - } - } - - if isMissingFilter := imageFilter.IsMissing; isMissingFilter != nil && *isMissingFilter != "" { - switch *isMissingFilter { - case "studio": - query.addWhere("images.studio_id IS NULL") - case "performers": - query.addWhere("performers_join.image_id IS NULL") - case "galleries": - query.addWhere("galleries_join.image_id IS NULL") - case "tags": - query.addWhere("tags_join.image_id IS NULL") - default: - query.addWhere("(images." + *isMissingFilter + " IS NULL OR TRIM(images." + *isMissingFilter + ") = '')") - } - } - - if tagsFilter := imageFilter.Tags; tagsFilter != nil && len(tagsFilter.Value) > 0 { - for _, tagID := range tagsFilter.Value { - query.addArg(tagID) - } - - query.body += " LEFT JOIN tags on tags_join.tag_id = tags.id" - whereClause, havingClause := getMultiCriterionClause("images", "tags", "images_tags", "image_id", "tag_id", tagsFilter) - query.addWhere(whereClause) - query.addHaving(havingClause) - } - - if tagCountFilter := imageFilter.TagCount; tagCountFilter != nil { - clause, count := getCountCriterionClause(imageTable, imagesTagsTable, imageIDColumn, *tagCountFilter) - - if count == 1 { - query.addArg(tagCountFilter.Value) - } - - query.addWhere(clause) - } - - if galleriesFilter := imageFilter.Galleries; galleriesFilter != nil && len(galleriesFilter.Value) > 0 { - for _, galleryID := range galleriesFilter.Value { - query.addArg(galleryID) - } - - query.body += " LEFT JOIN galleries ON galleries_join.gallery_id = galleries.id" - whereClause, havingClause := getMultiCriterionClause("images", "galleries", "galleries_images", "image_id", "gallery_id", galleriesFilter) - query.addWhere(whereClause) - query.addHaving(havingClause) - } - - if performersFilter := imageFilter.Performers; performersFilter != nil && len(performersFilter.Value) > 0 { - for _, performerID := range performersFilter.Value { - query.addArg(performerID) - } - - query.body += " LEFT JOIN performers ON performers_join.performer_id = performers.id" - whereClause, havingClause := getMultiCriterionClause("images", "performers", "performers_images", "image_id", "performer_id", performersFilter) - query.addWhere(whereClause) - query.addHaving(havingClause) - } - - if performerCountFilter := imageFilter.PerformerCount; performerCountFilter != nil { - clause, count := getCountCriterionClause(imageTable, performersImagesTable, imageIDColumn, *performerCountFilter) - - if count == 1 { - query.addArg(performerCountFilter.Value) - } - - query.addWhere(clause) - } - - if studiosFilter := imageFilter.Studios; studiosFilter != nil && len(studiosFilter.Value) > 0 { - for _, studioID := range studiosFilter.Value { - query.addArg(studioID) - } - - whereClause, havingClause := getMultiCriterionClause("images", "studio", "", "", "studio_id", studiosFilter) - query.addWhere(whereClause) - query.addHaving(havingClause) - } - - handleImagePerformerTagsCriterion(&query, imageFilter.PerformerTags) + query.addFilter(filter) query.sortAndPagination = qb.getImageSort(findFilter) + getPagination(findFilter) - return query + return &query, nil } func (qb *imageQueryBuilder) Query(imageFilter *models.ImageFilterType, findFilter *models.FindFilterType) ([]*models.Image, int, error) { - query := qb.makeQuery(imageFilter, findFilter) + query, err := qb.makeQuery(imageFilter, findFilter) + if err != nil { + return nil, 0, err + } idsResult, countResult, err := query.executeFind() if err != nil { @@ -409,32 +305,131 @@ func (qb *imageQueryBuilder) Query(imageFilter *models.ImageFilterType, findFilt } func (qb *imageQueryBuilder) QueryCount(imageFilter *models.ImageFilterType, findFilter *models.FindFilterType) (int, error) { - query := qb.makeQuery(imageFilter, findFilter) + query, err := qb.makeQuery(imageFilter, findFilter) + if err != nil { + return 0, err + } return query.executeCount() } -func handleImagePerformerTagsCriterion(query *queryBuilder, performerTagsFilter *models.MultiCriterionInput) { - if performerTagsFilter != nil && len(performerTagsFilter.Value) > 0 { - for _, tagID := range performerTagsFilter.Value { - query.addArg(tagID) +func imageIsMissingCriterionHandler(qb *imageQueryBuilder, isMissing *string) criterionHandlerFunc { + return func(f *filterBuilder) { + if isMissing != nil && *isMissing != "" { + switch *isMissing { + case "studio": + f.addWhere("images.studio_id IS NULL") + case "performers": + qb.performersRepository().join(f, "performers_join", "images.id") + f.addWhere("performers_join.image_id IS NULL") + case "galleries": + qb.galleriesRepository().join(f, "galleries_join", "images.id") + f.addWhere("galleries_join.image_id IS NULL") + case "tags": + qb.tagsRepository().join(f, "tags_join", "images.id") + f.addWhere("tags_join.image_id IS NULL") + default: + f.addWhere("(images." + *isMissing + " IS NULL OR TRIM(images." + *isMissing + ") = '')") + } } + } +} - query.body += " LEFT JOIN performers_tags AS performer_tags_join on performers_join.performer_id = performer_tags_join.performer_id" +func (qb *imageQueryBuilder) getMultiCriterionHandlerBuilder(foreignTable, joinTable, foreignFK string, addJoinsFunc func(f *filterBuilder)) multiCriterionHandlerBuilder { + return multiCriterionHandlerBuilder{ + primaryTable: imageTable, + foreignTable: foreignTable, + joinTable: joinTable, + primaryFK: imageIDColumn, + foreignFK: foreignFK, + addJoinsFunc: addJoinsFunc, + } +} - if performerTagsFilter.Modifier == models.CriterionModifierIncludes { - // includes any of the provided ids - query.addWhere("performer_tags_join.tag_id IN " + getInBinding(len(performerTagsFilter.Value))) - } else if performerTagsFilter.Modifier == models.CriterionModifierIncludesAll { - // includes all of the provided ids - query.addWhere("performer_tags_join.tag_id IN " + getInBinding(len(performerTagsFilter.Value))) - query.addHaving(fmt.Sprintf("count(distinct performer_tags_join.tag_id) IS %d", len(performerTagsFilter.Value))) - } else if performerTagsFilter.Modifier == models.CriterionModifierExcludes { - query.addWhere(fmt.Sprintf(`not exists - (select performers_images.performer_id from performers_images - left join performers_tags on performers_tags.performer_id = performers_images.performer_id where - performers_images.image_id = images.id AND - performers_tags.tag_id in %s)`, getInBinding(len(performerTagsFilter.Value)))) +func imageTagsCriterionHandler(qb *imageQueryBuilder, tags *models.MultiCriterionInput) criterionHandlerFunc { + addJoinsFunc := func(f *filterBuilder) { + qb.tagsRepository().join(f, "tags_join", "images.id") + f.addJoin(tagTable, "", "tags_join.tag_id = tags.id") + } + h := qb.getMultiCriterionHandlerBuilder(tagTable, imagesTagsTable, tagIDColumn, addJoinsFunc) + + return h.handler(tags) +} + +func imageTagCountCriterionHandler(qb *imageQueryBuilder, tagCount *models.IntCriterionInput) criterionHandlerFunc { + h := countCriterionHandlerBuilder{ + primaryTable: imageTable, + joinTable: imagesTagsTable, + primaryFK: imageIDColumn, + } + + return h.handler(tagCount) +} + +func imageGalleriesCriterionHandler(qb *imageQueryBuilder, galleries *models.MultiCriterionInput) criterionHandlerFunc { + addJoinsFunc := func(f *filterBuilder) { + qb.galleriesRepository().join(f, "galleries_join", "images.id") + f.addJoin(galleryTable, "", "galleries_join.gallery_id = galleries.id") + } + h := qb.getMultiCriterionHandlerBuilder(galleryTable, galleriesImagesTable, galleryIDColumn, addJoinsFunc) + + return h.handler(galleries) +} + +func imagePerformersCriterionHandler(qb *imageQueryBuilder, performers *models.MultiCriterionInput) criterionHandlerFunc { + addJoinsFunc := func(f *filterBuilder) { + qb.performersRepository().join(f, "performers_join", "images.id") + f.addJoin(performerTable, "", "performers_join.performer_id = performers.id") + } + h := qb.getMultiCriterionHandlerBuilder(performerTable, performersImagesTable, performerIDColumn, addJoinsFunc) + + return h.handler(performers) +} + +func imagePerformerCountCriterionHandler(qb *imageQueryBuilder, performerCount *models.IntCriterionInput) criterionHandlerFunc { + h := countCriterionHandlerBuilder{ + primaryTable: imageTable, + joinTable: performersImagesTable, + primaryFK: imageIDColumn, + } + + return h.handler(performerCount) +} + +func imageStudioCriterionHandler(qb *imageQueryBuilder, studios *models.MultiCriterionInput) criterionHandlerFunc { + addJoinsFunc := func(f *filterBuilder) { + f.addJoin(studioTable, "studio", "studio.id = images.studio_id") + } + h := qb.getMultiCriterionHandlerBuilder("studio", "", studioIDColumn, addJoinsFunc) + + return h.handler(studios) +} + +func imagePerformerTagsCriterionHandler(qb *imageQueryBuilder, performerTagsFilter *models.MultiCriterionInput) criterionHandlerFunc { + return func(f *filterBuilder) { + if performerTagsFilter != nil && len(performerTagsFilter.Value) > 0 { + qb.performersRepository().join(f, "performers_join", "images.id") + f.addJoin("performers_tags", "performer_tags_join", "performers_join.performer_id = performer_tags_join.performer_id") + + var args []interface{} + for _, tagID := range performerTagsFilter.Value { + args = append(args, tagID) + } + + if performerTagsFilter.Modifier == models.CriterionModifierIncludes { + // includes any of the provided ids + f.addWhere("performer_tags_join.tag_id IN "+getInBinding(len(performerTagsFilter.Value)), args...) + } else if performerTagsFilter.Modifier == models.CriterionModifierIncludesAll { + // includes all of the provided ids + f.addWhere("performer_tags_join.tag_id IN "+getInBinding(len(performerTagsFilter.Value)), args...) + f.addHaving(fmt.Sprintf("count(distinct performer_tags_join.tag_id) IS %d", len(performerTagsFilter.Value))) + } else if performerTagsFilter.Modifier == models.CriterionModifierExcludes { + f.addWhere(fmt.Sprintf(`not exists + (select performers_images.performer_id from performers_images + left join performers_tags on performers_tags.performer_id = performers_images.performer_id where + performers_images.image_id = images.id AND + performers_tags.tag_id in %s)`, getInBinding(len(performerTagsFilter.Value))), args...) + } } } } diff --git a/pkg/sqlite/image_test.go b/pkg/sqlite/image_test.go index 50c3f35fc..70343f44f 100644 --- a/pkg/sqlite/image_test.go +++ b/pkg/sqlite/image_test.go @@ -155,6 +155,143 @@ func verifyImagePath(t *testing.T, pathCriterion models.StringCriterionInput, ex }) } +func TestImageQueryPathOr(t *testing.T) { + const image1Idx = 1 + const image2Idx = 2 + + image1Path := getImageStringValue(image1Idx, "Path") + image2Path := getImageStringValue(image2Idx, "Path") + + imageFilter := models.ImageFilterType{ + Path: &models.StringCriterionInput{ + Value: image1Path, + Modifier: models.CriterionModifierEquals, + }, + Or: &models.ImageFilterType{ + Path: &models.StringCriterionInput{ + Value: image2Path, + Modifier: models.CriterionModifierEquals, + }, + }, + } + + withTxn(func(r models.Repository) error { + sqb := r.Image() + + images := queryImages(t, sqb, &imageFilter, nil) + + assert.Len(t, images, 2) + assert.Equal(t, image1Path, images[0].Path) + assert.Equal(t, image2Path, images[1].Path) + + return nil + }) +} + +func TestImageQueryPathAndRating(t *testing.T) { + const imageIdx = 1 + imagePath := getImageStringValue(imageIdx, "Path") + imageRating := getRating(imageIdx) + + imageFilter := models.ImageFilterType{ + Path: &models.StringCriterionInput{ + Value: imagePath, + Modifier: models.CriterionModifierEquals, + }, + And: &models.ImageFilterType{ + Rating: &models.IntCriterionInput{ + Value: int(imageRating.Int64), + Modifier: models.CriterionModifierEquals, + }, + }, + } + + withTxn(func(r models.Repository) error { + sqb := r.Image() + + images := queryImages(t, sqb, &imageFilter, nil) + + assert.Len(t, images, 1) + assert.Equal(t, imagePath, images[0].Path) + assert.Equal(t, imageRating.Int64, images[0].Rating.Int64) + + return nil + }) +} + +func TestImageQueryPathNotRating(t *testing.T) { + const imageIdx = 1 + + imageRating := getRating(imageIdx) + + pathCriterion := models.StringCriterionInput{ + Value: "image_.*1_Path", + Modifier: models.CriterionModifierMatchesRegex, + } + + ratingCriterion := models.IntCriterionInput{ + Value: int(imageRating.Int64), + Modifier: models.CriterionModifierEquals, + } + + imageFilter := models.ImageFilterType{ + Path: &pathCriterion, + Not: &models.ImageFilterType{ + Rating: &ratingCriterion, + }, + } + + withTxn(func(r models.Repository) error { + sqb := r.Image() + + images := queryImages(t, sqb, &imageFilter, nil) + + for _, image := range images { + verifyString(t, image.Path, pathCriterion) + ratingCriterion.Modifier = models.CriterionModifierNotEquals + verifyInt64(t, image.Rating, ratingCriterion) + } + + return nil + }) +} + +func TestImageIllegalQuery(t *testing.T) { + assert := assert.New(t) + + const imageIdx = 1 + subFilter := models.ImageFilterType{ + Path: &models.StringCriterionInput{ + Value: getImageStringValue(imageIdx, "Path"), + Modifier: models.CriterionModifierEquals, + }, + } + + imageFilter := &models.ImageFilterType{ + And: &subFilter, + Or: &subFilter, + } + + withTxn(func(r models.Repository) error { + sqb := r.Image() + + _, _, err := sqb.Query(imageFilter, nil) + assert.NotNil(err) + + imageFilter.Or = nil + imageFilter.Not = &subFilter + _, _, err = sqb.Query(imageFilter, nil) + assert.NotNil(err) + + imageFilter.And = nil + imageFilter.Or = &subFilter + _, _, err = sqb.Query(imageFilter, nil) + assert.NotNil(err) + + return nil + }) +} + func TestImageQueryRating(t *testing.T) { const rating = 3 ratingCriterion := models.IntCriterionInput{ @@ -449,6 +586,70 @@ func TestImageQueryIsMissingRating(t *testing.T) { }) } +func TestImageQueryGallery(t *testing.T) { + withTxn(func(r models.Repository) error { + sqb := r.Image() + galleryCriterion := models.MultiCriterionInput{ + Value: []string{ + strconv.Itoa(galleryIDs[galleryIdxWithImage]), + }, + Modifier: models.CriterionModifierIncludes, + } + + imageFilter := models.ImageFilterType{ + Galleries: &galleryCriterion, + } + + images, _, err := sqb.Query(&imageFilter, nil) + if err != nil { + t.Errorf("Error querying image: %s", err.Error()) + } + + assert.Len(t, images, 1) + + // ensure ids are correct + for _, image := range images { + assert.True(t, image.ID == imageIDs[imageIdxWithGallery]) + } + + galleryCriterion = models.MultiCriterionInput{ + Value: []string{ + strconv.Itoa(galleryIDs[galleryIdx1WithImage]), + strconv.Itoa(galleryIDs[galleryIdx2WithImage]), + }, + Modifier: models.CriterionModifierIncludesAll, + } + + images, _, err = sqb.Query(&imageFilter, nil) + if err != nil { + t.Errorf("Error querying image: %s", err.Error()) + } + + assert.Len(t, images, 1) + assert.Equal(t, imageIDs[imageIdxWithTwoGalleries], images[0].ID) + + galleryCriterion = models.MultiCriterionInput{ + Value: []string{ + strconv.Itoa(performerIDs[galleryIdx1WithImage]), + }, + Modifier: models.CriterionModifierExcludes, + } + + q := getImageStringValue(imageIdxWithTwoGalleries, titleField) + findFilter := models.FindFilterType{ + Q: &q, + } + + images, _, err = sqb.Query(&imageFilter, &findFilter) + if err != nil { + t.Errorf("Error querying image: %s", err.Error()) + } + assert.Len(t, images, 0) + + return nil + }) +} + func TestImageQueryPerformers(t *testing.T) { withTxn(func(r models.Repository) error { sqb := r.Image() diff --git a/pkg/sqlite/setup_test.go b/pkg/sqlite/setup_test.go index 481ebc629..a3abf2dc1 100644 --- a/pkg/sqlite/setup_test.go +++ b/pkg/sqlite/setup_test.go @@ -48,6 +48,9 @@ const ( const ( imageIdxWithGallery = iota + imageIdx1WithGallery + imageIdx2WithGallery + imageIdxWithTwoGalleries imageIdxWithPerformer imageIdx1WithPerformer imageIdx2WithPerformer @@ -102,6 +105,9 @@ const ( const ( galleryIdxWithScene = iota galleryIdxWithImage + galleryIdx1WithImage + galleryIdx2WithImage + galleryIdxWithTwoImages galleryIdxWithPerformer galleryIdx1WithPerformer galleryIdx2WithPerformer @@ -230,6 +236,10 @@ var ( var ( imageGalleryLinks = [][2]int{ {imageIdxWithGallery, galleryIdxWithImage}, + {imageIdx1WithGallery, galleryIdxWithTwoImages}, + {imageIdx2WithGallery, galleryIdxWithTwoImages}, + {imageIdxWithTwoGalleries, galleryIdx1WithImage}, + {imageIdxWithTwoGalleries, galleryIdx2WithImage}, } imageStudioLinks = [][2]int{ {imageIdxWithStudio, studioIdxWithImage}, @@ -513,6 +523,14 @@ func getHeight(index int) sql.NullInt64 { } } +func getWidth(index int) sql.NullInt64 { + height := getHeight(index) + return sql.NullInt64{ + Int64: height.Int64 * 2, + Valid: height.Valid, + } +} + func getSceneDate(index int) models.SQLiteDate { dates := []string{"null", "", "0001-01-01", "2001-02-03"} date := dates[index%len(dates)] @@ -571,6 +589,7 @@ func createImages(qb models.ImageReaderWriter, n int) error { Rating: getRating(i), OCounter: getOCounter(i), Height: getHeight(i), + Width: getWidth(i), } created, err := qb.Create(image) @@ -599,6 +618,7 @@ func createGalleries(gqb models.GalleryReaderWriter, n int) error { Path: models.NullString(getGalleryStringValue(i, pathField)), URL: getGalleryNullStringValue(i, urlField), Checksum: getGalleryStringValue(i, checksumField), + Rating: getRating(i), } created, err := gqb.Create(gallery) diff --git a/ui/v2.5/src/components/Changelog/versions/v070.md b/ui/v2.5/src/components/Changelog/versions/v070.md index a38326c2d..365a40f7e 100644 --- a/ui/v2.5/src/components/Changelog/versions/v070.md +++ b/ui/v2.5/src/components/Changelog/versions/v070.md @@ -1,4 +1,5 @@ ### ✨ New Features +* Auto-tagger now tags images and galleries. * Added rating field to performers and studios. * Support serving UI from specific directory location. * Added details, death date, hair color, and weight to Performers. From 896c3874afd7e18c8b31a0458a0675387aaccf01 Mon Sep 17 00:00:00 2001 From: InfiniteTF Date: Mon, 3 May 2021 06:21:20 +0200 Subject: [PATCH 58/66] Stash-Box Performer Tagger (#1277) * Add bulk stash-box performer task * Add stash-box performer scraper to scrape with menu --- graphql/documents/data/scrapers.graphql | 7 + graphql/documents/mutations/stash-box.graphql | 4 + .../queries/scrapers/scrapers.graphql | 8 +- graphql/schema/schema.graphql | 6 +- graphql/schema/types/filters.graphql | 6 +- graphql/schema/types/scraper.graphql | 24 +- graphql/stash-box/query.graphql | 12 + pkg/api/resolver_mutation_stash_box.go | 6 + pkg/api/resolver_query_scraper.go | 22 +- pkg/manager/job_status.go | 23 +- pkg/manager/manager_tasks.go | 106 +++ pkg/manager/task_stash_box_tag.go | 252 ++++++++ pkg/models/mocks/PerformerReaderWriter.go | 23 + pkg/models/performer.go | 1 + .../stashbox/graphql/generated_client.go | 378 +++++++---- pkg/scraper/stashbox/stash_box.go | 127 +++- pkg/sqlite/performer.go | 28 +- pkg/sqlite/scene.go | 18 +- pkg/sqlite/studio.go | 6 +- ui/v2.5/package.json | 3 +- .../src/components/Changelog/versions/v070.md | 1 + .../PerformerDetails/PerformerEditPanel.tsx | 46 ++ .../components/Performers/PerformerList.tsx | 6 + .../Scenes/SceneDetails/SceneEditPanel.tsx | 6 +- .../SettingsTasksPanel/SettingsTasksPanel.tsx | 2 + .../Tagger/PerformerFieldSelector.tsx | 63 ++ .../src/components/Tagger/PerformerModal.tsx | 209 +++--- .../src/components/Tagger/PerformerResult.tsx | 25 +- .../components/Tagger/StashSearchResult.tsx | 1 + .../src/components/Tagger/StudioResult.tsx | 5 +- ui/v2.5/src/components/Tagger/Tagger.tsx | 8 +- ui/v2.5/src/components/Tagger/constants.ts | 20 + ui/v2.5/src/components/Tagger/index.ts | 1 + .../components/Tagger/performers/Config.tsx | 103 +++ .../Tagger/performers/PerformerTagger.tsx | 611 ++++++++++++++++++ .../Tagger/performers/StashSearchResult.tsx | 112 ++++ ui/v2.5/src/components/Tagger/queries.ts | 63 +- ui/v2.5/src/components/Tagger/styles.scss | 94 ++- ui/v2.5/src/components/Tagger/utils.ts | 62 +- ui/v2.5/src/core/StashService.ts | 47 +- .../models/list-filter/criteria/criterion.ts | 5 +- .../models/list-filter/criteria/is-missing.ts | 4 +- .../src/models/list-filter/criteria/utils.ts | 1 + ui/v2.5/src/models/list-filter/filter.ts | 33 +- ui/v2.5/src/utils/text.ts | 6 + ui/v2.5/yarn.lock | 9 +- 46 files changed, 2311 insertions(+), 292 deletions(-) create mode 100644 pkg/manager/task_stash_box_tag.go create mode 100644 ui/v2.5/src/components/Tagger/PerformerFieldSelector.tsx create mode 100644 ui/v2.5/src/components/Tagger/performers/Config.tsx create mode 100755 ui/v2.5/src/components/Tagger/performers/PerformerTagger.tsx create mode 100755 ui/v2.5/src/components/Tagger/performers/StashSearchResult.tsx diff --git a/graphql/documents/data/scrapers.graphql b/graphql/documents/data/scrapers.graphql index cda034f73..f9fa5a879 100644 --- a/graphql/documents/data/scrapers.graphql +++ b/graphql/documents/data/scrapers.graphql @@ -197,3 +197,10 @@ fragment ScrapedStashBoxSceneData on ScrapedScene { ...ScrapedSceneMovieData } } + +fragment ScrapedStashBoxPerformerData on StashBoxPerformerQueryResult { + query + results { + ...ScrapedScenePerformerData + } +} diff --git a/graphql/documents/mutations/stash-box.graphql b/graphql/documents/mutations/stash-box.graphql index 24a9dc169..c20cdd25f 100644 --- a/graphql/documents/mutations/stash-box.graphql +++ b/graphql/documents/mutations/stash-box.graphql @@ -1,3 +1,7 @@ mutation SubmitStashBoxFingerprints($input: StashBoxFingerprintSubmissionInput!) { submitStashBoxFingerprints(input: $input) } + +mutation StashBoxBatchPerformerTag($input: StashBoxBatchPerformerTagInput!) { + stashBoxBatchPerformerTag(input: $input) +} diff --git a/graphql/documents/queries/scrapers/scrapers.graphql b/graphql/documents/queries/scrapers/scrapers.graphql index bb9d99284..d5c54bac1 100644 --- a/graphql/documents/queries/scrapers/scrapers.graphql +++ b/graphql/documents/queries/scrapers/scrapers.graphql @@ -90,8 +90,14 @@ query ScrapeMovieURL($url: String!) { } } -query QueryStashBoxScene($input: StashBoxQueryInput!) { +query QueryStashBoxScene($input: StashBoxSceneQueryInput!) { queryStashBoxScene(input: $input) { ...ScrapedStashBoxSceneData } } + +query QueryStashBoxPerformer($input: StashBoxPerformerQueryInput!) { + queryStashBoxPerformer(input: $input) { + ...ScrapedStashBoxPerformerData + } +} diff --git a/graphql/schema/schema.graphql b/graphql/schema/schema.graphql index 08e8834be..68bc12c00 100644 --- a/graphql/schema/schema.graphql +++ b/graphql/schema/schema.graphql @@ -91,7 +91,8 @@ type Query { scrapeFreeonesPerformerList(query: String!): [String!]! """Query StashBox for scenes""" - queryStashBoxScene(input: StashBoxQueryInput!): [ScrapedScene!]! + queryStashBoxScene(input: StashBoxSceneQueryInput!): [ScrapedScene!]! + queryStashBoxPerformer(input: StashBoxPerformerQueryInput!): [StashBoxPerformerQueryResult!]! # Plugins """List loaded plugins""" @@ -234,6 +235,9 @@ type Mutation { """Backup the database. Optionally returns a link to download the database file""" backupDatabase(input: BackupDatabaseInput!): String + + """Run batch performer tag task. Returns the job ID.""" + stashBoxBatchPerformerTag(input: StashBoxBatchPerformerTagInput!): String! } type Subscription { diff --git a/graphql/schema/types/filters.graphql b/graphql/schema/types/filters.graphql index 9f5f3c91e..bd6703087 100644 --- a/graphql/schema/types/filters.graphql +++ b/graphql/schema/types/filters.graphql @@ -70,7 +70,7 @@ input PerformerFilterType { """Filter by gallery count""" gallery_count: IntCriterionInput """Filter by StashID""" - stash_id: String + stash_id: StringCriterionInput """Filter by rating""" rating: IntCriterionInput """Filter by url""" @@ -130,7 +130,7 @@ input SceneFilterType { """Filter by performer count""" performer_count: IntCriterionInput """Filter by StashID""" - stash_id: String + stash_id: StringCriterionInput """Filter by url""" url: StringCriterionInput } @@ -148,7 +148,7 @@ input StudioFilterType { """Filter to only include studios with this parent studio""" parents: MultiCriterionInput """Filter by StashID""" - stash_id: String + stash_id: StringCriterionInput """Filter to only include studios missing this property""" is_missing: String """Filter by rating""" diff --git a/graphql/schema/types/scraper.graphql b/graphql/schema/types/scraper.graphql index 0a0cec8c5..860457bb0 100644 --- a/graphql/schema/types/scraper.graphql +++ b/graphql/schema/types/scraper.graphql @@ -115,7 +115,7 @@ type ScrapedGallery { performers: [ScrapedScenePerformer!] } -input StashBoxQueryInput { +input StashBoxSceneQueryInput { """Index of the configured stash-box instance to use""" stash_box_index: Int! """Instructs query by scene fingerprints""" @@ -124,8 +124,30 @@ input StashBoxQueryInput { q: String } +input StashBoxPerformerQueryInput { + """Index of the configured stash-box instance to use""" + stash_box_index: Int! + """Instructs query by scene fingerprints""" + performer_ids: [ID!] + """Query by query string""" + q: String +} + +type StashBoxPerformerQueryResult { + query: String! + results: [ScrapedScenePerformer!]! +} + type StashBoxFingerprint { algorithm: String! hash: String! duration: Int! } + +input StashBoxBatchPerformerTagInput { + endpoint: Int! + exclude_fields: [String!] + refresh: Boolean! + performer_ids: [ID!] + performer_names: [String!] +} diff --git a/graphql/stash-box/query.graphql b/graphql/stash-box/query.graphql index 0e74c92c3..ad1c937f5 100644 --- a/graphql/stash-box/query.graphql +++ b/graphql/stash-box/query.graphql @@ -139,6 +139,18 @@ query SearchScene($term: String!) { } } +query SearchPerformer($term: String!) { + searchPerformer(term: $term) { + ...PerformerFragment + } +} + +query FindPerformerByID($id: ID!) { + findPerformer(id: $id) { + ...PerformerFragment + } +} + mutation SubmitFingerprint($input: FingerprintSubmission!) { submitFingerprint(input: $input) } diff --git a/pkg/api/resolver_mutation_stash_box.go b/pkg/api/resolver_mutation_stash_box.go index 7cb7134ad..4161ec91c 100644 --- a/pkg/api/resolver_mutation_stash_box.go +++ b/pkg/api/resolver_mutation_stash_box.go @@ -4,6 +4,7 @@ import ( "context" "fmt" + "github.com/stashapp/stash/pkg/manager" "github.com/stashapp/stash/pkg/manager/config" "github.com/stashapp/stash/pkg/models" "github.com/stashapp/stash/pkg/scraper/stashbox" @@ -20,3 +21,8 @@ func (r *mutationResolver) SubmitStashBoxFingerprints(ctx context.Context, input return client.SubmitStashBoxFingerprints(input.SceneIds, boxes[input.StashBoxIndex].Endpoint) } + +func (r *mutationResolver) StashBoxBatchPerformerTag(ctx context.Context, input models.StashBoxBatchPerformerTagInput) (string, error) { + manager.GetInstance().StashBoxBatchPerformerTag(input) + return "todo", nil +} diff --git a/pkg/api/resolver_query_scraper.go b/pkg/api/resolver_query_scraper.go index 0daf80154..301870351 100644 --- a/pkg/api/resolver_query_scraper.go +++ b/pkg/api/resolver_query_scraper.go @@ -88,7 +88,7 @@ func (r *queryResolver) ScrapeMovieURL(ctx context.Context, url string) (*models return manager.GetInstance().ScraperCache.ScrapeMovieURL(url) } -func (r *queryResolver) QueryStashBoxScene(ctx context.Context, input models.StashBoxQueryInput) ([]*models.ScrapedScene, error) { +func (r *queryResolver) QueryStashBoxScene(ctx context.Context, input models.StashBoxSceneQueryInput) ([]*models.ScrapedScene, error) { boxes := config.GetInstance().GetStashBoxes() if input.StashBoxIndex < 0 || input.StashBoxIndex >= len(boxes) { @@ -107,3 +107,23 @@ func (r *queryResolver) QueryStashBoxScene(ctx context.Context, input models.Sta return nil, nil } + +func (r *queryResolver) QueryStashBoxPerformer(ctx context.Context, input models.StashBoxPerformerQueryInput) ([]*models.StashBoxPerformerQueryResult, error) { + boxes := config.GetInstance().GetStashBoxes() + + if input.StashBoxIndex < 0 || input.StashBoxIndex >= len(boxes) { + return nil, fmt.Errorf("invalid stash_box_index %d", input.StashBoxIndex) + } + + client := stashbox.NewClient(*boxes[input.StashBoxIndex], r.txnManager) + + if len(input.PerformerIds) > 0 { + return client.FindStashBoxPerformersByNames(input.PerformerIds) + } + + if input.Q != nil { + return client.QueryStashBoxPerformer(*input.Q) + } + + return nil, nil +} diff --git a/pkg/manager/job_status.go b/pkg/manager/job_status.go index 4a6c7197a..ef4dfad62 100644 --- a/pkg/manager/job_status.go +++ b/pkg/manager/job_status.go @@ -3,16 +3,17 @@ package manager type JobStatus int const ( - Idle JobStatus = 0 - Import JobStatus = 1 - Export JobStatus = 2 - Scan JobStatus = 3 - Generate JobStatus = 4 - Clean JobStatus = 5 - Scrape JobStatus = 6 - AutoTag JobStatus = 7 - Migrate JobStatus = 8 - PluginOperation JobStatus = 9 + Idle JobStatus = 0 + Import JobStatus = 1 + Export JobStatus = 2 + Scan JobStatus = 3 + Generate JobStatus = 4 + Clean JobStatus = 5 + Scrape JobStatus = 6 + AutoTag JobStatus = 7 + Migrate JobStatus = 8 + PluginOperation JobStatus = 9 + StashBoxBatchPerformer JobStatus = 10 ) func (s JobStatus) String() string { @@ -37,6 +38,8 @@ func (s JobStatus) String() string { statusMessage = "Clean" case PluginOperation: statusMessage = "Plugin Operation" + case StashBoxBatchPerformer: + statusMessage = "Stash-Box Performer Batch Operation" } return statusMessage diff --git a/pkg/manager/manager_tasks.go b/pkg/manager/manager_tasks.go index fbc729174..2455d70f7 100644 --- a/pkg/manager/manager_tasks.go +++ b/pkg/manager/manager_tasks.go @@ -1209,3 +1209,109 @@ func (s *singleton) neededGenerate(scenes []*models.Scene, input models.Generate } return &totals } + +func (s *singleton) StashBoxBatchPerformerTag(input models.StashBoxBatchPerformerTagInput) { + if s.Status.Status != Idle { + return + } + s.Status.SetStatus(StashBoxBatchPerformer) + s.Status.indefiniteProgress() + + go func() { + defer s.returnToIdleState() + logger.Infof("Initiating stash-box batch performer tag") + + boxes := config.GetInstance().GetStashBoxes() + if input.Endpoint < 0 || input.Endpoint >= len(boxes) { + logger.Error(fmt.Errorf("invalid stash_box_index %d", input.Endpoint)) + return + } + box := boxes[input.Endpoint] + + var tasks []StashBoxPerformerTagTask + + if len(input.PerformerIds) > 0 { + if err := s.TxnManager.WithReadTxn(context.TODO(), func(r models.ReaderRepository) error { + performerQuery := r.Performer() + + for _, performerID := range input.PerformerIds { + if id, err := strconv.Atoi(performerID); err == nil { + performer, err := performerQuery.Find(id) + if err == nil { + tasks = append(tasks, StashBoxPerformerTagTask{ + txnManager: s.TxnManager, + performer: performer, + refresh: input.Refresh, + box: box, + excluded_fields: input.ExcludeFields, + }) + } else { + return err + } + } + } + return nil + }); err != nil { + logger.Error(err.Error()) + } + } else if len(input.PerformerNames) > 0 { + for i := range input.PerformerNames { + if len(input.PerformerNames[i]) > 0 { + tasks = append(tasks, StashBoxPerformerTagTask{ + txnManager: s.TxnManager, + name: &input.PerformerNames[i], + refresh: input.Refresh, + box: box, + excluded_fields: input.ExcludeFields, + }) + } + } + } else { + if err := s.TxnManager.WithReadTxn(context.TODO(), func(r models.ReaderRepository) error { + performerQuery := r.Performer() + var performers []*models.Performer + var err error + if input.Refresh { + performers, err = performerQuery.FindByStashIDStatus(true, box.Endpoint) + } else { + performers, err = performerQuery.FindByStashIDStatus(false, box.Endpoint) + } + if err != nil { + return fmt.Errorf("Error querying performers: %s", err.Error()) + } + + for _, performer := range performers { + tasks = append(tasks, StashBoxPerformerTagTask{ + txnManager: s.TxnManager, + performer: performer, + refresh: input.Refresh, + box: box, + excluded_fields: input.ExcludeFields, + }) + } + return nil + }); err != nil { + logger.Error(err.Error()) + return + } + } + + if len(tasks) == 0 { + s.returnToIdleState() + return + } + + s.Status.setProgress(0, len(tasks)) + + logger.Infof("Starting stash-box batch operation for %d performers", len(tasks)) + + var wg sync.WaitGroup + for _, task := range tasks { + wg.Add(1) + go task.Start(&wg) + wg.Wait() + + s.Status.incrementProgress() + } + }() +} diff --git a/pkg/manager/task_stash_box_tag.go b/pkg/manager/task_stash_box_tag.go new file mode 100644 index 000000000..ed049bc92 --- /dev/null +++ b/pkg/manager/task_stash_box_tag.go @@ -0,0 +1,252 @@ +package manager + +import ( + "context" + "database/sql" + "sync" + "time" + + "github.com/stashapp/stash/pkg/logger" + "github.com/stashapp/stash/pkg/models" + "github.com/stashapp/stash/pkg/scraper/stashbox" + "github.com/stashapp/stash/pkg/utils" +) + +type StashBoxPerformerTagTask struct { + txnManager models.TransactionManager + box *models.StashBox + name *string + performer *models.Performer + refresh bool + excluded_fields []string +} + +func (t *StashBoxPerformerTagTask) Start(wg *sync.WaitGroup) { + defer wg.Done() + + t.stashBoxPerformerTag() +} + +func (t *StashBoxPerformerTagTask) stashBoxPerformerTag() { + var performer *models.ScrapedScenePerformer + var err error + + client := stashbox.NewClient(*t.box, t.txnManager) + + if t.refresh { + var performerID string + t.txnManager.WithReadTxn(context.TODO(), func(r models.ReaderRepository) error { + stashids, _ := r.Performer().GetStashIDs(t.performer.ID) + for _, id := range stashids { + if id.Endpoint == t.box.Endpoint { + performerID = id.StashID + } + } + return nil + }) + if performerID != "" { + performer, err = client.FindStashBoxPerformerByID(performerID) + } + } else { + var name string + if t.name != nil { + name = *t.name + } else { + name = t.performer.Name.String + } + performer, err = client.FindStashBoxPerformerByName(name) + } + + if err != nil { + logger.Errorf("Error fetching performer data from stash-box: %s", err.Error()) + return + } + + excluded := map[string]bool{} + for _, field := range t.excluded_fields { + excluded[field] = true + } + + if performer != nil { + updatedTime := time.Now() + + if t.performer != nil { + partial := models.PerformerPartial{ + ID: t.performer.ID, + UpdatedAt: &models.SQLiteTimestamp{Timestamp: updatedTime}, + } + + if performer.Aliases != nil && !excluded["aliases"] { + value := getNullString(performer.Aliases) + partial.Aliases = &value + } + if performer.Birthdate != nil && *performer.Birthdate != "" && !excluded["birthdate"] { + value := getDate(performer.Birthdate) + partial.Birthdate = &value + } + if performer.CareerLength != nil && !excluded["career_length"] { + value := getNullString(performer.CareerLength) + partial.CareerLength = &value + } + if performer.Country != nil && !excluded["country"] { + value := getNullString(performer.Country) + partial.Country = &value + } + if performer.Ethnicity != nil && !excluded["ethnicity"] { + value := getNullString(performer.Ethnicity) + partial.Ethnicity = &value + } + if performer.EyeColor != nil && !excluded["eye_color"] { + value := getNullString(performer.EyeColor) + partial.EyeColor = &value + } + if performer.FakeTits != nil && !excluded["fake_tits"] { + value := getNullString(performer.FakeTits) + partial.FakeTits = &value + } + if performer.Gender != nil && !excluded["gender"] { + value := getNullString(performer.Gender) + partial.Gender = &value + } + if performer.Height != nil && !excluded["height"] { + value := getNullString(performer.Height) + partial.Height = &value + } + if performer.Instagram != nil && !excluded["instagram"] { + value := getNullString(performer.Instagram) + partial.Instagram = &value + } + if performer.Measurements != nil && !excluded["measurements"] { + value := getNullString(performer.Measurements) + partial.Measurements = &value + } + if excluded["name"] { + value := sql.NullString{String: performer.Name, Valid: true} + partial.Name = &value + } + if performer.Piercings != nil && !excluded["piercings"] { + value := getNullString(performer.Piercings) + partial.Piercings = &value + } + if performer.Tattoos != nil && !excluded["tattoos"] { + value := getNullString(performer.Tattoos) + partial.Tattoos = &value + } + if performer.Twitter != nil && !excluded["twitter"] { + value := getNullString(performer.Tattoos) + partial.Twitter = &value + } + if performer.URL != nil && !excluded["url"] { + value := getNullString(performer.URL) + partial.URL = &value + } + + t.txnManager.WithTxn(context.TODO(), func(r models.Repository) error { + _, err := r.Performer().Update(partial) + + if !t.refresh { + err = r.Performer().UpdateStashIDs(t.performer.ID, []models.StashID{ + { + Endpoint: t.box.Endpoint, + StashID: *performer.RemoteSiteID, + }, + }) + if err != nil { + return err + } + } + + if len(performer.Images) > 0 && !excluded["image"] { + image, err := utils.ReadImageFromURL(performer.Images[0]) + if err != nil { + return err + } + err = r.Performer().UpdateImage(t.performer.ID, image) + } + + if err == nil { + logger.Infof("Updated performer %s", performer.Name) + } + return err + }) + } else if t.name != nil { + currentTime := time.Now() + newPerformer := models.Performer{ + Aliases: getNullString(performer.Aliases), + Birthdate: getDate(performer.Birthdate), + CareerLength: getNullString(performer.CareerLength), + Checksum: utils.MD5FromString(performer.Name), + Country: getNullString(performer.Country), + CreatedAt: models.SQLiteTimestamp{Timestamp: currentTime}, + Ethnicity: getNullString(performer.Ethnicity), + EyeColor: getNullString(performer.EyeColor), + FakeTits: getNullString(performer.FakeTits), + Favorite: sql.NullBool{Bool: false, Valid: true}, + Gender: getNullString(performer.Gender), + Height: getNullString(performer.Height), + Instagram: getNullString(performer.Instagram), + Measurements: getNullString(performer.Measurements), + Name: sql.NullString{String: performer.Name, Valid: true}, + Piercings: getNullString(performer.Piercings), + Tattoos: getNullString(performer.Tattoos), + Twitter: getNullString(performer.Twitter), + URL: getNullString(performer.URL), + UpdatedAt: models.SQLiteTimestamp{Timestamp: currentTime}, + } + err := t.txnManager.WithTxn(context.TODO(), func(r models.Repository) error { + createdPerformer, err := r.Performer().Create(newPerformer) + if err != nil { + return err + } + + err = r.Performer().UpdateStashIDs(createdPerformer.ID, []models.StashID{ + { + Endpoint: t.box.Endpoint, + StashID: *performer.RemoteSiteID, + }, + }) + if err != nil { + return err + } + + if len(performer.Images) > 0 { + image, err := utils.ReadImageFromURL(performer.Images[0]) + if err != nil { + return err + } + err = r.Performer().UpdateImage(createdPerformer.ID, image) + } + return err + }) + if err != nil { + logger.Errorf("Failed to save performer %s: %s", *t.name, err.Error()) + } else { + logger.Infof("Saved performer %s", *t.name) + } + } + } else { + var name string + if t.name != nil { + name = *t.name + } else if t.performer != nil { + name = t.performer.Name.String + } + logger.Infof("No match found for %s", name) + } +} + +func getDate(val *string) models.SQLiteDate { + if val == nil { + return models.SQLiteDate{Valid: false} + } else { + return models.SQLiteDate{String: *val, Valid: false} + } +} + +func getNullString(val *string) sql.NullString { + if val == nil { + return sql.NullString{Valid: false} + } else { + return sql.NullString{String: *val, Valid: true} + } +} diff --git a/pkg/models/mocks/PerformerReaderWriter.go b/pkg/models/mocks/PerformerReaderWriter.go index 20629b3b5..5d3c5cb6f 100644 --- a/pkg/models/mocks/PerformerReaderWriter.go +++ b/pkg/models/mocks/PerformerReaderWriter.go @@ -498,3 +498,26 @@ func (_m *PerformerReaderWriter) UpdateTags(sceneID int, tagIDs []int) error { return r0 } + +// FindByStashIDStatus provides a mock function with given fields: hasStashID, stashboxEndpoint +func (_m *PerformerReaderWriter) FindByStashIDStatus(hasStashID bool, stashboxEndpoint string) ([]*models.Performer, error) { + ret := _m.Called(hasStashID, stashboxEndpoint) + + var r0 []*models.Performer + if rf, ok := ret.Get(0).(func(bool, string) []*models.Performer); ok { + r0 = rf(hasStashID, stashboxEndpoint) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).([]*models.Performer) + } + } + + var r1 error + if rf, ok := ret.Get(1).(func(bool, string) error); ok { + r1 = rf(hasStashID, stashboxEndpoint) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} diff --git a/pkg/models/performer.go b/pkg/models/performer.go index fabcff44a..437921e00 100644 --- a/pkg/models/performer.go +++ b/pkg/models/performer.go @@ -8,6 +8,7 @@ type PerformerReader interface { FindByImageID(imageID int) ([]*Performer, error) FindByGalleryID(galleryID int) ([]*Performer, error) FindByNames(names []string, nocase bool) ([]*Performer, error) + FindByStashIDStatus(hasStashID bool, stashboxEndpoint string) ([]*Performer, error) CountByTagID(tagID int) (int, error) Count() (int, error) All() ([]*Performer, error) diff --git a/pkg/scraper/stashbox/graphql/generated_client.go b/pkg/scraper/stashbox/graphql/generated_client.go index aaae56b8d..e3f4b45dd 100644 --- a/pkg/scraper/stashbox/graphql/generated_client.go +++ b/pkg/scraper/stashbox/graphql/generated_client.go @@ -166,6 +166,12 @@ type FindScenesByFingerprints struct { type SearchScene struct { SearchScene []*SceneFragment "json:\"searchScene\" graphql:\"searchScene\"" } +type SearchPerformer struct { + SearchPerformer []*PerformerFragment "json:\"searchPerformer\" graphql:\"searchPerformer\"" +} +type FindPerformerByID struct { + FindPerformer *PerformerFragment "json:\"findPerformer\" graphql:\"findPerformer\"" +} type SubmitFingerprintPayload struct { SubmitFingerprint bool "json:\"submitFingerprint\" graphql:\"submitFingerprint\"" } @@ -175,67 +181,6 @@ const FindSceneByFingerprintQuery = `query FindSceneByFingerprint ($fingerprint: ... SceneFragment } } -fragment SceneFragment on Scene { - id - title - details - duration - date - urls { - ... URLFragment - } - images { - ... ImageFragment - } - studio { - ... StudioFragment - } - tags { - ... TagFragment - } - performers { - ... PerformerAppearanceFragment - } - fingerprints { - ... FingerprintFragment - } -} -fragment ImageFragment on Image { - id - url - width - height -} -fragment StudioFragment on Studio { - name - id - urls { - ... URLFragment - } - images { - ... ImageFragment - } -} -fragment PerformerAppearanceFragment on PerformerAppearance { - as - performer { - ... PerformerFragment - } -} -fragment FuzzyDateFragment on FuzzyDate { - date - accuracy -} -fragment MeasurementsFragment on Measurements { - band_size - cup_size - waist - hip -} -fragment URLFragment on URL { - url - type -} fragment TagFragment on Tag { name id @@ -273,6 +218,10 @@ fragment PerformerFragment on Performer { ... BodyModificationFragment } } +fragment FuzzyDateFragment on FuzzyDate { + date + accuracy +} fragment BodyModificationFragment on BodyModification { location description @@ -282,6 +231,63 @@ fragment FingerprintFragment on Fingerprint { hash duration } +fragment SceneFragment on Scene { + id + title + details + duration + date + urls { + ... URLFragment + } + images { + ... ImageFragment + } + studio { + ... StudioFragment + } + tags { + ... TagFragment + } + performers { + ... PerformerAppearanceFragment + } + fingerprints { + ... FingerprintFragment + } +} +fragment URLFragment on URL { + url + type +} +fragment StudioFragment on Studio { + name + id + urls { + ... URLFragment + } + images { + ... ImageFragment + } +} +fragment ImageFragment on Image { + id + url + width + height +} +fragment PerformerAppearanceFragment on PerformerAppearance { + as + performer { + ... PerformerFragment + } +} +fragment MeasurementsFragment on Measurements { + band_size + cup_size + waist + hip +} ` func (c *Client) FindSceneByFingerprint(ctx context.Context, fingerprint FingerprintQueryInput, httpRequestOptions ...client.HTTPRequestOption) (*FindSceneByFingerprint, error) { @@ -302,30 +308,20 @@ const FindScenesByFingerprintsQuery = `query FindScenesByFingerprints ($fingerpr ... SceneFragment } } -fragment BodyModificationFragment on BodyModification { - location - description -} -fragment ImageFragment on Image { - id - url - width - height -} -fragment StudioFragment on Studio { - name - id - urls { - ... URLFragment - } - images { - ... ImageFragment - } -} fragment TagFragment on Tag { name id } +fragment FuzzyDateFragment on FuzzyDate { + date + accuracy +} +fragment PerformerAppearanceFragment on PerformerAppearance { + as + performer { + ... PerformerFragment + } +} fragment PerformerFragment on Performer { id name @@ -365,6 +361,10 @@ fragment MeasurementsFragment on Measurements { waist hip } +fragment BodyModificationFragment on BodyModification { + location + description +} fragment SceneFragment on Scene { id title @@ -394,15 +394,21 @@ fragment URLFragment on URL { url type } -fragment PerformerAppearanceFragment on PerformerAppearance { - as - performer { - ... PerformerFragment - } +fragment ImageFragment on Image { + id + url + width + height } -fragment FuzzyDateFragment on FuzzyDate { - date - accuracy +fragment StudioFragment on Studio { + name + id + urls { + ... URLFragment + } + images { + ... ImageFragment + } } fragment FingerprintFragment on Fingerprint { algorithm @@ -429,11 +435,11 @@ const SearchSceneQuery = `query SearchScene ($term: String!) { ... SceneFragment } } -fragment PerformerAppearanceFragment on PerformerAppearance { - as - performer { - ... PerformerFragment - } +fragment MeasurementsFragment on Measurements { + band_size + cup_size + waist + hip } fragment BodyModificationFragment on BodyModification { location @@ -444,6 +450,24 @@ fragment FingerprintFragment on Fingerprint { hash duration } +fragment URLFragment on URL { + url + type +} +fragment ImageFragment on Image { + id + url + width + height +} +fragment TagFragment on Tag { + name + id +} +fragment FuzzyDateFragment on FuzzyDate { + date + accuracy +} fragment SceneFragment on Scene { id title @@ -469,13 +493,21 @@ fragment SceneFragment on Scene { ... FingerprintFragment } } -fragment URLFragment on URL { - url - type -} -fragment TagFragment on Tag { +fragment StudioFragment on Studio { name id + urls { + ... URLFragment + } + images { + ... ImageFragment + } +} +fragment PerformerAppearanceFragment on PerformerAppearance { + as + performer { + ... PerformerFragment + } } fragment PerformerFragment on Performer { id @@ -510,6 +542,26 @@ fragment PerformerFragment on Performer { ... BodyModificationFragment } } +` + +func (c *Client) SearchScene(ctx context.Context, term string, httpRequestOptions ...client.HTTPRequestOption) (*SearchScene, error) { + vars := map[string]interface{}{ + "term": term, + } + + var res SearchScene + if err := c.Client.Post(ctx, SearchSceneQuery, &res, vars, httpRequestOptions...); err != nil { + return nil, err + } + + return &res, nil +} + +const SearchPerformerQuery = `query SearchPerformer ($term: String!) { + searchPerformer(term: $term) { + ... PerformerFragment + } +} fragment FuzzyDateFragment on FuzzyDate { date accuracy @@ -520,31 +572,139 @@ fragment MeasurementsFragment on Measurements { waist hip } -fragment ImageFragment on Image { - id - url - width - height +fragment BodyModificationFragment on BodyModification { + location + description } -fragment StudioFragment on Studio { - name +fragment PerformerFragment on Performer { id + name + disambiguation + aliases + gender urls { ... URLFragment } images { ... ImageFragment } + birthdate { + ... FuzzyDateFragment + } + ethnicity + country + eye_color + hair_color + height + measurements { + ... MeasurementsFragment + } + breast_type + career_start_year + career_end_year + tattoos { + ... BodyModificationFragment + } + piercings { + ... BodyModificationFragment + } +} +fragment URLFragment on URL { + url + type +} +fragment ImageFragment on Image { + id + url + width + height } ` -func (c *Client) SearchScene(ctx context.Context, term string, httpRequestOptions ...client.HTTPRequestOption) (*SearchScene, error) { +func (c *Client) SearchPerformer(ctx context.Context, term string, httpRequestOptions ...client.HTTPRequestOption) (*SearchPerformer, error) { vars := map[string]interface{}{ "term": term, } - var res SearchScene - if err := c.Client.Post(ctx, SearchSceneQuery, &res, vars, httpRequestOptions...); err != nil { + var res SearchPerformer + if err := c.Client.Post(ctx, SearchPerformerQuery, &res, vars, httpRequestOptions...); err != nil { + return nil, err + } + + return &res, nil +} + +const FindPerformerByIDQuery = `query FindPerformerByID ($id: ID!) { + findPerformer(id: $id) { + ... PerformerFragment + } +} +fragment FuzzyDateFragment on FuzzyDate { + date + accuracy +} +fragment MeasurementsFragment on Measurements { + band_size + cup_size + waist + hip +} +fragment BodyModificationFragment on BodyModification { + location + description +} +fragment PerformerFragment on Performer { + id + name + disambiguation + aliases + gender + urls { + ... URLFragment + } + images { + ... ImageFragment + } + birthdate { + ... FuzzyDateFragment + } + ethnicity + country + eye_color + hair_color + height + measurements { + ... MeasurementsFragment + } + breast_type + career_start_year + career_end_year + tattoos { + ... BodyModificationFragment + } + piercings { + ... BodyModificationFragment + } +} +fragment URLFragment on URL { + url + type +} +fragment ImageFragment on Image { + id + url + width + height +} +` + +func (c *Client) FindPerformerByID(ctx context.Context, id string, httpRequestOptions ...client.HTTPRequestOption) (*FindPerformerByID, error) { + vars := map[string]interface{}{ + "id": id, + } + + var res FindPerformerByID + if err := c.Client.Post(ctx, FindPerformerByIDQuery, &res, vars, httpRequestOptions...); err != nil { return nil, err } diff --git a/pkg/scraper/stashbox/stash_box.go b/pkg/scraper/stashbox/stash_box.go index 20a0fc95a..222462d5b 100644 --- a/pkg/scraper/stashbox/stash_box.go +++ b/pkg/scraper/stashbox/stash_box.go @@ -227,6 +227,92 @@ func (c Client) submitStashBoxFingerprints(fingerprints []graphql.FingerprintSub return true, nil } +// QueryStashBoxPerformer queries stash-box for performers using a query string. +func (c Client) QueryStashBoxPerformer(queryStr string) ([]*models.StashBoxPerformerQueryResult, error) { + performers, err := c.queryStashBoxPerformer(queryStr) + + res := []*models.StashBoxPerformerQueryResult{ + { + Query: queryStr, + Results: performers, + }, + } + return res, err +} + +func (c Client) queryStashBoxPerformer(queryStr string) ([]*models.ScrapedScenePerformer, error) { + performers, err := c.client.SearchPerformer(context.TODO(), queryStr) + if err != nil { + return nil, err + } + + performerFragments := performers.SearchPerformer + + var ret []*models.ScrapedScenePerformer + for _, fragment := range performerFragments { + performer := performerFragmentToScrapedScenePerformer(*fragment) + ret = append(ret, performer) + } + + return ret, nil +} + +// FindStashBoxPerformersByNames queries stash-box for performers by name +func (c Client) FindStashBoxPerformersByNames(performerIDs []string) ([]*models.StashBoxPerformerQueryResult, error) { + ids, err := utils.StringSliceToIntSlice(performerIDs) + if err != nil { + return nil, err + } + + var performers []*models.Performer + + if err := c.txnManager.WithReadTxn(context.TODO(), func(r models.ReaderRepository) error { + qb := r.Performer() + + for _, performerID := range ids { + performer, err := qb.Find(performerID) + if err != nil { + return err + } + + if performer == nil { + return fmt.Errorf("performer with id %d not found", performerID) + } + + if performer.Name.Valid { + performers = append(performers, performer) + } + } + + return nil + }); err != nil { + return nil, err + } + + return c.findStashBoxPerformersByNames(performers) +} + +func (c Client) findStashBoxPerformersByNames(performers []*models.Performer) ([]*models.StashBoxPerformerQueryResult, error) { + var ret []*models.StashBoxPerformerQueryResult + for _, performer := range performers { + if performer.Name.Valid { + performerResults, err := c.queryStashBoxPerformer(performer.Name.String) + if err != nil { + return nil, err + } + + result := models.StashBoxPerformerQueryResult{ + Query: strconv.Itoa(performer.ID), + Results: performerResults, + } + + ret = append(ret, &result) + } + } + + return ret, nil +} + func findURL(urls []*graphql.URLFragment, urlType string) *string { for _, u := range urls { if u.Type == urlType { @@ -238,9 +324,12 @@ func findURL(urls []*graphql.URLFragment, urlType string) *string { return nil } -func enumToStringPtr(e fmt.Stringer) *string { +func enumToStringPtr(e fmt.Stringer, titleCase bool) *string { if e != nil { ret := e.String() + if titleCase { + ret = strings.Title(strings.ToLower(ret)) + } return &ret } @@ -264,6 +353,8 @@ func formatCareerLength(start, end *int) *string { var ret string if end == nil { ret = fmt.Sprintf("%d -", *start) + } else if start == nil { + ret = fmt.Sprintf("- %d", *end) } else { ret = fmt.Sprintf("%d - %d", *start, *end) } @@ -354,19 +445,19 @@ func performerFragmentToScrapedScenePerformer(p graphql.PerformerFragment) *mode } if p.Gender != nil { - sp.Gender = enumToStringPtr(p.Gender) + sp.Gender = enumToStringPtr(p.Gender, false) } if p.Ethnicity != nil { - sp.Ethnicity = enumToStringPtr(p.Ethnicity) + sp.Ethnicity = enumToStringPtr(p.Ethnicity, true) } if p.EyeColor != nil { - sp.EyeColor = enumToStringPtr(p.EyeColor) + sp.EyeColor = enumToStringPtr(p.EyeColor, true) } if p.BreastType != nil { - sp.FakeTits = enumToStringPtr(p.BreastType) + sp.FakeTits = enumToStringPtr(p.BreastType, true) } return sp @@ -463,3 +554,29 @@ func sceneFragmentToScrapedScene(txnManager models.TransactionManager, s *graphq return ss, nil } + +func (c Client) FindStashBoxPerformerByID(id string) (*models.ScrapedScenePerformer, error) { + performer, err := c.client.FindPerformerByID(context.TODO(), id) + if err != nil { + return nil, err + } + + ret := performerFragmentToScrapedScenePerformer(*performer.FindPerformer) + return ret, nil +} + +func (c Client) FindStashBoxPerformerByName(name string) (*models.ScrapedScenePerformer, error) { + performers, err := c.client.SearchPerformer(context.TODO(), name) + if err != nil { + return nil, err + } + + var ret *models.ScrapedScenePerformer + for _, performer := range performers.SearchPerformer { + if strings.ToLower(performer.Name) == strings.ToLower(name) { + ret = performerFragmentToScrapedScenePerformer(*performer) + } + } + + return ret, nil +} diff --git a/pkg/sqlite/performer.go b/pkg/sqlite/performer.go index 1fbf8a87b..f3ebb01d8 100644 --- a/pkg/sqlite/performer.go +++ b/pkg/sqlite/performer.go @@ -258,18 +258,11 @@ func (qb *performerQueryBuilder) Query(performerFilter *models.PerformerFilterTy query.body += `left join performers_image on performers_image.performer_id = performers.id ` query.addWhere("performers_image.performer_id IS NULL") - case "stash_id": - query.addWhere("performer_stash_ids.performer_id IS NULL") default: query.addWhere("(performers." + *isMissingFilter + " IS NULL OR TRIM(performers." + *isMissingFilter + ") = '')") } } - if stashIDFilter := performerFilter.StashID; stashIDFilter != nil { - query.addWhere("performer_stash_ids.stash_id = ?") - query.addArg(stashIDFilter) - } - query.handleStringCriterionInput(performerFilter.Ethnicity, tableName+".ethnicity") query.handleStringCriterionInput(performerFilter.Country, tableName+".country") query.handleStringCriterionInput(performerFilter.EyeColor, tableName+".eye_color") @@ -283,6 +276,7 @@ func (qb *performerQueryBuilder) Query(performerFilter *models.PerformerFilterTy query.handleStringCriterionInput(performerFilter.HairColor, tableName+".hair_color") query.handleStringCriterionInput(performerFilter.URL, tableName+".url") query.handleIntCriterionInput(performerFilter.Weight, tableName+".weight") + query.handleStringCriterionInput(performerFilter.StashID, "performer_stash_ids.stash_id") // TODO - need better handling of aliases query.handleStringCriterionInput(performerFilter.Aliases, tableName+".aliases") @@ -470,3 +464,23 @@ func (qb *performerQueryBuilder) GetStashIDs(performerID int) ([]*models.StashID func (qb *performerQueryBuilder) UpdateStashIDs(performerID int, stashIDs []models.StashID) error { return qb.stashIDRepository().replace(performerID, stashIDs) } + +func (qb *performerQueryBuilder) FindByStashIDStatus(hasStashID bool, stashboxEndpoint string) ([]*models.Performer, error) { + query := selectAll("performers") + ` + LEFT JOIN performer_stash_ids on performer_stash_ids.performer_id = performers.id + ` + + if hasStashID { + query += ` + WHERE performer_stash_ids.stash_id IS NOT NULL + AND performer_stash_ids.endpoint = ? + ` + } else { + query += ` + WHERE performer_stash_ids.stash_id IS NULL + ` + } + + args := []interface{}{stashboxEndpoint} + return qb.queryPerformers(query, args) +} diff --git a/pkg/sqlite/scene.go b/pkg/sqlite/scene.go index 2ded07b9c..8ed7a711e 100644 --- a/pkg/sqlite/scene.go +++ b/pkg/sqlite/scene.go @@ -362,6 +362,7 @@ func (qb *sceneQueryBuilder) makeFilter(sceneFilter *models.SceneFilterType) *fi query.handleCriterionFunc(hasMarkersCriterionHandler(sceneFilter.HasMarkers)) query.handleCriterionFunc(sceneIsMissingCriterionHandler(qb, sceneFilter.IsMissing)) query.handleCriterionFunc(stringCriterionHandler(sceneFilter.URL, "scenes.url")) + query.handleCriterionFunc(stringCriterionHandler(sceneFilter.StashID, "scene_stash_ids.stash_id")) query.handleCriterionFunc(sceneTagsCriterionHandler(qb, sceneFilter.Tags)) query.handleCriterionFunc(sceneTagCountCriterionHandler(qb, sceneFilter.TagCount)) @@ -369,7 +370,6 @@ func (qb *sceneQueryBuilder) makeFilter(sceneFilter *models.SceneFilterType) *fi query.handleCriterionFunc(scenePerformerCountCriterionHandler(qb, sceneFilter.PerformerCount)) query.handleCriterionFunc(sceneStudioCriterionHandler(qb, sceneFilter.Studios)) query.handleCriterionFunc(sceneMoviesCriterionHandler(qb, sceneFilter.Movies)) - query.handleCriterionFunc(sceneStashIDsHandler(qb, sceneFilter.StashID)) query.handleCriterionFunc(scenePerformerTagsCriterionHandler(qb, sceneFilter.PerformerTags)) return query @@ -400,6 +400,10 @@ func (qb *sceneQueryBuilder) Query(sceneFilter *models.SceneFilterType, findFilt } filter := qb.makeFilter(sceneFilter) + if sceneFilter.StashID != nil { + qb.stashIDRepository().join(filter, "scene_stash_ids", "scenes.id") + } + query.addFilter(filter) qb.setSceneSort(&query, findFilter) @@ -519,9 +523,6 @@ func sceneIsMissingCriterionHandler(qb *sceneQueryBuilder, isMissing *string) cr case "tags": qb.tagsRepository().join(f, "tags_join", "scenes.id") f.addWhere("tags_join.scene_id IS NULL") - case "stash_id": - qb.stashIDRepository().join(f, "scene_stash_ids", "scenes.id") - f.addWhere("scene_stash_ids.scene_id IS NULL") default: f.addWhere("(scenes." + *isMissing + " IS NULL OR TRIM(scenes." + *isMissing + ") = '')") } @@ -598,15 +599,6 @@ func sceneMoviesCriterionHandler(qb *sceneQueryBuilder, movies *models.MultiCrit return h.handler(movies) } -func sceneStashIDsHandler(qb *sceneQueryBuilder, stashID *string) criterionHandlerFunc { - return func(f *filterBuilder) { - if stashID != nil && *stashID != "" { - qb.stashIDRepository().join(f, "scene_stash_ids", "scenes.id") - stringLiteralCriterionHandler(stashID, "scene_stash_ids.stash_id")(f) - } - } -} - func scenePerformerTagsCriterionHandler(qb *sceneQueryBuilder, performerTagsFilter *models.MultiCriterionInput) criterionHandlerFunc { return func(f *filterBuilder) { if performerTagsFilter != nil && len(performerTagsFilter.Value) > 0 { diff --git a/pkg/sqlite/studio.go b/pkg/sqlite/studio.go index be9c6eca1..76099481e 100644 --- a/pkg/sqlite/studio.go +++ b/pkg/sqlite/studio.go @@ -178,11 +178,6 @@ func (qb *studioQueryBuilder) Query(studioFilter *models.StudioFilterType, findF query.addHaving(havingClause) } - if stashIDFilter := studioFilter.StashID; stashIDFilter != nil { - query.addWhere("studio_stash_ids.stash_id = ?") - query.addArg(stashIDFilter) - } - if rating := studioFilter.Rating; rating != nil { query.handleIntCriterionInput(studioFilter.Rating, "studios.rating") } @@ -190,6 +185,7 @@ func (qb *studioQueryBuilder) Query(studioFilter *models.StudioFilterType, findF query.handleCountCriterion(studioFilter.ImageCount, studioTable, imageTable, studioIDColumn) query.handleCountCriterion(studioFilter.GalleryCount, studioTable, galleryTable, studioIDColumn) query.handleStringCriterionInput(studioFilter.URL, "studios.url") + query.handleStringCriterionInput(studioFilter.StashID, "studio_stash_ids.stash_id") if isMissingFilter := studioFilter.IsMissing; isMissingFilter != nil && *isMissingFilter != "" { switch *isMissingFilter { diff --git a/ui/v2.5/package.json b/ui/v2.5/package.json index 883804306..765216f10 100644 --- a/ui/v2.5/package.json +++ b/ui/v2.5/package.json @@ -34,8 +34,7 @@ "@fortawesome/free-regular-svg-icons": "^5.15.2", "@fortawesome/free-solid-svg-icons": "^5.15.2", "@fortawesome/react-fontawesome": "^0.1.14", - "@types/react-select": "^3.1.2", - "@types/yup": "^0.29.11", + "@types/react-select": "^4.0.8", "apollo-upload-client": "^14.1.3", "axios": "0.21.1", "base64-blob": "^1.4.1", diff --git a/ui/v2.5/src/components/Changelog/versions/v070.md b/ui/v2.5/src/components/Changelog/versions/v070.md index 365a40f7e..6766e30ee 100644 --- a/ui/v2.5/src/components/Changelog/versions/v070.md +++ b/ui/v2.5/src/components/Changelog/versions/v070.md @@ -1,4 +1,5 @@ ### ✨ New Features +* Added stash-box performer tagger. * Auto-tagger now tags images and galleries. * Added rating field to performers and studios. * Support serving UI from specific directory location. diff --git a/ui/v2.5/src/components/Performers/PerformerDetails/PerformerEditPanel.tsx b/ui/v2.5/src/components/Performers/PerformerDetails/PerformerEditPanel.tsx index 4e4e78484..f100b3b1c 100644 --- a/ui/v2.5/src/components/Performers/PerformerDetails/PerformerEditPanel.tsx +++ b/ui/v2.5/src/components/Performers/PerformerDetails/PerformerEditPanel.tsx @@ -23,6 +23,8 @@ import { usePerformerCreate, useTagCreate, queryScrapePerformerURL, + useConfiguration, + queryStashBoxPerformer, } from "src/core/StashService"; import { Icon, @@ -33,6 +35,7 @@ import { TagSelect, } from "src/components/Shared"; import { ImageUtils } from "src/utils"; +import { getCountryByISO } from "src/utils/country"; import { useToast } from "src/hooks"; import { Prompt, useHistory } from "react-router-dom"; import { useFormik } from "formik"; @@ -77,6 +80,7 @@ export const PerformerEditPanel: React.FC = ({ const [scrapedPerformer, setScrapedPerformer] = useState< GQL.ScrapedPerformer | undefined >(); + const stashConfig = useConfiguration(); const imageEncoding = ImageUtils.usePasteImage(onImageLoad, true); @@ -544,15 +548,57 @@ export const PerformerEditPanel: React.FC = ({ } } + async function onScrapeStashBoxClicked(stashBoxIndex: number) { + if (!performer.id) return; + + setIsLoading(true); + try { + const result = await queryStashBoxPerformer(stashBoxIndex, performer.id); + if (!result.data || !result.data.queryStashBoxPerformer) { + return; + } + + if (result.data.queryStashBoxPerformer.length > 0) { + const performerResult = + result.data.queryStashBoxPerformer[0].results[0]; + setScrapedPerformer({ + ...performerResult, + image: performerResult.images?.[0] ?? undefined, + country: getCountryByISO(performerResult.country), + __typename: "ScrapedPerformer", + }); + } else { + Toast.success({ + content: "No performers found", + }); + } + } catch (e) { + Toast.error(e); + } finally { + setIsLoading(false); + } + } + function renderScraperMenu() { if (!performer) { return; } + const stashBoxes = stashConfig.data?.configuration.general.stashBoxes ?? []; const popover = ( <> + {stashBoxes.map((s, index) => ( +
      + +
      + ))} {queryableScrapers ? queryableScrapers.map((s) => (
      diff --git a/ui/v2.5/src/components/Performers/PerformerList.tsx b/ui/v2.5/src/components/Performers/PerformerList.tsx index 8aa7c038c..668a2f7e3 100644 --- a/ui/v2.5/src/components/Performers/PerformerList.tsx +++ b/ui/v2.5/src/components/Performers/PerformerList.tsx @@ -14,6 +14,7 @@ import { usePerformersList } from "src/hooks"; import { showWhenSelected, PersistanceLevel } from "src/hooks/ListHook"; import { ListFilterModel } from "src/models/list-filter/filter"; import { DisplayMode } from "src/models/list-filter/types"; +import { PerformerTagger } from "src/components/Tagger"; import { ExportDialog, DeleteEntityDialog } from "src/components/Shared"; import { PerformerCard } from "./PerformerCard"; import { PerformerListTable } from "./PerformerListTable"; @@ -184,6 +185,11 @@ export const PerformerList: React.FC = ({ /> ); } + if (filter.displayMode === DisplayMode.Tagger) { + return ( + + ); + } } return listData.template; diff --git a/ui/v2.5/src/components/Scenes/SceneDetails/SceneEditPanel.tsx b/ui/v2.5/src/components/Scenes/SceneDetails/SceneEditPanel.tsx index bdaca955e..ccf1adfcc 100644 --- a/ui/v2.5/src/components/Scenes/SceneDetails/SceneEditPanel.tsx +++ b/ui/v2.5/src/components/Scenes/SceneDetails/SceneEditPanel.tsx @@ -284,10 +284,6 @@ export const SceneEditPanel: React.FC = ({ } } - // function onStashBoxQueryClicked(/* stashBoxIndex: number */) { - // TODO - // } - async function onScrapeClicked(scraper: GQL.Scraper) { setIsLoading(true); try { @@ -361,7 +357,7 @@ export const SceneEditPanel: React.FC = ({ key={s.endpoint} onClick={() => onScrapeStashBoxClicked(index)} > - stash-box + {s.name ?? "Stash-Box"} ))} {queryableScrapers.map((s) => ( diff --git a/ui/v2.5/src/components/Settings/SettingsTasksPanel/SettingsTasksPanel.tsx b/ui/v2.5/src/components/Settings/SettingsTasksPanel/SettingsTasksPanel.tsx index 1eeaf655e..6a24a72bc 100644 --- a/ui/v2.5/src/components/Settings/SettingsTasksPanel/SettingsTasksPanel.tsx +++ b/ui/v2.5/src/components/Settings/SettingsTasksPanel/SettingsTasksPanel.tsx @@ -84,6 +84,8 @@ export const SettingsTasksPanel: React.FC = () => { return "Running Plugin Operation"; case "Migrate": return "Migrating"; + case "Stash-Box Performer Batch Operation": + return "Tagging performers from Stash-Box instance"; default: return "Idle"; } diff --git a/ui/v2.5/src/components/Tagger/PerformerFieldSelector.tsx b/ui/v2.5/src/components/Tagger/PerformerFieldSelector.tsx new file mode 100644 index 000000000..f0fbae2dd --- /dev/null +++ b/ui/v2.5/src/components/Tagger/PerformerFieldSelector.tsx @@ -0,0 +1,63 @@ +import React, { useState } from "react"; +import { Button } from "react-bootstrap"; + +import { Modal, Icon } from "src/components/Shared"; +import { TextUtils } from "src/utils"; + +interface IProps { + fields: string[]; + show: boolean; + excludedFields: string[]; + onSelect: (fields: string[]) => void; +} + +const PerformerFieldSelect: React.FC = ({ + fields, + show, + excludedFields, + onSelect, +}) => { + const [excluded, setExcluded] = useState>( + excludedFields.reduce((dict, field) => ({ ...dict, [field]: true }), {}) + ); + + const toggleField = (name: string) => + setExcluded({ + ...excluded, + [name]: !excluded[name], + }); + + const renderField = (name: string) => ( +
      + + {TextUtils.capitalize(name)} +
      + ); + + return ( + + onSelect(Object.keys(excluded).filter((f) => excluded[f])), + }} + > +

      Select tagged fields

      +
      + These fields will be tagged by default. Click the button to toggle. +
      + {fields.map((f) => renderField(f))} +
      + ); +}; + +export default PerformerFieldSelect; diff --git a/ui/v2.5/src/components/Tagger/PerformerModal.tsx b/ui/v2.5/src/components/Tagger/PerformerModal.tsx index 41233e2ad..390c8d0d1 100755 --- a/ui/v2.5/src/components/Tagger/PerformerModal.tsx +++ b/ui/v2.5/src/components/Tagger/PerformerModal.tsx @@ -1,6 +1,7 @@ import React, { useState } from "react"; import { Button } from "react-bootstrap"; import cx from "classnames"; +import { IconName } from "@fortawesome/fontawesome-svg-core"; import { LoadingIndicator, @@ -10,26 +11,43 @@ import { } from "src/components/Shared"; import * as GQL from "src/core/generated-graphql"; import { genderToString } from "src/core/StashService"; +import { TextUtils } from "src/utils"; import { IStashBoxPerformer } from "./utils"; interface IPerformerModalProps { performer: IStashBoxPerformer; modalVisible: boolean; - showModal: (show: boolean) => void; - handlePerformerCreate: (imageIndex: number) => void; + closeModal: () => void; + handlePerformerCreate: (imageIndex: number, excludedFields: string[]) => void; + excludedPerformerFields?: string[]; + header: string; + icon: IconName; + create?: boolean; + endpoint: string; } const PerformerModal: React.FC = ({ modalVisible, performer, handlePerformerCreate, - showModal, + closeModal, + excludedPerformerFields = [], + header, + icon, + create = false, + endpoint, }) => { const [imageIndex, setImageIndex] = useState(0); const [imageState, setImageState] = useState< "loading" | "error" | "loaded" | "empty" >("empty"); const [loadDict, setLoadDict] = useState>({}); + const [excluded, setExcluded] = useState>( + excludedPerformerFields.reduce( + (dict, field) => ({ ...dict, [field]: true }), + {} + ) + ); const { images } = performer; @@ -51,106 +69,101 @@ const PerformerModal: React.FC = ({ }; const handleError = () => setImageState("error"); + const toggleField = (name: string) => + setExcluded({ + ...excluded, + [name]: !excluded[name], + }); + + const renderField = ( + name: string, + text: string | null | undefined, + truncate: boolean = true + ) => + text && ( +
      +
      + {!create && ( + + )} + {TextUtils.capitalize(name)}: +
      + {truncate ? ( + + ) : ( + {text} + )} +
      + ); + + const base = endpoint.match(/https?:\/\/.*?\//)?.[0]; + const link = base ? `${base}performers/${performer.stash_id}` : undefined; + return ( handlePerformerCreate(imageIndex), + onClick: () => + handlePerformerCreate( + imageIndex, + create ? [] : Object.keys(excluded).filter((key) => excluded[key]) + ), }} - cancel={{ onClick: () => showModal(false), variant: "secondary" }} - onHide={() => showModal(false)} + cancel={{ onClick: () => closeModal(), variant: "secondary" }} + onHide={() => closeModal()} dialogClassName="performer-create-modal" + icon={icon} + header={header} >
      -
      -
      - Performer information -
      -
      - Name: - -
      -
      - Gender: - -
      -
      - Birthdate: - -
      -
      - Death Date: - -
      -
      - Ethnicity: - -
      -
      - Country: - -
      -
      - Hair Color: - -
      -
      - Eye Color: - -
      -
      - Height: - -
      -
      - Weight: - -
      -
      - Measurements: - -
      - {performer?.gender !== GQL.GenderEnum.Male && ( -
      - Fake Tits: - -
      +
      + {renderField("name", performer.name)} + {renderField("gender", genderToString(performer.gender))} + {renderField("birthdate", performer.birthdate)} + {renderField("death_date", performer.death_date)} + {renderField("ethnicity", performer.ethnicity)} + {renderField("country", performer.country)} + {renderField("hair_color", performer.hair_color)} + {renderField("eye_color", performer.eye_color)} + {renderField("height", performer.height)} + {renderField("weight", performer.weight)} + {renderField("measurements", performer.measurements)} + {performer?.gender !== GQL.GenderEnum.Male && + renderField("fake_tits", performer.fake_tits)} + {renderField("career_length", performer.career_length)} + {renderField("tattoos", performer.tattoos, false)} + {renderField("piercings", performer.piercings, false)} + {link && ( +
      + + Stash-Box Source + + +
      )} -
      - Career Length: - -
      -
      - Tattoos: - -
      -
      - Piercings: - -
      {images.length > 0 && ( -
      +
      + {!create && ( + + )} = ({
      )}
      -
      - -
      +
      Select performer image
      {imageIndex + 1} of {images.length}
      -
      diff --git a/ui/v2.5/src/components/Tagger/PerformerResult.tsx b/ui/v2.5/src/components/Tagger/PerformerResult.tsx index 74aa9ddff..497203de7 100755 --- a/ui/v2.5/src/components/Tagger/PerformerResult.tsx +++ b/ui/v2.5/src/components/Tagger/PerformerResult.tsx @@ -5,7 +5,7 @@ import cx from "classnames"; import { SuccessIcon, PerformerSelect } from "src/components/Shared"; import * as GQL from "src/core/generated-graphql"; import { ValidTypes } from "src/components/Shared/Select"; -import { IStashBoxPerformer } from "./utils"; +import { IStashBoxPerformer, filterPerformer } from "./utils"; import PerformerModal from "./PerformerModal"; @@ -18,11 +18,13 @@ export type PerformerOperation = interface IPerformerResultProps { performer: IStashBoxPerformer; setPerformer: (data: PerformerOperation) => void; + endpoint: string; } const PerformerResult: React.FC = ({ performer, setPerformer, + endpoint, }) => { const [selectedPerformer, setSelectedPerformer] = useState(); const [selectedSource, setSelectedSource] = useState< @@ -37,7 +39,10 @@ const PerformerResult: React.FC = ({ { variables: { performer_filter: { - stash_id: performer.stash_id, + stash_id: { + value: performer.stash_id, + modifier: GQL.CriterionModifier.Equals, + }, }, }, } @@ -74,14 +79,20 @@ const PerformerResult: React.FC = ({ } }; - const handlePerformerCreate = (imageIndex: number) => { + const handlePerformerCreate = ( + imageIndex: number, + excludedFields: string[] + ) => { const selectedImage = performer.images[imageIndex]; const images = selectedImage ? [selectedImage] : []; + setSelectedSource("create"); setPerformer({ type: "create", data: { - ...performer, + ...filterPerformer(performer, excludedFields), + name: performer.name, + stash_id: performer.stash_id, images, }, }); @@ -117,10 +128,14 @@ const PerformerResult: React.FC = ({ return (
      showModal(false)} modalVisible={modalVisible} performer={performer} handlePerformerCreate={handlePerformerCreate} + icon="star" + header="Create Performer" + create + endpoint={endpoint} />
      Performer: diff --git a/ui/v2.5/src/components/Tagger/StashSearchResult.tsx b/ui/v2.5/src/components/Tagger/StashSearchResult.tsx index a8a06e6bf..c823a8c48 100755 --- a/ui/v2.5/src/components/Tagger/StashSearchResult.tsx +++ b/ui/v2.5/src/components/Tagger/StashSearchResult.tsx @@ -416,6 +416,7 @@ const StashSearchResult: React.FC = ({ setPerformer(data, performer.stash_id) } key={`${scene.stash_id}${performer.stash_id}`} + endpoint={endpoint} /> ))}
      diff --git a/ui/v2.5/src/components/Tagger/StudioResult.tsx b/ui/v2.5/src/components/Tagger/StudioResult.tsx index f082ad87f..159c79946 100755 --- a/ui/v2.5/src/components/Tagger/StudioResult.tsx +++ b/ui/v2.5/src/components/Tagger/StudioResult.tsx @@ -34,7 +34,10 @@ const StudioResult: React.FC = ({ studio, setStudio }) => { } = GQL.useFindStudiosQuery({ variables: { studio_filter: { - stash_id: studio?.stash_id, + stash_id: { + value: studio?.stash_id ?? "no-stashid", + modifier: GQL.CriterionModifier.Equals, + }, }, }, }); diff --git a/ui/v2.5/src/components/Tagger/Tagger.tsx b/ui/v2.5/src/components/Tagger/Tagger.tsx index 35bbb4a58..24e94dac4 100755 --- a/ui/v2.5/src/components/Tagger/Tagger.tsx +++ b/ui/v2.5/src/components/Tagger/Tagger.tsx @@ -8,8 +8,8 @@ import { useLocalForage } from "src/hooks"; import * as GQL from "src/core/generated-graphql"; import { LoadingIndicator, TruncatedText } from "src/components/Shared"; import { - stashBoxQuery, - stashBoxBatchQuery, + stashBoxSceneQuery, + stashBoxSceneBatchQuery, useConfiguration, } from "src/core/StashService"; import { Manual } from "src/components/Help/Manual"; @@ -190,7 +190,7 @@ const TaggerList: React.FC = ({ }, [config.mode, config.blacklist]); const doBoxSearch = (sceneID: string, searchVal: string) => { - stashBoxQuery(searchVal, selectedEndpoint.index) + stashBoxSceneQuery(searchVal, selectedEndpoint.index) .then((queryData) => { const s = selectScenes(queryData.data?.queryStashBoxScene); setSearchResults({ @@ -257,7 +257,7 @@ const TaggerList: React.FC = ({ .filter((s) => s.stash_ids.length === 0) .map((s) => s.id); - const results = await stashBoxBatchQuery( + const results = await stashBoxSceneBatchQuery( sceneIDs, selectedEndpoint.index ).catch(() => { diff --git a/ui/v2.5/src/components/Tagger/constants.ts b/ui/v2.5/src/components/Tagger/constants.ts index d065f9867..a3dc21a45 100644 --- a/ui/v2.5/src/components/Tagger/constants.ts +++ b/ui/v2.5/src/components/Tagger/constants.ts @@ -10,6 +10,7 @@ export const DEFAULT_BLACKLIST = [ "\\[", "\\]", ]; +export const DEFAULT_EXCLUDED_PERFORMER_FIELDS = ["name"]; export const initialConfig: ITaggerConfig = { blacklist: DEFAULT_BLACKLIST, @@ -19,6 +20,7 @@ export const initialConfig: ITaggerConfig = { setTags: false, tagOperation: "merge", fingerprintQueue: {}, + excludedPerformerFields: DEFAULT_EXCLUDED_PERFORMER_FIELDS, }; export type ParseMode = "auto" | "filename" | "dir" | "path" | "metadata"; @@ -39,4 +41,22 @@ export interface ITaggerConfig { tagOperation: string; selectedEndpoint?: string; fingerprintQueue: Record; + excludedPerformerFields?: string[]; } + +export const PERFORMER_FIELDS = [ + "name", + "aliases", + "image", + "gender", + "birthdate", + "ethnicity", + "country", + "eye_color", + "height", + "measurements", + "fake_tits", + "career_length", + "tattoos", + "piercings", +]; diff --git a/ui/v2.5/src/components/Tagger/index.ts b/ui/v2.5/src/components/Tagger/index.ts index 05d179c57..cbf7a9f20 100644 --- a/ui/v2.5/src/components/Tagger/index.ts +++ b/ui/v2.5/src/components/Tagger/index.ts @@ -1 +1,2 @@ export { Tagger as default } from "./Tagger"; +export { PerformerTagger } from "./performers/PerformerTagger"; diff --git a/ui/v2.5/src/components/Tagger/performers/Config.tsx b/ui/v2.5/src/components/Tagger/performers/Config.tsx new file mode 100644 index 000000000..24934fa89 --- /dev/null +++ b/ui/v2.5/src/components/Tagger/performers/Config.tsx @@ -0,0 +1,103 @@ +import React, { Dispatch, useState } from "react"; +import { Badge, Button, Card, Collapse, Form } from "react-bootstrap"; +import { useConfiguration } from "src/core/StashService"; + +import { TextUtils } from "src/utils"; +import { ITaggerConfig, PERFORMER_FIELDS } from "../constants"; +import PerformerFieldSelector from "../PerformerFieldSelector"; + +interface IConfigProps { + show: boolean; + config: ITaggerConfig; + setConfig: Dispatch; +} + +const Config: React.FC = ({ show, config, setConfig }) => { + const stashConfig = useConfiguration(); + const [showExclusionModal, setShowExclusionModal] = useState(false); + + const excludedFields = config.excludedPerformerFields ?? []; + + const handleInstanceSelect = (e: React.ChangeEvent) => { + const selectedEndpoint = e.currentTarget.value; + setConfig({ + ...config, + selectedEndpoint, + }); + }; + + const stashBoxes = stashConfig.data?.configuration.general.stashBoxes ?? []; + + const handleFieldSelect = (fields: string[]) => { + setConfig({ ...config, excludedPerformerFields: fields }); + setShowExclusionModal(false); + }; + + return ( + <> + + +
      +

      Configuration

      +
      +
      + +
      Excluded fields:
      + + {excludedFields.length > 0 + ? excludedFields.map((f) => ( + + {TextUtils.capitalize(f)} + + )) + : "No fields are excluded"} + + + These fields will not be changed when updating performers. + + +
      + + + Active stash-box instance: + + + {!stashBoxes.length && } + {stashConfig.data?.configuration.general.stashBoxes.map( + (i) => ( + + ) + )} + + +
      +
      +
      +
      + + + ); +}; + +export default Config; diff --git a/ui/v2.5/src/components/Tagger/performers/PerformerTagger.tsx b/ui/v2.5/src/components/Tagger/performers/PerformerTagger.tsx new file mode 100755 index 000000000..988633ea9 --- /dev/null +++ b/ui/v2.5/src/components/Tagger/performers/PerformerTagger.tsx @@ -0,0 +1,611 @@ +import React, { useRef, useState } from "react"; +import { Button, Card, Form, InputGroup, ProgressBar } from "react-bootstrap"; +import { Link } from "react-router-dom"; +import { HashLink } from "react-router-hash-link"; +import { useLocalForage } from "src/hooks"; + +import * as GQL from "src/core/generated-graphql"; +import { LoadingIndicator, Modal } from "src/components/Shared"; +import { + stashBoxPerformerQuery, + useConfiguration, + useMetadataUpdate, +} from "src/core/StashService"; +import { Manual } from "src/components/Help/Manual"; + +import StashSearchResult from "./StashSearchResult"; +import PerformerConfig from "./Config"; +import { LOCAL_FORAGE_KEY, ITaggerConfig, initialConfig } from "../constants"; +import { + IStashBoxPerformer, + selectPerformers, + filterPerformer, +} from "../utils"; +import PerformerModal from "../PerformerModal"; +import { useUpdatePerformer } from "../queries"; + +const CLASSNAME = "PerformerTagger"; + +interface IPerformerTaggerListProps { + performers: GQL.PerformerDataFragment[]; + selectedEndpoint: { endpoint: string; index: number }; + isIdle: boolean; + config: ITaggerConfig; + stashBoxes?: GQL.StashBox[]; +} + +const PerformerTaggerList: React.FC = ({ + performers, + selectedEndpoint, + isIdle, + config, + stashBoxes, +}) => { + const [loading, setLoading] = useState(false); + const [searchResults, setSearchResults] = useState< + Record + >({}); + const [searchErrors, setSearchErrors] = useState< + Record + >({}); + const [taggedPerformers, setTaggedPerformers] = useState< + Record> + >({}); + const [queries, setQueries] = useState>({}); + const [queryAll, setQueryAll] = useState(false); + + const [refresh, setRefresh] = useState(false); + const { data: allPerformers } = GQL.useFindPerformersQuery({ + variables: { + performer_filter: { + stash_id: { + value: "", + modifier: refresh + ? GQL.CriterionModifier.NotNull + : GQL.CriterionModifier.IsNull, + }, + }, + filter: { + per_page: 0, + }, + }, + }); + const [showBatchAdd, setShowBatchAdd] = useState(false); + const [showBatchUpdate, setShowBatchUpdate] = useState(false); + const performerInput = useRef(null); + const [doBatchQuery] = GQL.useStashBoxBatchPerformerTagMutation(); + + const [error, setError] = useState< + Record + >({}); + const [loadingUpdate, setLoadingUpdate] = useState(); + const [modalPerformer, setModalPerformer] = useState< + IStashBoxPerformer | undefined + >(); + + const doBoxSearch = (performerID: string, searchVal: string) => { + stashBoxPerformerQuery(searchVal, selectedEndpoint.index) + .then((queryData) => { + const s = selectPerformers( + queryData.data?.queryStashBoxPerformer?.[0].results ?? [] + ); + setSearchResults({ + ...searchResults, + [performerID]: s, + }); + setSearchErrors({ + ...searchErrors, + [performerID]: undefined, + }); + setLoading(false); + }) + .catch(() => { + setLoading(false); + // Destructure to remove existing result + const { [performerID]: unassign, ...results } = searchResults; + setSearchResults(results); + setSearchErrors({ + ...searchErrors, + [performerID]: "Network Error", + }); + }); + + setLoading(true); + }; + + const doBoxUpdate = ( + performerID: string, + stashID: string, + endpointIndex: number + ) => { + setLoadingUpdate(stashID); + setError({ + ...error, + [performerID]: undefined, + }); + stashBoxPerformerQuery(stashID, endpointIndex) + .then((queryData) => { + const data = selectPerformers( + queryData.data?.queryStashBoxPerformer?.[0].results ?? [] + ); + if (data.length > 0) { + setModalPerformer({ + ...data[0], + id: performerID, + }); + } + }) + .finally(() => setLoadingUpdate(undefined)); + }; + + const handleBatchAdd = () => { + if (performerInput.current) { + const names = performerInput.current.value + .split(",") + .map((n) => n.trim()) + .filter((n) => n.length > 0); + + if (names.length > 0) { + doBatchQuery({ + variables: { + input: { + performer_names: names, + endpoint: selectedEndpoint.index, + refresh: false, + }, + }, + }); + } + } + setShowBatchAdd(false); + }; + + const handleBatchUpdate = () => { + const ids = !queryAll ? performers.map((p) => p.id) : undefined; + doBatchQuery({ + variables: { + input: { + performer_ids: ids, + endpoint: selectedEndpoint.index, + refresh, + exclude_fields: config.excludedPerformerFields ?? [], + }, + }, + }); + setShowBatchUpdate(false); + }; + + const handleTaggedPerformer = ( + performer: Pick & + Partial> + ) => { + setTaggedPerformers({ + ...taggedPerformers, + [performer.id]: performer, + }); + }; + + const updatePerformer = useUpdatePerformer(); + + const handlePerformerUpdate = async ( + imageIndex: number, + excludedFields: string[] + ) => { + const performerData = modalPerformer; + setModalPerformer(undefined); + if (performerData?.id) { + const filteredData = filterPerformer(performerData, excludedFields); + + const res = await updatePerformer({ + ...filteredData, + image: excludedFields.includes("image") + ? undefined + : performerData.images[imageIndex], + id: performerData.id, + }); + if (!res.data?.performerUpdate) + setError({ + ...error, + [performerData.id]: { + message: `Failed to save performer "${performerData.name}"`, + details: + res?.errors?.[0].message === + "UNIQUE constraint failed: performers.checksum" + ? "Name already exists" + : res?.errors?.[0].message, + }, + }); + } + setModalPerformer(undefined); + }; + + const renderPerformers = () => + performers.map((performer) => { + const isTagged = taggedPerformers[performer.id]; + const hasStashIDs = performer.stash_ids.length > 0; + + let mainContent; + if (!isTagged && hasStashIDs) { + mainContent = ( +
      +
      Performer already tagged
      +
      + ); + } else if (!isTagged && !hasStashIDs) { + mainContent = ( + + + setQueries({ + ...queries, + [performer.id]: e.currentTarget.value, + }) + } + onKeyPress={(e: React.KeyboardEvent) => + e.key === "Enter" && + doBoxSearch( + performer.id, + queries[performer.id] ?? performer.name ?? "" + ) + } + /> + + + + + ); + } else if (isTagged) { + mainContent = ( +
      +
      Performer successfully tagged:
      +
      + + {taggedPerformers[performer.id].name} + +
      +
      + ); + } + + let subContent; + if (performer.stash_ids.length > 0) { + const stashLinks = performer.stash_ids.map((stashID) => { + const base = stashID.endpoint.match(/https?:\/\/.*?\//)?.[0]; + const link = base ? ( + + {stashID.stash_id} + + ) : ( +
      {stashID.stash_id}
      + ); + + const endpoint = + stashBoxes?.findIndex((box) => box.endpoint === stashID.endpoint) ?? + -1; + + return ( +
      + + {link} + + {endpoint !== -1 && ( + + )} + + + {error[performer.id] && ( +
      + + Error: + {error[performer.id]?.message} + +
      {error[performer.id]?.details}
      +
      + )} +
      + ); + }); + subContent = <>{stashLinks}; + } else if (searchErrors[performer.id]) { + subContent = ( +
      + {searchErrors[performer.id]} +
      + ); + } else if (searchResults[performer.id]?.length === 0) { + subContent = ( +
      No results found.
      + ); + } + + let searchResult; + if (searchResults[performer.id]?.length > 0 && !isTagged) { + searchResult = ( + + ); + } + + return ( +
      + {modalPerformer && ( + setModalPerformer(undefined)} + modalVisible={modalPerformer !== undefined} + performer={modalPerformer} + handlePerformerCreate={handlePerformerUpdate} + excludedPerformerFields={config.excludedPerformerFields} + icon="tags" + header="Update Performer" + endpoint={selectedEndpoint.endpoint} + /> + )} + + + +
      + +

      {performer.name}

      + + {mainContent} +
      {subContent}
      + {searchResult} +
      +
      + ); + }); + + return ( + + setShowBatchUpdate(false), + }} + disabled={!isIdle} + > + + +
      Performer selection
      +
      + setQueryAll(false)} + /> + setQueryAll(true)} + /> +
      + + +
      Tag Status
      +
      + setRefresh(false)} + /> + + Updating untagged performers will try to match any performers that + lack a stashid and update the metadata. + + setRefresh(true)} + /> + + Refreshing will update the data of any tagged performers from the + stash-box instance. + +
      + {`${ + queryAll + ? allPerformers?.findPerformers.count + : performers.filter((p) => + refresh ? p.stash_ids.length > 0 : p.stash_ids.length === 0 + ).length + } performers will be processed`} +
      + setShowBatchAdd(false), + }} + disabled={!isIdle} + > + + + Any names entered will be queried from the remote Stash-Box instance + and added if found. Only exact matches will be considered a match. + + +
      + + +
      +
      {renderPerformers()}
      +
      + ); +}; + +interface ITaggerProps { + performers: GQL.PerformerDataFragment[]; +} + +export const PerformerTagger: React.FC = ({ performers }) => { + const jobStatus = useMetadataUpdate(); + const stashConfig = useConfiguration(); + const [{ data: config }, setConfig] = useLocalForage( + LOCAL_FORAGE_KEY, + initialConfig + ); + const [showConfig, setShowConfig] = useState(false); + const [showManual, setShowManual] = useState(false); + + if (!config) return ; + + const savedEndpointIndex = + stashConfig.data?.configuration.general.stashBoxes.findIndex( + (s) => s.endpoint === config.selectedEndpoint + ) ?? -1; + const selectedEndpointIndex = + savedEndpointIndex === -1 && + stashConfig.data?.configuration.general.stashBoxes.length + ? 0 + : savedEndpointIndex; + const selectedEndpoint = + stashConfig.data?.configuration.general.stashBoxes[selectedEndpointIndex]; + + const progress = + jobStatus.data?.metadataUpdate.status === + "Stash-Box Performer Batch Operation" && + jobStatus.data.metadataUpdate.progress >= 0 + ? jobStatus.data.metadataUpdate.progress * 100 + : null; + + return ( + <> + setShowManual(false)} + defaultActiveTab="Tagger.md" + /> + {progress !== null && ( + +
      Status: Tagging performers
      + +
      + )} +
      + {selectedEndpointIndex !== -1 && selectedEndpoint ? ( + <> +
      + + +
      + + + + + ) : ( +
      +

      + To use the performer tagger a stash-box instance needs to be + configured. +

      +
      + Please see{" "} + + el.scrollIntoView({ behavior: "smooth", block: "center" }) + } + > + Settings. + +
      +
      + )} +
      + + ); +}; diff --git a/ui/v2.5/src/components/Tagger/performers/StashSearchResult.tsx b/ui/v2.5/src/components/Tagger/performers/StashSearchResult.tsx new file mode 100755 index 000000000..cc74777d9 --- /dev/null +++ b/ui/v2.5/src/components/Tagger/performers/StashSearchResult.tsx @@ -0,0 +1,112 @@ +import React, { useState } from "react"; +import { Button } from "react-bootstrap"; + +import * as GQL from "src/core/generated-graphql"; +import { IStashBoxPerformer, filterPerformer } from "../utils"; +import { useUpdatePerformer } from "../queries"; +import PerformerModal from "../PerformerModal"; + +interface IStashSearchResultProps { + performer: GQL.SlimPerformerDataFragment; + stashboxPerformers: IStashBoxPerformer[]; + endpoint: string; + onPerformerTagged: ( + performer: Pick & + Partial> + ) => void; + excludedPerformerFields: string[]; +} + +const StashSearchResult: React.FC = ({ + performer, + stashboxPerformers, + onPerformerTagged, + excludedPerformerFields, + endpoint, +}) => { + const [modalPerformer, setModalPerformer] = useState< + IStashBoxPerformer | undefined + >(); + const [saveState, setSaveState] = useState(""); + const [error, setError] = useState<{ message?: string; details?: string }>( + {} + ); + + const updatePerformer = useUpdatePerformer(); + + const handleSave = async (image: number, excludedFields: string[]) => { + if (modalPerformer) { + const performerData = filterPerformer(modalPerformer, excludedFields); + setError({}); + setSaveState("Saving performer"); + setModalPerformer(undefined); + + const res = await updatePerformer({ + ...performerData, + image: excludedFields.includes("image") + ? undefined + : modalPerformer.images[image], + stash_ids: [{ stash_id: modalPerformer.stash_id, endpoint }], + id: performer.id, + }); + + if (!res?.data?.performerUpdate) + setError({ + message: `Failed to save performer "${performer.name}"`, + details: + res?.errors?.[0].message === + "UNIQUE constraint failed: performers.checksum" + ? "Name already exists" + : res?.errors?.[0].message, + }); + else onPerformerTagged(performer); + setSaveState(""); + } + }; + + const performers = stashboxPerformers.map((p) => ( + + )); + + return ( + <> + {modalPerformer && ( + setModalPerformer(undefined)} + modalVisible={modalPerformer !== undefined} + performer={modalPerformer} + handlePerformerCreate={handleSave} + icon="tags" + header="Update Performer" + excludedPerformerFields={excludedPerformerFields} + endpoint={endpoint} + /> + )} +
      {performers}
      +
      + {error.message && ( +
      + + Error: + {error.message} + +
      {error.details}
      +
      + )} + {saveState && ( + {saveState} + )} +
      + + ); +}; + +export default StashSearchResult; diff --git a/ui/v2.5/src/components/Tagger/queries.ts b/ui/v2.5/src/components/Tagger/queries.ts index d2ef882e4..584080192 100644 --- a/ui/v2.5/src/components/Tagger/queries.ts +++ b/ui/v2.5/src/components/Tagger/queries.ts @@ -31,7 +31,10 @@ export const useUpdatePerformerStashID = () => { query: GQL.FindPerformersDocument, variables: { performer_filter: { - stash_id: newStashID, + stash_id: { + value: newStashID, + modifier: GQL.CriterionModifier.Equals, + }, }, }, data: { @@ -48,6 +51,49 @@ export const useUpdatePerformerStashID = () => { return updatePerformerHandler; }; +export const useUpdatePerformer = () => { + const [updatePerformer] = GQL.usePerformerUpdateMutation({ + onError: (errors) => errors, + errorPolicy: "all", + }); + + const updatePerformerHandler = (input: GQL.PerformerUpdateInput) => + updatePerformer({ + variables: { + input, + }, + update: (store, updatedPerformer) => { + if (!updatedPerformer.data?.performerUpdate) return; + + updatedPerformer.data.performerUpdate.stash_ids.forEach((id) => { + store.writeQuery< + GQL.FindPerformersQuery, + GQL.FindPerformersQueryVariables + >({ + query: GQL.FindPerformersDocument, + variables: { + performer_filter: { + stash_id: { + value: id.stash_id, + modifier: GQL.CriterionModifier.Equals, + }, + }, + }, + data: { + findPerformers: { + count: 1, + performers: [updatedPerformer.data!.performerUpdate!], + __typename: "FindPerformersResultType", + }, + }, + }); + }); + }, + }); + + return updatePerformerHandler; +}; + export const useCreatePerformer = () => { const [createPerformer] = GQL.usePerformerCreateMutation({ onError: (errors) => errors, @@ -91,7 +137,10 @@ export const useCreatePerformer = () => { query: GQL.FindPerformersDocument, variables: { performer_filter: { - stash_id: stashID, + stash_id: { + value: stashID, + modifier: GQL.CriterionModifier.Equals, + }, }, }, data: { @@ -135,7 +184,10 @@ export const useUpdateStudioStashID = () => { query: GQL.FindStudiosDocument, variables: { studio_filter: { - stash_id: newStashID, + stash_id: { + value: newStashID, + modifier: GQL.CriterionModifier.Equals, + }, }, }, data: { @@ -189,7 +241,10 @@ export const useCreateStudio = () => { query: GQL.FindStudiosDocument, variables: { studio_filter: { - stash_id: stashID, + stash_id: { + value: stashID, + modifier: GQL.CriterionModifier.Equals, + }, }, }, data: { diff --git a/ui/v2.5/src/components/Tagger/styles.scss b/ui/v2.5/src/components/Tagger/styles.scss index 29254ce0d..1b42be2a7 100644 --- a/ui/v2.5/src/components/Tagger/styles.scss +++ b/ui/v2.5/src/components/Tagger/styles.scss @@ -92,7 +92,7 @@ .performer-create-modal { font-size: 1.2rem; - max-width: 768px; + max-width: 800px; .image-selection { height: 450px; @@ -100,6 +100,13 @@ .performer-image { height: 85%; + position: relative; + + &-exclude { + position: absolute; + right: 20px; + top: 10px; + } } img { @@ -111,4 +118,89 @@ .LoadingIndicator { height: 100%; } + + &-field { + margin-bottom: 5px; + + .btn { + margin-right: 5px; + } + + .fa-icon { + width: 12px; + } + } +} + +.PerformerTagger { + display: flex; + flex-wrap: wrap; + justify-content: center; + max-width: 1600px; + + &-header { + color: white; + + &:hover { + color: white; + } + } + + &-performer { + background-color: #495b68; + border-radius: 3px; + display: flex; + margin: 1rem; + max-width: 100%; + padding: 1rem; + + .performer-card { + flex-shrink: 0; + width: 12rem; + + img { + height: 100%; + max-height: 18rem; + object-fit: cover; + object-position: top; + } + } + } + + &-details { + flex-grow: 1; + margin-left: 1rem; + width: 24rem; + } + + &-performer-search { + display: flex; + flex-wrap: wrap; + + &-item { + align-items: center; + align-text: left; + display: flex; + overflow: hidden; + } + } + + &-thumb { + height: 40px; + margin-right: 10px; + } + + &-box-link { + margin-bottom: 5px; + + .input-group-text { + font-family: monospace; + } + } +} + +.FieldSelect { + .fa-icon { + width: 12px; + } } diff --git a/ui/v2.5/src/components/Tagger/utils.ts b/ui/v2.5/src/components/Tagger/utils.ts index 6222cde6d..c669a7769 100644 --- a/ui/v2.5/src/components/Tagger/utils.ts +++ b/ui/v2.5/src/components/Tagger/utils.ts @@ -107,7 +107,7 @@ const selectTags = (tags: GQL.ScrapedSceneTag[]): IStashBoxTag[] => name: t.name ?? "", })); -const selectPerformers = ( +export const selectPerformers = ( performers: GQL.ScrapedScenePerformer[] ): IStashBoxPerformer[] => performers.map((p) => ({ @@ -186,3 +186,63 @@ export const sortScenesByDuration = ( if (aDiff > bDiff) return 1; return 0; }); + +export const filterPerformer = ( + performer: IStashBoxPerformer, + excludedFields: string[] +) => { + const { + name, + aliases, + gender, + birthdate, + ethnicity, + country, + eye_color, + height, + measurements, + fake_tits, + career_length, + tattoos, + piercings, + } = performer; + return { + name: !excludedFields.includes("name") && name ? name : undefined, + aliases: + !excludedFields.includes("aliases") && aliases ? aliases : undefined, + gender: !excludedFields.includes("gender") && gender ? gender : undefined, + birthdate: + !excludedFields.includes("birthdate") && birthdate + ? birthdate + : undefined, + ethnicity: + !excludedFields.includes("ethnicity") && ethnicity + ? ethnicity + : undefined, + country: + !excludedFields.includes("country") && country ? country : undefined, + eye_color: + !excludedFields.includes("eye_color") && eye_color + ? eye_color + : undefined, + height: !excludedFields.includes("height") && height ? height : undefined, + measurements: + !excludedFields.includes("measurements") && measurements + ? measurements + : undefined, + fake_tits: + !excludedFields.includes("fake_tits") && fake_tits + ? fake_tits + : undefined, + career_length: + !excludedFields.includes("career_length") && career_length + ? career_length + : undefined, + tattoos: + !excludedFields.includes("tattoos") && tattoos ? tattoos : undefined, + piercings: + !excludedFields.includes("piercings") && piercings + ? piercings + : undefined, + }; +}; diff --git a/ui/v2.5/src/core/StashService.ts b/ui/v2.5/src/core/StashService.ts index 780507119..b95a04739 100644 --- a/ui/v2.5/src/core/StashService.ts +++ b/ui/v2.5/src/core/StashService.ts @@ -813,6 +813,20 @@ export const queryStashBoxScene = (stashBoxIndex: number, sceneID: string) => }, }); +export const queryStashBoxPerformer = ( + stashBoxIndex: number, + performerID: string +) => + client.query({ + query: GQL.QueryStashBoxPerformerDocument, + variables: { + input: { + stash_box_index: stashBoxIndex, + performer_ids: [performerID], + }, + }, + }); + export const queryScrapeGallery = ( scraperId: string, gallery: GQL.GalleryUpdateInput @@ -1006,7 +1020,7 @@ export const makePerformerCreateInput = ( return input; }; -export const stashBoxQuery = (searchVal: string, stashBoxIndex: number) => +export const stashBoxSceneQuery = (searchVal: string, stashBoxIndex: number) => client?.query< GQL.QueryStashBoxSceneQuery, GQL.QueryStashBoxSceneQueryVariables @@ -1015,7 +1029,22 @@ export const stashBoxQuery = (searchVal: string, stashBoxIndex: number) => variables: { input: { q: searchVal, stash_box_index: stashBoxIndex } }, }); -export const stashBoxBatchQuery = (sceneIds: string[], stashBoxIndex: number) => +export const stashBoxPerformerQuery = ( + searchVal: string, + stashBoxIndex: number +) => + client?.query< + GQL.QueryStashBoxPerformerQuery, + GQL.QueryStashBoxPerformerQueryVariables + >({ + query: GQL.QueryStashBoxPerformerDocument, + variables: { input: { q: searchVal, stash_box_index: stashBoxIndex } }, + }); + +export const stashBoxSceneBatchQuery = ( + sceneIds: string[], + stashBoxIndex: number +) => client?.query< GQL.QueryStashBoxSceneQuery, GQL.QueryStashBoxSceneQueryVariables @@ -1025,3 +1054,17 @@ export const stashBoxBatchQuery = (sceneIds: string[], stashBoxIndex: number) => input: { scene_ids: sceneIds, stash_box_index: stashBoxIndex }, }, }); + +export const stashBoxPerformerBatchQuery = ( + performerIds: string[], + stashBoxIndex: number +) => + client?.query< + GQL.QueryStashBoxPerformerQuery, + GQL.QueryStashBoxPerformerQueryVariables + >({ + query: GQL.QueryStashBoxPerformerDocument, + variables: { + input: { performer_ids: performerIds, stash_box_index: stashBoxIndex }, + }, + }); diff --git a/ui/v2.5/src/models/list-filter/criteria/criterion.ts b/ui/v2.5/src/models/list-filter/criteria/criterion.ts index 382024976..e8a56792d 100644 --- a/ui/v2.5/src/models/list-filter/criteria/criterion.ts +++ b/ui/v2.5/src/models/list-filter/criteria/criterion.ts @@ -52,7 +52,8 @@ export type CriterionType = | "gallery_count" | "performer_count" | "death_year" - | "url"; + | "url" + | "stash_id"; type Option = string | number | IOptionType; export type CriterionValue = string | number | ILabeledId[]; @@ -150,6 +151,8 @@ export abstract class Criterion { return "Performer Count"; case "url": return "URL"; + case "stash_id": + return "StashID"; } } diff --git a/ui/v2.5/src/models/list-filter/criteria/is-missing.ts b/ui/v2.5/src/models/list-filter/criteria/is-missing.ts index d77557e8b..2ab7325ec 100644 --- a/ui/v2.5/src/models/list-filter/criteria/is-missing.ts +++ b/ui/v2.5/src/models/list-filter/criteria/is-missing.ts @@ -20,7 +20,6 @@ export class SceneIsMissingCriterion extends IsMissingCriterion { "movie", "performers", "tags", - "stash_id", ]; } @@ -66,7 +65,6 @@ export class PerformerIsMissingCriterion extends IsMissingCriterion { "gender", "scenes", "image", - "stash_id", "details", ]; } @@ -107,7 +105,7 @@ export class TagIsMissingCriterionOption implements ICriterionOption { export class StudioIsMissingCriterion extends IsMissingCriterion { public type: CriterionType = "studioIsMissing"; - public options: string[] = ["image", "stash_id", "details"]; + public options: string[] = ["image", "details"]; } export class StudioIsMissingCriterionOption implements ICriterionOption { diff --git a/ui/v2.5/src/models/list-filter/criteria/utils.ts b/ui/v2.5/src/models/list-filter/criteria/utils.ts index 2f1e82030..3e6283975 100644 --- a/ui/v2.5/src/models/list-filter/criteria/utils.ts +++ b/ui/v2.5/src/models/list-filter/criteria/utils.ts @@ -107,6 +107,7 @@ export function makeCriteria(type: CriterionType = "none") { case "piercings": case "aliases": case "url": + case "stash_id": return new StringCriterion(type, type); } } diff --git a/ui/v2.5/src/models/list-filter/filter.ts b/ui/v2.5/src/models/list-filter/filter.ts index 64b282b3e..729063d90 100644 --- a/ui/v2.5/src/models/list-filter/filter.ts +++ b/ui/v2.5/src/models/list-filter/filter.ts @@ -161,6 +161,7 @@ export class ListFilterModel { new StudiosCriterionOption(), new MoviesCriterionOption(), ListFilterModel.createCriterionOption("url"), + ListFilterModel.createCriterionOption("stash_id"), ]; break; case FilterMode.Images: @@ -204,7 +205,11 @@ export class ListFilterModel { "random", "rating", ]; - this.displayModeOptions = [DisplayMode.Grid, DisplayMode.List]; + this.displayModeOptions = [ + DisplayMode.Grid, + DisplayMode.List, + DisplayMode.Tagger, + ]; const numberCriteria: CriterionType[] = [ "birth_year", @@ -224,6 +229,7 @@ export class ListFilterModel { "tattoos", "piercings", "aliases", + "stash_id", ]; this.criterionOptions = [ @@ -265,6 +271,7 @@ export class ListFilterModel { ListFilterModel.createCriterionOption("image_count"), ListFilterModel.createCriterionOption("gallery_count"), ListFilterModel.createCriterionOption("url"), + ListFilterModel.createCriterionOption("stash_id"), ]; break; case FilterMode.Movies: @@ -655,6 +662,14 @@ export class ListFilterModel { }; break; } + case "stash_id": { + const stashIdCrit = criterion as StringCriterion; + result.stash_id = { + value: stashIdCrit.value, + modifier: stashIdCrit.modifier, + }; + break; + } // no default } }); @@ -832,6 +847,14 @@ export class ListFilterModel { }; break; } + case "stash_id": { + const stashIdCrit = criterion as StringCriterion; + result.stash_id = { + value: stashIdCrit.value, + modifier: stashIdCrit.modifier, + }; + break; + } // no default } }); @@ -1099,6 +1122,14 @@ export class ListFilterModel { }; break; } + case "stash_id": { + const stashIdCrit = criterion as StringCriterion; + result.stash_id = { + value: stashIdCrit.value, + modifier: stashIdCrit.modifier, + }; + break; + } // no default } }); diff --git a/ui/v2.5/src/utils/text.ts b/ui/v2.5/src/utils/text.ts index cf6c512fa..aecac9f8e 100644 --- a/ui/v2.5/src/utils/text.ts +++ b/ui/v2.5/src/utils/text.ts @@ -186,6 +186,11 @@ const formatDate = (intl: IntlShape, date?: string) => { return intl.formatDate(date, { format: "long", timeZone: "utc" }); }; +const capitalize = (val: string) => + val + .replace(/^[-_]*(.)/, (_, c) => c.toUpperCase()) + .replace(/[-_]+(.)/g, (_, c) => ` ${c.toUpperCase()}`); + const TextUtils = { fileSize, formatFileSizeUnit, @@ -200,6 +205,7 @@ const TextUtils = { twitterURL, instagramURL, formatDate, + capitalize, }; export default TextUtils; diff --git a/ui/v2.5/yarn.lock b/ui/v2.5/yarn.lock index fad7ebb00..3ff710deb 100644 --- a/ui/v2.5/yarn.lock +++ b/ui/v2.5/yarn.lock @@ -2881,11 +2881,12 @@ "@types/history" "*" "@types/react" "*" -"@types/react-select@^3.1.2": - version "3.1.2" - resolved "https://registry.yarnpkg.com/@types/react-select/-/react-select-3.1.2.tgz#38627df4b49be9b28f800ed72b35d830369a624b" - integrity sha512-ygvR/2FL87R2OLObEWFootYzkvm67LRA+URYEAcBuvKk7IXmdsnIwSGm60cVXGaqkJQHozb2Cy1t94tCYb6rJA== +"@types/react-select@^4.0.8": + version "4.0.8" + resolved "https://registry.yarnpkg.com/@types/react-select/-/react-select-4.0.8.tgz#109e8223cf3d9a50c20b386b3bc7fbc46c701916" + integrity sha512-dOfoJxPq4s4shWmI9mDjhs6w7tXlH4bgQarqp5HulL3jgwzEPGK/DaGah4pdCNrY70mnIvMAN7cAzZbUWomESQ== dependencies: + "@emotion/serialize" "^1.0.0" "@types/react" "*" "@types/react-dom" "*" "@types/react-transition-group" "*" From 2ab42e9cd316956bff03f6dfd249552a8c81a953 Mon Sep 17 00:00:00 2001 From: bnkai <48220860+bnkai@users.noreply.github.com> Date: Mon, 3 May 2021 07:21:51 +0300 Subject: [PATCH 59/66] Populate image/gallery title during scan (#1359) --- pkg/image/image.go | 7 +++++++ pkg/manager/task_scan.go | 11 +++++++++++ pkg/utils/file.go | 11 +++++++++++ ui/v2.5/src/components/Changelog/versions/v070.md | 1 + 4 files changed, 30 insertions(+) diff --git a/pkg/image/image.go b/pkg/image/image.go index d54271d66..64b766e6d 100644 --- a/pkg/image/image.go +++ b/pkg/image/image.go @@ -257,3 +257,10 @@ func GetTitle(s *models.Image) string { _, fn := getFilePath(s.Path) return filepath.Base(fn) } + +// GetFilename gets the base name of the image file +// If stripExt is set the file extension is omitted from the name +func GetFilename(s *models.Image, stripExt bool) string { + _, fn := getFilePath(s.Path) + return utils.GetNameFromPath(fn, stripExt) +} diff --git a/pkg/manager/task_scan.go b/pkg/manager/task_scan.go index 361793c10..41497cb9e 100644 --- a/pkg/manager/task_scan.go +++ b/pkg/manager/task_scan.go @@ -240,6 +240,10 @@ func (t *ScanTask) scanGallery() { Timestamp: fileModTime, Valid: true, }, + Title: sql.NullString{ + String: utils.GetNameFromPath(t.FilePath, t.StripFileExtension), + Valid: true, + }, CreatedAt: models.SQLiteTimestamp{Timestamp: currentTime}, UpdatedAt: models.SQLiteTimestamp{Timestamp: currentTime}, } @@ -853,6 +857,9 @@ func (t *ScanTask) scanImage() { CreatedAt: models.SQLiteTimestamp{Timestamp: currentTime}, UpdatedAt: models.SQLiteTimestamp{Timestamp: currentTime}, } + newImage.Title.String = image.GetFilename(&newImage, t.StripFileExtension) + newImage.Title.Valid = true + if err := image.SetFileDetails(&newImage); err != nil { logger.Error(err.Error()) return @@ -966,6 +973,10 @@ func (t *ScanTask) associateImageWithFolderGallery(imageID int, qb models.Galler }, CreatedAt: models.SQLiteTimestamp{Timestamp: currentTime}, UpdatedAt: models.SQLiteTimestamp{Timestamp: currentTime}, + Title: sql.NullString{ + String: utils.GetNameFromPath(path, false), + Valid: true, + }, } logger.Infof("Creating gallery for folder %s", path) diff --git a/pkg/utils/file.go b/pkg/utils/file.go index 563c3fa54..6ecdf2c50 100644 --- a/pkg/utils/file.go +++ b/pkg/utils/file.go @@ -283,3 +283,14 @@ func IsPathInDir(dir, pathToCheck string) bool { return false } + +// GetNameFromPath returns the name of a file from its path +// if stripExtension is true the extension is omitted from the name +func GetNameFromPath(path string, stripExtension bool) string { + fn := filepath.Base(path) + if stripExtension { + ext := filepath.Ext(fn) + fn = strings.TrimSuffix(fn, ext) + } + return fn +} diff --git a/ui/v2.5/src/components/Changelog/versions/v070.md b/ui/v2.5/src/components/Changelog/versions/v070.md index 6766e30ee..c727e269f 100644 --- a/ui/v2.5/src/components/Changelog/versions/v070.md +++ b/ui/v2.5/src/components/Changelog/versions/v070.md @@ -33,6 +33,7 @@ * Change performer text query to search by name and alias only. ### 🐛 Bug fixes +* Fix image/gallery title not being set during scan. * Reverted video previews always playing on small devices. * Fix performer/studio being cleared when skipped in scene tagger. * Fixed error when auto-tagging for performers/studios/tags with regex characters in the name. From 08c294414dfac9e7170ace52b2c2e763394265b9 Mon Sep 17 00:00:00 2001 From: WithoutPants <53250216+WithoutPants@users.noreply.github.com> Date: Tue, 4 May 2021 07:42:33 +1000 Subject: [PATCH 60/66] Fix initial plugins/scrapers paths when initialising in home directory (#1358) * Only set default values once config file present * Fix configLocation presentation when using home --- pkg/manager/config/config.go | 17 +++++++++++++++-- pkg/manager/config/init.go | 17 ----------------- pkg/manager/manager.go | 8 ++------ ui/v2.5/src/components/Setup/Setup.tsx | 4 ++++ 4 files changed, 21 insertions(+), 25 deletions(-) diff --git a/pkg/manager/config/config.go b/pkg/manager/config/config.go index 1c6a17516..2da527ce2 100644 --- a/pkg/manager/config/config.go +++ b/pkg/manager/config/config.go @@ -687,12 +687,25 @@ func (i *Instance) Validate() error { return nil } -func setDefaultValues() { +func (i *Instance) setDefaultValues() { viper.SetDefault(ParallelTasks, parallelTasksDefault) viper.SetDefault(PreviewSegmentDuration, previewSegmentDurationDefault) viper.SetDefault(PreviewSegments, previewSegmentsDefault) viper.SetDefault(PreviewExcludeStart, previewExcludeStartDefault) viper.SetDefault(PreviewExcludeEnd, previewExcludeEndDefault) + + // #1356 - only set these defaults once config file exists + if i.GetConfigFile() != "" { + viper.SetDefault(Database, i.GetDefaultDatabaseFilePath()) + + // Set generated to the metadata path for backwards compat + viper.SetDefault(Generated, viper.GetString(Metadata)) + + // Set default scrapers and plugins paths + viper.SetDefault(ScrapersPath, i.GetDefaultScrapersPath()) + viper.SetDefault(PluginsPath, i.GetDefaultPluginsPath()) + viper.WriteConfig() + } } // SetInitialConfig fills in missing required config fields @@ -710,5 +723,5 @@ func (i *Instance) SetInitialConfig() { i.Set(SessionStoreKey, sessionStoreKey) } - setDefaultValues() + i.setDefaultValues() } diff --git a/pkg/manager/config/init.go b/pkg/manager/config/init.go index 932641ff8..a2b98cf0b 100644 --- a/pkg/manager/config/init.go +++ b/pkg/manager/config/init.go @@ -51,28 +51,11 @@ func initConfig(flags flagStruct) error { err := viper.ReadInConfig() // Find and read the config file // continue, but set an error to be handled by caller - postInitConfig() instance.SetInitialConfig() return err } -func postInitConfig() { - c := instance - if c.GetConfigFile() != "" { - viper.SetDefault(Database, c.GetDefaultDatabaseFilePath()) - } - - // Set generated to the metadata path for backwards compat - viper.SetDefault(Generated, viper.GetString(Metadata)) - - // Set default scrapers and plugins paths - viper.SetDefault(ScrapersPath, c.GetDefaultScrapersPath()) - viper.SetDefault(PluginsPath, c.GetDefaultPluginsPath()) - - viper.WriteConfig() -} - func initFlags() flagStruct { flags := flagStruct{} diff --git a/pkg/manager/manager.go b/pkg/manager/manager.go index 1ead1e463..df3b3d50d 100644 --- a/pkg/manager/manager.go +++ b/pkg/manager/manager.go @@ -40,12 +40,6 @@ type singleton struct { var instance *singleton var once sync.Once -type flagStruct struct { - configFilePath string -} - -var flags = flagStruct{} - func GetInstance() *singleton { Initialize() return instance @@ -134,6 +128,8 @@ func initPluginCache() *plugin.Cache { // configuration has been set. Should only be called if the configuration // is valid. func (s *singleton) PostInit() error { + s.Config.SetInitialConfig() + s.Paths = paths.NewPaths(s.Config.GetGeneratedPath()) s.PluginCache = initPluginCache() s.ScraperCache = instance.initScraperCache() diff --git a/ui/v2.5/src/components/Setup/Setup.tsx b/ui/v2.5/src/components/Setup/Setup.tsx index 14fb46aa3..82465ee8f 100644 --- a/ui/v2.5/src/components/Setup/Setup.tsx +++ b/ui/v2.5/src/components/Setup/Setup.tsx @@ -225,6 +225,10 @@ export const Setup: React.FC = () => { return <current working directory>/config.yml; } + if (configLocation === "") { + return $HOME/.stash/config.yml; + } + return {configLocation}; } From 31981d41165687320d5524bcd72da29eb97bc6e4 Mon Sep 17 00:00:00 2001 From: InfiniteTF Date: Wed, 5 May 2021 05:22:05 +0200 Subject: [PATCH 61/66] Add in-memory screenshot generation for sprites and phash (#1316) --- pkg/ffmpeg/encoder.go | 30 +++++++++++++-- pkg/ffmpeg/encoder_sprite_screenshot.go | 38 +++++++++++++++++++ pkg/ffmpeg/encoder_transcode.go | 8 ++-- pkg/manager/generator_phash.go | 34 +++-------------- pkg/manager/generator_sprite.go | 27 +++---------- .../src/components/Changelog/versions/v070.md | 1 + 6 files changed, 82 insertions(+), 56 deletions(-) create mode 100644 pkg/ffmpeg/encoder_sprite_screenshot.go diff --git a/pkg/ffmpeg/encoder.go b/pkg/ffmpeg/encoder.go index 5beb09410..9d96dadf3 100644 --- a/pkg/ffmpeg/encoder.go +++ b/pkg/ffmpeg/encoder.go @@ -1,7 +1,7 @@ package ffmpeg import ( - "fmt" + "bytes" "io/ioutil" "os" "os/exec" @@ -62,7 +62,7 @@ func KillRunningEncoders(path string) { for _, process := range processes { // assume it worked, don't check for error - fmt.Printf("Killing encoder process for file: %s", path) + logger.Infof("Killing encoder process for file: %s", path) process.Kill() // wait for the process to die before returning @@ -82,7 +82,8 @@ func KillRunningEncoders(path string) { } } -func (e *Encoder) run(probeResult VideoFile, args []string) (string, error) { +// FFmpeg runner with progress output, used for transcodes +func (e *Encoder) runTranscode(probeResult VideoFile, args []string) (string, error) { cmd := exec.Command(e.Path, args...) stderr, err := cmd.StderrPipe() @@ -137,3 +138,26 @@ func (e *Encoder) run(probeResult VideoFile, args []string) (string, error) { return stdoutString, nil } + +func (e *Encoder) run(probeResult VideoFile, args []string) (string, error) { + cmd := exec.Command(e.Path, args...) + + var stdout, stderr bytes.Buffer + cmd.Stdout = &stdout + cmd.Stderr = &stderr + + if err := cmd.Start(); err != nil { + return "", err + } + + registerRunningEncoder(probeResult.Path, cmd.Process) + err := waitAndDeregister(probeResult.Path, cmd) + + if err != nil { + // error message should be in the stderr stream + logger.Errorf("ffmpeg error when running command <%s>: %s", strings.Join(cmd.Args, " "), stderr.String()) + return stdout.String(), err + } + + return stdout.String(), nil +} diff --git a/pkg/ffmpeg/encoder_sprite_screenshot.go b/pkg/ffmpeg/encoder_sprite_screenshot.go new file mode 100644 index 000000000..c1a87788e --- /dev/null +++ b/pkg/ffmpeg/encoder_sprite_screenshot.go @@ -0,0 +1,38 @@ +package ffmpeg + +import ( + "fmt" + "image" + "strings" +) + +type SpriteScreenshotOptions struct { + Time float64 + Width int +} + +func (e *Encoder) SpriteScreenshot(probeResult VideoFile, options SpriteScreenshotOptions) (image.Image, error) { + args := []string{ + "-v", "error", + "-ss", fmt.Sprintf("%v", options.Time), + "-i", probeResult.Path, + "-vframes", "1", + "-vf", fmt.Sprintf("scale=%v:-1", options.Width), + "-c:v", "bmp", + "-f", "rawvideo", + "-", + } + data, err := e.run(probeResult, args) + if err != nil { + return nil, err + } + + reader := strings.NewReader(data) + + img, _, err := image.Decode(reader) + if err != nil { + return nil, err + } + + return img, err +} diff --git a/pkg/ffmpeg/encoder_transcode.go b/pkg/ffmpeg/encoder_transcode.go index 1349bac70..235fb6959 100644 --- a/pkg/ffmpeg/encoder_transcode.go +++ b/pkg/ffmpeg/encoder_transcode.go @@ -64,7 +64,7 @@ func (e *Encoder) Transcode(probeResult VideoFile, options TranscodeOptions) { "-strict", "-2", options.OutputPath, } - _, _ = e.run(probeResult, args) + _, _ = e.runTranscode(probeResult, args) } //transcode the video, remove the audio @@ -84,7 +84,7 @@ func (e *Encoder) TranscodeVideo(probeResult VideoFile, options TranscodeOptions "-vf", "scale=" + scale, options.OutputPath, } - _, _ = e.run(probeResult, args) + _, _ = e.runTranscode(probeResult, args) } //copy the video stream as is, transcode audio @@ -96,7 +96,7 @@ func (e *Encoder) TranscodeAudio(probeResult VideoFile, options TranscodeOptions "-strict", "-2", options.OutputPath, } - _, _ = e.run(probeResult, args) + _, _ = e.runTranscode(probeResult, args) } //copy the video stream as is, drop audio @@ -107,5 +107,5 @@ func (e *Encoder) CopyVideo(probeResult VideoFile, options TranscodeOptions) { "-c:v", "copy", options.OutputPath, } - _, _ = e.run(probeResult, args) + _, _ = e.runTranscode(probeResult, args) } diff --git a/pkg/manager/generator_phash.go b/pkg/manager/generator_phash.go index 4e711560b..5ea390452 100644 --- a/pkg/manager/generator_phash.go +++ b/pkg/manager/generator_phash.go @@ -5,12 +5,9 @@ import ( "image" "image/color" "math" - "os" - "sort" "github.com/corona10/goimagehash" "github.com/disintegration/imaging" - "github.com/fvbommel/sortorder" "github.com/stashapp/stash/pkg/ffmpeg" "github.com/stashapp/stash/pkg/logger" @@ -67,37 +64,22 @@ func (g *PhashGenerator) generateSprite(encoder *ffmpeg.Encoder) (image.Image, e chunkCount := g.Columns * g.Rows offset := 0.05 * g.Info.VideoFile.Duration stepSize := (0.9 * g.Info.VideoFile.Duration) / float64(chunkCount) + var images []image.Image for i := 0; i < chunkCount; i++ { time := offset + (float64(i) * stepSize) - num := fmt.Sprintf("%.3d", i) - filename := "phash_" + g.VideoChecksum + "_" + num + ".bmp" - options := ffmpeg.ScreenshotOptions{ - OutputPath: instance.Paths.Generated.GetTmpPath(filename), - Time: time, - Width: 160, + options := ffmpeg.SpriteScreenshotOptions{ + Time: time, + Width: 160, } - if err := encoder.Screenshot(g.Info.VideoFile, options); err != nil { - return nil, err - } - } - - // Combine all of the thumbnails into a sprite image - pattern := fmt.Sprintf("phash_%s_.+\\.bmp$", g.VideoChecksum) - imagePaths, err := utils.MatchEntries(instance.Paths.Generated.Tmp, pattern) - if err != nil { - return nil, err - } - sort.Sort(sortorder.Natural(imagePaths)) - var images []image.Image - for _, imagePath := range imagePaths { - img, err := imaging.Open(imagePath) + img, err := encoder.SpriteScreenshot(g.Info.VideoFile, options) if err != nil { return nil, err } images = append(images, img) } + // Combine all of the thumbnails into a sprite image if len(images) == 0 { return nil, fmt.Errorf("images slice is empty, failed to generate phash sprite for %s", g.Info.VideoFile.Path) } @@ -113,9 +95,5 @@ func (g *PhashGenerator) generateSprite(encoder *ffmpeg.Encoder) (image.Image, e montage = imaging.Paste(montage, img, image.Pt(x, y)) } - for _, imagePath := range imagePaths { - os.Remove(imagePath) - } - return montage, nil } diff --git a/pkg/manager/generator_sprite.go b/pkg/manager/generator_sprite.go index 7bbc780c4..457ad4ca5 100644 --- a/pkg/manager/generator_sprite.go +++ b/pkg/manager/generator_sprite.go @@ -8,11 +8,9 @@ import ( "math" "os" "path/filepath" - "sort" "strings" "github.com/disintegration/imaging" - "github.com/fvbommel/sortorder" "github.com/stashapp/stash/pkg/ffmpeg" "github.com/stashapp/stash/pkg/logger" @@ -75,29 +73,15 @@ func (g *SpriteGenerator) generateSpriteImage(encoder *ffmpeg.Encoder) error { // Create `this.chunkCount` thumbnails in the tmp directory stepSize := g.Info.VideoFile.Duration / float64(g.Info.ChunkCount) + var images []image.Image for i := 0; i < g.Info.ChunkCount; i++ { time := float64(i) * stepSize - num := fmt.Sprintf("%.3d", i) - filename := "thumbnail_" + g.VideoChecksum + "_" + num + ".jpg" - options := ffmpeg.ScreenshotOptions{ - OutputPath: instance.Paths.Generated.GetTmpPath(filename), - Time: time, - Width: 160, + options := ffmpeg.SpriteScreenshotOptions{ + Time: time, + Width: 160, } - encoder.Screenshot(g.Info.VideoFile, options) - } - - // Combine all of the thumbnails into a sprite image - pattern := fmt.Sprintf("thumbnail_%s_.+\\.jpg$", g.VideoChecksum) - imagePaths, err := utils.MatchEntries(instance.Paths.Generated.Tmp, pattern) - if err != nil { - return err - } - sort.Sort(sortorder.Natural(imagePaths)) - var images []image.Image - for _, imagePath := range imagePaths { - img, err := imaging.Open(imagePath) + img, err := encoder.SpriteScreenshot(g.Info.VideoFile, options) if err != nil { return err } @@ -107,6 +91,7 @@ func (g *SpriteGenerator) generateSpriteImage(encoder *ffmpeg.Encoder) error { if len(images) == 0 { return fmt.Errorf("images slice is empty, failed to generate sprite images for %s", g.Info.VideoFile.Path) } + // Combine all of the thumbnails into a sprite image width := images[0].Bounds().Size().X height := images[0].Bounds().Size().Y canvasWidth := width * g.Columns diff --git a/ui/v2.5/src/components/Changelog/versions/v070.md b/ui/v2.5/src/components/Changelog/versions/v070.md index c727e269f..915f22be6 100644 --- a/ui/v2.5/src/components/Changelog/versions/v070.md +++ b/ui/v2.5/src/components/Changelog/versions/v070.md @@ -15,6 +15,7 @@ * Added scene queue. ### 🎨 Improvements +* Improve sprite generation performance when using network storage. * Remove duplicate values when scraping lists of elements. * Improved performance of the auto-tagger. * Clean generation artifacts after generating each scene. From bdac352250dbbb7c6a34ce79847806e93439ef58 Mon Sep 17 00:00:00 2001 From: WithoutPants <53250216+WithoutPants@users.noreply.github.com> Date: Wed, 5 May 2021 14:14:39 +1000 Subject: [PATCH 62/66] Update demo video link --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index e81be3020..ce1322be4 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,7 @@ https://stashapp.cc * You can tag videos and find them later. * It provides statistics about performers, tags, studios and other things. -You can [watch a demo video](https://vimeo.com/275537038) to see it in action (password is stashapp). +You can [watch a SFW demo video](https://vimeo.com/545323354) to see it in action. For further information you can [read the in-app manual](ui/v2.5/src/docs/en). From 81cf3d3337f570ebaf3bd4b8e6c464a5f61e0a45 Mon Sep 17 00:00:00 2001 From: InfiniteTF Date: Fri, 7 May 2021 05:00:29 +0200 Subject: [PATCH 63/66] Fix sorting of tagger fingerprint matches (#1369) --- .../components/Tagger/StashSearchResult.tsx | 5 +++- ui/v2.5/src/components/Tagger/Tagger.tsx | 25 ++++++++++++------- ui/v2.5/src/components/Tagger/utils.ts | 9 +++++++ 3 files changed, 29 insertions(+), 10 deletions(-) diff --git a/ui/v2.5/src/components/Tagger/StashSearchResult.tsx b/ui/v2.5/src/components/Tagger/StashSearchResult.tsx index c823a8c48..75d3f45de 100755 --- a/ui/v2.5/src/components/Tagger/StashSearchResult.tsx +++ b/ui/v2.5/src/components/Tagger/StashSearchResult.tsx @@ -46,7 +46,10 @@ const getDurationStatus = (
      ); - const minDiff = Math.min(scene.duration, ...durations); + const minDiff = Math.min( + Math.abs(scene.duration - stashDuration), + ...durations + ); return
      Duration off by at least {Math.floor(minDiff)}s
      ; }; diff --git a/ui/v2.5/src/components/Tagger/Tagger.tsx b/ui/v2.5/src/components/Tagger/Tagger.tsx index 24e94dac4..f4b089fc0 100755 --- a/ui/v2.5/src/components/Tagger/Tagger.tsx +++ b/ui/v2.5/src/components/Tagger/Tagger.tsx @@ -2,6 +2,7 @@ import React, { useEffect, useRef, useState } from "react"; import { Button, Card, Form, InputGroup } from "react-bootstrap"; import { Link } from "react-router-dom"; import { HashLink } from "react-router-hash-link"; +import { uniqBy } from "lodash"; import { ScenePreview } from "src/components/Scenes/SceneCard"; import { useLocalForage } from "src/hooks"; @@ -333,11 +334,17 @@ const TaggerList: React.FC = ({ config.mode, config.blacklist ); - const fingerprintMatch = - fingerprints[scene.checksum ?? ""] ?? - fingerprints[scene.oshash ?? ""] ?? - fingerprints[scene.phash ?? ""] ?? - null; + + // Get all scenes matching one of the fingerprints, and return array of unique scenes + const fingerprintMatches = uniqBy( + [ + ...(fingerprints[scene.checksum ?? ""] ?? []), + ...(fingerprints[scene.oshash ?? ""] ?? []), + ...(fingerprints[scene.phash ?? ""] ?? []), + ].flat(), + (f) => f.stash_id + ); + const isTagged = taggedScenes[scene.id]; const hasStashIDs = scene.stash_ids.length > 0; const width = scene.file.width ? scene.file.width : 0; @@ -432,9 +439,9 @@ const TaggerList: React.FC = ({ } let searchResult; - if (fingerprintMatch && !isTagged && !hasStashIDs) { + if (fingerprintMatches.length > 0 && !isTagged && !hasStashIDs) { searchResult = sortScenesByDuration( - fingerprintMatch, + fingerprintMatches, scene.file.duration ?? 0 ).map((match, i) => ( = ({ } else if ( searchResults[scene.id]?.length > 0 && !isTagged && - !fingerprintMatch + fingerprintMatches.length === 0 ) { searchResult = (
        @@ -495,7 +502,7 @@ const TaggerList: React.FC = ({ ); } - return hideUnmatched && !fingerprintMatch ? null : ( + return hideUnmatched && fingerprintMatches.length === 0 ? null : (
        diff --git a/ui/v2.5/src/components/Tagger/utils.ts b/ui/v2.5/src/components/Tagger/utils.ts index c669a7769..3eec89786 100644 --- a/ui/v2.5/src/components/Tagger/utils.ts +++ b/ui/v2.5/src/components/Tagger/utils.ts @@ -179,6 +179,15 @@ export const sortScenesByDuration = ( if (aDur.length > 0 && bDur.length === 0) return -1; if (aDur.length === 0 && bDur.length > 0) return 1; + const aMatches = aDur.filter((match) => match <= 5); + const bMatches = bDur.filter((match) => match <= 5); + + if (aMatches.length > 0 || bMatches.length > 0) { + if (aMatches.length > bMatches.length) return -1; + if (aMatches.length < bMatches.length) return 1; + return 0; + } + const aDiff = Math.min(...aDur); const bDiff = Math.min(...bDur); From 3f97b3a1cbfc2dbea59ee1d6c0150475c376cea2 Mon Sep 17 00:00:00 2001 From: WithoutPants <53250216+WithoutPants@users.noreply.github.com> Date: Sun, 9 May 2021 19:25:57 +1000 Subject: [PATCH 64/66] Remove unnecessary graphql fields (#1370) * Remove unnecessary graphql fields * Optimise joined queries * Tag resolver query optimisation --- graphql/documents/data/gallery-slim.graphql | 25 +++++++-- graphql/documents/data/gallery.graphql | 6 +-- graphql/documents/data/image.graphql | 4 +- graphql/documents/data/movie.graphql | 2 +- graphql/documents/data/performer.graphql | 2 +- graphql/documents/data/scene.graphql | 6 +-- graphql/documents/data/tag-slim.graphql | 5 ++ graphql/documents/queries/gallery.graphql | 2 +- pkg/sqlite/filter.go | 54 +++++++++++++++++++ pkg/sqlite/gallery.go | 28 +++++++--- pkg/sqlite/image.go | 28 +++++++--- pkg/sqlite/scene.go | 14 +++-- .../Galleries/EditGalleriesDialog.tsx | 12 ++--- .../src/components/Galleries/GalleryCard.tsx | 2 +- .../GalleryDetails/GalleryScenesPanel.tsx | 2 +- .../src/components/Galleries/GalleryList.tsx | 6 +-- .../components/Galleries/GalleryWallCard.tsx | 2 +- .../SceneDetails/SceneGalleriesPanel.tsx | 2 +- ui/v2.5/src/hooks/ListHook.tsx | 6 +-- 19 files changed, 156 insertions(+), 52 deletions(-) create mode 100644 graphql/documents/data/tag-slim.graphql diff --git a/graphql/documents/data/gallery-slim.graphql b/graphql/documents/data/gallery-slim.graphql index 51dbc3484..c408f8deb 100644 --- a/graphql/documents/data/gallery-slim.graphql +++ b/graphql/documents/data/gallery-slim.graphql @@ -1,4 +1,4 @@ -fragment GallerySlimData on Gallery { +fragment SlimGalleryData on Gallery { id checksum path @@ -10,16 +10,31 @@ fragment GallerySlimData on Gallery { organized image_count cover { - ...SlimImageData + file { + size + width + height + } + + paths { + thumbnail + } } studio { - ...StudioData + id + name + image_path } tags { - ...TagData + id + name } performers { - ...PerformerData + id + name + gender + favorite + image_path } scenes { id diff --git a/graphql/documents/data/gallery.graphql b/graphql/documents/data/gallery.graphql index 7c7fd8e24..d1475157a 100644 --- a/graphql/documents/data/gallery.graphql +++ b/graphql/documents/data/gallery.graphql @@ -15,16 +15,16 @@ fragment GalleryData on Gallery { ...SlimImageData } studio { - ...StudioData + ...SlimStudioData } tags { - ...TagData + ...SlimTagData } performers { ...PerformerData } scenes { - ...SceneData + ...SlimSceneData } } diff --git a/graphql/documents/data/image.graphql b/graphql/documents/data/image.graphql index cf4d30e41..14317988e 100644 --- a/graphql/documents/data/image.graphql +++ b/graphql/documents/data/image.graphql @@ -23,11 +23,11 @@ fragment ImageData on Image { } studio { - ...StudioData + ...SlimStudioData } tags { - ...TagData + ...SlimTagData } performers { diff --git a/graphql/documents/data/movie.graphql b/graphql/documents/data/movie.graphql index ef3ab3f9f..e8e378926 100644 --- a/graphql/documents/data/movie.graphql +++ b/graphql/documents/data/movie.graphql @@ -9,7 +9,7 @@ fragment MovieData on Movie { director studio { - ...StudioData + ...SlimStudioData } synopsis diff --git a/graphql/documents/data/performer.graphql b/graphql/documents/data/performer.graphql index ef0dde256..4c3033c1a 100644 --- a/graphql/documents/data/performer.graphql +++ b/graphql/documents/data/performer.graphql @@ -24,7 +24,7 @@ fragment PerformerData on Performer { gallery_count tags { - ...TagData + ...SlimTagData } stash_ids { diff --git a/graphql/documents/data/scene.graphql b/graphql/documents/data/scene.graphql index 491983b4f..83077895c 100644 --- a/graphql/documents/data/scene.graphql +++ b/graphql/documents/data/scene.graphql @@ -37,11 +37,11 @@ fragment SceneData on Scene { } galleries { - ...GallerySlimData + ...SlimGalleryData } studio { - ...StudioData + ...SlimStudioData } movies { @@ -52,7 +52,7 @@ fragment SceneData on Scene { } tags { - ...TagData + ...SlimTagData } performers { diff --git a/graphql/documents/data/tag-slim.graphql b/graphql/documents/data/tag-slim.graphql new file mode 100644 index 000000000..61fd320e5 --- /dev/null +++ b/graphql/documents/data/tag-slim.graphql @@ -0,0 +1,5 @@ +fragment SlimTagData on Tag { + id + name + image_path +} diff --git a/graphql/documents/queries/gallery.graphql b/graphql/documents/queries/gallery.graphql index c289d9758..bfc034de4 100644 --- a/graphql/documents/queries/gallery.graphql +++ b/graphql/documents/queries/gallery.graphql @@ -2,7 +2,7 @@ query FindGalleries($filter: FindFilterType, $gallery_filter: GalleryFilterType) findGalleries(gallery_filter: $gallery_filter, filter: $filter) { count galleries { - ...GallerySlimData + ...SlimGalleryData } } } diff --git a/pkg/sqlite/filter.go b/pkg/sqlite/filter.go index db3a5b8d0..d851fb7f0 100644 --- a/pkg/sqlite/filter.go +++ b/pkg/sqlite/filter.go @@ -376,6 +376,60 @@ func stringLiteralCriterionHandler(v *string, column string) criterionHandlerFun } } +// handle for MultiCriterion where there is a join table between the new +// objects +type joinedMultiCriterionHandlerBuilder struct { + // table containing the primary objects + primaryTable string + // table joining primary and foreign objects + joinTable string + // alias for join table, if required + joinAs string + // foreign key of the primary object on the join table + primaryFK string + // foreign key of the foreign object on the join table + foreignFK string + + addJoinTable func(f *filterBuilder) +} + +func (m *joinedMultiCriterionHandlerBuilder) handler(criterion *models.MultiCriterionInput) criterionHandlerFunc { + return func(f *filterBuilder) { + if criterion != nil && len(criterion.Value) > 0 { + var args []interface{} + for _, tagID := range criterion.Value { + args = append(args, tagID) + } + + joinAlias := m.joinAs + if joinAlias == "" { + joinAlias = m.joinTable + } + + whereClause := "" + havingClause := "" + if criterion.Modifier == models.CriterionModifierIncludes { + // includes any of the provided ids + m.addJoinTable(f) + whereClause = fmt.Sprintf("%s.%s IN %s", joinAlias, m.foreignFK, getInBinding(len(criterion.Value))) + } else if criterion.Modifier == models.CriterionModifierIncludesAll { + // includes all of the provided ids + m.addJoinTable(f) + whereClause = fmt.Sprintf("%s.%s IN %s", joinAlias, m.foreignFK, getInBinding(len(criterion.Value))) + havingClause = fmt.Sprintf("count(distinct %s.%s) IS %d", joinAlias, m.foreignFK, len(criterion.Value)) + } else if criterion.Modifier == models.CriterionModifierExcludes { + // excludes all of the provided ids + // need to use actual join table name for this + // not exists (select . from where . = .id and . in ) + whereClause = fmt.Sprintf("not exists (select %[1]s.%[2]s from %[1]s where %[1]s.%[2]s = %[3]s.id and %[1]s.%[4]s in %[5]s)", m.joinTable, m.primaryFK, m.primaryTable, m.foreignFK, getInBinding(len(criterion.Value))) + } + + f.addWhere(whereClause, args...) + f.addHaving(havingClause) + } + } +} + type multiCriterionHandlerBuilder struct { primaryTable string foreignTable string diff --git a/pkg/sqlite/gallery.go b/pkg/sqlite/gallery.go index 466238061..b366d301d 100644 --- a/pkg/sqlite/gallery.go +++ b/pkg/sqlite/gallery.go @@ -321,11 +321,17 @@ func (qb *galleryQueryBuilder) getMultiCriterionHandlerBuilder(foreignTable, joi } func galleryTagsCriterionHandler(qb *galleryQueryBuilder, tags *models.MultiCriterionInput) criterionHandlerFunc { - addJoinsFunc := func(f *filterBuilder) { - qb.tagsRepository().join(f, "tags_join", "galleries.id") - f.addJoin(tagTable, "", "tags_join.tag_id = tags.id") + h := joinedMultiCriterionHandlerBuilder{ + primaryTable: galleryTable, + joinTable: galleriesTagsTable, + joinAs: "tags_join", + primaryFK: galleryIDColumn, + foreignFK: tagIDColumn, + + addJoinTable: func(f *filterBuilder) { + qb.tagsRepository().join(f, "tags_join", "galleries.id") + }, } - h := qb.getMultiCriterionHandlerBuilder(tagTable, galleriesTagsTable, tagIDColumn, addJoinsFunc) return h.handler(tags) } @@ -341,11 +347,17 @@ func galleryTagCountCriterionHandler(qb *galleryQueryBuilder, tagCount *models.I } func galleryPerformersCriterionHandler(qb *galleryQueryBuilder, performers *models.MultiCriterionInput) criterionHandlerFunc { - addJoinsFunc := func(f *filterBuilder) { - qb.performersRepository().join(f, "performers_join", "galleries.id") - f.addJoin(performerTable, "", "performers_join.performer_id = performers.id") + h := joinedMultiCriterionHandlerBuilder{ + primaryTable: galleryTable, + joinTable: performersGalleriesTable, + joinAs: "performers_join", + primaryFK: galleryIDColumn, + foreignFK: performerIDColumn, + + addJoinTable: func(f *filterBuilder) { + qb.performersRepository().join(f, "performers_join", "galleries.id") + }, } - h := qb.getMultiCriterionHandlerBuilder(performerTable, performersGalleriesTable, performerIDColumn, addJoinsFunc) return h.handler(performers) } diff --git a/pkg/sqlite/image.go b/pkg/sqlite/image.go index 61ece04c6..51cc0d945 100644 --- a/pkg/sqlite/image.go +++ b/pkg/sqlite/image.go @@ -347,11 +347,17 @@ func (qb *imageQueryBuilder) getMultiCriterionHandlerBuilder(foreignTable, joinT } func imageTagsCriterionHandler(qb *imageQueryBuilder, tags *models.MultiCriterionInput) criterionHandlerFunc { - addJoinsFunc := func(f *filterBuilder) { - qb.tagsRepository().join(f, "tags_join", "images.id") - f.addJoin(tagTable, "", "tags_join.tag_id = tags.id") + h := joinedMultiCriterionHandlerBuilder{ + primaryTable: imageTable, + joinTable: imagesTagsTable, + joinAs: "tags_join", + primaryFK: imageIDColumn, + foreignFK: tagIDColumn, + + addJoinTable: func(f *filterBuilder) { + qb.tagsRepository().join(f, "tags_join", "images.id") + }, } - h := qb.getMultiCriterionHandlerBuilder(tagTable, imagesTagsTable, tagIDColumn, addJoinsFunc) return h.handler(tags) } @@ -377,11 +383,17 @@ func imageGalleriesCriterionHandler(qb *imageQueryBuilder, galleries *models.Mul } func imagePerformersCriterionHandler(qb *imageQueryBuilder, performers *models.MultiCriterionInput) criterionHandlerFunc { - addJoinsFunc := func(f *filterBuilder) { - qb.performersRepository().join(f, "performers_join", "images.id") - f.addJoin(performerTable, "", "performers_join.performer_id = performers.id") + h := joinedMultiCriterionHandlerBuilder{ + primaryTable: imageTable, + joinTable: performersImagesTable, + joinAs: "performers_join", + primaryFK: imageIDColumn, + foreignFK: performerIDColumn, + + addJoinTable: func(f *filterBuilder) { + qb.performersRepository().join(f, "performers_join", "images.id") + }, } - h := qb.getMultiCriterionHandlerBuilder(performerTable, performersImagesTable, performerIDColumn, addJoinsFunc) return h.handler(performers) } diff --git a/pkg/sqlite/scene.go b/pkg/sqlite/scene.go index 8ed7a711e..c3780a573 100644 --- a/pkg/sqlite/scene.go +++ b/pkg/sqlite/scene.go @@ -562,11 +562,17 @@ func sceneTagCountCriterionHandler(qb *sceneQueryBuilder, tagCount *models.IntCr } func scenePerformersCriterionHandler(qb *sceneQueryBuilder, performers *models.MultiCriterionInput) criterionHandlerFunc { - addJoinsFunc := func(f *filterBuilder) { - qb.performersRepository().join(f, "performers_join", "scenes.id") - f.addJoin("performers", "", "performers_join.performer_id = performers.id") + h := joinedMultiCriterionHandlerBuilder{ + primaryTable: sceneTable, + joinTable: performersScenesTable, + joinAs: "performers_join", + primaryFK: sceneIDColumn, + foreignFK: performerIDColumn, + + addJoinTable: func(f *filterBuilder) { + qb.performersRepository().join(f, "performers_join", "scenes.id") + }, } - h := qb.getMultiCriterionHandlerBuilder(performerTable, performersScenesTable, performerIDColumn, addJoinsFunc) return h.handler(performers) } diff --git a/ui/v2.5/src/components/Galleries/EditGalleriesDialog.tsx b/ui/v2.5/src/components/Galleries/EditGalleriesDialog.tsx index f54668ca9..78ce68b60 100644 --- a/ui/v2.5/src/components/Galleries/EditGalleriesDialog.tsx +++ b/ui/v2.5/src/components/Galleries/EditGalleriesDialog.tsx @@ -10,7 +10,7 @@ import MultiSet from "../Shared/MultiSet"; import { RatingStars } from "../Scenes/SceneDetails/RatingStars"; interface IListOperationProps { - selected: GQL.GallerySlimDataFragment[]; + selected: GQL.SlimGalleryDataFragment[]; onClose: (applied: boolean) => void; } @@ -146,7 +146,7 @@ export const EditGalleriesDialog: React.FC = ( setIsUpdating(false); } - function getRating(state: GQL.GallerySlimDataFragment[]) { + function getRating(state: GQL.SlimGalleryDataFragment[]) { let ret: number | undefined; let first = true; @@ -162,7 +162,7 @@ export const EditGalleriesDialog: React.FC = ( return ret; } - function getStudioId(state: GQL.GallerySlimDataFragment[]) { + function getStudioId(state: GQL.SlimGalleryDataFragment[]) { let ret: string | undefined; let first = true; @@ -181,7 +181,7 @@ export const EditGalleriesDialog: React.FC = ( return ret; } - function getPerformerIds(state: GQL.GallerySlimDataFragment[]) { + function getPerformerIds(state: GQL.SlimGalleryDataFragment[]) { let ret: string[] = []; let first = true; @@ -205,7 +205,7 @@ export const EditGalleriesDialog: React.FC = ( return ret; } - function getTagIds(state: GQL.GallerySlimDataFragment[]) { + function getTagIds(state: GQL.SlimGalleryDataFragment[]) { let ret: string[] = []; let first = true; @@ -234,7 +234,7 @@ export const EditGalleriesDialog: React.FC = ( let updateOrganized: boolean | undefined; let first = true; - state.forEach((gallery: GQL.GallerySlimDataFragment) => { + state.forEach((gallery: GQL.SlimGalleryDataFragment) => { const galleryRating = gallery.rating; const GalleriestudioID = gallery?.studio?.id; const galleryPerformerIDs = (gallery.performers ?? []) diff --git a/ui/v2.5/src/components/Galleries/GalleryCard.tsx b/ui/v2.5/src/components/Galleries/GalleryCard.tsx index 6bbb4a952..0a1f8c458 100644 --- a/ui/v2.5/src/components/Galleries/GalleryCard.tsx +++ b/ui/v2.5/src/components/Galleries/GalleryCard.tsx @@ -15,7 +15,7 @@ import { TextUtils } from "src/utils"; import { PerformerPopoverButton } from "../Shared/PerformerPopoverButton"; interface IProps { - gallery: GQL.GallerySlimDataFragment; + gallery: GQL.SlimGalleryDataFragment; selecting?: boolean; selected?: boolean | undefined; zoomIndex?: number; diff --git a/ui/v2.5/src/components/Galleries/GalleryDetails/GalleryScenesPanel.tsx b/ui/v2.5/src/components/Galleries/GalleryDetails/GalleryScenesPanel.tsx index 8f21d9798..e2d5d4814 100644 --- a/ui/v2.5/src/components/Galleries/GalleryDetails/GalleryScenesPanel.tsx +++ b/ui/v2.5/src/components/Galleries/GalleryDetails/GalleryScenesPanel.tsx @@ -3,7 +3,7 @@ import * as GQL from "src/core/generated-graphql"; import { SceneCard } from "src/components/Scenes/SceneCard"; interface IGalleryScenesPanelProps { - scenes: GQL.SceneDataFragment[]; + scenes: GQL.SlimSceneDataFragment[]; } export const GalleryScenesPanel: React.FC = ({ diff --git a/ui/v2.5/src/components/Galleries/GalleryList.tsx b/ui/v2.5/src/components/Galleries/GalleryList.tsx index 8e3096a16..ee56499ca 100644 --- a/ui/v2.5/src/components/Galleries/GalleryList.tsx +++ b/ui/v2.5/src/components/Galleries/GalleryList.tsx @@ -5,7 +5,7 @@ import { Link, useHistory } from "react-router-dom"; import Mousetrap from "mousetrap"; import { FindGalleriesQueryResult, - GallerySlimDataFragment, + SlimGalleryDataFragment, } from "src/core/generated-graphql"; import { useGalleriesList } from "src/hooks"; import { TextUtils } from "src/utils"; @@ -130,7 +130,7 @@ export const GalleryList: React.FC = ({ } function renderEditGalleriesDialog( - selectedImages: GallerySlimDataFragment[], + selectedImages: SlimGalleryDataFragment[], onClose: (applied: boolean) => void ) { return ( @@ -141,7 +141,7 @@ export const GalleryList: React.FC = ({ } function renderDeleteGalleriesDialog( - selectedImages: GallerySlimDataFragment[], + selectedImages: SlimGalleryDataFragment[], onClose: (confirmed: boolean) => void ) { return ( diff --git a/ui/v2.5/src/components/Galleries/GalleryWallCard.tsx b/ui/v2.5/src/components/Galleries/GalleryWallCard.tsx index b724ea09b..d2d02aba1 100644 --- a/ui/v2.5/src/components/Galleries/GalleryWallCard.tsx +++ b/ui/v2.5/src/components/Galleries/GalleryWallCard.tsx @@ -12,7 +12,7 @@ const CLASSNAME_IMG = `${CLASSNAME}-img`; const CLASSNAME_TITLE = `${CLASSNAME}-title`; interface IProps { - gallery: GQL.GallerySlimDataFragment; + gallery: GQL.SlimGalleryDataFragment; } const GalleryWallCard: React.FC = ({ gallery }) => { diff --git a/ui/v2.5/src/components/Scenes/SceneDetails/SceneGalleriesPanel.tsx b/ui/v2.5/src/components/Scenes/SceneDetails/SceneGalleriesPanel.tsx index eeb8c8392..30b8ba83d 100644 --- a/ui/v2.5/src/components/Scenes/SceneDetails/SceneGalleriesPanel.tsx +++ b/ui/v2.5/src/components/Scenes/SceneDetails/SceneGalleriesPanel.tsx @@ -3,7 +3,7 @@ import * as GQL from "src/core/generated-graphql"; import { GalleryCard } from "src/components/Galleries/GalleryCard"; interface ISceneGalleriesPanelProps { - galleries: GQL.GallerySlimDataFragment[]; + galleries: GQL.SlimGalleryDataFragment[]; } export const SceneGalleriesPanel: React.FC = ({ diff --git a/ui/v2.5/src/hooks/ListHook.tsx b/ui/v2.5/src/hooks/ListHook.tsx index c2891c4ff..6108c9acb 100644 --- a/ui/v2.5/src/hooks/ListHook.tsx +++ b/ui/v2.5/src/hooks/ListHook.tsx @@ -13,7 +13,7 @@ import Mousetrap from "mousetrap"; import { SlimSceneDataFragment, SceneMarkerDataFragment, - GallerySlimDataFragment, + SlimGalleryDataFragment, StudioDataFragment, PerformerDataFragment, FindScenesQueryResult, @@ -621,9 +621,9 @@ export const useImagesList = ( }); export const useGalleriesList = ( - props: IListHookOptions + props: IListHookOptions ) => - useList({ + useList({ ...props, filterMode: FilterMode.Galleries, useData: useFindGalleries, From 5a37e6cf52b2a65f849c601cf52d4ada2d744276 Mon Sep 17 00:00:00 2001 From: InfiniteTF Date: Mon, 10 May 2021 01:33:08 +0200 Subject: [PATCH 65/66] Add search modal for stash-box performer scraper (#1373) * Cache tagger fingerprint lookups between renders * Show search modal for stash-box performer scraper --- .../PerformerDetails/PerformerEditPanel.tsx | 65 +++++++------- .../PerformerDetails/PerformerScrapeModal.tsx | 10 ++- .../PerformerStashBoxModal.tsx | 84 +++++++++++++++++++ ui/v2.5/src/components/Tagger/Tagger.tsx | 6 +- 4 files changed, 129 insertions(+), 36 deletions(-) create mode 100644 ui/v2.5/src/components/Performers/PerformerDetails/PerformerStashBoxModal.tsx diff --git a/ui/v2.5/src/components/Performers/PerformerDetails/PerformerEditPanel.tsx b/ui/v2.5/src/components/Performers/PerformerDetails/PerformerEditPanel.tsx index f100b3b1c..c4eb9fd80 100644 --- a/ui/v2.5/src/components/Performers/PerformerDetails/PerformerEditPanel.tsx +++ b/ui/v2.5/src/components/Performers/PerformerDetails/PerformerEditPanel.tsx @@ -24,7 +24,6 @@ import { useTagCreate, queryScrapePerformerURL, useConfiguration, - queryStashBoxPerformer, } from "src/core/StashService"; import { Icon, @@ -42,6 +41,11 @@ import { useFormik } from "formik"; import { RatingStars } from "src/components/Scenes/SceneDetails/RatingStars"; import { PerformerScrapeDialog } from "./PerformerScrapeDialog"; import PerformerScrapeModal from "./PerformerScrapeModal"; +import PerformerStashBoxModal, { IStashBox } from "./PerformerStashBoxModal"; + +const isScraper = ( + scraper: GQL.Scraper | GQL.StashBox +): scraper is GQL.Scraper => (scraper as GQL.Scraper).id !== undefined; interface IPerformerDetails { performer: Partial; @@ -64,7 +68,7 @@ export const PerformerEditPanel: React.FC = ({ const history = useHistory(); // Editing state - const [scraper, setScraper] = useState(); + const [scraper, setScraper] = useState(); const [newTags, setNewTags] = useState(); const [isDeleteAlertOpen, setIsDeleteAlertOpen] = useState(false); @@ -495,7 +499,8 @@ export const PerformerEditPanel: React.FC = ({ } async function onScrapePerformer( - selectedPerformer: GQL.ScrapedPerformerDataFragment + selectedPerformer: GQL.ScrapedPerformerDataFragment, + selectedScraper: GQL.Scraper ) { setScraper(undefined); try { @@ -509,7 +514,7 @@ export const PerformerEditPanel: React.FC = ({ ...ret } = selectedPerformer; - const result = await queryScrapePerformer(scraper.id, ret); + const result = await queryScrapePerformer(selectedScraper.id, ret); if (!result?.data?.scrapePerformer) return; // if this is a new performer, just dump the data @@ -548,34 +553,21 @@ export const PerformerEditPanel: React.FC = ({ } } - async function onScrapeStashBoxClicked(stashBoxIndex: number) { - if (!performer.id) return; + async function onScrapeStashBox(performerResult: GQL.ScrapedScenePerformer) { + setScraper(undefined); - setIsLoading(true); - try { - const result = await queryStashBoxPerformer(stashBoxIndex, performer.id); - if (!result.data || !result.data.queryStashBoxPerformer) { - return; - } + const result: Partial = { + ...performerResult, + image: performerResult.images?.[0] ?? undefined, + country: getCountryByISO(performerResult.country), + __typename: "ScrapedPerformer", + }; - if (result.data.queryStashBoxPerformer.length > 0) { - const performerResult = - result.data.queryStashBoxPerformer[0].results[0]; - setScrapedPerformer({ - ...performerResult, - image: performerResult.images?.[0] ?? undefined, - country: getCountryByISO(performerResult.country), - __typename: "ScrapedPerformer", - }); - } else { - Toast.success({ - content: "No performers found", - }); - } - } catch (e) { - Toast.error(e); - } finally { - setIsLoading(false); + // if this is a new performer, just dump the data + if (isNew) { + updatePerformerEditStateFromScraper(result); + } else { + setScrapedPerformer(result); } } @@ -593,7 +585,7 @@ export const PerformerEditPanel: React.FC = ({
        @@ -732,14 +724,21 @@ export const PerformerEditPanel: React.FC = ({ } const renderScrapeModal = () => - scraper !== undefined && ( + scraper !== undefined && isScraper(scraper) ? ( setScraper(undefined)} onSelectPerformer={onScrapePerformer} name={formik.values.name || ""} /> - ); + ) : scraper !== undefined && !isScraper(scraper) ? ( + setScraper(undefined)} + onSelectPerformer={onScrapeStashBox} + name={formik.values.name || ""} + /> + ) : undefined; function renderDeleteAlert() { return ( diff --git a/ui/v2.5/src/components/Performers/PerformerDetails/PerformerScrapeModal.tsx b/ui/v2.5/src/components/Performers/PerformerDetails/PerformerScrapeModal.tsx index e8cfab3b0..fce360a5a 100644 --- a/ui/v2.5/src/components/Performers/PerformerDetails/PerformerScrapeModal.tsx +++ b/ui/v2.5/src/components/Performers/PerformerDetails/PerformerScrapeModal.tsx @@ -12,7 +12,10 @@ const CLASSNAME_LIST = `${CLASSNAME}-list`; interface IProps { scraper: GQL.Scraper; onHide: () => void; - onSelectPerformer: (performer: GQL.ScrapedPerformerDataFragment) => void; + onSelectPerformer: ( + performer: GQL.ScrapedPerformerDataFragment, + scraper: GQL.Scraper + ) => void; name?: string; } const PerformerScrapeModal: React.FC = ({ @@ -56,7 +59,10 @@ const PerformerScrapeModal: React.FC = ({
          {performers.map((p) => (
        • -
        • diff --git a/ui/v2.5/src/components/Performers/PerformerDetails/PerformerStashBoxModal.tsx b/ui/v2.5/src/components/Performers/PerformerDetails/PerformerStashBoxModal.tsx new file mode 100644 index 000000000..49139d786 --- /dev/null +++ b/ui/v2.5/src/components/Performers/PerformerDetails/PerformerStashBoxModal.tsx @@ -0,0 +1,84 @@ +import React, { useEffect, useRef, useState } from "react"; +import { debounce } from "lodash"; +import { Button, Form } from "react-bootstrap"; + +import * as GQL from "src/core/generated-graphql"; +import { Modal, LoadingIndicator } from "src/components/Shared"; + +const CLASSNAME = "PerformerScrapeModal"; +const CLASSNAME_LIST = `${CLASSNAME}-list`; + +export interface IStashBox extends GQL.StashBox { + index: number; +} + +interface IProps { + instance: IStashBox; + onHide: () => void; + onSelectPerformer: (performer: GQL.ScrapedScenePerformer) => void; + name?: string; +} +const PerformerStashBoxModal: React.FC = ({ + instance, + name, + onHide, + onSelectPerformer, +}) => { + const inputRef = useRef(null); + const [query, setQuery] = useState(name ?? ""); + const { data, loading } = GQL.useQueryStashBoxPerformerQuery({ + variables: { + input: { + stash_box_index: instance.index, + q: query, + }, + }, + skip: query === "", + }); + + const performers = data?.queryStashBoxPerformer?.[0].results ?? []; + + const onInputChange = debounce((input: string) => { + setQuery(input); + }, 500); + + useEffect(() => inputRef.current?.focus(), []); + + return ( + +
          + onInputChange(e.currentTarget.value)} + defaultValue={name ?? ""} + placeholder="Performer name..." + className="text-input mb-4" + ref={inputRef} + /> + {loading ? ( +
          + +
          + ) : performers.length > 0 ? ( +
            + {performers.map((p) => ( +
          • + +
          • + ))} +
          + ) : ( + query !== "" &&
          No results found.
          + )} +
          +
          + ); +}; + +export default PerformerStashBoxModal; diff --git a/ui/v2.5/src/components/Tagger/Tagger.tsx b/ui/v2.5/src/components/Tagger/Tagger.tsx index f4b089fc0..b0203ed71 100755 --- a/ui/v2.5/src/components/Tagger/Tagger.tsx +++ b/ui/v2.5/src/components/Tagger/Tagger.tsx @@ -150,6 +150,9 @@ interface ITaggerListProps { clearSubmissionQueue: (endpoint: string) => void; } +// Caches fingerprint lookups between page renders +let fingerprintCache: Record = {}; + const TaggerList: React.FC = ({ scenes, queue, @@ -181,7 +184,7 @@ const TaggerList: React.FC = ({ const [loadingFingerprints, setLoadingFingerprints] = useState(false); const [fingerprints, setFingerprints] = useState< Record - >({}); + >(fingerprintCache); const [hideUnmatched, setHideUnmatched] = useState(false); const fingerprintQueue = config.fingerprintQueue[selectedEndpoint.endpoint] ?? []; @@ -285,6 +288,7 @@ const TaggerList: React.FC = ({ }); setFingerprints(newFingerprints); + fingerprintCache = newFingerprints; setLoadingFingerprints(false); setFingerprintError(""); }; From e0623eb302cfec0b61388b3fa82f7fdbc87b33a8 Mon Sep 17 00:00:00 2001 From: WithoutPants <53250216+WithoutPants@users.noreply.github.com> Date: Thu, 13 May 2021 22:15:21 +1000 Subject: [PATCH 66/66] Fix initial setup issue issues (#1380) * Refactor initial setup behaviour * Adjust wizard --- .../queries/settings/metadata.graphql | 1 + graphql/schema/types/metadata.graphql | 1 + pkg/manager/config/config.go | 35 ++++++----- pkg/manager/config/init.go | 62 +++++++++++++++---- pkg/manager/manager.go | 20 ++++-- ui/v2.5/src/components/Setup/Setup.tsx | 48 +++++++++++++- 6 files changed, 133 insertions(+), 34 deletions(-) diff --git a/graphql/documents/queries/settings/metadata.graphql b/graphql/documents/queries/settings/metadata.graphql index 048d287e0..05dd6d04c 100644 --- a/graphql/documents/queries/settings/metadata.graphql +++ b/graphql/documents/queries/settings/metadata.graphql @@ -12,5 +12,6 @@ query SystemStatus { databasePath appSchema status + configPath } } diff --git a/graphql/schema/types/metadata.graphql b/graphql/schema/types/metadata.graphql index ac8185982..6c492fdeb 100644 --- a/graphql/schema/types/metadata.graphql +++ b/graphql/schema/types/metadata.graphql @@ -116,6 +116,7 @@ enum SystemStatusEnum { type SystemStatus { databaseSchema: Int databasePath: String + configPath: String appSchema: Int! status: SystemStatusEnum! } diff --git a/pkg/manager/config/config.go b/pkg/manager/config/config.go index 2da527ce2..440a1fded 100644 --- a/pkg/manager/config/config.go +++ b/pkg/manager/config/config.go @@ -141,7 +141,9 @@ func (e MissingConfigError) Error() string { return fmt.Sprintf("missing the following mandatory settings: %s", strings.Join(e.missingFields, ", ")) } -type Instance struct{} +type Instance struct { + isNewSystem bool +} var instance *Instance @@ -152,6 +154,10 @@ func GetInstance() *Instance { return instance } +func (i *Instance) IsNewSystem() bool { + return i.isNewSystem +} + func (i *Instance) SetConfigFile(fn string) { viper.SetConfigFile(fn) } @@ -687,29 +693,26 @@ func (i *Instance) Validate() error { return nil } -func (i *Instance) setDefaultValues() { +func (i *Instance) setDefaultValues() error { viper.SetDefault(ParallelTasks, parallelTasksDefault) viper.SetDefault(PreviewSegmentDuration, previewSegmentDurationDefault) viper.SetDefault(PreviewSegments, previewSegmentsDefault) viper.SetDefault(PreviewExcludeStart, previewExcludeStartDefault) viper.SetDefault(PreviewExcludeEnd, previewExcludeEndDefault) - // #1356 - only set these defaults once config file exists - if i.GetConfigFile() != "" { - viper.SetDefault(Database, i.GetDefaultDatabaseFilePath()) + viper.SetDefault(Database, i.GetDefaultDatabaseFilePath()) - // Set generated to the metadata path for backwards compat - viper.SetDefault(Generated, viper.GetString(Metadata)) + // Set generated to the metadata path for backwards compat + viper.SetDefault(Generated, viper.GetString(Metadata)) - // Set default scrapers and plugins paths - viper.SetDefault(ScrapersPath, i.GetDefaultScrapersPath()) - viper.SetDefault(PluginsPath, i.GetDefaultPluginsPath()) - viper.WriteConfig() - } + // Set default scrapers and plugins paths + viper.SetDefault(ScrapersPath, i.GetDefaultScrapersPath()) + viper.SetDefault(PluginsPath, i.GetDefaultPluginsPath()) + return viper.WriteConfig() } // SetInitialConfig fills in missing required config fields -func (i *Instance) SetInitialConfig() { +func (i *Instance) SetInitialConfig() error { // generate some api keys const apiKeyLength = 32 @@ -723,5 +726,9 @@ func (i *Instance) SetInitialConfig() { i.Set(SessionStoreKey, sessionStoreKey) } - i.setDefaultValues() + return i.setDefaultValues() +} + +func (i *Instance) FinalizeSetup() { + i.isNewSystem = false } diff --git a/pkg/manager/config/init.go b/pkg/manager/config/init.go index a2b98cf0b..8b6c2ff0e 100644 --- a/pkg/manager/config/init.go +++ b/pkg/manager/config/init.go @@ -1,6 +1,7 @@ package config import ( + "fmt" "net" "os" "sync" @@ -24,8 +25,22 @@ func Initialize() (*Instance, error) { instance = &Instance{} flags := initFlags() - err = initConfig(flags) + if err = initConfig(flags); err != nil { + return + } + initEnvs() + + if instance.isNewSystem { + if instance.Validate() == nil { + // system has been initialised by the environment + instance.isNewSystem = false + } + } + + if !instance.isNewSystem { + err = instance.SetInitialConfig() + } }) return instance, err } @@ -34,26 +49,47 @@ func initConfig(flags flagStruct) error { // The config file is called config. Leave off the file extension. viper.SetConfigName("config") - if flagConfigFileExists, _ := utils.FileExists(flags.configFilePath); flagConfigFileExists { - viper.SetConfigFile(flags.configFilePath) - } viper.AddConfigPath(".") // Look for config in the working directory viper.AddConfigPath("$HOME/.stash") // Look for the config in the home directory - // for Docker compatibility, if STASH_CONFIG_FILE is set, then touch the - // given filename + configFile := "" envConfigFile := os.Getenv("STASH_CONFIG_FILE") - if envConfigFile != "" { - utils.Touch(envConfigFile) - viper.SetConfigFile(envConfigFile) + + if flags.configFilePath != "" { + configFile = flags.configFilePath + } else if envConfigFile != "" { + configFile = envConfigFile + } + + if configFile != "" { + viper.SetConfigFile(configFile) + + // if file does not exist, assume it is a new system + if exists, _ := utils.FileExists(configFile); !exists { + instance.isNewSystem = true + + // ensure we can write to the file + if err := utils.Touch(configFile); err != nil { + return fmt.Errorf(`could not write to provided config path "%s": %s`, configFile, err.Error()) + } else { + // remove the file + os.Remove(configFile) + } + + return nil + } } err := viper.ReadInConfig() // Find and read the config file - // continue, but set an error to be handled by caller + // if not found, assume its a new system + if _, isMissing := err.(viper.ConfigFileNotFoundError); isMissing { + instance.isNewSystem = true + return nil + } else if err != nil { + return err + } - instance.SetInitialConfig() - - return err + return nil } func initFlags() flagStruct { diff --git a/pkg/manager/manager.go b/pkg/manager/manager.go index df3b3d50d..4aa05dcb4 100644 --- a/pkg/manager/manager.go +++ b/pkg/manager/manager.go @@ -49,6 +49,11 @@ func Initialize() *singleton { once.Do(func() { _ = utils.EnsureDir(paths.GetStashHomeDirectory()) cfg, err := config.Initialize() + + if err != nil { + panic(fmt.Sprintf("error initializing configuration: %s", err.Error())) + } + initLog() instance = &singleton{ @@ -59,8 +64,7 @@ func Initialize() *singleton { TxnManager: sqlite.NewTransactionManager(), } - cfgFile := cfg.GetConfigFile() - if cfgFile != "" { + if !cfg.IsNewSystem() { logger.Infof("using config file: %s", cfg.GetConfigFile()) if err == nil { @@ -75,7 +79,11 @@ func Initialize() *singleton { } } } else { - logger.Warn("config file not found. Assuming new system...") + cfgFile := cfg.GetConfigFile() + if cfgFile != "" { + cfgFile = cfgFile + " " + } + logger.Warnf("config file %snot found. Assuming new system...", cfgFile) } initFFMPEG() @@ -235,6 +243,8 @@ func (s *singleton) Setup(input models.SetupInput) error { return fmt.Errorf("error initializing the database: %s", err.Error()) } + s.Config.FinalizeSetup() + return nil } @@ -283,8 +293,9 @@ func (s *singleton) GetSystemStatus() *models.SystemStatus { dbSchema := int(database.Version()) dbPath := database.DatabasePath() appSchema := int(database.AppSchemaVersion()) + configFile := s.Config.GetConfigFile() - if s.Config.GetConfigFile() == "" { + if s.Config.IsNewSystem() { status = models.SystemStatusEnumSetup } else if dbSchema < appSchema { status = models.SystemStatusEnumNeedsMigration @@ -295,5 +306,6 @@ func (s *singleton) GetSystemStatus() *models.SystemStatus { DatabasePath: &dbPath, AppSchema: appSchema, Status: status, + ConfigPath: &configFile, } } diff --git a/ui/v2.5/src/components/Setup/Setup.tsx b/ui/v2.5/src/components/Setup/Setup.tsx index 82465ee8f..a80fee0cd 100644 --- a/ui/v2.5/src/components/Setup/Setup.tsx +++ b/ui/v2.5/src/components/Setup/Setup.tsx @@ -1,4 +1,4 @@ -import React, { useState } from "react"; +import React, { useEffect, useState } from "react"; import { Alert, Button, @@ -27,6 +27,12 @@ export const Setup: React.FC = () => { const { data: systemStatus, loading: statusLoading } = useSystemStatus(); + useEffect(() => { + if (systemStatus?.systemStatus.configPath) { + setConfigLocation(systemStatus.systemStatus.configPath); + } + }, [systemStatus]); + const discordLink = ( Discord @@ -59,6 +65,38 @@ export const Setup: React.FC = () => { setStep(step + 1); } + function renderWelcomeSpecificConfig() { + return ( + <> +
          +

          Welcome to Stash

          +

          + If you're reading this, then Stash couldn't find the + configuration file specified at the command line or the environment. + This wizard will guide you through the process of setting up a new + configuration. +

          +

          + Stash will use the following configuration file path:{" "} + {configLocation} +

          +

          + When you're ready to proceed with setting up a new system, + click Next. +

          +
          + +
          +
          + +
          +
          + + ); + } + function renderWelcome() { return ( <> @@ -433,8 +471,6 @@ export const Setup: React.FC = () => { return renderSuccess(); } - const steps = [renderWelcome, renderSetPaths, renderConfirm, renderFinish]; - // only display setup wizard if system is not setup if (statusLoading) { return ; @@ -450,6 +486,12 @@ export const Setup: React.FC = () => { return ; } + const welcomeStep = + systemStatus && systemStatus.systemStatus.configPath !== "" + ? renderWelcomeSpecificConfig + : renderWelcome; + const steps = [welcomeStep, renderSetPaths, renderConfirm, renderFinish]; + return ( {maybeRenderGeneratedSelectDialog()}