summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--.gitignore4
-rw-r--r--client/Dockerfile8
-rwxr-xr-xclient/build.js12
-rw-r--r--client/css/colors.styl2
-rw-r--r--client/css/core-forms.styl16
-rw-r--r--client/css/core-general.styl2
-rw-r--r--client/css/expander-control.styl3
-rw-r--r--client/css/metric-sorter-view.styl56
-rw-r--r--client/css/post-content-control.styl3
-rw-r--r--client/css/post-list-view.styl146
-rw-r--r--client/css/post-main-view.styl74
-rw-r--r--client/css/post-metric-input.styl49
-rw-r--r--client/css/post-upload.styl19
-rw-r--r--client/css/settings.styl10
-rw-r--r--client/css/tag-view.styl23
-rw-r--r--client/css/user-registration.styl5
-rw-r--r--client/html/comment.tpl4
-rw-r--r--client/html/compact_metric_list_item.tpl26
-rw-r--r--client/html/compact_post_metric_list_item.tpl32
-rw-r--r--client/html/compact_post_metric_range_list_item.tpl42
-rw-r--r--client/html/endless_pager_page.tpl3
-rw-r--r--client/html/fav.tpl4
-rw-r--r--client/html/help_keyboard.tpl21
-rw-r--r--client/html/metric_header.tpl11
-rw-r--r--client/html/metric_header_item.tpl6
-rw-r--r--client/html/metric_sorter.tpl39
-rw-r--r--client/html/metric_sorter_side.tpl5
-rw-r--r--client/html/post_edit_sidebar.tpl6
-rw-r--r--client/html/post_main.tpl58
-rw-r--r--client/html/post_metric_input.tpl5
-rw-r--r--client/html/post_readonly_sidebar.tpl28
-rw-r--r--client/html/post_upload.tpl13
-rw-r--r--client/html/post_upload_row.tpl12
-rw-r--r--client/html/posts_header.tpl99
-rw-r--r--client/html/posts_page.tpl6
-rw-r--r--client/html/score.tpl6
-rw-r--r--client/html/settings.tpl34
-rw-r--r--client/html/similar_post_item.tpl5
-rw-r--r--client/html/tag.tpl3
-rw-r--r--client/html/tag_input.tpl4
-rw-r--r--client/html/tag_metric.tpl40
-rw-r--r--client/html/user_registration.tpl4
-rw-r--r--client/js/controllers/metric_sorter_contoller.js187
-rw-r--r--client/js/controllers/post_list_controller.js77
-rw-r--r--client/js/controllers/post_main_controller.js23
-rw-r--r--client/js/controllers/post_upload_controller.js38
-rw-r--r--client/js/controllers/tag_controller.js42
-rw-r--r--client/js/controls/expander_control.js4
-rw-r--r--client/js/controls/metric_header_control.js85
-rw-r--r--client/js/controls/post_content_control.js45
-rw-r--r--client/js/controls/post_edit_sidebar_control.js72
-rw-r--r--client/js/controls/post_metric_input_control.js164
-rw-r--r--client/js/controls/post_metric_list_control.js51
-rw-r--r--client/js/controls/post_readonly_sidebar_control.js76
-rw-r--r--client/js/controls/tag_input_control.js64
-rw-r--r--client/js/main.js1
-rw-r--r--client/js/models/abstract_list.js5
-rw-r--r--client/js/models/metric.js88
-rw-r--r--client/js/models/metric_list.js24
-rw-r--r--client/js/models/post.js34
-rw-r--r--client/js/models/post_list.js49
-rw-r--r--client/js/models/post_metric.js39
-rw-r--r--client/js/models/post_metric_list.js24
-rw-r--r--client/js/models/post_metric_range.js43
-rw-r--r--client/js/models/post_metric_range_list.js24
-rw-r--r--client/js/models/settings.js18
-rw-r--r--client/js/models/tag.js33
-rw-r--r--client/js/models/tag_list.js27
-rw-r--r--client/js/router.js10
-rw-r--r--client/js/tags.js43
-rw-r--r--client/js/util/keyboard.js8
-rw-r--r--client/js/util/search.js1
-rw-r--r--client/js/util/touch.js47
-rw-r--r--client/js/util/uri.js11
-rw-r--r--client/js/util/views.js35
-rw-r--r--client/js/views/endless_page_view.js111
-rw-r--r--client/js/views/metric_sorter_view.js138
-rw-r--r--client/js/views/post_main_view.js46
-rw-r--r--client/js/views/post_upload_view.js60
-rw-r--r--client/js/views/posts_header_view.js206
-rw-r--r--client/js/views/posts_page_view.js38
-rw-r--r--client/js/views/settings_view.js10
-rw-r--r--client/js/views/tag_metric_view.js97
-rw-r--r--client/js/views/tag_view.js14
-rw-r--r--client/package-lock.json4165
-rw-r--r--client/package.json2
-rw-r--r--docker-compose.test.yml23
-rw-r--r--docker-compose.yml10
-rw-r--r--server/config.yaml.dist7
-rw-r--r--server/szurubooru/api/__init__.py1
-rw-r--r--server/szurubooru/api/metric_api.py94
-rw-r--r--server/szurubooru/api/post_api.py82
-rw-r--r--server/szurubooru/api/tag_api.py17
-rw-r--r--server/szurubooru/func/metrics.py273
-rw-r--r--server/szurubooru/func/posts.py114
-rw-r--r--server/szurubooru/func/similar.py32
-rw-r--r--server/szurubooru/func/tags.py13
-rw-r--r--server/szurubooru/func/util.py4
-rw-r--r--server/szurubooru/migrations/versions/0061c5c3299f_postmetric_depends_on_posttag.py35
-rw-r--r--server/szurubooru/migrations/versions/3c1f0316fa7f_resize_post_columns.py2
-rw-r--r--server/szurubooru/migrations/versions/51ac43760440_create_metric_tables.py50
-rw-r--r--server/szurubooru/migrations/versions/aae2050fb28c_add_version_to_metric_tables.py32
-rw-r--r--server/szurubooru/model/__init__.py1
-rw-r--r--server/szurubooru/model/metric.py141
-rw-r--r--server/szurubooru/model/post.py6
-rw-r--r--server/szurubooru/model/tag.py5
-rw-r--r--server/szurubooru/rest/context.py29
-rw-r--r--server/szurubooru/search/configs/__init__.py1
-rw-r--r--server/szurubooru/search/configs/post_metric_search_config.py44
-rw-r--r--server/szurubooru/search/configs/post_search_config.py554
-rw-r--r--server/szurubooru/search/configs/util.py4
-rw-r--r--server/szurubooru/search/executor.py124
-rw-r--r--server/szurubooru/tests/api/test_metric_retrieving.py63
-rw-r--r--server/szurubooru/tests/api/test_post_retrieving.py49
-rw-r--r--server/szurubooru/tests/api/test_post_updating.py15
-rw-r--r--server/szurubooru/tests/api/test_tag_updating.py24
-rw-r--r--server/szurubooru/tests/conftest.py53
-rw-r--r--server/szurubooru/tests/func/test_metrics.py459
-rw-r--r--server/szurubooru/tests/func/test_posts.py59
-rw-r--r--server/szurubooru/tests/func/test_similar.py60
-rw-r--r--server/szurubooru/tests/func/test_tags.py30
-rw-r--r--server/szurubooru/tests/model/test_metric.py235
-rw-r--r--server/szurubooru/tests/search/configs/test_post_metric_search_config.py89
-rw-r--r--server/szurubooru/tests/search/configs/test_post_search_config.py159
124 files changed, 5743 insertions, 4577 deletions
diff --git a/.gitignore b/.gitignore
index b21e3ad..ec86ed2 100644
--- a/.gitignore
+++ b/.gitignore
@@ -13,3 +13,7 @@ server/**/lib/
server/**/bin/
server/**/pyvenv.cfg
__pycache__/
+
+# IDE files
+.idea
+*.iml \ No newline at end of file
diff --git a/client/Dockerfile b/client/Dockerfile
index ea5151f..1e3926d 100644
--- a/client/Dockerfile
+++ b/client/Dockerfile
@@ -7,13 +7,17 @@ RUN npm install
COPY . ./
ARG BUILD_INFO="docker-latest"
-ARG CLIENT_BUILD_ARGS=""
+ARG CLIENT_BUILD_ARGS="--debug"
RUN BASE_URL="__BASEURL__" node build.js --gzip ${CLIENT_BUILD_ARGS}
-FROM --platform=$BUILDPLATFORM scratch as approot
+FROM --platform=$BUILDPLATFORM nginx:alpine as approot
+# Copy and fix script
COPY docker-start.sh /
+RUN apk add --no-cache dos2unix && \
+ dos2unix /docker-start.sh && \
+ chmod +x /docker-start.sh
WORKDIR /etc/nginx
COPY nginx.conf.docker ./nginx.conf
diff --git a/client/build.js b/client/build.js
index eaf28a5..51ef643 100755
--- a/client/build.js
+++ b/client/build.js
@@ -144,7 +144,7 @@ function bundleCss() {
}
fs.copyFileSync(
- './node_modules/font-awesome/css/font-awesome.min.css',
+ './node_modules/@fortawesome/fontawesome-free/css/all.min.css',
'./public/css/vendor.min.css');
if (process.argv.includes('--gzip')) {
gzipFile('./public/css/vendor.min.css');
@@ -245,15 +245,15 @@ function bundleBinaryAssets() {
fs.copyFileSync('./img/favicon.png', './public/img/favicon.png');
console.info('Copied images');
- fs.copyFileSync('./fonts/open_sans.woff2', './public/fonts/open_sans.woff2')
- for (let file of glob.sync('./node_modules/font-awesome/fonts/*.*')) {
+ fs.copyFileSync('./fonts/open_sans.woff2', './public/webfonts/open_sans.woff2')
+ for (let file of glob.sync('./node_modules/@fortawesome/fontawesome-free/webfonts/*.*')) {
if (fs.lstatSync(file).isDirectory()) {
continue;
}
- fs.copyFileSync(file, path.join('./public/fonts/', path.basename(file)));
+ fs.copyFileSync(file, path.join('./public/webfonts/', path.basename(file)));
}
if (process.argv.includes('--gzip')) {
- for (let file of glob.sync('./public/fonts/*.*')) {
+ for (let file of glob.sync('./public/webfonts/*.*')) {
if (file.endsWith('woff2')) {
continue;
}
@@ -302,7 +302,7 @@ function makeOutputDirs() {
const dirs = [
'./public',
'./public/css',
- './public/fonts',
+ './public/webfonts',
'./public/img',
'./public/js'
];
diff --git a/client/css/colors.styl b/client/css/colors.styl
index cf7e7ca..c348bd6 100644
--- a/client/css/colors.styl
+++ b/client/css/colors.styl
@@ -4,7 +4,7 @@ $window-color-darktheme = #1a1a1a
$top-navigation-color = #F5F5F5
$top-navigation-color-darktheme = #333333
$text-color = #111
-$text-color-darktheme = #e6e6e6
+$text-color-darktheme = #aaa
$inactive-link-color = #888
$inactive-link-color-darktheme = #cccccc
$line-color = #DDD
diff --git a/client/css/core-forms.styl b/client/css/core-forms.styl
index ee21ed1..976e3a0 100644
--- a/client/css/core-forms.styl
+++ b/client/css/core-forms.styl
@@ -87,6 +87,11 @@ input[type=radio], input[type=checkbox]
border: 2px solid $input-enabled-border-color
content: ''
+.darktheme
+ .radio:before, .checkbox:before
+ background: darken($input-enabled-background-color, 75%)
+ border: 2px solid darken($input-enabled-border-color, 60%)
+
.radio:after
background: $main-color
transition: opacity 0.1s linear
@@ -135,6 +140,17 @@ input[type=radio]:disabled + .radio,
input[type=checkbox]:disabled + .checkbox
border-color: $input-disabled-text-color
+.darktheme
+ input[type=radio]:disabled + .radio:before,
+ input[type=checkbox]:disabled + .checkbox:before,
+ input[type=radio]:disabled + .radio:after,
+ input[type=checkbox]:disabled + .checkbox:after
+ border-color: darken($input-disabled-text-color, 60%)
+
+ input[type=radio]:disabled + .radio,
+ input[type=checkbox]:disabled + .checkbox
+ border-color: darken($input-disabled-text-color, 60%)
+
input[type=radio]:focus + .radio:before,
input[type=checkbox]:focus + .checkbox:before
border-color: $main-color
diff --git a/client/css/core-general.styl b/client/css/core-general.styl
index d25c5f6..b1f8401 100644
--- a/client/css/core-general.styl
+++ b/client/css/core-general.styl
@@ -11,7 +11,7 @@ $inactive-tab-text-color-darktheme = $inactive-link-color-darktheme
font-family: 'Open Sans';
font-style: normal;
font-weight: 400;
- src: local('Open Sans'), local('OpenSans'), url(../fonts/open_sans.woff2) format('woff2');
+ src: local('Open Sans'), local('OpenSans'), url(../webfonts/open_sans.woff2) format('woff2');
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02C6, U+02DA, U+02DC, U+2000-206F, U+2074, U+20AC, U+2212, U+2215, U+E0FF, U+EFFD, U+F000;
/* make <body> cover entire viewport */
diff --git a/client/css/expander-control.styl b/client/css/expander-control.styl
index 8f68957..928f5e4 100644
--- a/client/css/expander-control.styl
+++ b/client/css/expander-control.styl
@@ -14,7 +14,8 @@
padding: 0 0.5em
display: block
color: mix($text-color, $inactive-link-color)
- font-size: 120%
+ span
+ font-size: 120%
i
font-size: 1em
color: $inactive-link-color
diff --git a/client/css/metric-sorter-view.styl b/client/css/metric-sorter-view.styl
new file mode 100644
index 0000000..5388c6e
--- /dev/null
+++ b/client/css/metric-sorter-view.styl
@@ -0,0 +1,56 @@
+@import colors
+
+#metric-sorter
+ width: 100%
+ @media (max-width: 1000px)
+ padding: 0 !important
+ background-color: white !important
+ h2
+ display: none
+ .messages .message
+ margin-top: 0.5em
+ margin-bottom: 0
+ form
+ width: 100%
+ .posts-container
+ display: flex
+ flex-wrap: wrap
+ margin-bottom: 1em
+ @media (max-width: 1000px)
+ margin-left: -1em
+ margin-right: -1em
+ margin-top: -1em
+ .left-post-container, .right-post-container
+ flex: 1
+ width: 100%
+ .append
+ color: $inactive-link-color
+ .sorting-buttons
+ display: flex
+ @media (min-width: 1000px)
+ padding: 0 0.5em
+ @media (max-width: 1000px)
+ padding: 0.5em 1em
+ width: 100%
+ .compare-block
+ margin: auto
+ button
+ width: 1.3em
+ height: 1.3em
+ font-size: 200%
+ @media (min-width: 1000px)
+ padding: 0
+ padding-top: 0.05em
+ @media (max-width: 1000px)
+ padding: 0
+ i
+ transform: rotate(90deg)
+
+ @media (max-width: 1000px)
+ display: flex
+ .save-btn
+ margin: auto
+ margin-right: 1em
+ .skip-btn
+ margin: auto
+ margin-left: 1em
diff --git a/client/css/post-content-control.styl b/client/css/post-content-control.styl
index 37538f8..5c4a18d 100644
--- a/client/css/post-content-control.styl
+++ b/client/css/post-content-control.styl
@@ -12,7 +12,6 @@
text-align: center
.post-content
- text-align: left
margin: 0 auto
position: relative
@@ -24,6 +23,8 @@
bottom: 0
width: 100%
height: 100%
+ img
+ position: relative !important // to center image on small screens
img
image-orientation: from-image
diff --git a/client/css/post-list-view.styl b/client/css/post-list-view.styl
index e4f75d5..60679bf 100644
--- a/client/css/post-list-view.styl
+++ b/client/css/post-list-view.styl
@@ -3,24 +3,24 @@
.post-list
ul
list-style-type: none
- margin: 0
padding: 0
display: flex
align-content: flex-end
flex-wrap: wrap
- margin: 0 -0.25em
+ margin: 0 -1.5em
+ @media (max-width: 1000px)
+ margin: 0 -1em
li
position: relative
flex-grow: 1
- margin: 0 0.25em 0.5em 0.25em
display: inline-block
text-align: left
- min-width: 10em
+ min-width: 8em
width: 12vw
&:not(.flexbox-dummy)
- min-height: 7.5em
- height: 9vw
+ min-height: 8em
+ height: 12vw
.thumbnail-wrapper
display: inline-block
@@ -84,6 +84,31 @@
&[data-disabled]
background: rgba(200, 200, 200, 0.7)
+ .relation-flipper
+ display: inline-block
+ padding: 0.5em
+ box-sizing: border-box
+ border: 0
+ &:after
+ display: inline-block
+ width: 1em
+ height: 1em
+ text-align: center
+ line-height: 1em
+ font-size: 1.6em
+ &.related
+ background: rgba(0, 230, 0, 0.7)
+ &:after
+ color: white
+ content: '☑'
+ &:not(.related)
+ background: rgba(245, 0, 0, 0.7)
+ &:after
+ color: white
+ content: '☐'
+ &[data-disabled]
+ background: rgba(200, 200, 200, 0.7)
+
.safety-flipper a
display: inline-block
margin: 0.1em
@@ -130,8 +155,9 @@
background: rgba(255, 0, 0, 0.7)
&:after
color: white
- font-family: FontAwesome;
+ font-family: 'Font Awesome 5 Free';
content: "\f1f8"; // fa-trash
+ font-weight: 900; // not visible at other weights
&:not(.delete)
background: rgba(200, 200, 200, 0.7)
&:after
@@ -200,45 +226,93 @@
width: 100%
margin-bottom: 0.5em
.append
+ @media (max-width: 500px)
+ margin-left: 0.8em
vertical-align: middle
font-size: 0.95em
color: $inactive-link-color
- .bulk-edit
+ .bulk-edit-btn-holder, .metrics-btn-holder
&:not(.opened)
.close
display: none
&.opened
.open
display: none
+ .metrics-block
&.hidden
display: none
- .bulk-edit-tags
- &.opened
- .hint
- @media (max-width: 1000px)
- display: block
+ label
+ display: inline !important
+ margin-left: -0.5em
+ line-height 1.7em
+ a.append
+ line-height 1.7em
+ padding: 0.2em 0
+ form, ul
+ display: flex
+ flex-wrap: wrap
+ .metric-list
+ li
+ display: inline-block
+ line-height: 1.7em
+ margin-right: 0.5em
+ @media (max-width: 700px)
margin-bottom: 0.5em
- &:not(.opened)
- [type=text],
- .start
- display: none
- .hint
- display: none
- input[name=tag]
- width: 24em
- @media (max-width: 1000px)
+ border-width: 1px
+ &.selected
+ border-style: solid
+ &.tag-default
+ background-color: $main-color
+ &:not(.selected)
+ border-style: dotted
+ &.tag-default
+ border-color: $main-color
+ li a
+ vertical-align: middle
+ padding: 0.2em 0.5em
+ .bulk-edit-block
+ &.hidden
+ display: none
+ form
+ &:not(.opened)
+ display: inline-block
+ &.opened
display: block
- width: 100%
- margin-bottom: 0.5em
- .append
- &.open,
- &.hint
- @media (max-width: 1000px)
- margin-left: 0
- .hint
+ input[type=submit]
margin-right: 1em
- .bulk-edit-safety
.append
+ margin-left: 0
+ margin-right: 1em
+ .bulk-edit
+ &:not(.opened)
+ .close
+ display: none
+ &.opened
+ .open
+ display: none
+ &.hidden
+ display: none
+ .bulk-edit-tags
+ &.opened
+ .hint
+ @media (max-width: 1000px)
+ display: block
+ margin-bottom: 0.5em
+ &:not(.opened)
+ [type=text],
+ .start
+ display: none
+ .hint
+ display: none
+ input[name=tag]
+ width: 12em
+ @media (max-width: 1000px)
+ display: block
+ width: 100%
+ margin-bottom: 0.5em
+ .hint
+ margin-right: 1em
+
@media (max-width: 1000px)
margin-left: 0
.bulk-edit-delete
@@ -271,3 +345,13 @@
border-color: @background-color
&.disabled
background-color: alpha(@background-color, 0.15)
+
+ .icon-button
+ padding: 0.2em
+ margin-right: 0.25em
+ margin-bottom: 0.25em
+ i
+ vertical-align: middle
+ .icon-inline
+ vertical-align: baseline
+ margin-left: 0.3em
diff --git a/client/css/post-main-view.styl b/client/css/post-main-view.styl
index 1183cce..fb03c4c 100644
--- a/client/css/post-main-view.styl
+++ b/client/css/post-main-view.styl
@@ -15,40 +15,44 @@
border: 0
outline: 0
- >.sidebar>nav.buttons, >.content nav.buttons
- margin-top: 0
- display: flex
- flex-wrap: wrap
- article
- flex: 1 0 33%
- a
- display: inline-block
- width: 100%
- padding: 0.3em 0
+ nav.buttons
+ margin-top: 0
+ display: flex
+ flex-wrap: wrap
+ article
+ flex: 1 0 25%
+ a
+ display: inline-block
+ width: 100%
+ padding: 0.3em 0
+ text-align: center
+ vertical-align: middle
+ transition: background 0.2s linear, box-shadow 0.2s linear
+ &:not(.inactive):hover
+ background: lighten($main-color, 90%)
+ i
+ font-size: 140%
text-align: center
- vertical-align: middle
- transition: background 0.2s linear, box-shadow 0.2s linear
- &:not(.inactive):hover
- background: lighten($main-color, 90%)
- i
- font-size: 140%
- text-align: center
- @media (max-width: 800px)
- margin-top: 0.6em
- margin-bottom: 0.6em
+ @media (max-width: 800px)
+ margin-top: 1em
+ margin-bottom: 0
+ .tag-icon
+ width: 1em
+ text-align: right
>.content
width: 100%
.post-container
- margin-bottom: 0.6em
+ @media (min-width: 800px)
+ margin-bottom: 2em
+ @media (max-width: 800px)
+ margin: -1em // to counter the padding of #content-holder
+ margin-bottom: 0
.post-content
margin: 0
- .after-mobile-controls
- width: 100%
-
.darktheme .post-view
>.sidebar, >.content
nav.buttons
@@ -60,8 +64,6 @@
@media (max-width: 800px)
.post-view
flex-wrap: wrap
- >.after-mobile-controls
- order: 3
>.sidebar
order: 2
min-width: 100%
@@ -69,9 +71,12 @@
margin-right: 0
>.content
order: 1
+ .comment-container
+ margin-top: 2em
.post-view .readonly-sidebar
+ display: grid
.details
i
margin-right: 0.6em
@@ -114,8 +119,11 @@
display: inline-block
width: 2em
- .relations
- margin-top: 2em
+ @media (max-width: 800px)
+ order: 2
+ margin-top: 1em
+
+ .relations, .similar, .lookalikes
h1
margin-bottom: 0.5em
.thumbnail
@@ -124,11 +132,17 @@
li
margin: 0 0.3em 0.3em 0
display: inline-block
+ @media (max-width: 800px)
+ h1
+ margin-top: 0.5em
- .tags
- margin-top: 2em
+ .tags, .metrics
h1
margin-bottom: 0.5em
+ @media (max-width: 800px)
+ margin-top: 0
+ h1
+ display: none
.post-view .edit-sidebar
.expander-content
diff --git a/client/css/post-metric-input.styl b/client/css/post-metric-input.styl
new file mode 100644
index 0000000..2d4046b
--- /dev/null
+++ b/client/css/post-metric-input.styl
@@ -0,0 +1,49 @@
+@import colors
+
+.metric-controls
+ margin-left: 1.4em
+ display: inline-block
+ color: $inactive-link-color
+
+hr.separator
+ display: none
+
+ul.compact-unset-metrics, ul.compact-post-metrics
+ width: 100%
+ margin: 0.5em 0 0 0
+ padding: 0
+ li
+ margin: 0
+ width: 100%
+ line-height: 140%
+ overflow: hidden
+ text-overflow: ellipsis
+ transition: background-color 0.5s linear
+ a
+ display: inline
+ a:focus
+ outline: 0
+ box-shadow: inset 0 0 0 2px $main-color
+ .remove-metric
+ color: $inactive-link-color
+ unselectable()
+ margin-right: 0.5em
+ i
+ padding-right: 0.4em
+ .metric-bounds
+ color: $inactive-link-color
+ unselectable()
+ font-size: 90%
+ margin-left: 0.7em
+
+ul.compact-post-metrics
+ input[type=number]
+ margin-left: 0.5em
+ margin-bottom: 0.5em
+ width: 4em
+ min-width: 60px
+ label
+ display: none
+ .range-delimiter
+ margin-left: 0.5em
+ color: $inactive-link-color
diff --git a/client/css/post-upload.styl b/client/css/post-upload.styl
index cb6b006..572b74c 100644
--- a/client/css/post-upload.styl
+++ b/client/css/post-upload.styl
@@ -12,10 +12,13 @@ $cancel-button-color = tomato
text-align: left
&.inactive input[type=submit],
+ &.inactive .control-checkbox,
+ &.inactive .tags,
&.inactive .skip-duplicates
&.inactive .always-upload-similar
&.inactive .pause-remain-on-error
&.uploading input[type=submit],
+ &.uploading .control-checkbox,
&.uploading .skip-duplicates,
&.uploading .always-upload-similar
&.uploading .pause-remain-on-error
@@ -29,6 +32,20 @@ $cancel-button-color = tomato
padding: 2em
small
font-size: 60%
+ // Make the inputs aligned:
+ .url-holder
+ input
+ flex: 8
+ button
+ flex: 1
+ min-width: fit-content
+ .tags
+ margin-top: 1em
+ input
+ flex: 8
+ button
+ flex: 1
+ min-width: fit-content
input[type=submit]
margin-top: 1em
@@ -40,7 +57,7 @@ $cancel-button-color = tomato
&:focus
border: 2px solid $text-color
- .skip-duplicates
+ .control-checkbox
margin-left: 1em
.always-upload-similar
diff --git a/client/css/settings.styl b/client/css/settings.styl
new file mode 100644
index 0000000..95e037b
--- /dev/null
+++ b/client/css/settings.styl
@@ -0,0 +1,10 @@
+#settings
+ .uploadSafety
+ &>label
+ width: 100%
+ .radio-wrapper
+ display: flex
+ flex-wrap: wrap
+ .radio-wrapper label
+ flex-grow: 1
+ display: inline-block \ No newline at end of file
diff --git a/client/css/tag-view.styl b/client/css/tag-view.styl
index 8750647..f93e0af 100644
--- a/client/css/tag-view.styl
+++ b/client/css/tag-view.styl
@@ -1,3 +1,6 @@
+@import colors
+$delete-button-color = tomato
+
#tag
width: 100%
max-width: 40em
@@ -31,3 +34,23 @@
content: '(none)'
section
margin-bottom: 1em
+ .metric-bounds-edit
+ display: flex
+ margin-bottom: 1em
+ input[type=number]
+ flex: 2
+ margin-left: 0.5em
+ width: auto
+ min-width: 60px
+ label
+ flex 1
+ input[name=metric-min]
+ margin-right: 2em
+ .confirmation
+ margin-bottom: 2em
+ .delete
+ background: $delete-button-color
+ border-color: $delete-button-color
+ margin-left: 0.5em
+ &:focus
+ border: 2px solid $text-color
diff --git a/client/css/user-registration.styl b/client/css/user-registration.styl
index 8e1ef8d..2feeb63 100644
--- a/client/css/user-registration.styl
+++ b/client/css/user-registration.styl
@@ -2,9 +2,12 @@
#user-registration
padding-bottom: calc(2vw - 1em) !important
+ @media (max-width: 750px)
+ display: inline-grid !important
form
float: left
- margin-right: 3em
+ @media (min-width: 600px)
+ margin-right: 3em
margin-bottom: 1em
.info
float: left
diff --git a/client/html/comment.tpl b/client/html/comment.tpl
index 6bea704..8cb4475 100644
--- a/client/html/comment.tpl
+++ b/client/html/comment.tpl
@@ -45,13 +45,13 @@
%><span class='action-container'><%
%><% if (ctx.canEditComment) { %><%
%><a href class='edit'><%
- %><i class='fa fa-pencil'></i>&nbsp;edit<%
+ %><i class='fa fa-edit'></i>&nbsp;edit<%
%></a><%
%><% } %><%
%><% if (ctx.canDeleteComment) { %><%
%><a href class='delete'><%
- %><i class='fa fa-remove'></i>&nbsp;delete<%
+ %><i class='fa fa-times'></i>&nbsp;delete<%
%></a><%
%><% } %><%
%></span><%
diff --git a/client/html/compact_metric_list_item.tpl b/client/html/compact_metric_list_item.tpl
new file mode 100644
index 0000000..ddddc33
--- /dev/null
+++ b/client/html/compact_metric_list_item.tpl
@@ -0,0 +1,26 @@
+<li><!--
+--><% if (ctx.editMode) { %><!--
+ --><a href="<%- ctx.formatClientLink('tag', ctx.tag.names[0]) %>"
+ class="<%= ctx.makeCssName(ctx.tag.category, 'tag') %>"><!--
+ --><i class='fa fa-sliders-h tag-icon'></i><!--
+ --></a><!--
+ --><a href="<%- ctx.formatClientLink('posts', {
+ query: 'metric-' + ctx.escapeTagName(ctx.tag.names[0]) +
+ ':' + ctx.tag.metric.min + '..' + ctx.tag.metric.max +
+ ' sort:metric-' + ctx.escapeTagName(ctx.tag.names[0])
+ }) %>"
+ class="<%= ctx.makeCssName(ctx.tag.category, 'tag') %>"><!--
+ --><%- ctx.tag.names[0] %>&#32;<!--
+ --></a><!--
+ --><span class='metric-bounds' data-pseudo-content=
+ '<%- ctx.tag.metric.min %> &mdash; <%- ctx.tag.metric.max %>'></span><!--
+ --><span class='metric-controls'>Set<!--
+ --><a href class='create-exact'> exact</a><!--
+ --><a href class='create-range'> range</a><!--
+ --><a href='<%= ctx.getMetricSorterUrl(ctx.post.id, {
+ metrics: ctx.tag.names[0],
+ query: ctx.query}) %>'
+ class='sort'> sort</a><!--
+ --></span><!--
+--><% } %><!--
+--></li>
diff --git a/client/html/compact_post_metric_list_item.tpl b/client/html/compact_post_metric_list_item.tpl
new file mode 100644
index 0000000..aa6da3f
--- /dev/null
+++ b/client/html/compact_post_metric_list_item.tpl
@@ -0,0 +1,32 @@
+<li><!--
+--><% if (ctx.editMode) { %><!--
+ --><a href class='remove-metric' data-pseudo-content='×'/><!--
+ --><a href="<%- ctx.formatClientLink('posts', {
+ query: 'metric-' + ctx.escapeTagName(ctx.tag.names[0]) +
+ ':' + ctx.tag.metric.min + '..' + ctx.tag.metric.max +
+ ' sort:metric-' + ctx.escapeTagName(ctx.tag.names[0])
+ }) %>"
+ class="<%= ctx.makeCssName(ctx.tag.category, 'tag') %>"><!--
+ --><i class='fas fa-angle-right tag-icon'></i><!--
+ --><%- ctx.postMetric.tagName %>:</a><!--
+ --><%= ctx.makeNumericInput({
+ name: 'value',
+ value: ctx.postMetric.value,
+ step: 'any',
+ min: ctx.tag.metric.min,
+ max: ctx.tag.metric.max,
+ }) %><!--
+--><% } else { %><!--
+ --><a href="<%- ctx.formatClientLink('tag', ctx.tag.names[0]) %>"
+ class="<%= ctx.makeCssName(ctx.tag.category, 'tag') %>"><!--
+ --><i class='fas fa-angle-right tag-icon'></i><!--
+ --></a><!--
+ --><a href="<%- ctx.formatClientLink('posts', {
+ query: 'metric-' + ctx.escapeTagName(ctx.tag.names[0]) +
+ ':' + ctx.tag.metric.min + '..' + ctx.tag.metric.max +
+ ' sort:metric-' + ctx.escapeTagName(ctx.tag.names[0])
+ }) %>"
+ class="<%= ctx.makeCssName(ctx.tag.category, 'tag') %>"><!--
+ --><%- ctx.postMetric.tagName %>: <%- ctx.postMetric.value || 0 %></a><!--
+--><% } %><!--
+--></li>
diff --git a/client/html/compact_post_metric_range_list_item.tpl b/client/html/compact_post_metric_range_list_item.tpl
new file mode 100644
index 0000000..fd4c40e
--- /dev/null
+++ b/client/html/compact_post_metric_range_list_item.tpl
@@ -0,0 +1,42 @@
+<li><!--
+--><% if (ctx.editMode) { %><!--
+ --><a class='remove-metric' data-pseudo-content='×'/><!--
+ --><a href="<%- ctx.formatClientLink('posts', {
+ query: 'metric-' + ctx.escapeTagName(ctx.tag.names[0]) +
+ ':' + ctx.tag.metric.min + '..' + ctx.tag.metric.max +
+ ' sort:metric-' + ctx.escapeTagName(ctx.tag.names[0])
+ }) %>"
+ class="<%= ctx.makeCssName(ctx.tag.category, 'tag') %>"><!--
+ --><i class='fas fa-arrows-alt-h tag-icon'></i><!--
+ --><%- ctx.postMetricRange.tagName %>:</a><!--
+ --><%= ctx.makeNumericInput({
+ name: 'low',
+ value: ctx.postMetricRange.low,
+ step: 'any',
+ min: ctx.tag.metric.min,
+ max: ctx.tag.metric.max,
+ }) %><!--
+ --><span class='range-delimiter'>&mdash;</span><!--
+ --><%= ctx.makeNumericInput({
+ name: 'high',
+ value: ctx.postMetricRange.high,
+ step: 'any',
+ min: ctx.tag.metric.min,
+ max: ctx.tag.metric.max,
+ }) %><!--
+--><% } else { %><!--
+ --><a href="<%- ctx.formatClientLink('tag', ctx.tag.names[0]) %>"
+ class="<%= ctx.makeCssName(ctx.tag.category, 'tag') %>"><!--
+ --><i class='fas fa-arrows-alt-h tag-icon'></i><!--
+ --></a><!--
+ --><a href="<%- ctx.formatClientLink('posts', {
+ query: 'metric-' + ctx.escapeTagName(ctx.tag.names[0]) +
+ ':' + ctx.tag.metric.min + '..' + ctx.tag.metric.max +
+ ' sort:metric-' + ctx.escapeTagName(ctx.tag.names[0])
+ }) %>"
+ class="<%= ctx.makeCssName(ctx.tag.category, 'tag') %>"><!--
+ --><%- ctx.postMetricRange.tagName %>:
+ <%- ctx.postMetricRange.low || 0 %> &mdash; <%- ctx.postMetricRange.high || 0 %><!--
+ --></a><!--
+--><% } %><!--
+--></li>
diff --git a/client/html/endless_pager_page.tpl b/client/html/endless_pager_page.tpl
index 9b1cf6c..1212ba8 100644
--- a/client/html/endless_pager_page.tpl
+++ b/client/html/endless_pager_page.tpl
@@ -1,4 +1,5 @@
<div class='page'>
- <p class='page-header'><span>Page <%- ctx.page %> of <%- ctx.totalPages %></span></p>
+<!-- Don't show the header, make page transition seamless: -->
+<!-- <p class='page-header'><span>Page <%- ctx.page %> of <%- ctx.totalPages %></span></p>*} -->
<div class='page-content-holder'></div>
</div>
diff --git a/client/html/fav.tpl b/client/html/fav.tpl
index 38cbd6b..7b72a7c 100644
--- a/client/html/fav.tpl
+++ b/client/html/fav.tpl
@@ -4,11 +4,11 @@
<i class='fa fa-heart'></i>
<% } else { %>
<a href class='add-favorite'>
- <i class='fa fa-heart-o'></i>
+ <i class='far fa-heart'></i>
<% } %>
<% } else { %>
<a class='add-favorite inactive'>
- <i class='fa fa-heart-o'></i>
+ <i class='far fa-heart'></i>
<% } %>
<span class='vim-nav-hint'>add to favorites</span>
</a>
diff --git a/client/html/help_keyboard.tpl b/client/html/help_keyboard.tpl
index f200ce0..5fe53b9 100644
--- a/client/html/help_keyboard.tpl
+++ b/client/html/help_keyboard.tpl
@@ -15,11 +15,21 @@ shortcuts:</p>
</tr>
<tr>
+ <td><kbd>P</kbd></td>
+ <td>Focus first post in post list</td>
+ </tr>
+
+ <tr>
<td><kbd>A</kbd> and <kbd>D</kbd>, <kbd>←</kbd> and <kbd>→</kbd></td>
<td>Go to newer/older page or post</td>
</tr>
<tr>
+ <td><kbd>R</kbd></td>
+ <td>Go to random post</td>
+ </tr>
+
+ <tr>
<td><kbd>F</kbd></td>
<td>Cycle post fit mode</td>
</tr>
@@ -30,13 +40,18 @@ shortcuts:</p>
</tr>
<tr>
- <td><kbd>P</kbd></td>
- <td>Focus first post in post list</td>
+ <td><kbd>T</kbd></td>
+ <td>(In edit mode) Focus tag input</td>
+ </tr>
+
+ <tr>
+ <td><kbd>Command/Ctrl+S</kbd></td>
+ <td>(In edit mode) Save post</td>
</tr>
<tr>
<td><kbd>Delete</kbd></td>
- <td>Delete post (while in edit mode)</td>
+ <td>(In edit mode) delete post</td>
</tr>
</tbody>
</table>
diff --git a/client/html/metric_header.tpl b/client/html/metric_header.tpl
new file mode 100644
index 0000000..a711575
--- /dev/null
+++ b/client/html/metric_header.tpl
@@ -0,0 +1,11 @@
+<form class='horizontal'>
+ <ul class='metric-list'></ul>
+ <wbr>
+ <%= ctx.makeCheckbox({
+ text: 'Show values on posts',
+ name: 'show-values-on-posts',
+ checked: ctx.showValuesOnPost,
+ class: 'append'}) %>
+ <a class='mousetrap button append close sorting'
+ href="<%= ctx.getMetricSorterUrl('random', ctx.parameters) %>">Start sorting</a>
+</form>
diff --git a/client/html/metric_header_item.tpl b/client/html/metric_header_item.tpl
new file mode 100644
index 0000000..880dfed
--- /dev/null
+++ b/client/html/metric_header_item.tpl
@@ -0,0 +1,6 @@
+<li class="<%= ctx.makeCssName(ctx.metric.tag.category, 'tag') %><%
+ if (ctx.selected) { %> selected<% } %>">
+ <a href class="<%= ctx.makeCssName(ctx.metric.tag.category, 'tag') %><%
+ if (ctx.selected) { %> selected<% } %>"><%
+ %><%- ctx.metric.tag.names[0] %></a>
+</li> \ No newline at end of file
diff --git a/client/html/metric_sorter.tpl b/client/html/metric_sorter.tpl
new file mode 100644
index 0000000..3023fbd
--- /dev/null
+++ b/client/html/metric_sorter.tpl
@@ -0,0 +1,39 @@
+<div class='content-wrapper' id='metric-sorter'>
+ <h2>Sorting metric "<%- ctx.primaryMetric %>"</h2>
+ <form>
+ <div class='posts-container'>
+ <div class='left-post-container'></div>
+ <% if (window.innerWidth <= 1000) { %>
+ <div class='messages'></div>
+ <% } %>
+ <div class='sorting-buttons'>
+ <div class='compare-block'>
+ <% if (window.innerWidth <= 1000) { %>
+ <input class='mousetrap save-btn' type='submit' value='Save'>
+ <% } %>
+ <button class='compare left-lt-right'>
+ <i class='fa fa-less-than'></i>
+ </button>
+ <button class='compare left-gt-right'>
+ <i class='fa fa-greater-than'></i>
+ </button>
+ <% if (window.innerWidth <= 1000) { %>
+ <a href class='mousetrap append skip-btn'>Skip</a>
+ <% } %>
+ </div>
+ </div>
+ <div class='right-post-container'></div>
+ </div>
+
+ <% if (window.innerWidth > 1000) { %>
+ <div class='messages'></div>
+ <% } %>
+
+ <div class='buttons'>
+ <% if (window.innerWidth > 1000) { %>
+ <input class='mousetrap save-btn' type='submit' value='Save'>
+ <a href class='mousetrap append skip-btn'>Skip</a>
+ <% } %>
+ </div>
+ </form>
+</div>
diff --git a/client/html/metric_sorter_side.tpl b/client/html/metric_sorter_side.tpl
new file mode 100644
index 0000000..8d0b010
--- /dev/null
+++ b/client/html/metric_sorter_side.tpl
@@ -0,0 +1,5 @@
+<% if (ctx.post) { %>
+ <a href='<%= ctx.getPostUrl(ctx.post.id, ctx.parameters) %>'>
+ <div class='post-container'></div>
+ </a>
+<% } %>
diff --git a/client/html/post_edit_sidebar.tpl b/client/html/post_edit_sidebar.tpl
index 07dcf6f..9408606 100644
--- a/client/html/post_edit_sidebar.tpl
+++ b/client/html/post_edit_sidebar.tpl
@@ -79,6 +79,12 @@
</section>
<% } %>
+ <% if (ctx.canEditPostMetrics) { %>
+ <section class='metrics'>
+ <%= ctx.makeTextInput({}) %>
+ </section>
+ <% } %>
+
<% if (ctx.canEditPostNotes) { %>
<section class='notes'>
<a href class='add'>Add a note</a>
diff --git a/client/html/post_main.tpl b/client/html/post_main.tpl
index 84e48b1..cebab17 100644
--- a/client/html/post_main.tpl
+++ b/client/html/post_main.tpl
@@ -29,7 +29,26 @@
<span class='vim-nav-hint'>Next post &gt;</span>
</a>
</article>
- <% if (ctx.canEditPosts || ctx.canDeletePosts || ctx.canFeaturePosts) { %>
+ <article class='random-post'>
+ <% if (ctx.randomPostId) { %>
+ <% if (ctx.editMode) { %>
+ <a rel='next' href='<%= ctx.getPostEditUrl(ctx.randomPostId, {
+ query: ctx.parameters.query,
+ metrics: ctx.parameters.metrics,
+ cachenumber: Math.round(Math.random() * 1000)}) %>'>
+ <% } else { %>
+ <a rel='next' href='<%= ctx.getPostUrl(ctx.randomPostId, {
+ query: ctx.parameters.query,
+ metrics: ctx.parameters.metrics,
+ cachenumber: Math.round(Math.random() * 1000)}) %>'>
+ <% } %>
+ <% } else { %>
+ <a rel='next' class='inactive'>
+ <% } %>
+ <i class='fa fa-random'></i>
+ <span class='vim-nav-hint'>Random post</span>
+ </a>
+ </article>
<article class='edit-post'>
<% if (ctx.editMode) { %>
<a href='<%= ctx.getPostUrl(ctx.post.id, ctx.parameters) %>'>
@@ -37,30 +56,45 @@
<span class='vim-nav-hint'>Back to view mode</span>
</a>
<% } else { %>
- <a href='<%= ctx.getPostEditUrl(ctx.post.id, ctx.parameters) %>'>
- <i class='fa fa-pencil'></i>
- <span class='vim-nav-hint'>Edit post</span>
+ <% if (ctx.canEditPosts || ctx.canDeletePosts || ctx.canFeaturePosts) { %>
+ <a href='<%= ctx.getPostEditUrl(ctx.post.id, ctx.parameters) %>'>
+ <% } else { %>
+ <a class='inactive'>
+ <% } %>
+ <i class='fa fa-pencil-alt'></i>
+ <span class='vim-nav-hint'>Edit post</span>
</a>
<% } %>
</article>
- <% } %>
</nav>
<div class='sidebar-container'></div>
+
+ <% if (window.innerWidth <= 1000) { %>
+ <div class='comments-panel'>
+ <% if (ctx.canListComments) { %>
+ <div class='comments-container'></div>
+ <% } %>
+ <% if (ctx.canCreateComments) { %>
+ <a id='add-comment-button'><h3>Add comment</h3></a>
+ <div class='comment-form-container'></div>
+ <% } %>
+ </div>
+ <% } %>
</aside>
<div class='content'>
<div class='post-container'></div>
- <div class='after-mobile-controls'>
- <% if (ctx.canCreateComments) { %>
- <h2>Add comment</h2>
- <div class='comment-form-container'></div>
- <% } %>
-
+ <% if (window.innerWidth > 1000) { %>
<% if (ctx.canListComments) { %>
<div class='comments-container'></div>
<% } %>
- </div>
+
+ <% if (ctx.canCreateComments) { %>
+ <a id='add-comment-button'><h3>Add comment</h3></a>
+ <div class='comment-form-container'></div>
+ <% } %>
+ <% } %>
</div>
</div>
diff --git a/client/html/post_metric_input.tpl b/client/html/post_metric_input.tpl
new file mode 100644
index 0000000..b324062
--- /dev/null
+++ b/client/html/post_metric_input.tpl
@@ -0,0 +1,5 @@
+<div class='metric-input'>
+ <ul class='compact-unset-metrics'></ul>
+ <hr class='separator'>
+ <ul class='compact-post-metrics'></ul>
+</div>
diff --git a/client/html/post_readonly_sidebar.tpl b/client/html/post_readonly_sidebar.tpl
index 4709452..3e0927b 100644
--- a/client/html/post_readonly_sidebar.tpl
+++ b/client/html/post_readonly_sidebar.tpl
@@ -21,7 +21,7 @@
--></a>
(<%- ctx.post.canvasWidth %>x<%- ctx.post.canvasHeight %>)
<% if (ctx.post.flags.length) { %><!--
- --><% if (ctx.post.flags.includes('loop')) { %><i class='fa fa-repeat'></i><% } %><!--
+ --><% if (ctx.post.flags.includes('loop')) { %><i class='fa fa-redo-alt'></i><% } %><!--
--><% if (ctx.post.flags.includes('sound')) { %><i class='fa fa-volume-up'></i><% } %>
<% } %>
</section>
@@ -56,9 +56,9 @@
<section class='search'>
Search on
- <a href='http://iqdb.org/?url=<%- encodeURIComponent(ctx.post.fullContentUrl) %>'>IQDB</a> &middot;
- <a href='https://danbooru.donmai.us/posts?tags=md5:<%- ctx.post.checksumMD5 %>'>Danbooru</a> &middot;
- <a href='https://lens.google.com/uploadbyurl?url=<%- encodeURIComponent(ctx.post.fullContentUrl) %>'>Google Images</a>
+ <a target="_blank" href='http://iqdb.org/?url=<%- encodeURIComponent(ctx.post.fullContentUrl) %>'>IQDB</a> &middot;
+ <a target="_blank" href='https://danbooru.donmai.us/posts?tags=md5:<%- ctx.post.checksumMD5 %>'>Danbooru</a> &middot;
+ <a target="_blank" href='https://lens.google.com/uploadbyurl?url=<%- encodeURIComponent(ctx.post.fullContentUrl) %>'>Google Images</a>
</section>
<section class='social'>
@@ -83,6 +83,19 @@
</nav>
<% } %>
+ <% if (ctx.canViewSimilar) { %>
+ <nav class='similar'>
+ <h1>Similar</h1>
+ <ul></ul>
+ <a href='<%- ctx.formatClientLink("posts", {query: "similar:" + ctx.post.id}) %>'>See more</a>
+ </nav>
+
+ <nav class='lookalikes'>
+ <h1>Look-alikes</h1>
+ <ul></ul>
+ </nav>
+ <% } %>
+
<nav class='tags'>
<h1>Tags (<%- ctx.post.tags.length %>)</h1>
<% if (ctx.post.tags.length) { %>
@@ -116,4 +129,11 @@
</p>
<% } %>
</nav>
+
+ <% if (ctx.post.metrics.length + ctx.post.metricRanges.length) { %>
+ <nav class='metrics'>
+ <h1>Metrics (<%- ctx.post.metrics.length + ctx.post.metricRanges.length %>)</h1>
+ <ul class='compact-post-metrics'></ul>
+ </nav>
+ <% } %>
</div>
diff --git a/client/html/post_upload.tpl b/client/html/post_upload.tpl
index 3c1b238..c256e55 100644
--- a/client/html/post_upload.tpl
+++ b/client/html/post_upload.tpl
@@ -5,7 +5,7 @@
<div class='control-strip'>
<input type='submit' value='Upload all' class='submit'/>
- <span class='skip-duplicates'>
+ <span class='skip-duplicates control-checkbox'>
<%= ctx.makeCheckbox({
text: 'Skip duplicate',
name: 'skip-duplicates',
@@ -13,6 +13,13 @@
}) %>
</span>
+ <span class='copy-tags-to-originals control-checkbox'>
+ <%= ctx.makeCheckbox({
+ text: 'Copy tags to originals',
+ name: 'copy-tags-to-originals',
+ }) %>
+ </span>
+
<span class='always-upload-similar'>
<%= ctx.makeCheckbox({
text: 'Force upload similar',
@@ -29,6 +36,10 @@
}) %>
</span>
+ <div class='tags'>
+ <%= ctx.makeTextInput({}) %>
+ </div>
+
<input type='button' value='Cancel' class='cancel'/>
</div>
diff --git a/client/html/post_upload_row.tpl b/client/html/post_upload_row.tpl
index 2885e3d..26f174b 100644
--- a/client/html/post_upload_row.tpl
+++ b/client/html/post_upload_row.tpl
@@ -33,7 +33,7 @@
</nav>
<nav>
<ul>
- <li><a href class='remove'><i class='fa fa-remove'></i></a></li>
+ <li><a href class='remove'><i class='fa fa-times'></i></a></li>
</ul>
</nav>
@@ -83,9 +83,13 @@
<%- Math.round((1-lookalike.distance) * 100) %>% match
</div>
<div class='controls'>
- <%= ctx.makeCheckbox({text: 'Copy tags', name: 'copy-tags'}) %>
- <br/>
- <%= ctx.makeCheckbox({text: 'Add relation', name: 'add-relation'}) %>
+ <% if (lookalike.distance > 0) { %>
+ <%= ctx.makeCheckbox({text: 'Copy tags', name: 'copy-tags'}) %>
+ <br/>
+ <%= ctx.makeCheckbox({text: 'Add relation', name: 'add-relation'}) %>
+ <% } else { %>
+ <%= ctx.makeCheckbox({text: 'Copy tags', name: 'copy-tags'}) %>
+ <% } %>
</div>
</li>
<% } %>
diff --git a/client/html/posts_header.tpl b/client/html/posts_header.tpl
index 77d16ed..7d5fa78 100644
--- a/client/html/posts_header.tpl
+++ b/client/html/posts_header.tpl
@@ -3,35 +3,88 @@
%><%= ctx.makeTextInput({text: 'Search query', id: 'search-text', name: 'search-text', value: ctx.parameters.query}) %><%
%><wbr/><%
%><input class='mousetrap' type='submit' value='Search'/><%
- %><wbr/><%
+ %><button id='randomize-button' class='icon-button'><%
+ %><i class="fa fa-random"><%
+ %></button><%
%><% if (ctx.enableSafety) { %><%
%><input data-safety=safe type='button' class='mousetrap safety safety-safe <%- ctx.settings.listPosts.safe ? '' : 'disabled' %>'/><%
%><input data-safety=sketchy type='button' class='mousetrap safety safety-sketchy <%- ctx.settings.listPosts.sketchy ? '' : 'disabled' %>'/><%
%><input data-safety=unsafe type='button' class='mousetrap safety safety-unsafe <%- ctx.settings.listPosts.unsafe ? '' : 'disabled' %>'/><%
%><% } %><%
+ %><% if (ctx.isLoggedIn) { %><%
+ %><a href class='mousetrap icon-button query-shortcut' data-term='special:liked'><%
+ %><i class="fa fa-thumbs-up term-selected"></i><%
+ %><i class="far fa-thumbs-up term-unselected"></i><%
+ %></a><%
+ %><a href class='mousetrap icon-button query-shortcut' data-term='special:fav'><%
+ %><i class="fa fa-heart term-selected"></i><%
+ %><i class="far fa-heart term-unselected"></i><%
+ %></a><%
+ %><% } %><%
+ %><wbr/><%
+ %><a class='mousetrap button append'
+ href='<%- ctx.formatClientLink('help', 'search', 'posts') %>'><%
+ if (window.innerWidth <= 500) { %>Help<%
+ } else { %>Syntax help<% }
+ %></a><%
%><wbr/><%
- %><a class='mousetrap button append' href='<%- ctx.formatClientLink('help', 'search', 'posts') %>'>Syntax help</a><%
+ %><span class="bulk-edit-btn-holder"><%
+ %><a href class='mousetrap button append open bulk-edit-btn'><%
+ if (window.innerWidth <= 500) { %>Mass<%
+ } else { %>Mass edit<% }
+ %><i class='fa fa-chevron-down icon-inline'></i><%
+ %></a><%
+ %><a href class='mousetrap button append close bulk-edit-btn'><%
+ if (window.innerWidth <= 500) { %>Mass<%
+ } else { %>Mass edit<% }
+ %><i class='fa fa-chevron-up icon-inline'></i><%
+ %></a><%
+ %></span><%
+ %><wbr/><%
+ if (ctx.canViewMetrics) {
+ %><span class="metrics-btn-holder"><%
+ %><a href class='mousetrap button append open metrics-btn'><%
+ %>Metrics<%
+ %><i class='fa fa-chevron-down icon-inline'></i><%
+ %></a><%
+ %><a href class='mousetrap button append close metrics-btn'><%
+ %>Metrics<%
+ %><i class='fa fa-chevron-up icon-inline'></i><%
+ %></a><%
+ %></span><%
+ }
%></form><%
- %><% if (ctx.canBulkEditTags) { %><%
- %><form class='horizontal bulk-edit bulk-edit-tags'><%
- %><span class='append hint'>Tagging with:</span><%
- %><a href class='mousetrap button append open'>Mass tag</a><%
- %><%= ctx.makeTextInput({name: 'tag', value: ctx.parameters.tag}) %><%
- %><input class='mousetrap start' type='submit' value='Start tagging'/><%
- %><a href class='mousetrap button append close'>Stop tagging</a><%
- %></form><%
- %><% } %><%
- %><% if (ctx.enableSafety && ctx.canBulkEditSafety) { %><%
- %><form class='horizontal bulk-edit bulk-edit-safety'><%
- %><a href class='mousetrap button append open'>Mass edit safety</a><%
- %><a href class='mousetrap button append close'>Stop editing safety</a><%
- %></form><%
- %><% } %><%
- %><% if (ctx.canBulkDelete) { %><%
- %><form class='horizontal bulk-edit bulk-edit-delete'><%
- %><a href class='mousetrap button append open'>Mass delete</a><%
- %><input class='mousetrap start' type='submit' value='Delete selected posts'/><%
- %><a href class='mousetrap button append close'>Stop deleting</a><%
+ %><div class='bulk-edit-block hidden'><%
+ if (ctx.canBulkEditTags) {
+ %><form class='horizontal bulk-edit bulk-edit-tags'><%
+ %><span class='append hint'>Tagging with:</span><%
+ %><a href class='mousetrap button append open'>Mass tag</a><%
+ %><wbr/><%
+ %><%= ctx.makeTextInput({name: 'tag', value: ctx.parameters.tag}) %><%
+ %><input class='mousetrap start' type='submit' value='Start tagging'/><%
+ %><a href class='mousetrap button append close'>Stop tagging</a><%
+ %></form><%
+ }
+ if (ctx.enableSafety && ctx.canBulkEditSafety) {
+ %><form class='horizontal bulk-edit bulk-edit-safety'><%
+ %><a href class='mousetrap button append open'>Mass edit safety</a><%
+ %><a href class='mousetrap button append close'>Stop editing safety</a><%
+ %></form><%
+ }
+ %><!--TODO: create permission--><%
+ %><form class='horizontal bulk-edit bulk-add-relation'><%
+ %><a href class='mousetrap button append open'>Mass add relation</a><%
+ %><a href class='mousetrap button append close'>Stop adding relation</a><%
%></form><%
- %><% } %><%
+ if (ctx.canBulkDelete) {
+ %><form class='horizontal bulk-edit bulk-edit-delete'><%
+ %><a href class='mousetrap button append open'>Mass delete</a><%
+ %><input class='mousetrap start' type='submit' value='Delete selected posts'/><%
+ %><a href class='mousetrap button append close'>Stop deleting</a><%
+ %></form><%
+ }
+ %></div><%
+ if (ctx.canViewMetrics) {
+ %><div class='metrics-block hidden'></div><%
+ }
%></div>
diff --git a/client/html/posts_page.tpl b/client/html/posts_page.tpl
index 52011ad..bbf6a2d 100644
--- a/client/html/posts_page.tpl
+++ b/client/html/posts_page.tpl
@@ -30,7 +30,7 @@
<% } %>
<% if (post.commentCount) { %>
<span class='icon'>
- <i class='fa fa-commenting'></i>
+ <i class='far fa-comment-dots'></i>
<%- post.commentCount %>
</span>
<% } %>
@@ -42,6 +42,10 @@
<a href class='tag-flipper'>
</a>
<% } %>
+ <% if (ctx.parameters && ctx.parameters.relations) { %>
+ <a href class='relation-flipper'>
+ </a>
+ <% } %>
<% if (ctx.canBulkEditSafety && ctx.parameters && ctx.parameters.safety) { %>
<span class='safety-flipper'>
<% for (let safety of ['safe', 'sketchy', 'unsafe']) { %>
diff --git a/client/html/score.tpl b/client/html/score.tpl
index c190069..2586fdb 100644
--- a/client/html/score.tpl
+++ b/client/html/score.tpl
@@ -3,14 +3,14 @@
<% if (ctx.ownScore == 1) { %>
<i class='fa fa-thumbs-up'></i>
<% } else { %>
- <i class='fa fa-thumbs-o-up'></i>
+ <i class='far fa-thumbs-up'></i>
<% } %>
<span class='vim-nav-hint'>upvote</span>
<span class='vim-nav-hint'>like</span>
</a>
<% } else { %>
<a class='upvote inactive'>
- <i class='fa fa-thumbs-o-up'></i>
+ <i class='far fa-thumbs-up'></i>
</a>
<% } %>
<span class='value'><%- ctx.score %></span>
@@ -19,7 +19,7 @@
<% if (ctx.ownScore == -1) { %>
<i class='fa fa-thumbs-down'></i>
<% } else { %>
- <i class='fa fa-thumbs-o-down'></i>
+ <i class='far fa-thumbs-down'></i>
<% } %>
<span class='vim-nav-hint'>downvote</span>
<span class='vim-nav-hint'>dislike</span>
diff --git a/client/html/settings.tpl b/client/html/settings.tpl
index 0edbe22..063331a 100644
--- a/client/html/settings.tpl
+++ b/client/html/settings.tpl
@@ -11,6 +11,30 @@
}) %>
</li>
+ <li class='uploadSafety'>
+ <label>Safety</label>
+ <div class='radio-wrapper'>
+ <%= ctx.makeRadio({
+ name: 'safety',
+ class: 'safety-safe',
+ value: 'safe',
+ selectedValue: ctx.browsingSettings.uploadSafety,
+ text: 'Safe'}) %>
+ <%= ctx.makeRadio({
+ name: 'safety',
+ class: 'safety-sketchy',
+ value: 'sketchy',
+ selectedValue: ctx.browsingSettings.uploadSafety,
+ text: 'Sketchy'}) %>
+ <%= ctx.makeRadio({
+ name: 'safety',
+ value: 'unsafe',
+ selectedValue: ctx.browsingSettings.uploadSafety,
+ class: 'safety-unsafe',
+ text: 'Unsafe'}) %>
+ </div>
+ </li>
+
<li>
<%= ctx.makeNumericInput({
text: 'Number of posts per page',
@@ -23,6 +47,16 @@
</li>
<li>
+ <%= ctx.makeNumericInput({
+ text: 'Number of similar posts',
+ name: 'similar-posts',
+ value: ctx.browsingSettings.similarPosts,
+ min: 0,
+ max: 100,
+ }) %>
+ </li>
+
+ <li>
<%= ctx.makeCheckbox({
text: 'Use dark theme',
name: 'dark-theme',
diff --git a/client/html/similar_post_item.tpl b/client/html/similar_post_item.tpl
new file mode 100644
index 0000000..7203b1e
--- /dev/null
+++ b/client/html/similar_post_item.tpl
@@ -0,0 +1,5 @@
+<li>
+ <a href='<%= ctx.getPostUrl(ctx.id, ctx.parameters) %>'>
+ <%= ctx.makeThumbnail(ctx.thumbnailUrl) %>
+ </a>
+</li>
diff --git a/client/html/tag.tpl b/client/html/tag.tpl
index d84e299..56255e8 100644
--- a/client/html/tag.tpl
+++ b/client/html/tag.tpl
@@ -6,6 +6,9 @@
--><% if (ctx.canEditAnything) { %><!--
--><li data-name='edit'><a href='<%- ctx.formatClientLink('tag', ctx.tag.names[0], 'edit') %>'>Edit</a></li><!--
--><% } %><!--
+ --><% if (ctx.tag.metric || ctx.canCreateMetric) { %><!--
+ --><li data-name='metric'><a href='<%- ctx.formatClientLink('tag', ctx.tag.names[0], 'metric') %>'>Metric</a></li><!--
+ --><% } %><!--
--><% if (ctx.canMerge) { %><!--
--><li data-name='merge'><a href='<%- ctx.formatClientLink('tag', ctx.tag.names[0], 'merge') %>'>Merge with&hellip;</a></li><!--
--><% } %><!--
diff --git a/client/html/tag_input.tpl b/client/html/tag_input.tpl
index d8ffd7b..60c8969 100644
--- a/client/html/tag_input.tpl
+++ b/client/html/tag_input.tpl
@@ -1,6 +1,6 @@
<div class='tag-input'>
<div class='main-control'>
- <input type='text' placeholder='type to add…'/>
+ <input type='text' placeholder='<%- ctx.tagsPlaceholder %>'/>
<button>Add</button>
</div>
@@ -18,4 +18,6 @@
</div>
<ul class='compact-tags'></ul>
+
+ <a class='tag-from-lookalikes'>From lookalikes...</a>
</div>
diff --git a/client/html/tag_metric.tpl b/client/html/tag_metric.tpl
new file mode 100644
index 0000000..3f06d0c
--- /dev/null
+++ b/client/html/tag_metric.tpl
@@ -0,0 +1,40 @@
+<div class='tag-metric'>
+ <form class='horizontal edit-metric'>
+ <div class='metric-bounds-edit'>
+ <%= ctx.makeNumericInput({
+ text: 'Minimum',
+ name: 'metric-min',
+ value: ctx.metricMin,
+ step: 'any',
+ readonly: !ctx.canEditMetricBounds,
+ }) %>
+ <%= ctx.makeNumericInput({
+ text: 'Maximum',
+ name: 'metric-max',
+ value: ctx.metricMax,
+ step: 'any',
+ readonly: !ctx.canEditMetricBounds,
+ }) %>
+ </div>
+
+ <% if (ctx.tag.metric && ctx.canDeleteMetric) { %>
+ <div class='confirmation'>
+ <%= ctx.makeCheckbox({name: 'confirm-delete',
+ text: 'I confirm that I want to delete this metric.'}) %>
+ </div>
+ <% } %>
+
+ <div class='messages'></div>
+
+ <div class='buttons'><!--
+ --><% if (!ctx.tag.metric && ctx.canCreateMetric) { %><!--
+ --><input type='submit' value='Create metric'/><!--
+ --><% } else if (ctx.tag.metric && ctx.canEditMetricBounds) { %><!--
+ --><input type='submit' value='Update metric'/><!--
+ --><% } %><!--
+ --><% if (ctx.tag.metric && ctx.canDeleteMetric) { %><!--
+ --><input type='button' name='delete' class='delete' value='Delete metric'/><!--
+ --><% } %>
+ </div>
+ </form>
+</div>
diff --git a/client/html/user_registration.tpl b/client/html/user_registration.tpl
index a6d291f..3ac98f4 100644
--- a/client/html/user_registration.tpl
+++ b/client/html/user_registration.tpl
@@ -47,8 +47,8 @@
<ul>
<li><i class='fa fa-upload'></i> upload new posts</li>
<li><i class='fa fa-heart'></i> mark them as favorite</li>
- <li><i class='fa fa-commenting-o'></i> add comments</li>
- <li><i class='fa fa-star-half-o'></i> vote up/down on posts and comments</li>
+ <li><i class='far fa-comment-dots'></i> add comments</li>
+ <li><i class='fa fa-star-half-alt'></i> vote up/down on posts and comments</li>
</ul>
<hr/>
<p>By creating an account, you are agreeing to the <a href='<%- ctx.formatClientLink('help', 'tos') %>'>Terms of Service</a>.</p>
diff --git a/client/js/controllers/metric_sorter_contoller.js b/client/js/controllers/metric_sorter_contoller.js
new file mode 100644
index 0000000..e7a8d76
--- /dev/null
+++ b/client/js/controllers/metric_sorter_contoller.js
@@ -0,0 +1,187 @@
+'use strict';
+
+const api = require('../api.js');
+const router = require('../router.js');
+const views = require('../util/views.js');
+const topNavigation = require('../models/top_navigation.js');
+const Post = require('../models/post.js');
+const PostMetric = require('../models/post_metric.js');
+const PostMetricRange = require('../models/post_metric_range.js');
+const PostList = require('../models/post_list.js');
+const MetricSorterView = require('../views/metric_sorter_view.js');
+const EmptyView = require('../views/empty_view.js');
+
+const LEFT = 'left';
+const RIGHT = 'right';
+
+class MetricSorterController {
+ constructor(ctx) {
+ if (!api.hasPrivilege('posts:view') ||
+ !api.hasPrivilege('metrics:edit:posts')) {
+ this._view = new EmptyView();
+ this._view.showError('You don\'t have privileges to edit post metric values.');
+ return;
+ }
+
+ topNavigation.activate('posts');
+ topNavigation.setTitle('Sorting metrics');
+
+ this._ctx = ctx;
+ this._metricNames = (ctx.parameters.metrics || '')
+ .split(' ')
+ .filter(m => m);
+ if (!this._metricNames.length) {
+ this._view = new EmptyView();
+ this._view.showError('No metrics selected');
+ return;
+ }
+ this._primaryMetricName = this._metricNames[0];
+
+ this._view = new MetricSorterView({
+ primaryMetric: this._primaryMetricName,
+ greaterPost: RIGHT,
+ });
+ this._view.addEventListener('submit', e => this._evtSubmit(e));
+ this._view.addEventListener('skip', e => this._evtSkip(e));
+ this._view.addEventListener('changeMetric', e => this._evtChangeMetric(e));
+
+ if (ctx.parameters.id === 'random') {
+ this.startSortingRandomPost();
+ } else {
+ this.startSortingPost(ctx.parameters.id);
+ }
+ }
+
+ startSortingPost(id) {
+ this._view.clearMessages();
+ this._foundExactValue = false;
+ Post.get(id).then(post => {
+ this._unsortedPost = post;
+ this._view.installLeftPost(post);
+ this.reloadMedianPost();
+ }).catch(error => {
+ this._view.showError(error.message)
+ });
+ }
+
+ startSortingRandomPost() {
+ this._view.clearMessages();
+ this._getRandomUnsortedPostId().then(id => {
+ this._ctx.parameters.id = id;
+ router.replace(views.getMetricSorterUrl(id, this._ctx.parameters));
+ this.startSortingPost(id);
+ }).catch(error => {
+ this._view.showError(error.message)
+ });
+ }
+
+ reloadMedianPost() {
+ const metricName = this._primaryMetricName;
+ let range = this._getOrCreateRange(this._unsortedPost, metricName);
+ this._tryGetMedianPost(metricName, range).then(medianResponse => {
+ if (medianResponse.post) {
+ this._sortedPost = medianResponse.post;
+ this._view.installRightPost(this._sortedPost);
+ } else {
+ // No existing metrics, apply the median value
+ this._foundExactValue = true;
+ let exactValue = (medianResponse.range.low + medianResponse.range.high) / 2;
+ this._view.showSuccess(`Found exact value: ${exactValue}`);
+ this._setExactMetric(this._unsortedPost, metricName, exactValue);
+ //TODO: maybe allow to set exact value?
+ }
+ }).catch(error => {
+ this._view.showError(error.message)
+ });
+ }
+
+ _getRandomUnsortedPostId() {
+ let unsetMetricsQuery = this._metricNames
+ .map(m => `${m} -metric:${m}`)
+ .join(' ');
+ let filterQuery = this._ctx.parameters.query || '';
+ let unsetFullQuery = `${filterQuery} ${unsetMetricsQuery} sort:random`;
+
+ return PostList.search(unsetFullQuery,
+ this._ctx.parameters.skips || 0, 1, ['id']).then(response => {
+ if (!response.results.length) {
+ return Promise.reject(new Error('No posts found'));
+ } else {
+ return Promise.resolve(response.results.at(0).id);
+ }
+ });
+ }
+
+ _tryGetMedianPost(metric, range) {
+ let low = range.low + 0.000000001;
+ let high = range.high - 0.000000001;
+ let median_query = `metric-${metric}:${low}..${high} sort:metric-${metric}`;
+ return PostList.getMedian(median_query, []).then(response => {
+ return Promise.resolve({
+ range: range,
+ post: response.results.at(0)
+ });
+ });
+ }
+
+ _getOrCreateRange(post, metricName) {
+ let range = post.metricRanges.findByTagName(metricName);
+ if (!range) {
+ let tag = post.tags.findByName(metricName);
+ range = PostMetricRange.create(post.id, tag);
+ post.metricRanges.add(range);
+ }
+ return range;
+ }
+
+ _setExactMetric(post, metricName, value) {
+ let range = post.metricRanges.findByTagName(metricName);
+ if (!range) {
+ post.metricRanges.remove(range);
+ }
+ let tag = post.tags.findByName(metricName);
+ let exactMetric = PostMetric.create(post.id, tag);
+ exactMetric.value = value;
+ post.metrics.add(exactMetric);
+ }
+
+ _evtSubmit(e) {
+ let range = this._getOrCreateRange(this._unsortedPost, this._primaryMetricName);
+ if (this._foundExactValue) {
+ this._unsortedPost.metricRanges.remove(range);
+ } else {
+ let medianValue = this._sortedPost.metrics.findByTagName(this._primaryMetricName).value;
+ if (e.detail.greaterPost === LEFT) {
+ range.low = medianValue;
+ } else {
+ range.high = medianValue;
+ }
+ }
+ this._unsortedPost.save().then(() => {
+ if (this._foundExactValue) {
+ this.startSortingRandomPost();
+ } else {
+ this.reloadMedianPost();
+ }
+ }, error => {
+ this._view.showError(error.message)
+ });
+ }
+
+ _evtSkip(e) {
+ this._ctx.parameters.skips = (this._ctx.parameters.skips || 0) + 1;
+ this.startSortingRandomPost();
+ }
+
+ _evtChangeMetric(e) {
+ // this._primaryMetricName = e.detail.metricName;
+ }
+}
+
+module.exports = router => {
+ router.enter(
+ ['post', ':id', 'metric-sorter'],
+ (ctx, next) => {
+ ctx.controller = new MetricSorterController(ctx);
+ });
+};
diff --git a/client/js/controllers/post_list_controller.js b/client/js/controllers/post_list_controller.js
index fdb7b84..0146e9e 100644
--- a/client/js/controllers/post_list_controller.js
+++ b/client/js/controllers/post_list_controller.js
@@ -3,6 +3,7 @@
const router = require("../router.js");
const api = require("../api.js");
const settings = require("../models/settings.js");
+const tags = require("../tags.js");
const uri = require("../util/uri.js");
const PostList = require("../models/post_list.js");
const topNavigation = require("../models/top_navigation.js");
@@ -41,9 +42,11 @@ class PostListController {
this._headerView = new PostsHeaderView({
hostNode: this._pageController.view.pageHeaderHolderNode,
parameters: ctx.parameters,
+ isLoggedIn: api.isLoggedIn(),
enableSafety: api.safetyEnabled(),
canBulkEditTags: api.hasPrivilege("posts:bulk-edit:tags"),
canBulkEditSafety: api.hasPrivilege("posts:bulk-edit:safety"),
+ canViewMetrics: api.hasPrivilege("metrics:list"),
canBulkDelete: api.hasPrivilege("posts:bulk-edit:delete"),
bulkEdit: {
tags: this._bulkEditTags,
@@ -70,21 +73,37 @@ class PostListController {
this._pageController.showSuccess(message);
}
+ showError(message) {
+ this._pageController.showError(message);
+ }
+
get _bulkEditTags() {
return (this._ctx.parameters.tag || "").split(/\s+/).filter((s) => s);
}
+ get _bulkEditRelationsIds() {
+ return (this._ctx.parameters.relations || "").split(/\s+/).filter(s => s)
+ .map(id => parseInt(id));
+ }
+
_evtNavigate(e) {
- router.showNoDispatch(
+ this._ctx = router.showNoDispatch(
uri.formatClientLink("posts", e.detail.parameters)
);
Object.assign(this._ctx.parameters, e.detail.parameters);
+ this._bulkEditTags.map((tagName) =>
+ tags.resolveTagAndCategory(tagName)
+ .catch((error) => window.alert(error.message))
+ );
this._syncPageController();
}
_evtTag(e) {
Promise.all(
- this._bulkEditTags.map((tag) => e.detail.post.tags.addByName(tag))
+ this._bulkEditTags.map((tag) => {
+ let tagData = tags.parseTagAndCategory(tag);
+ return e.detail.post.tags.addByName(tagData.name);
+ })
)
.then(e.detail.post.save())
.catch((error) => window.alert(error.message));
@@ -92,7 +111,8 @@ class PostListController {
_evtUntag(e) {
for (let tag of this._bulkEditTags) {
- e.detail.post.tags.removeByName(tag);
+ let tagData = tags.parseTagAndCategory(tag);
+ e.detail.post.tags.removeByName(tagData.name);
}
e.detail.post.save().catch((error) => window.alert(error.message));
}
@@ -102,6 +122,38 @@ class PostListController {
e.detail.post.save().catch((error) => window.alert(error.message));
}
+ _evtAddRelation(e) {
+ let addedPost = e.detail.post;
+ // If we're just starting to build this instance of relations,
+ // use the first post's list:
+ let relations = this._bulkEditRelationsIds || addedPost.relations;
+ for (let relationId of relations) {
+ addedPost.relations.push(relationId);
+ }
+ // Only save the updated post, the relationship will propagate to
+ // others automatically
+ addedPost.save().catch((error) => window.alert(error.message));
+ relations.push(addedPost.id);
+ this._updateRelationsForBulkEdit(relations);
+
+ }
+
+ _evtRemoveRelation(e) {
+ let removedPost = e.detail.post;
+ let relations = this._bulkEditRelationsIds;
+ removedPost.relations = removedPost.relations
+ .filter((id)=> !relations.some((relationId) => relationId == id));
+ // Only save the updated post, the relationship will propagate to others automatically
+ removedPost.save().catch((error) => window.alert(error.message));
+ relations = relations.filter((id) => id != removedPost.id);
+ this._updateRelationsForBulkEdit(relations);
+ }
+
+ _updateRelationsForBulkEdit(relations) {
+ //Whitespace instead of empty string so that it stays part of the query:
+ this._ctx.parameters.relations = relations.join(" ") || " ";
+ }
+
_evtMarkForDeletion(e) {
const postId = e.detail;
@@ -137,6 +189,7 @@ class PostListController {
_syncPageController() {
this._pageController.run({
parameters: this._ctx.parameters,
+ browserState: this._ctx.state,
defaultLimit: parseInt(settings.get().postsPerPage),
getClientUrlForPage: (offset, limit) => {
const parameters = Object.assign({}, this._ctx.parameters, {
@@ -146,11 +199,13 @@ class PostListController {
return uri.formatClientLink("posts", parameters);
},
requestPage: (offset, limit) => {
+ let query = uri.getPostsQuery(this._ctx.parameters);
return PostList.search(
- this._ctx.parameters.query,
+ query,
offset,
limit,
- fields
+ fields,
+ this._ctx.parameters.cachenumber
);
},
pageRenderer: (pageCtx) => {
@@ -160,6 +215,11 @@ class PostListController {
canBulkEditSafety: api.hasPrivilege(
"posts:bulk-edit:safety"
),
+ canViewMetrics: api.hasPrivilege("metrics:list"),
+ bulkEdit: {
+ tags: this._bulkEditTags,
+ relations: this._ctx.parameters.relations,
+ },
canBulkDelete: api.hasPrivilege("posts:bulk-edit:delete"),
bulkEdit: {
tags: this._bulkEditTags,
@@ -173,11 +233,18 @@ class PostListController {
view.addEventListener("changeSafety", (e) =>
this._evtChangeSafety(e)
);
+ view.addEventListener("addRelation", (e) =>
+ this._evtAddRelation(e)
+ );
+ view.addEventListener("removeRelation", (e) =>
+ this._evtRemoveRelation(e)
+ );
view.addEventListener("markForDeletion", (e) =>
this._evtMarkForDeletion(e)
);
return view;
},
+ readPageFromCache: (rawPage) => PostList.fromResponse(rawPage),
});
}
}
diff --git a/client/js/controllers/post_main_controller.js b/client/js/controllers/post_main_controller.js
index bd33812..7d6dcd3 100644
--- a/client/js/controllers/post_main_controller.js
+++ b/client/js/controllers/post_main_controller.js
@@ -8,6 +8,7 @@ const settings = require("../models/settings.js");
const Comment = require("../models/comment.js");
const Post = require("../models/post.js");
const PostList = require("../models/post_list.js");
+const Tag = require("../models/tag.js");
const PostMainView = require("../views/post_main_view.js");
const BasePostController = require("./base_post_controller.js");
const EmptyView = require("../views/empty_view.js");
@@ -17,30 +18,17 @@ class PostMainController extends BasePostController {
super(ctx);
let parameters = ctx.parameters;
+ let query = uri.getPostsQuery(parameters);
Promise.all([
Post.get(ctx.parameters.id),
PostList.getAround(
ctx.parameters.id,
- parameters ? parameters.query : null
+ query,
+ ctx.parameters.cachenumber
),
]).then(
(responses) => {
const [post, aroundResponse] = responses;
-
- // remove junk from query, but save it into history so that it can
- // be still accessed after history navigation / page refresh
- if (parameters.query) {
- ctx.state.parameters = parameters;
- const url = editMode
- ? uri.formatClientLink(
- "post",
- ctx.parameters.id,
- "edit"
- )
- : uri.formatClientLink("post", ctx.parameters.id);
- router.replace(url, ctx.state, false);
- }
-
this._post = post;
this._view = new PostMainView({
post: post,
@@ -51,6 +39,9 @@ class PostMainController extends BasePostController {
nextPostId: aroundResponse.next
? aroundResponse.next.id
: null,
+ randomPostId: aroundResponse.random
+ ? aroundResponse.random.id
+ : null,
canEditPosts: api.hasPrivilege("posts:edit"),
canDeletePosts: api.hasPrivilege("posts:delete"),
canFeaturePosts: api.hasPrivilege("posts:feature"),
diff --git a/client/js/controllers/post_upload_controller.js b/client/js/controllers/post_upload_controller.js
index 720a116..7ee63ac 100644
--- a/client/js/controllers/post_upload_controller.js
+++ b/client/js/controllers/post_upload_controller.js
@@ -5,6 +5,7 @@ const router = require("../router.js");
const uri = require("../util/uri.js");
const misc = require("../util/misc.js");
const progress = require("../util/progress.js");
+const settings = require("../models/settings.js");
const topNavigation = require("../models/top_navigation.js");
const Post = require("../models/post.js");
const Tag = require("../models/tag.js");
@@ -31,6 +32,7 @@ class PostUploadController {
canUploadAnonymously: api.hasPrivilege("posts:create:anonymous"),
canViewPosts: api.hasPrivilege("posts:view"),
enableSafety: api.safetyEnabled(),
+ defaultSafety: settings.get().uploadSafety
});
this._view.addEventListener("change", (e) => this._evtChange(e));
this._view.addEventListener("submit", (e) => this._evtSubmit(e));
@@ -55,6 +57,7 @@ class PostUploadController {
_evtSubmit(e) {
this._view.disableForm();
this._view.clearMessages();
+ const tagErrors = []; // to be displayed after all uploads
let anyFailures = false;
e.detail.uploadables
@@ -64,6 +67,7 @@ class PostUploadController {
this._uploadSinglePost(
uploadable,
e.detail.skipDuplicates,
+ e.detail.copyTagsToOriginals,
e.detail.alwaysUploadSimilar
).catch((error) => {
anyFailures = true;
@@ -108,6 +112,9 @@ class PostUploadController {
misc.disableExitConfirmation();
const ctx = router.show(uri.formatClientLink("posts"));
ctx.controller.showSuccess("Posts uploaded.");
+ for (let tagError of tagErrors) {
+ ctx.controller.showError(tagError);
+ }
},
(error) => {
this._view.showError(genericErrorMessage);
@@ -116,7 +123,7 @@ class PostUploadController {
);
}
- _uploadSinglePost(uploadable, skipDuplicates, alwaysUploadSimilar) {
+ _uploadSinglePost(uploadable, skipDuplicates, copyTagsToOriginals, alwaysUploadSimilar) {
progress.start();
let reverseSearchPromise = Promise.resolve();
if (!uploadable.lookalikesConfirmed) {
@@ -131,7 +138,11 @@ class PostUploadController {
if (searchResult) {
// notify about exact duplicate
if (searchResult.exactPost) {
- if (skipDuplicates) {
+ if (copyTagsToOriginals) {
+ return this._copyTagsToOriginalAndSave(
+ uploadable, searchResult.exactPost
+ );
+ } else if (skipDuplicates) {
this._view.removeUploadable(uploadable);
return Promise.resolve();
} else {
@@ -140,6 +151,12 @@ class PostUploadController {
`(@${searchResult.exactPost.id})`
);
error.uploadable = uploadable;
+ error.similarPosts = [
+ {
+ distance: 0,
+ post: searchResult.exactPost
+ }
+ ];
return Promise.reject(error);
}
}
@@ -156,6 +173,10 @@ class PostUploadController {
error.uploadable = uploadable;
error.similarPosts = searchResult.similarPosts;
return Promise.reject(error);
+ } else if (uploadable.foundOriginal) {
+ return this._copyTagsToOriginalAndSave(
+ uploadable, uploadable.foundOriginal
+ );
}
}
@@ -199,6 +220,19 @@ class PostUploadController {
}
return post;
}
+
+ _copyTagsToOriginalAndSave(uploadable, original) {
+ uploadable.tags.map(tag => original.tags.addByName(tag));
+ let savePromise = original.save()
+ .then(
+ () => {
+ this._view.removeUploadable(uploadable);
+ return Promise.resolve();
+ }
+ );
+ this._lastCancellablePromise = savePromise;
+ return savePromise;
+ }
}
module.exports = (router) => {
diff --git a/client/js/controllers/tag_controller.js b/client/js/controllers/tag_controller.js
index 80e32c7..b7e6ce3 100644
--- a/client/js/controllers/tag_controller.js
+++ b/client/js/controllers/tag_controller.js
@@ -2,6 +2,7 @@
const router = require("../router.js");
const api = require("../api.js");
+const Metric = require("../models/metric.js");
const misc = require("../util/misc.js");
const uri = require("../util/uri.js");
const Tag = require("../models/tag.js");
@@ -53,6 +54,9 @@ class TagController {
canEditDescription: api.hasPrivilege(
"tags:edit:description"
),
+ canCreateMetric: api.hasPrivilege("metrics:create"),
+ canDeleteMetric: api.hasPrivilege("metrics:delete"),
+ canEditMetricBounds: api.hasPrivilege("metrics:edit:bounds"),
canMerge: api.hasPrivilege("tags:merge"),
canDelete: api.hasPrivilege("tags:delete"),
categories: categories,
@@ -69,6 +73,12 @@ class TagController {
this._view.addEventListener("delete", (e) =>
this._evtDelete(e)
);
+ this._view.addEventListener("metricUpdate", (e) =>
+ this._evtMetricUpdate(e)
+ );
+ this._view.addEventListener("metricDelete", (e) =>
+ this._evtMetricDelete(e)
+ );
},
(error) => {
this._view = new EmptyView();
@@ -116,6 +126,35 @@ class TagController {
);
}
+ _evtMetricUpdate(e) {
+ this._view.clearMessages();
+ this._view.disableForm();
+ const metric = new Metric();
+ metric.min = e.detail.metricMin;
+ metric.max = e.detail.metricMax;
+ e.detail.tag.metric = metric;
+
+ e.detail.tag.save().then(() => {
+ this._view.showSuccess("Metric updated.");
+ this._view.enableForm();
+ }, error => {
+ this._view.showError(error.message);
+ this._view.enableForm();
+ });
+ }
+
+ _evtMetricDelete(e) {
+ this._view.clearMessages();
+ this._view.disableForm();
+ e.detail.tag.deleteMetric().then(() => {
+ this._view.showSuccess("Metric deleted.");
+ this._view.enableForm();
+ }, error => {
+ this._view.showError(error.message);
+ this._view.enableForm();
+ });
+ }
+
_evtMerge(e) {
this._view.clearMessages();
this._view.disableForm();
@@ -160,6 +199,9 @@ module.exports = (router) => {
router.enter(["tag", ":name", "edit"], (ctx, next) => {
ctx.controller = new TagController(ctx, "edit");
});
+ router.enter(["tag", ":name", "metric"], (ctx, next) => {
+ ctx.controller = new TagController(ctx, "metric");
+ });
router.enter(["tag", ":name", "merge"], (ctx, next) => {
ctx.controller = new TagController(ctx, "merge");
});
diff --git a/client/js/controls/expander_control.js b/client/js/controls/expander_control.js
index ffb0e90..f3105ca 100644
--- a/client/js/controls/expander_control.js
+++ b/client/js/controls/expander_control.js
@@ -42,6 +42,10 @@ class ExpanderControl {
this._syncIcon();
}
+ get containerNode() {
+ return this._expanderNode;
+ }
+
// eslint-disable-next-line accessor-pairs
set title(newTitle) {
if (this._expanderNode) {
diff --git a/client/js/controls/metric_header_control.js b/client/js/controls/metric_header_control.js
new file mode 100644
index 0000000..6a70b5b
--- /dev/null
+++ b/client/js/controls/metric_header_control.js
@@ -0,0 +1,85 @@
+'use strict';
+
+const events = require('../events.js');
+const misc = require('../util/misc.js');
+const views = require('../util/views.js');
+const MetricList = require('../models/metric_list.js');
+
+const mainTemplate = views.getTemplate('metric-header');
+const metricItemTemplate = views.getTemplate('metric-header-item');
+
+class MetricHeaderControl extends events.EventTarget {
+ constructor(hostNode, ctx) {
+ super();
+ this._ctx = ctx;
+ this._hostNode = hostNode;
+ this._selectedMetrics = new MetricList();
+
+ this._headerNode = mainTemplate(ctx);
+ this._metricListNode = this._headerNode.querySelector('ul.metric-list');
+
+ this._hostNode.insertBefore(
+ this._headerNode, this._hostNode.nextSibling);
+
+ MetricList.loadAll().then(response => {
+ this._ctx.allMetrics = response.results;
+ this._addSelectedMetrics(ctx.parameters.metrics);
+ this._installMetrics(response.results);
+ this._refreshStartSortingButton();
+ });
+ }
+
+ _addSelectedMetrics(metricsStr) {
+ let selectedNames = (metricsStr || '').split(' ');
+ for (let metric of [...this._ctx.allMetrics]) {
+ if (selectedNames.includes(metric.tag.names[0])) {
+ this._selectedMetrics.add(metric);
+ }
+ }
+ }
+
+ _installMetrics(metrics) {
+ for (let metric of metrics) {
+ const node = metricItemTemplate(Object.assign({},
+ {
+ metric: metric,
+ selected: this._selectedMetrics.includes(metric),
+ },
+ this._ctx));
+ node.addEventListener('click', e =>
+ this._evtMetricClicked(e, node, metric));
+ this._metricListNode.appendChild(node);
+ }
+ }
+
+ _evtMetricClicked(e, node, metric) {
+ e.preventDefault();
+ node.classList.toggle('selected');
+ node.querySelector('a').classList.toggle('selected');
+ if (this._selectedMetrics.includes(metric)) {
+ this._selectedMetrics.remove(metric);
+ } else {
+ this._selectedMetrics.add(metric);
+ }
+ this._ctx.parameters = Object.assign({},
+ this._ctx.parameters, {
+ metrics: this._selectedMetrics
+ .map(m => m.tag.names[0]).join(' '),
+ });
+ this._refreshStartSortingButton();
+ this.dispatchEvent(new CustomEvent('submit'));
+ }
+
+ _refreshStartSortingButton() {
+ let btn = this._hostNode.querySelector('a.sorting');
+ btn.hidden = !this._selectedMetrics.length;
+ btn.setAttribute('href', views.getMetricSorterUrl('random', this._ctx.parameters));
+ }
+
+ refreshQuery(query) {
+ this._ctx.parameters.query = query;
+ this._refreshStartSortingButton();
+ }
+}
+
+module.exports = MetricHeaderControl;
diff --git a/client/js/controls/post_content_control.js b/client/js/controls/post_content_control.js
index 8cbd5d8..0023854 100644
--- a/client/js/controls/post_content_control.js
+++ b/client/js/controls/post_content_control.js
@@ -29,6 +29,9 @@ class PostContentControl {
this._post.addEventListener("changeContent", (e) =>
this._evtPostContentChange(e)
);
+
+ // Always disable overlay, because I'm not going to use notes
+ this.disableOverlay();
}
disableOverlay() {
@@ -93,46 +96,34 @@ class PostContentControl {
}
_resize(width, height) {
- const resizeListenerNodes = [this._postContentNode].concat(
- ...this._postContentNode.querySelectorAll(".resize-listener")
- );
+ this._resizePostContent(width, height);
+ const resizeListenerNodes = this._postContentNode.querySelectorAll(".resize-listener");
for (let node of resizeListenerNodes) {
node.style.width = width + "px";
node.style.height = height + "px";
}
}
- _refreshSize() {
- if (window.innerWidth <= 800) {
- const buttons = document.querySelector(".sidebar > .buttons");
- if (buttons) {
- const content = document.querySelector(".content");
- content.insertBefore(buttons, content.querySelector(".post-container + *"));
-
- const afterControls = document.querySelector(".content > .after-mobile-controls");
- if (afterControls) {
- afterControls.parentElement.parentElement.appendChild(afterControls);
- }
- }
- } else {
- const buttons = document.querySelector(".content > .buttons");
- if (buttons) {
- const sidebar = document.querySelector(".sidebar");
- sidebar.insertBefore(buttons, sidebar.firstElementChild);
- }
-
- const afterControls = document.querySelector(".content + .after-mobile-controls");
- if (afterControls) {
- document.querySelector(".content").appendChild(afterControls);
- }
+ _resizePostContent(width, height) {
+ // on mobile don't make the content node narrower than the screen, so that small images are centered
+ // use browser width to accommodate Android's on-screen buttons in landscape mode
+ if (window.innerWidth < 1000) {
+ width = Math.max(window.innerWidth, width);
}
+ this._postContentNode.style.width = width + "px";
+ this._postContentNode.style.height = height + "px";
+ }
+ _refreshSize() {
this._currentFitFunction();
}
_install() {
this._reinstall();
- optimizedResize.add(() => this._refreshSize());
+ // Don't auto-resize on mobile, to prevent size jerk when scrolling
+ if (window.innerWidth > 1000) {
+ optimizedResize.add(() => this._refreshSize());
+ }
views.monitorNodeRemoval(this._hostNode, () => {
this._uninstall();
});
diff --git a/client/js/controls/post_edit_sidebar_control.js b/client/js/controls/post_edit_sidebar_control.js
index 3b1c16e..a673495 100644
--- a/client/js/controls/post_edit_sidebar_control.js
+++ b/client/js/controls/post_edit_sidebar_control.js
@@ -3,6 +3,7 @@
const api = require("../api.js");
const events = require("../events.js");
const misc = require("../util/misc.js");
+const keyboard = require('../util/keyboard.js');
const views = require("../util/views.js");
const Note = require("../models/note.js");
const Point = require("../models/point.js");
@@ -10,14 +11,16 @@ const TagInputControl = require("./tag_input_control.js");
const PoolInputControl = require("./pool_input_control.js");
const ExpanderControl = require("../controls/expander_control.js");
const FileDropperControl = require("../controls/file_dropper_control.js");
+const PostMetricInputControl = require("./post_metric_input_control.js");
const template = views.getTemplate("post-edit-sidebar");
class PostEditSidebarControl extends events.EventTarget {
- constructor(hostNode, post, postContentControl, postNotesOverlayControl) {
+ constructor(hostNode, ctx, postContentControl, postNotesOverlayControl) {
super();
this._hostNode = hostNode;
- this._post = post;
+ this._ctx = ctx;
+ this._post = ctx.post;
this._postContentControl = postContentControl;
this._postNotesOverlayControl = postNotesOverlayControl;
this._newPostContent = null;
@@ -33,11 +36,12 @@ class PostEditSidebarControl extends events.EventTarget {
canEditPostSafety: api.hasPrivilege("posts:edit:safety"),
canEditPostSource: api.hasPrivilege("posts:edit:source"),
canEditPostTags: api.hasPrivilege("posts:edit:tags"),
+ canEditPostMetrics: api.hasPrivilege("metrics:edit:posts"),
canEditPostRelations: api.hasPrivilege("posts:edit:relations"),
canEditPostNotes:
api.hasPrivilege("posts:edit:notes") &&
- post.type !== "video" &&
- post.type !== "flash",
+ this._post.type !== "video" &&
+ this._post.type !== "flash",
canEditPostFlags: api.hasPrivilege("posts:edit:flags"),
canEditPostContent: api.hasPrivilege("posts:edit:content"),
canEditPostThumbnail: api.hasPrivilege("posts:edit:thumbnail"),
@@ -63,6 +67,11 @@ class PostEditSidebarControl extends events.EventTarget {
`Tags (${this._post.tags.length})`,
this._hostNode.querySelectorAll(".tags")
);
+ this._metricsExpander = new ExpanderControl(
+ "post-metrics",
+ `Metrics (${this._post.tags.filterMetrics().length})`,
+ this._hostNode.querySelectorAll(".metrics")
+ );
this._notesExpander = new ExpanderControl(
"post-notes",
"Notes",
@@ -95,14 +104,23 @@ class PostEditSidebarControl extends events.EventTarget {
if (this._tagInputNode) {
this._tagControl = new TagInputControl(
this._tagInputNode,
- post.tags
+ this._post.tags,
+ null,
+ this._post,
);
}
if (this._poolInputNode) {
this._poolControl = new PoolInputControl(
this._poolInputNode,
- post.pools
+ this._post.pools
+ );
+ }
+
+ if (this._metricInputNode) {
+ this._metricControl = new PostMetricInputControl(
+ this._metricInputNode,
+ this._ctx
);
}
@@ -224,10 +242,23 @@ class PostEditSidebarControl extends events.EventTarget {
});
}
- this._tagControl.addEventListener("change", (e) => {
- this.dispatchEvent(new CustomEvent("change"));
- this._syncExpanderTitles();
- });
+ if (this._tagControl) {
+ this._tagControl.addEventListener("change", (e) => {
+ this.dispatchEvent(new CustomEvent("change"));
+ this._syncExpanderTitles();
+ this._post.removeMetricsWithoutTag();
+ if (this._metricControl) {
+ this._metricControl.refreshContent();
+ }
+ });
+ }
+
+ if (this._metricControl) {
+ this._metricControl.addEventListener("change", (e) => {
+ this.dispatchEvent(new CustomEvent("change"));
+ this._syncExpanderTitles();
+ });
+ }
if (this._noteTextareaNode) {
this._noteTextareaNode.addEventListener("change", (e) =>
@@ -241,12 +272,29 @@ class PostEditSidebarControl extends events.EventTarget {
this._syncExpanderTitles();
});
}
+
+ keyboard.bind(["command+s", "ctrl+s"], (e) => this._evtSubmit(e));
+ if (this._tagInputNode) {
+ const realTagInput = this._formNode.querySelector(".tag-input input");
+ keyboard.bindElement(realTagInput, ["command+s", "ctrl+s"], (e) => this._evtSubmit(e));
+ keyboard.bind('t', (e) => {
+ e.preventDefault();
+ realTagInput.focus();
+ });
+ }
}
_syncExpanderTitles() {
this._notesExpander.title = `Notes (${this._post.notes.length})`;
this._tagsExpander.title = `Tags (${this._post.tags.length})`;
this._poolsExpander.title = `Pools (${this._post.pools.length})`;
+ let metricCount = this._post.tags.filterMetrics().length;
+ if (metricCount > 0) {
+ this._metricsExpander.containerNode.style.display = "block";
+ this._metricsExpander.title = `Metrics (${metricCount})`;
+ } else {
+ this._metricsExpander.containerNode.style.display = "none";
+ }
}
_evtPostContentChange(e) {
@@ -537,6 +585,10 @@ class PostEditSidebarControl extends events.EventTarget {
return this._formNode.querySelector(".notes textarea");
}
+ get _metricInputNode() {
+ return this._formNode.querySelector(".metrics input");
+ }
+
enableForm() {
views.enableForm(this._formNode);
}
diff --git a/client/js/controls/post_metric_input_control.js b/client/js/controls/post_metric_input_control.js
new file mode 100644
index 0000000..45b6b24
--- /dev/null
+++ b/client/js/controls/post_metric_input_control.js
@@ -0,0 +1,164 @@
+'use strict';
+
+const uri = require('../util/uri.js');
+const PostMetric = require('../models/post_metric.js');
+const PostMetricRange = require('../models/post_metric_range.js');
+const events = require('../events.js');
+const views = require('../util/views.js');
+
+const mainTemplate = views.getTemplate('post-metric-input');
+const metricNodeTemplate = views.getTemplate('compact-metric-list-item');
+const postMetricNodeTemplate = views.getTemplate('compact-post-metric-list-item');
+const postMetricRangeNodeTemplate = views.getTemplate('compact-post-metric-range-list-item');
+
+class PostMetricInputControl extends events.EventTarget {
+ constructor(hostNode, ctx) {
+ super();
+ this._ctx = ctx;
+ this._post = ctx.post;
+ this._hostNode = hostNode;
+
+ // dom
+ const editAreaNode = mainTemplate({
+ tags: this._post.tags,
+ postMetrics: this._post.metrics,
+ });
+ this._editAreaNode = editAreaNode;
+ this._metricListNode = editAreaNode.querySelector('ul.compact-unset-metrics');
+ this._separatorNode = editAreaNode.querySelector('hr.separator');
+ this._postMetricListNode = editAreaNode.querySelector('ul.compact-post-metrics');
+
+ // show
+ this._hostNode.style.display = 'none';
+ this._hostNode.parentNode.insertBefore(
+ this._editAreaNode, hostNode.nextSibling);
+
+ // add existing metrics and post metrics:
+ this.refreshContent();
+ }
+
+ refreshContent() {
+ this._metricListNode.innerHTML = '';
+ for (let tag of this._post.tags.filterMetrics()) {
+ const metricNode = this._createMetricNode(tag);
+ this._metricListNode.appendChild(metricNode);
+ }
+ this._postMetricListNode.innerHTML = '';
+ for (let pm of this._post.metrics) {
+ const postMetricNode = this._createPostMetricNode(pm);
+ this._postMetricListNode.appendChild(postMetricNode);
+ }
+ for (let pmr of this._post.metricRanges) {
+ const postMetricRangeNode = this._createPostMetricRangeNode(pmr);
+ this._postMetricListNode.appendChild(postMetricRangeNode);
+ }
+ this._separatorNode.style.display =
+ this._postMetricListNode.innerHTML ? 'block' : 'none';
+ }
+
+ _createMetricNode(tag) {
+ const node = metricNodeTemplate({
+ editMode: true,
+ tag: tag,
+ post: this._post,
+ query: this._ctx.parameters.query,
+ });
+ const createExactNode = node.querySelector('a.create-exact');
+ if (this._post.metrics.hasTagName(tag.names[0])) {
+ createExactNode.style.display = 'none';
+ } else {
+ createExactNode.addEventListener('click', e => {
+ e.preventDefault();
+ this.createPostMetric(tag);
+ });
+ }
+ const createRangeNode = node.querySelector('a.create-range');
+ if (this._post.metricRanges.hasTagName(tag.names[0])) {
+ createRangeNode.style.display = 'none';
+ } else {
+ createRangeNode.addEventListener('click', e => {
+ e.preventDefault();
+ this.createPostMetricRange(tag);
+ });
+ }
+ const sortNode = node.querySelector('a.sort');
+ if (this._post.metrics.hasTagName(tag.names[0])) {
+ sortNode.style.display = 'none';
+ }
+ return node;
+ }
+
+ _createPostMetricNode(pm) {
+ const tag = this._post.tags.findByName(pm.tagName);
+ const node = postMetricNodeTemplate({
+ editMode: true,
+ postMetric: pm,
+ tag: tag,
+ });
+ node.querySelector('input[name=value]').addEventListener('change', e => {
+ pm.value = e.target.value;
+ this.dispatchEvent(new CustomEvent('change'));
+ });
+ node.querySelector('.remove-metric').addEventListener('click', e => {
+ e.preventDefault();
+ this.deletePostMetric(pm);
+ });
+ return node;
+ }
+
+ _createPostMetricRangeNode(pmr) {
+ const tag = this._post.tags.findByName(pmr.tagName);
+ const node = postMetricRangeNodeTemplate({
+ editMode: true,
+ postMetricRange: pmr,
+ tag: tag,
+ });
+ node.querySelector('input[name=low]').addEventListener('change', e => {
+ pmr.low = e.target.value;
+ this.dispatchEvent(new CustomEvent('change'));
+ });
+ node.querySelector('input[name=high]').addEventListener('change', e => {
+ pmr.high = e.target.value;
+ this.dispatchEvent(new CustomEvent('change'));
+ });
+ node.querySelector('.remove-metric').addEventListener('click', e => {
+ e.preventDefault();
+ this.deletePostMetricRange(pmr);
+ });
+ return node;
+ }
+
+ createPostMetric(tag) {
+ let postMetricRange = this._post.metricRanges.findByTagName(tag.names[0]);
+ if (postMetricRange) {
+ this._post.metricRanges.remove(postMetricRange);
+ }
+ this._post.metrics.add(PostMetric.create(this._post.id, tag));
+ this.refreshContent();
+ this.dispatchEvent(new CustomEvent('change'));
+ }
+
+ createPostMetricRange(tag) {
+ let postMetric = this._post.metrics.findByTagName(tag.names[0]);
+ if (postMetric) {
+ this._post.metrics.remove(postMetric);
+ }
+ this._post.metricRanges.add(PostMetricRange.create(this._post.id, tag));
+ this.refreshContent();
+ this.dispatchEvent(new CustomEvent('change'));
+ }
+
+ deletePostMetric(pm) {
+ this._post.metrics.remove(pm);
+ this.refreshContent();
+ this.dispatchEvent(new CustomEvent('change'));
+ }
+
+ deletePostMetricRange(pmr) {
+ this._post.metricRanges.remove(pmr);
+ this.refreshContent();
+ this.dispatchEvent(new CustomEvent('change'));
+ }
+}
+
+module.exports = PostMetricInputControl;
diff --git a/client/js/controls/post_metric_list_control.js b/client/js/controls/post_metric_list_control.js
new file mode 100644
index 0000000..b179b12
--- /dev/null
+++ b/client/js/controls/post_metric_list_control.js
@@ -0,0 +1,51 @@
+'use strict';
+
+const events = require('../events.js');
+const views = require('../util/views.js');
+
+const postMetricNodeTemplate = views.getTemplate('compact-post-metric-list-item');
+const postMetricRangeNodeTemplate = views.getTemplate('compact-post-metric-range-list-item');
+
+class PostMetricListControl extends events.EventTarget {
+ constructor(listNode, post) {
+ super();
+ this._post = post;
+ this._listNode = listNode;
+
+ this._refreshContent();
+ }
+
+ _refreshContent() {
+ this._listNode.innerHTML = '';
+ for (let pm of this._post.metrics) {
+ const postMetricNode = this._createPostMetricNode(pm);
+ this._listNode.appendChild(postMetricNode);
+ }
+ for (let pmr of this._post.metricRanges) {
+ const postMetricRangeNode = this._createPostMetricRangeNode(pmr);
+ this._listNode.appendChild(postMetricRangeNode);
+ }
+ }
+
+ _createPostMetricNode(pm) {
+ const tag = this._post.tags.findByName(pm.tagName);
+ const node = postMetricNodeTemplate({
+ editMode: false,
+ postMetric: pm,
+ tag: tag,
+ });
+ return node;
+ }
+
+ _createPostMetricRangeNode(pmr) {
+ const tag = this._post.tags.findByName(pmr.tagName);
+ const node = postMetricRangeNodeTemplate({
+ editMode: false,
+ postMetricRange: pmr,
+ tag: tag,
+ });
+ return node;
+ }
+}
+
+module.exports = PostMetricListControl;
diff --git a/client/js/controls/post_readonly_sidebar_control.js b/client/js/controls/post_readonly_sidebar_control.js
index d11fa92..fc26281 100644
--- a/client/js/controls/post_readonly_sidebar_control.js
+++ b/client/js/controls/post_readonly_sidebar_control.js
@@ -2,23 +2,28 @@
const api = require("../api.js");
const events = require("../events.js");
+const settings = require("../models/settings.js");
const views = require("../util/views.js");
const uri = require("../util/uri.js");
const misc = require("../util/misc.js");
+const PostMetricListControl = require("./post_metric_list_control.js");
+const PostList = require("../models/post_list.js");
const template = views.getTemplate("post-readonly-sidebar");
const scoreTemplate = views.getTemplate("score");
const favTemplate = views.getTemplate("fav");
+const similarItemTemplate = views.getTemplate("similar-post-item")
class PostReadonlySidebarControl extends events.EventTarget {
- constructor(hostNode, post, postContentControl) {
+ constructor(hostNode, ctx, postContentControl) {
super();
this._hostNode = hostNode;
- this._post = post;
+ this._ctx = ctx;
+ this._post = ctx.post;
this._postContentControl = postContentControl;
- post.addEventListener("changeFavorite", (e) => this._evtChangeFav(e));
- post.addEventListener("changeScore", (e) => this._evtChangeScore(e));
+ this._post.addEventListener("changeFavorite", (e) => this._evtChangeFav(e));
+ this._post.addEventListener("changeScore", (e) => this._evtChangeScore(e));
views.replaceContent(
this._hostNode,
@@ -28,6 +33,7 @@ class PostReadonlySidebarControl extends events.EventTarget {
canListPosts: api.hasPrivilege("posts:list"),
canEditPosts: api.hasPrivilege("posts:edit"),
canViewTags: api.hasPrivilege("tags:view"),
+ canViewSimilar: api.hasPrivilege("posts:view:similar"),
escapeTagName: uri.escapeTagName,
extractRootDomain: uri.extractRootDomain,
getPrettyName: misc.getPrettyName,
@@ -38,6 +44,13 @@ class PostReadonlySidebarControl extends events.EventTarget {
this._installScore();
this._installFitButtons();
this._syncFitButton();
+ if (this._metricsListNode) {
+ this._metricsControl = new PostMetricListControl(
+ this._metricsListNode, this._post
+ );
+ }
+ this._loadSimilarPosts();
+ this._loadLookalikePosts();
}
get _scoreContainerNode() {
@@ -80,6 +93,22 @@ class PostReadonlySidebarControl extends events.EventTarget {
return this._hostNode.querySelector(".fit-height");
}
+ get _metricsListNode() {
+ return this._hostNode.querySelector("ul.compact-post-metrics");
+ }
+
+ get _similarListNode() {
+ return this._hostNode.querySelector("nav.similar ul");
+ }
+
+ get _lookalikesNode() {
+ return this._hostNode.querySelector("nav.lookalikes");
+ }
+
+ get _lookalikesListNode() {
+ return this._hostNode.querySelector("nav.lookalikes ul");
+ }
+
_installFitButtons() {
this._fitBothButtonNode.addEventListener(
"click",
@@ -218,6 +247,45 @@ class PostReadonlySidebarControl extends events.EventTarget {
_evtChangeScore(e) {
this._installScore();
}
+
+ _loadSimilarPosts() {
+ return PostList.search(
+ "similar:" + this._post.id + " -id:" + this._post.id,
+ 0,
+ parseInt(settings.get().similarPosts),
+ ["id", "thumbnailUrl"],
+ )
+ .then((response) => {
+ const listNode = this._similarListNode;
+ for (let post of response.results) {
+ let poseNode = similarItemTemplate({
+ id: post.id,
+ thumbnailUrl: post.thumbnailUrl,
+ });
+ listNode.appendChild(poseNode);
+ }
+ });
+ }
+
+ _loadLookalikePosts() {
+ const limit = parseInt(settings.get().similarPosts);
+ const fields = ["id", "thumbnailUrl"];
+ const threshold = 1;
+ return PostList.reverseSearch(this._post.id, limit, threshold, fields)
+ .then((response) => {
+ if (response.results.length === 0) {
+ this._lookalikesNode.style.display = "none";
+ }
+ const listNode = this._lookalikesListNode;
+ for (let post of response.results) {
+ let poseNode = similarItemTemplate({
+ id: post.id,
+ thumbnailUrl: post.thumbnailUrl,
+ });
+ listNode.appendChild(poseNode);
+ }
+ });
+ }
}
module.exports = PostReadonlySidebarControl;
diff --git a/client/js/controls/tag_input_control.js b/client/js/controls/tag_input_control.js
index cca58d5..0399d21 100644
--- a/client/js/controls/tag_input_control.js
+++ b/client/js/controls/tag_input_control.js
@@ -5,10 +5,12 @@ const tags = require("../tags.js");
const misc = require("../util/misc.js");
const uri = require("../util/uri.js");
const Tag = require("../models/tag.js");
+const TagList = require("../models/tag_list.js");
const settings = require("../models/settings.js");
const events = require("../events.js");
const views = require("../util/views.js");
const TagAutoCompleteControl = require("./tag_auto_complete_control.js");
+const PostList = require("../models/post_list.js");
const KEY_SPACE = 32;
const KEY_RETURN = 13;
@@ -82,15 +84,18 @@ class SuggestionList {
}
class TagInputControl extends events.EventTarget {
- constructor(hostNode, tagList) {
+ constructor(hostNode, tagList, placeholder, post) {
super();
this.tags = tagList;
+ this._post = post;
this._hostNode = hostNode;
this._suggestions = new SuggestionList();
this._tagToListItemNode = new Map();
// dom
- const editAreaNode = template();
+ const editAreaNode = template({
+ tagsPlaceholder: placeholder || "type to add...",
+ });
this._editAreaNode = editAreaNode;
this._tagInputNode = editAreaNode.querySelector("input");
this._suggestionsNode = editAreaNode.querySelector(".tag-suggestions");
@@ -150,15 +155,14 @@ class TagInputControl extends events.EventTarget {
const listItemNode = this._createListItemNode(tag);
this._tagListNode.appendChild(listItemNode);
}
+
+ this._createSuggestionsFromLookalikes();
}
addTagByText(text, source) {
- for (let tagName of text
- .split(/\s+/)
- .filter((word) => word)
- .reverse()) {
- this.addTagByName(tagName, source);
- }
+ tags.resolveTagAndCategory(text).then(
+ tag => this.addTag(tag, source),
+ error => window.alert(error.message));
}
addTagByName(name, source) {
@@ -191,7 +195,7 @@ class TagInputControl extends events.EventTarget {
}
return this.tags
- .addByName(tag.names[0], false)
+ .addByTag(tag, false)
.then(() => {
const listItemNode = this._createListItemNode(tag);
if (!tag.category) {
@@ -263,7 +267,7 @@ class TagInputControl extends events.EventTarget {
_evtAddTagButtonClick(e) {
e.preventDefault();
- this.addTagByName(this._tagInputNode.value, SOURCE_USER_INPUT);
+ this.addTagByText(this._tagInputNode.value, SOURCE_USER_INPUT);
this._tagInputNode.value = "";
}
@@ -298,6 +302,7 @@ class TagInputControl extends events.EventTarget {
const tagIconNode = document.createElement("i");
tagIconNode.classList.add("fa");
tagIconNode.classList.add("fa-tag");
+ tagIconNode.classList.add("tag-icon");
tagLinkNode.appendChild(tagIconNode);
const searchLinkNode = document.createElement("a");
@@ -346,6 +351,20 @@ class TagInputControl extends events.EventTarget {
return listItemNode;
}
+ _createSuggestionsFromLookalikes() {
+ const node = this._editAreaNode.querySelector("a.tag-from-lookalikes");
+ if (this._post === undefined) {
+ node.style.display = "none";
+ } else {
+ node.addEventListener("click", (e) => {
+ e.preventDefault();
+ this._suggestions.clear();
+ this._loadSuggestionsFromLookalikes();
+ this._removeSuggestionsPopupOpacity();
+ });
+ }
+ }
+
_deleteListItemNode(tag) {
const listItemNode = this._getListItemNode(tag);
if (listItemNode) {
@@ -396,6 +415,31 @@ class TagInputControl extends events.EventTarget {
});
}
+ _loadSuggestionsFromLookalikes() {
+ const limit = 20;
+ const fields = ["id", "thumbnailUrl", "tags"];
+ const threshold = 1;
+ PostList.reverseSearch(this._post.id, limit, threshold, fields)
+ .then((response) => {
+ const tagOccurrences = {};
+ for (let post of response.results) {
+ for (let tag of post.tags) {
+ const name = tag.names[0];
+ let count = tagOccurrences[name] || 0;
+ tagOccurrences[name] = count + 1;
+ }
+ }
+ for (const [tagName, count] of Object.entries(tagOccurrences)) {
+ this._suggestions.set(tagName, count);
+ }
+ if (this._suggestions.length) {
+ this._openSuggestionsPopup();
+ } else {
+ this._closeSuggestionsPopup();
+ }
+ });
+ }
+
_refreshSuggestionsPopup() {
if (!this._suggestionsNode.classList.contains("shown")) {
return;
diff --git a/client/js/main.js b/client/js/main.js
index c5bdc53..1d37865 100644
--- a/client/js/main.js
+++ b/client/js/main.js
@@ -83,6 +83,7 @@ Promise.resolve()
controllers.push(
require("./controllers/user_registration_controller.js")
);
+ controllers.push(require('./controllers/metric_sorter_contoller.js'));
// 404 controller needs to be registered last
controllers.push(require("./controllers/not_found_controller.js"));
diff --git a/client/js/models/abstract_list.js b/client/js/models/abstract_list.js
index 7cf518c..754b8e2 100644
--- a/client/js/models/abstract_list.js
+++ b/client/js/models/abstract_list.js
@@ -10,6 +10,7 @@ class AbstractList extends events.EventTarget {
static fromResponse(response) {
const ret = new this();
+ ret.raw_data = response;
for (let item of response) {
const addedItem = this._itemClass.fromResponse(item);
if (addedItem.addEventListener) {
@@ -98,6 +99,10 @@ class AbstractList extends events.EventTarget {
return this._list.filter(...args);
}
+ includes(item) {
+ return this._list.includes(item);
+ }
+
[Symbol.iterator]() {
return this._list[Symbol.iterator]();
}
diff --git a/client/js/models/metric.js b/client/js/models/metric.js
new file mode 100644
index 0000000..bb9cf84
--- /dev/null
+++ b/client/js/models/metric.js
@@ -0,0 +1,88 @@
+'use strict';
+
+const api = require('../api.js');
+const uri = require('../util/uri.js');
+const events = require('../events.js');
+const misc = require('../util/misc.js');
+const Tag = require('./tag.js');
+
+class Metric extends events.EventTarget {
+ constructor() {
+ super();
+ this._orig = {};
+
+ this._updateFromResponse({});
+ }
+
+ get version() { return this._version; }
+ get min() { return this._min; }
+ get max() { return this._max; }
+ get tag() { return this._tag; }
+
+ set min(value) { this._min = value; }
+ set max(value) { this._max = value; }
+
+ static fromResponse(response) {
+ const ret = new Metric();
+ ret._updateFromResponse(response);
+ return ret;
+ }
+
+ static get(name) {
+ //TODO get metric. Or only via tag?
+ return api.get(uri.formatApiLink('metric', name))
+ .then(response => {
+ return Promise.resolve(Metric.fromResponse(response));
+ });
+ }
+
+ save() {
+ const detail = {version: this._version};
+
+ if (this._min !== this._orig._min) {
+ detail.min = this._min;
+ }
+ if (this._max !== this._orig._max) {
+ detail.max = this._max;
+ }
+
+ return api.post(uri.formatApiLink('metrics'), detail)
+ .then(response => {
+ this._updateFromResponse(response);
+ this.dispatchEvent(new CustomEvent('change', {
+ detail: {
+ metric: this,
+ },
+ }));
+ return Promise.resolve();
+ });
+ }
+
+ delete() {
+ return api.delete(
+ uri.formatApiLink('metric', this._orig),
+ {version: this._version})
+ .then(response => {
+ this.dispatchEvent(new CustomEvent('delete', {
+ detail: {
+ metric: this,
+ },
+ }));
+ return Promise.resolve();
+ });
+ }
+
+ _updateFromResponse(response) {
+ const map = {
+ _version: response.version,
+ _min: response.min,
+ _max: response.max,
+ _tag: Tag.fromResponse(response.tag || {}),
+ };
+
+ Object.assign(this, map);
+ Object.assign(this._orig, map);
+ }
+}
+
+module.exports = Metric;
diff --git a/client/js/models/metric_list.js b/client/js/models/metric_list.js
new file mode 100644
index 0000000..83aba11
--- /dev/null
+++ b/client/js/models/metric_list.js
@@ -0,0 +1,24 @@
+'use strict';
+
+const api = require('../api.js');
+const uri = require('../util/uri.js');
+const AbstractList = require('./abstract_list.js');
+const Metric = require('./metric.js');
+
+class MetricList extends AbstractList {
+ static loadAll() {
+ return api.get(
+ uri.formatApiLink('metrics'))
+ .then(response => {
+ return Promise.resolve(Object.assign(
+ {},
+ response,
+ {results: MetricList.fromResponse(response.results)}));
+ });
+ }
+}
+
+MetricList._itemClass = Metric;
+MetricList._itemName = 'metric';
+
+module.exports = MetricList;
diff --git a/client/js/models/post.js b/client/js/models/post.js
index 01f81bf..3df3b2c 100644
--- a/client/js/models/post.js
+++ b/client/js/models/post.js
@@ -9,6 +9,8 @@ const NoteList = require("./note_list.js");
const CommentList = require("./comment_list.js");
const PoolList = require("./pool_list.js");
const Pool = require("./pool.js");
+const PostMetricList = require("./post_metric_list.js");
+const PostMetricRangeList = require("./post_metric_range_list.js");
const misc = require("../util/misc.js");
class Post extends events.EventTarget {
@@ -21,6 +23,8 @@ class Post extends events.EventTarget {
obj._notes = new NoteList();
obj._comments = new CommentList();
obj._pools = new PoolList();
+ obj._metrics = new PostMetricList();
+ obj._metricRanges = new PostMetricRangeList();
}
this._updateFromResponse({});
@@ -126,6 +130,14 @@ class Post extends events.EventTarget {
return this._pools;
}
+ get metrics() {
+ return this._metrics;
+ }
+
+ get metricRanges() {
+ return this._metricRanges;
+ }
+
get score() {
return this._score;
}
@@ -268,6 +280,19 @@ class Post extends events.EventTarget {
text: note.text,
}));
}
+ if (misc.arraysDiffer(this._metrics, this._orig._metrics)) {
+ detail.metrics = this._metrics.map(metric => ({
+ tag_name: metric.tagName,
+ value: metric.value,
+ }));
+ }
+ if (misc.arraysDiffer(this._metricRanges, this._orig._metricRanges)) {
+ detail.metricRanges = this._metricRanges.map(metricRange => ({
+ tag_name: metricRange.tagName,
+ low: metricRange.low,
+ high: metricRange.high,
+ }));
+ }
if (this._newContent) {
files.content = this._newContent;
}
@@ -454,6 +479,13 @@ class Post extends events.EventTarget {
});
}
+ removeMetricsWithoutTag() {
+ this._metrics.filter(pm => !this._tags.findByName(pm.tagName))
+ .map(pm => this._metrics.remove(pm));
+ this._metricRanges.filter(pmr => !this._tags.findByName(pmr.tagName))
+ .map(pmr => this._metricRanges.remove(pmr));
+ }
+
mutateContentUrl() {
this._contentUrl =
this._orig._contentUrl +
@@ -499,6 +531,8 @@ class Post extends events.EventTarget {
obj._notes.sync(response.notes);
obj._comments.sync(response.comments);
obj._pools.sync(response.pools);
+ obj._metrics.sync(response.metrics);
+ obj._metricRanges.sync(response.metricRanges);
}
Object.assign(this, map());
diff --git a/client/js/models/post_list.js b/client/js/models/post_list.js
index 8c2c9d4..5ec8f3a 100644
--- a/client/js/models/post_list.js
+++ b/client/js/models/post_list.js
@@ -7,23 +7,25 @@ const AbstractList = require("./abstract_list.js");
const Post = require("./post.js");
class PostList extends AbstractList {
- static getAround(id, searchQuery) {
+ static getAround(id, searchQuery, cachenumber) {
return api.get(
uri.formatApiLink("post", id, "around", {
- query: PostList._decorateSearchQuery(searchQuery || ""),
+ query: PostList.decorateSearchQuery(searchQuery || ""),
fields: "id",
+ cachenumber: cachenumber,
})
);
}
- static search(text, offset, limit, fields) {
+ static search(text, offset, limit, fields, cachenumber) {
return api
.get(
uri.formatApiLink("posts", {
- query: PostList._decorateSearchQuery(text || ""),
+ query: PostList.decorateSearchQuery(text || ""),
offset: offset,
limit: limit,
fields: fields.join(","),
+ cachenumber: cachenumber,
})
)
.then((response) => {
@@ -35,7 +37,44 @@ class PostList extends AbstractList {
});
}
- static _decorateSearchQuery(text) {
+ static getMedian(text, fields) {
+ return api
+ .get(
+ uri.formatApiLink("posts", "median", {
+ query: PostList.decorateSearchQuery(text || ""),
+ fields: fields.join(","),
+ })
+ )
+ .then((response) => {
+ return Promise.resolve(
+ Object.assign({}, response, {
+ results: PostList.fromResponse(response.results)
+ })
+ );
+ });
+ }
+
+ static reverseSearch(id, limit, threshold, fields) {
+ return api
+ .get(
+ uri.formatApiLink("post", id, "reverse-search", {
+ query: PostList.decorateSearchQuery(""),
+ limit: limit,
+ threshold: threshold,
+ fields: fields.join(","),
+ })
+ )
+ .then((response) => {
+ const results = response.similarPosts.map((sim) => sim.post);
+ return Promise.resolve(
+ Object.assign({}, response, {
+ results: PostList.fromResponse(results)
+ })
+ );
+ });
+ }
+
+ static decorateSearchQuery(text) {
const browsingSettings = settings.get();
const disabledSafety = [];
if (api.safetyEnabled()) {
diff --git a/client/js/models/post_metric.js b/client/js/models/post_metric.js
new file mode 100644
index 0000000..dd06957
--- /dev/null
+++ b/client/js/models/post_metric.js
@@ -0,0 +1,39 @@
+'use strict';
+
+const events = require('../events.js');
+
+class PostMetric extends events.EventTarget {
+ constructor() {
+ super();
+ this._updateFromResponse({});
+ }
+
+ static create(postId, tag) {
+ const metric = new PostMetric();
+ metric._postId = postId;
+ metric._tagName = tag.names[0];
+ metric._value = tag.metric.min;
+ return metric;
+ }
+
+ static fromResponse(response) {
+ const metric = new PostMetric();
+ metric._updateFromResponse(response);
+ return metric;
+ }
+
+ get tagName() { return this._tagName; }
+ get postId() { return this._postId; }
+ get value() { return this._value; }
+
+ set value(value) { this._value = value; }
+
+ _updateFromResponse(response) {
+ this._version = response.version;
+ this._postId = response.post_id;
+ this._tagName = response.tag_name;
+ this._value = response.value;
+ }
+}
+
+module.exports = PostMetric; \ No newline at end of file
diff --git a/client/js/models/post_metric_list.js b/client/js/models/post_metric_list.js
new file mode 100644
index 0000000..5e2c838
--- /dev/null
+++ b/client/js/models/post_metric_list.js
@@ -0,0 +1,24 @@
+'use strict';
+
+const AbstractList = require('./abstract_list.js');
+const PostMetric = require('./post_metric.js');
+
+class PostMetricList extends AbstractList {
+ findByTagName(testName) {
+ for (let postMetric of this._list) {
+ if (postMetric.tagName.toLowerCase() === testName.toLowerCase()) {
+ return postMetric;
+ }
+ }
+ return null;
+ }
+
+ hasTagName(testName) {
+ return !!this.findByTagName(testName);
+ }
+}
+
+PostMetricList._itemClass = PostMetric;
+PostMetricList._itemName = 'postMetric';
+
+module.exports = PostMetricList;
diff --git a/client/js/models/post_metric_range.js b/client/js/models/post_metric_range.js
new file mode 100644
index 0000000..ab7bddb
--- /dev/null
+++ b/client/js/models/post_metric_range.js
@@ -0,0 +1,43 @@
+'use strict';
+
+const events = require('../events.js');
+
+class PostMetricRange extends events.EventTarget {
+ constructor() {
+ super();
+ this._updateFromResponse({});
+ }
+
+ static create(postId, tag) {
+ const metric = new PostMetricRange();
+ metric._postId = postId;
+ metric._tagName = tag.names[0];
+ metric._low = tag.metric.min;
+ metric._high = tag.metric.max;
+ return metric;
+ }
+
+ static fromResponse(response) {
+ const metric = new PostMetricRange();
+ metric._updateFromResponse(response);
+ return metric;
+ }
+
+ get tagName() { return this._tagName; }
+ get postId() { return this._postId; }
+ get low() { return this._low; }
+ get high() { return this._high; }
+
+ set low(value) { this._low = value; }
+ set high(value) { this._high = value; }
+
+ _updateFromResponse(response) {
+ this._version = response.version;
+ this._postId = response.post_id;
+ this._tagName = response.tag_name;
+ this._low = response.low;
+ this._high = response.high;
+ }
+}
+
+module.exports = PostMetricRange; \ No newline at end of file
diff --git a/client/js/models/post_metric_range_list.js b/client/js/models/post_metric_range_list.js
new file mode 100644
index 0000000..68e701b
--- /dev/null
+++ b/client/js/models/post_metric_range_list.js
@@ -0,0 +1,24 @@
+'use strict';
+
+const AbstractList = require('./abstract_list.js');
+const PostMetricRange = require('./post_metric_range.js');
+
+class PostMetricRangeList extends AbstractList {
+ findByTagName(testName) {
+ for (let pmr of this._list) {
+ if (pmr.tagName.toLowerCase() === testName.toLowerCase()) {
+ return pmr;
+ }
+ }
+ return null;
+ }
+
+ hasTagName(testName) {
+ return !!this.findByTagName(testName);
+ }
+}
+
+PostMetricRangeList._itemClass = PostMetricRange;
+PostMetricRangeList._itemName = 'postMetricRange';
+
+module.exports = PostMetricRangeList;
diff --git a/client/js/models/settings.js b/client/js/models/settings.js
index b39f07f..d32a15a 100644
--- a/client/js/models/settings.js
+++ b/client/js/models/settings.js
@@ -1,21 +1,24 @@
"use strict";
+const api = require("../api.js");
const events = require("../events.js");
const defaultSettings = {
listPosts: {
safe: true,
- sketchy: true,
+ sketchy: false,
unsafe: false,
},
+ uploadSafety: "safe",
upscaleSmallPosts: false,
endlessScroll: false,
keyboardShortcuts: true,
- transparencyGrid: true,
+ transparencyGrid: false,
fitMode: "fit-both",
tagSuggestions: true,
autoplayVideos: false,
- postsPerPage: 42,
+ postsPerPage: 40,
+ similarPosts: 10,
tagUnderscoresAsSpaces: false,
darkTheme: false,
postFlow: false,
@@ -30,7 +33,7 @@ class Settings extends events.EventTarget {
_getFromLocalStorage() {
let ret = Object.assign({}, defaultSettings);
try {
- Object.assign(ret, JSON.parse(localStorage.getItem("settings")));
+ Object.assign(ret, JSON.parse(localStorage.getItem(this._settingsKey)));
} catch (e) {
// continue regardless of error
}
@@ -39,7 +42,7 @@ class Settings extends events.EventTarget {
save(newSettings, silent) {
newSettings = Object.assign(this.cache, newSettings);
- localStorage.setItem("settings", JSON.stringify(newSettings));
+ localStorage.setItem(this._settingsKey, JSON.stringify(newSettings));
this.cache = this._getFromLocalStorage();
if (silent !== true) {
this.dispatchEvent(
@@ -55,6 +58,11 @@ class Settings extends events.EventTarget {
get() {
return this.cache;
}
+
+ get _settingsKey() {
+ //FIXME(hunternif): username is null if settings are accessed before api.loginFromCookies()
+ return "settings" //-" + api.userName;
+ }
}
module.exports = new Settings();
diff --git a/client/js/models/tag.js b/client/js/models/tag.js
index a5632c8..eb24ccd 100644
--- a/client/js/models/tag.js
+++ b/client/js/models/tag.js
@@ -64,6 +64,14 @@ class Tag extends events.EventTarget {
this._description = value;
}
+ get metric() {
+ return this._metric;
+ }
+
+ set metric(value) {
+ this._metric = value;
+ }
+
static fromResponse(response) {
const ret = new Tag();
ret._updateFromResponse(response);
@@ -99,6 +107,12 @@ class Tag extends events.EventTarget {
(relation) => relation.names[0]
);
}
+ if (this._metric !== this._orig._metric) {
+ detail.metric = {
+ min: this._metric.min,
+ max: this._metric.max
+ };
+ }
let promise = this._origName
? api.put(uri.formatApiLink("tag", this._origName), detail)
@@ -166,16 +180,33 @@ class Tag extends events.EventTarget {
});
}
+ deleteMetric() {
+ return api.delete(
+ uri.formatApiLink("metric", this._origName),
+ {version: this.metric.version})
+ .then((response) => {
+ this.dispatchEvent(
+ new CustomEvent("delete", {
+ detail: {
+ metric: this.metric,
+ },
+ })
+ );
+ return Promise.resolve();
+ });
+ }
+
_updateFromResponse(response) {
const map = {
_version: response.version,
_origName: response.names ? response.names[0] : null,
- _names: response.names,
+ _names: response.names || [],
_category: response.category,
_description: response.description,
_creationTime: response.creationTime,
_lastEditTime: response.lastEditTime,
_postCount: response.usages || 0,
+ _metric: response.metric,
};
for (let obj of [this, this._orig]) {
diff --git a/client/js/models/tag_list.js b/client/js/models/tag_list.js
index 7e6b643..7882164 100644
--- a/client/js/models/tag_list.js
+++ b/client/js/models/tag_list.js
@@ -25,29 +25,36 @@ class TagList extends AbstractList {
});
}
- isTaggedWith(testName) {
+ findByName(testName) {
for (let tag of this._list) {
for (let tagName of tag.names) {
if (tagName.toLowerCase() === testName.toLowerCase()) {
- return true;
+ return tag;
}
}
}
- return false;
+ return null;
}
- addByName(tagName, addImplications) {
- if (this.isTaggedWith(tagName)) {
- return Promise.resolve();
- }
+ isTaggedWith(testName) {
+ return !!this.findByName(testName);
+ }
+ addByName(tagName, addImplications) {
const tag = new Tag();
tag.names = [tagName];
+ return this.addByTag(tag, addImplications);
+ }
+
+ addByTag(tag, addImplications) {
+ if (this.isTaggedWith(tag.names[0])) {
+ return Promise.resolve();
+ }
this.add(tag);
if (addImplications !== false) {
- return Tag.get(tagName).then((actualTag) => {
+ return Tag.get(tag.names[0]).then((actualTag) => {
return Promise.all(
actualTag.implications.map((relation) =>
this.addByName(relation.names[0], true)
@@ -68,6 +75,10 @@ class TagList extends AbstractList {
}
}
}
+
+ filterMetrics() {
+ return this.filter(tag => tag.metric)
+ }
}
TagList._itemClass = Tag;
diff --git a/client/js/router.js b/client/js/router.js
index 460a641..c697246 100644
--- a/client/js/router.js
+++ b/client/js/router.js
@@ -157,7 +157,10 @@ class Router {
window.addEventListener("popstate", this._onPopState, false);
document.addEventListener(clickEvent, this._onClick, false);
const url = location.pathname + location.search + location.hash;
- return this.replace(url, history.state, true);
+ // clear cached page data, in case we are refreshing the page:
+ const initialState = Object.assign({}, history.state);
+ delete initialState.pageCache;
+ return this.replace(url, initialState, true);
}
stop() {
@@ -172,6 +175,7 @@ class Router {
showNoDispatch(path, state) {
const ctx = new Context(path, state);
ctx.pushState();
+ // replaces old ctx with the current ctx (new page + history state)
this.ctx = ctx;
return ctx;
}
@@ -201,6 +205,7 @@ class Router {
dispatch(ctx, middle) {
const swap = (_ctx, next) => {
+ // replaces old ctx with the current ctx (new page + history state)
this.ctx = ctx;
middle();
next();
@@ -208,11 +213,12 @@ class Router {
const callChain = (this.ctx ? this._exits : []).concat(
[swap],
this._callbacks,
- [this._unhandled, (ctx, next) => {}]
+ [this._unhandled, (ctx, next) => { }]
);
let i = 0;
let fn = () => {
+ // Passes old ctx into the callbacks
callChain[i++](this.ctx, fn);
};
fn();
diff --git a/client/js/tags.js b/client/js/tags.js
index ecf9b66..2f2732d 100644
--- a/client/js/tags.js
+++ b/client/js/tags.js
@@ -2,6 +2,7 @@
const misc = require("./util/misc.js");
const TagCategoryList = require("./models/tag_category_list.js");
+const Tag = require("./models/tag.js");
let _stylesheet = null;
@@ -15,13 +16,53 @@ function refreshCategoryColorMap() {
for (let category of response.results) {
const ruleName = misc.makeCssName(category.name, "tag");
_stylesheet.sheet.insertRule(
- `.${ruleName} { color: ${category.color} }`,
+ `.${ruleName} { color: ${category.color}; border-color: ${category.color} }`,
+ _stylesheet.sheet.cssRules.length
+ );
+ _stylesheet.sheet.insertRule(
+ `.${ruleName}.selected { color: white; background-color: ${category.color} }`,
_stylesheet.sheet.cssRules.length
);
}
});
}
+function parseTagAndCategory(text) {
+ let nameAndCat = text.split(":");
+ if (nameAndCat.length > 1) {
+ // "cat:my:tag" should parse to category "cat" and tag "my:tag"
+ let category = nameAndCat.shift();
+ let name = nameAndCat.join(":");
+ return {name: name, category: category};
+ } else {
+ return {name: text, category: null};
+ }
+}
+
+function resolveTagAndCategory(text) {
+ let tagData = parseTagAndCategory(text);
+ return _createTagByCategoryAndName(tagData.category, tagData.name);
+}
+
+function _createTagByCategoryAndName(category, name) {
+ category = category ? category.trim() : "default";
+ name = name.trim();
+ if (!name) {
+ return Promise.reject(new Error("Empty tag name"));
+ }
+ // if tag with this name already exists, existing category will be used
+ return Tag.get(name).then((tag) => {
+ return Promise.resolve(tag);
+ }, () => {
+ const tag = new Tag();
+ tag.names = [name];
+ tag.category = category;
+ return tag.save().then(() => Promise.resolve(tag));
+ });
+}
+
module.exports = {
refreshCategoryColorMap: refreshCategoryColorMap,
+ parseTagAndCategory: parseTagAndCategory,
+ resolveTagAndCategory: resolveTagAndCategory,
};
diff --git a/client/js/util/keyboard.js b/client/js/util/keyboard.js
index 3fe3263..c66eda1 100644
--- a/client/js/util/keyboard.js
+++ b/client/js/util/keyboard.js
@@ -21,6 +21,13 @@ function bind(hotkey, func) {
}
return false;
}
+function bindElement(element, hotkey, func) {
+ if (settings.get().keyboardShortcuts) {
+ mousetrap(element).bind(hotkey, func);
+ return true;
+ }
+ return false;
+}
function unbind(hotkey) {
mousetrap.unbind(hotkey);
@@ -28,6 +35,7 @@ function unbind(hotkey) {
module.exports = {
bind: bind,
+ bindElement: bindElement,
unbind: unbind,
pause: () => {
paused = true;
diff --git a/client/js/util/search.js b/client/js/util/search.js
index c540c2f..578b4fd 100644
--- a/client/js/util/search.js
+++ b/client/js/util/search.js
@@ -6,6 +6,7 @@ const views = require("./views.js");
function searchInputNodeFocusHelper(inputNode) {
keyboard.bind("q", () => {
+ e.preventDefault();
inputNode.focus();
inputNode.setSelectionRange(
inputNode.value.length,
diff --git a/client/js/util/touch.js b/client/js/util/touch.js
index 64bd00a..9903c71 100644
--- a/client/js/util/touch.js
+++ b/client/js/util/touch.js
@@ -8,48 +8,69 @@ const direction = {
UP: "up",
};
+function getSwipeThresholdInPx() {
+ // how big is the movement in pixels to be registered as swipe
+ return Math.min(screen.width, screen.height) * 0.15;
+}
+
function handleTouchStart(handler, evt) {
const touchEvent = evt.touches[0];
- handler._xStart = touchEvent.clientX;
- handler._yStart = touchEvent.clientY;
+ if (window.visualViewport.scale === 1) {
+ handler._xStart = touchEvent.clientX;
+ handler._yStart = touchEvent.clientY;
+ handler._startScrollY = window.scrollY;
+ }
}
function handleTouchMove(handler, evt) {
if (!handler._xStart || !handler._yStart) {
return;
}
+ if (window.visualViewport.scale > 1) {
+ handler._xStart = null;
+ handler._yStart = null;
+ return;
+ }
const xDirection = handler._xStart - evt.touches[0].clientX;
const yDirection = handler._yStart - evt.touches[0].clientY;
+ let threshold = getSwipeThresholdInPx();
if (Math.abs(xDirection) > Math.abs(yDirection)) {
- if (xDirection > 0) {
+ if (Math.abs(xDirection) < threshold) {
+ return;
+ } else if (xDirection > 0) {
handler._direction = direction.LEFT;
} else {
handler._direction = direction.RIGHT;
}
- } else if (yDirection > 0) {
- handler._direction = direction.DOWN;
} else {
- handler._direction = direction.UP;
+ if (Math.abs(yDirection) < threshold) {
+ return;
+ } else if (yDirection > 0) {
+ handler._direction = direction.UP;
+ } else {
+ handler._direction = direction.DOWN;
+ }
}
}
-function handleTouchEnd(handler) {
+function handleTouchEnd(handler, evt) {
+ evt.startScrollY = handler._startScrollY;
switch (handler._direction) {
case direction.NONE:
return;
case direction.LEFT:
- handler._swipeLeftTask();
+ handler._swipeLeftTask(evt);
break;
case direction.RIGHT:
- handler._swipeRightTask();
+ handler._swipeRightTask(evt);
break;
case direction.DOWN:
- handler._swipeDownTask();
+ handler._swipeDownTask(evt);
break;
case direction.UP:
- handler._swipeUpTask();
+ handler._swipeUpTask(evt);
// no default
}
@@ -82,8 +103,8 @@ class Touch {
this._target.addEventListener("touchmove", (evt) => {
handleTouchMove(this, evt);
});
- this._target.addEventListener("touchend", () => {
- handleTouchEnd(this);
+ this._target.addEventListener("touchend", (evt) => {
+ handleTouchEnd(this, evt);
});
}
}
diff --git a/client/js/util/uri.js b/client/js/util/uri.js
index 8064cdb..172b8c8 100644
--- a/client/js/util/uri.js
+++ b/client/js/util/uri.js
@@ -32,6 +32,16 @@ function unescapeParam(text) {
return decodeURIComponent(text);
}
+function getPostsQuery(parameters) {
+ let normalQuery = parameters.query || "";
+ let metricQuery = (parameters.metrics || "")
+ .split(" ") //see metric_header_control
+ .filter(m => m)
+ .map(m => m + " sort:metric-" + m)
+ .join(" ");
+ return normalQuery + " " + metricQuery;
+}
+
function formatClientLink(...values) {
let parts = [];
for (let value of values) {
@@ -90,6 +100,7 @@ function escapeTagName(text) {
}
module.exports = {
+ getPostsQuery: getPostsQuery,
formatClientLink: formatClientLink,
formatApiLink: formatApiLink,
escapeTagName: escapeTagName,
diff --git a/client/js/util/views.js b/client/js/util/views.js
index f6280a1..9958c87 100644
--- a/client/js/util/views.js
+++ b/client/js/util/views.js
@@ -85,7 +85,9 @@ function makeCheckbox(options) {
disabled: options.readonly,
required: options.required,
}),
- makeElement("span", { class: "checkbox" }, options.text)
+ makeElement("span", {
+ class: "checkbox " + (options.class || '')
+ }, options.text)
);
}
@@ -111,7 +113,7 @@ function makeSelect(options) {
}
function makeInput(options) {
- options.value = options.value || "";
+ options.value = options.value === 0 ? 0 : options.value || "";
return _makeLabel(options) + makeElement("input", options);
}
@@ -181,7 +183,11 @@ function getPostUrl(id, parameters) {
return uri.formatClientLink(
"post",
id,
- parameters ? { query: parameters.query } : {}
+ parameters ? {
+ query: parameters.query,
+ metrics: parameters.metrics,
+ cachenumber: parameters.cachenumber,
+ } : {}
);
}
@@ -190,7 +196,23 @@ function getPostEditUrl(id, parameters) {
"post",
id,
"edit",
- parameters ? { query: parameters.query } : {}
+ parameters ? {
+ query: parameters.query,
+ metrics: parameters.metrics,
+ cachenumber: parameters.cachenumber,
+ } : {}
+ );
+}
+
+function getMetricSorterUrl(id, parameters) {
+ return uri.formatClientLink(
+ "post",
+ id,
+ "metric-sorter",
+ parameters ? {
+ query: parameters.query,
+ metrics: parameters.metrics,
+ } : {}
);
}
@@ -300,6 +322,8 @@ function _serializeElement(name, attributes) {
attributes[key] === undefined
) {
return "";
+ } else if (attributes[key] === 0) {
+ return `${key}="0"`;
}
const attribute = misc.escapeHtml(attributes[key] || "");
return `${key}="${attribute}"`;
@@ -432,6 +456,7 @@ function getTemplate(templatePath) {
Object.assign(ctx, {
getPostUrl: getPostUrl,
getPostEditUrl: getPostEditUrl,
+ getMetricSorterUrl: getMetricSorterUrl,
makeRelativeTime: makeRelativeTime,
makeFileSize: makeFileSize,
makeMarkdown: makeMarkdown,
@@ -457,6 +482,7 @@ function getTemplate(templatePath) {
makeCssName: misc.makeCssName,
makeNumericInput: makeNumericInput,
formatClientLink: uri.formatClientLink,
+ escapeTagName: uri.escapeTagName,
});
return htmlToDom(templateFactory(ctx));
};
@@ -594,4 +620,5 @@ module.exports = {
showError: showError,
showSuccess: showSuccess,
showInfo: showInfo,
+ getMetricSorterUrl: getMetricSorterUrl,
};
diff --git a/client/js/views/endless_page_view.js b/client/js/views/endless_page_view.js
index f94c371..a4cb420 100644
--- a/client/js/views/endless_page_view.js
+++ b/client/js/views/endless_page_view.js
@@ -38,18 +38,26 @@ class EndlessPageView {
this.defaultLimit = parseInt(ctx.parameters.limit || ctx.defaultLimit);
const initialOffset = parseInt(ctx.parameters.offset || 0);
- this._loadPage(ctx, initialOffset, this.defaultLimit, true).then(
- (pageNode) => {
- if (initialOffset !== 0) {
- pageNode.scrollIntoView();
+ if (this._isCacheValid(ctx)) {
+ this._loadCachedPages(ctx);
+ } else {
+ this._clearCache(ctx);
+ this._loadPage(ctx, initialOffset, this.defaultLimit, true).then(
+ (pageNode) => {
+ if (initialOffset !== 0) {
+ pageNode.scrollIntoView();
+ }
}
- }
- );
+ );
+ }
this._timeout = window.setInterval(() => {
window.requestAnimationFrame(() => {
this._probePageLoad(ctx);
this._syncUrl(ctx);
+ if (this._shouldUseCache(ctx)) {
+ ctx.browserState.scrollY = window.scrollY;
+ }
});
}, 250);
@@ -72,12 +80,7 @@ class EndlessPageView {
return this._hostNode.querySelector(".pages-holder");
}
- _destroy() {
- window.clearInterval(this._timeout);
- this._active = false;
- }
-
- _syncUrl(ctx) {
+ get _topPageNode() {
let topPageNode = null;
let element = document.elementFromPoint(
window.innerWidth / 2,
@@ -90,18 +93,35 @@ class EndlessPageView {
}
element = element.parentNode;
}
+ return topPageNode;
+ }
+
+ _destroy() {
+ window.clearInterval(this._timeout);
+ this._active = false;
+ }
+
+ _syncUrl(ctx) {
+ const topPageNode = this._topPageNode;
if (!topPageNode) {
return;
}
let topOffset = parseInt(topPageNode.getAttribute("data-offset"));
let topLimit = parseInt(topPageNode.getAttribute("data-limit"));
if (topOffset !== this.currentOffset) {
+ const path = ctx.getClientUrlForPage(
+ topOffset,
+ topLimit === ctx.defaultLimit ? null : topLimit
+ );
+ if (this._shouldUseCache(ctx)) {
+ // We only scrolled, so we should continue using the same cache entry;
+ // Update the cache path so it's not invalidated:
+ ctx.browserState.pageCache.path = "/" + path;
+ }
router.replace(
- ctx.getClientUrlForPage(
- topOffset,
- topLimit === ctx.defaultLimit ? null : topLimit
- ),
- ctx.state,
+ path,
+ // ctx here is not "real" context, it's the object from _syncPageController()
+ ctx.browserState,
false
);
this.currentOffset = topOffset;
@@ -116,11 +136,9 @@ class EndlessPageView {
if (this.totalRecords === null) {
return;
}
+ const scrollThreshold = this._topPageNode.scrollHeight * 0.2;
- if (
- this.minOffsetShown > 0 &&
- isScrolledIntoView(this.topPageGuardNode)
- ) {
+ if (this.minOffsetShown > 0 && window.scrollY < scrollThreshold) {
this._loadPage(
ctx,
this.minOffsetShown - this.defaultLimit,
@@ -129,14 +147,51 @@ class EndlessPageView {
);
}
+ const pageBottom = this._pagesHolderNode.getBoundingClientRect().bottom;
if (
this.maxOffsetShown < this.totalRecords &&
- isScrolledIntoView(this.bottomPageGuardNode)
+ pageBottom < window.innerHeight + scrollThreshold
) {
this._loadPage(ctx, this.maxOffsetShown, this.defaultLimit, true);
}
}
+ _shouldUseCache(ctx) {
+ return ctx.browserState !== undefined && ctx.browserState != null &&
+ ctx.readPageFromCache !== undefined;
+ }
+
+ _isCacheValid(ctx) {
+ if (!this._shouldUseCache(ctx)) return false;
+ const cache = ctx.browserState.pageCache;
+ return cache !== null && cache !== undefined &&
+ cache.path == history.state.path &&
+ cache.pages !== undefined && cache.pages !== null;
+ }
+
+ _clearCache(ctx) {
+ if (!this._shouldUseCache(ctx)) return;
+ ctx.browserState.pageCache = {path: history.state.path, pages: {}};
+ }
+
+ _loadCachedPages(ctx) {
+ if (!this._shouldUseCache(ctx)) return;
+ // k-v map of page offset to raw response
+ const pages = ctx.browserState.pageCache.pages || {};
+ window.requestAnimationFrame(() => {
+ for (const [offset, data] of Object.entries(pages)) {
+ const response = {
+ offset: data.offset,
+ limit: data.limit,
+ total: data.total,
+ results: ctx.readPageFromCache(data.raw_data)
+ };
+ this._renderPage(ctx, true, response);
+ }
+ window.scroll(0, ctx.browserState.scrollY || 0);
+ });
+ }
+
_loadPage(ctx, offset, limit, append) {
this._runningRequests++;
return new Promise((resolve, reject) => {
@@ -146,6 +201,16 @@ class EndlessPageView {
this._runningRequests--;
return Promise.reject();
}
+ if (this._shouldUseCache(ctx)) {
+ // Need to extract raw_data, otherwise it can't be stored in history
+ const pages = ctx.browserState.pageCache.pages || {};
+ pages[offset] = {
+ offset: response.offset,
+ limit: response.limit,
+ total: response.total,
+ raw_data: response.results.raw_data,
+ };
+ }
window.requestAnimationFrame(() => {
let pageNode = this._renderPage(ctx, append, response);
this._runningRequests--;
@@ -190,7 +255,7 @@ class EndlessPageView {
}
if (
response.offset + response.results.length >
- this.maxOffsetShown ||
+ this.maxOffsetShown ||
this.maxOffsetShown === null
) {
this.maxOffsetShown =
diff --git a/client/js/views/metric_sorter_view.js b/client/js/views/metric_sorter_view.js
new file mode 100644
index 0000000..763a1de
--- /dev/null
+++ b/client/js/views/metric_sorter_view.js
@@ -0,0 +1,138 @@
+'use strict';
+
+const events = require('../events.js');
+const views = require('../util/views.js');
+const iosCorrectedInnerHeight = require('ios-inner-height');
+const PostContentControl = require('../controls/post_content_control.js');
+
+const template = views.getTemplate('metric-sorter');
+const sideTemplate = views.getTemplate('metric-sorter-side');
+
+//TODO: find a way to export these constants once
+const LEFT = 'left';
+const RIGHT = 'right';
+
+class MetricSorterView extends events.EventTarget {
+ constructor(ctx) {
+ super();
+
+ this._ctx = ctx;
+ this._hostNode = document.getElementById('content-holder');
+ views.replaceContent(this._hostNode, template(ctx));
+ this._formNode.addEventListener('submit', e => this._evtFormSubmit(e));
+ this._skipButtonNode.addEventListener('click', e => this._evtSkipClick(e));
+ this._compareLessBtnNode.addEventListener('click', e => this._evtCompareClick(e));
+ this._compareGreaterBtnNode.addEventListener('click', e => this._evtCompareClick(e));
+ this._refreshCompareButton();
+ }
+
+ installLeftPost(post) {
+ this._leftPostControl = this._installPostControl(post, this._leftSideNode);
+ }
+
+ installRightPost(post) {
+ this._rightPostControl = this._installPostControl(post, this._rightSideNode);
+ }
+
+ _installPostControl(post, sideNode) {
+ views.replaceContent(
+ sideNode,
+ sideTemplate(Object.assign({}, this._ctx, {
+ post: post,
+ })));
+ let containerNode = this._getSidePostContainerNode(sideNode);
+ return new PostContentControl(
+ containerNode,
+ post,
+ () => {
+ // TODO: come up with a more reliable resizing mechanism
+ return window.innerWidth < 1000 ?
+ [
+ window.innerWidth,
+ iosCorrectedInnerHeight() / 2
+ ] : [
+ containerNode.getBoundingClientRect().width,
+ window.innerHeight - containerNode.getBoundingClientRect().top -
+ this._buttonsNode.getBoundingClientRect().height * 2
+ ];
+ });
+ }
+
+ clearMessages() {
+ views.clearMessages(this._hostNode);
+ }
+
+ enableForm() {
+ views.enableForm(this._formNode);
+ }
+
+ disableForm() {
+ views.disableForm(this._formNode);
+ }
+
+ showSuccess(message) {
+ views.showSuccess(this._hostNode, message);
+ }
+
+ showError(message) {
+ views.showError(this._hostNode, message);
+ }
+
+ get _formNode() {
+ return this._hostNode.querySelector('form');
+ }
+
+ get _leftSideNode() {
+ return this._hostNode.querySelector('.left-post-container');
+ }
+
+ get _rightSideNode() {
+ return this._hostNode.querySelector('.right-post-container');
+ }
+
+ get _compareGreaterBtnNode() {
+ return this._hostNode.querySelector('.left-gt-right')
+ }
+
+ get _compareLessBtnNode() {
+ return this._hostNode.querySelector('.left-lt-right')
+ }
+
+ get _buttonsNode() {
+ return this._hostNode.querySelector('.buttons');
+ }
+
+ get _skipButtonNode() {
+ return this._hostNode.querySelector('.skip-btn');
+ }
+
+ _getSidePostContainerNode(sideNode) {
+ return sideNode.querySelector('.post-container');
+ }
+
+ _evtSkipClick(e) {
+ e.preventDefault();
+ this.dispatchEvent(new CustomEvent('skip'));
+ }
+
+ _evtFormSubmit(e) {
+ e.preventDefault();
+ this.dispatchEvent(new CustomEvent('submit', {
+ detail: {
+ greaterPost: this._ctx.greaterPost,
+ }}));
+ }
+
+ _evtCompareClick(e) {
+ e.preventDefault();
+ this._ctx.greaterPost = this._ctx.greaterPost === LEFT ? RIGHT : LEFT;
+ this._refreshCompareButton();
+ }
+
+ _refreshCompareButton() {
+ this._compareGreaterBtnNode.hidden = this._ctx.greaterPost === RIGHT;
+ this._compareLessBtnNode.hidden = this._ctx.greaterPost === LEFT;
+ }
+}
+
+module.exports = MetricSorterView;
diff --git a/client/js/views/post_main_view.js b/client/js/views/post_main_view.js
index 5ef7f61..c6b312b 100644
--- a/client/js/views/post_main_view.js
+++ b/client/js/views/post_main_view.js
@@ -32,15 +32,10 @@ class PostMainView {
postContainerNode,
ctx.post,
() => {
- const margin = sidebarNode.getBoundingClientRect().left;
-
return [
- window.innerWidth -
- postContainerNode.getBoundingClientRect().left -
- margin,
+ postContainerNode.getBoundingClientRect().width,
iosCorrectedInnerHeight() -
- topNavigationNode.getBoundingClientRect().height -
- margin * 2,
+ postContainerNode.getBoundingClientRect().top,
];
}
);
@@ -55,7 +50,8 @@ class PostMainView {
}
this._installSidebar(ctx);
- this._installCommentForm();
+ const commentForm = this._installCommentForm();
+ this._installAddCommentButton(commentForm);
this._installComments(ctx.post.comments);
const showPreviousImage = () => {
@@ -86,6 +82,12 @@ class PostMainView {
}
};
+ const showRandomImage = () => {
+ if (ctx.randomPostId) {
+ router.show(ctx.getPostUrl(ctx.randomPostId, ctx.parameters));
+ }
+ };
+
keyboard.bind("e", () => {
if (ctx.editMode) {
router.show(uri.formatClientLink("post", ctx.post.id));
@@ -95,6 +97,7 @@ class PostMainView {
});
keyboard.bind(["a", "left"], showPreviousImage);
keyboard.bind(["d", "right"], showNextImage);
+ keyboard.bind("r", showRandomImage);
keyboard.bind("del", (e) => {
if (ctx.editMode) {
this.sidebarControl._evtDeleteClick(e);
@@ -105,14 +108,16 @@ class PostMainView {
postContainerNode,
() => {
if (!ctx.editMode) {
- showPreviousImage();
+ showNextImage();
}
},
() => {
if (!ctx.editMode) {
- showNextImage();
+ showPreviousImage();
}
- }
+ },
+ () => {},
+ () => {}
);
}
@@ -124,14 +129,14 @@ class PostMainView {
if (ctx.editMode) {
this.sidebarControl = new PostEditSidebarControl(
sidebarContainerNode,
- ctx.post,
+ ctx,
this._postContentControl,
this._postNotesOverlayControl
);
} else {
this.sidebarControl = new PostReadonlySidebarControl(
sidebarContainerNode,
- ctx.post,
+ ctx,
this._postContentControl
);
}
@@ -142,14 +147,25 @@ class PostMainView {
"#content-holder .comment-form-container"
);
if (!commentFormContainer) {
- return;
+ return null;
}
-
this.commentControl = new CommentControl(
commentFormContainer,
null,
true
);
+ return commentFormContainer;
+ }
+
+ _installAddCommentButton(commentForm) {
+ const addCommentButton = document.querySelector("#add-comment-button");
+ if (!addCommentButton || !commentForm) {
+ return;
+ }
+ commentForm.hidden = true; // collapse by default
+ addCommentButton.addEventListener("click", () => {
+ commentForm.hidden = !commentForm.hidden
+ });
}
_installComments(comments) {
diff --git a/client/js/views/post_upload_view.js b/client/js/views/post_upload_view.js
index 4ef4c1a..83fdf12 100644
--- a/client/js/views/post_upload_view.js
+++ b/client/js/views/post_upload_view.js
@@ -4,10 +4,16 @@ const events = require("../events.js");
const api = require("../api.js");
const views = require("../util/views.js");
const FileDropperControl = require("../controls/file_dropper_control.js");
+const TagList = require("../models/tag_list.js");
+const TagInputControl = require("../controls/tag_input_control.js");
const template = views.getTemplate("post-upload");
const rowTemplate = views.getTemplate("post-upload-row");
+const misc = require("../util/misc.js");
+const TagAutoCompleteControl =
+ require("../controls/tag_auto_complete_control.js");
+
function _mimeTypeToPostType(mimeType) {
return (
{
@@ -178,6 +184,11 @@ class PostUploadView extends events.EventTarget {
this._evtUrlsAdded(e)
);
+ this._skipDuplicatesCheckboxNode.addEventListener("change", e =>
+ this._evtSkipDuplicatesCheck(e, this._skipDuplicatesCheckboxNode.checked)
+ );
+ this._copyTagsToOriginalsSpanNode.hidden = true;
+
this._cancelButtonNode.addEventListener("click", (e) =>
this._evtCancelButtonClick(e)
);
@@ -185,6 +196,11 @@ class PostUploadView extends events.EventTarget {
this._evtFormSubmit(e)
);
this._formNode.classList.add("inactive");
+
+ if (this._tagInputNode) {
+ this._tagControl = new TagInputControl(
+ this._tagInputNode, new TagList(), "Type common tags…");
+ }
}
enableForm() {
@@ -224,6 +240,7 @@ class PostUploadView extends events.EventTarget {
this._formNode.classList.remove("inactive");
let duplicatesFound = 0;
for (let uploadable of uploadables) {
+ uploadable.safety = this._ctx.defaultSafety || uploadable.safety;
if (this._uploadables.find(uploadable) !== -1) {
duplicatesFound++;
continue;
@@ -277,6 +294,15 @@ class PostUploadView extends events.EventTarget {
this.addUploadables(e.detail.urls.map((url) => new Url(url)));
}
+ _evtSkipDuplicatesCheck(e, checked) {
+ if (checked) {
+ this._copyTagsToOriginalsSpanNode.hidden = false;
+ } else {
+ this._copyTagsToOriginalsCheckboxNode.checked = false;
+ this._copyTagsToOriginalsSpanNode.hidden = true;
+ }
+ }
+
_evtCancelButtonClick(e) {
e.preventDefault();
this._emit("cancel");
@@ -307,17 +333,27 @@ class PostUploadView extends events.EventTarget {
}
uploadable.tags = [];
+ if (this._tagControl) {
+ uploadable.tags = this._tagControl.tags.map(tag => tag.names[0]);
+ }
+
uploadable.relations = [];
for (let [i, lookalike] of uploadable.lookalikes.entries()) {
let lookalikeNode = rowNode.querySelector(
`.lookalikes li:nth-child(${i + 1})`
);
- if (lookalikeNode.querySelector("[name=copy-tags]").checked) {
- uploadable.tags = uploadable.tags.concat(
- lookalike.post.tagNames
- );
+ if ((lookalikeNode.querySelector("[name=copy-tags]") || "").checked) {
+ if (lookalike.distance === 0.0) {
+ // found exact match, copy tags to it instead
+ uploadable.foundOriginal = lookalike.post;
+ } else {
+ uploadable.tags = uploadable.tags.concat(
+ lookalike.post.tagNames
+ );
+ uploadable.foundOriginal = undefined;
+ }
}
- if (lookalikeNode.querySelector("[name=add-relation]").checked) {
+ if ((lookalikeNode.querySelector("[name=add-relation]") || "").checked) {
uploadable.relations.push(lookalike.post.id);
}
}
@@ -362,6 +398,8 @@ class PostUploadView extends events.EventTarget {
detail: {
uploadables: this._uploadables,
skipDuplicates: this._skipDuplicatesCheckboxNode.checked,
+ copyTagsToOriginals:
+ this._copyTagsToOriginalsCheckboxNode.checked,
alwaysUploadSimilar:
this._alwaysUploadSimilarCheckboxNode.checked,
pauseRemainOnError:
@@ -429,6 +467,14 @@ class PostUploadView extends events.EventTarget {
return this._hostNode.querySelector("form [name=skip-duplicates]");
}
+ get _copyTagsToOriginalsSpanNode() {
+ return this._hostNode.querySelector(".copy-tags-to-originals");
+ }
+
+ get _copyTagsToOriginalsCheckboxNode() {
+ return this._hostNode.querySelector("form [name=copy-tags-to-originals]");
+ }
+
get _alwaysUploadSimilarCheckboxNode() {
return this._hostNode.querySelector(
"form [name=always-upload-similar]"
@@ -452,6 +498,10 @@ class PostUploadView extends events.EventTarget {
get _contentInputNode() {
return this._formNode.querySelector(".dropper-container");
}
+
+ get _tagInputNode() {
+ return this._formNode.querySelector(".tags input");
+ }
}
module.exports = PostUploadView;
diff --git a/client/js/views/posts_header_view.js b/client/js/views/posts_header_view.js
index 38a4aa9..7923752 100644
--- a/client/js/views/posts_header_view.js
+++ b/client/js/views/posts_header_view.js
@@ -8,6 +8,7 @@ const search = require("../util/search.js");
const views = require("../util/views.js");
const TagList = require("../models/tag_list.js");
const TagAutoCompleteControl = require("../controls/tag_auto_complete_control.js");
+const MetricHeaderControl = require("../controls/metric_header_control");
const template = views.getTemplate("posts-header");
@@ -141,6 +142,24 @@ class BulkTagEditor extends BulkEditor {
}
}
+class BulkAddRelationEditor extends BulkEditor {
+ constructor(hostNode) {
+ super(hostNode);
+ }
+
+ _evtOpenLinkClick(e) {
+ e.preventDefault();
+ this.toggleOpen(true);
+ this.dispatchEvent(new CustomEvent("open", { detail: {} }));
+ }
+
+ _evtCloseLinkClick(e) {
+ e.preventDefault();
+ this.toggleOpen(false);
+ this.dispatchEvent(new CustomEvent("close", { detail: {} }));
+ }
+}
+
class BulkDeleteEditor extends BulkEditor {
constructor(hostNode) {
super(hostNode);
@@ -200,6 +219,22 @@ class PostsHeaderView extends events.EventTarget {
this._formNode.addEventListener("submit", (e) =>
this._evtFormSubmit(e)
);
+ this._randomizeButtonNode.addEventListener("click", (e) =>
+ this._evtRandomizeButtonClick(e)
+ );
+
+ for (let shortcut of this._shortcutButtonNodes) {
+ this._setupQueryShortcutButton(shortcut, ctx.parameters.query);
+ shortcut.addEventListener("click", (e) =>
+ this._evtToggleQueryShortcut(e)
+ );
+ this.addEventListener("navigate", (e) =>
+ this._setupQueryShortcutButton(
+ shortcut,
+ e.detail.parameters.query
+ )
+ );
+ }
this._bulkEditors = [];
if (this._bulkEditTagsNode) {
@@ -214,6 +249,33 @@ class PostsHeaderView extends events.EventTarget {
this._bulkEditors.push(this._bulkSafetyEditor);
}
+ if (this._bulkAddRelationNode) {
+ this._bulkAddRelationEditor = new BulkAddRelationEditor(
+ this._bulkAddRelationNode
+ );
+ this._bulkEditors.push(this._bulkAddRelationEditor);
+ }
+
+ this._bulkEditOpenButtonNode.addEventListener("click", (e) =>
+ this._evtOpenBulkEditBtnClick(e)
+ );
+ this._bulkEditCloseButtonNode.addEventListener("click", (e) =>
+ this._evtCloseBulkEditBtnClick(e)
+ );
+
+ if (this._metricsButtonHolderNode) {
+ this._metricControl = new MetricHeaderControl(this._metricsBlockNode, ctx);
+ this._metricControl.addEventListener("submit", (e) =>
+ this._navigate()
+ );
+ this._metricsOpenButtonNode.addEventListener("click", (e) =>
+ this._evtOpenMetricsBtnClick(e)
+ );
+ this._metricsCloseButtonNode.addEventListener("click", (e) =>
+ this._evtCloseMetricsBtnClick(e)
+ );
+ }
+
if (this._bulkEditDeleteNode) {
this._bulkDeleteEditor = new BulkDeleteEditor(
this._bulkEditDeleteNode
@@ -239,9 +301,14 @@ class PostsHeaderView extends events.EventTarget {
this._openBulkEditor(this._bulkTagEditor);
} else if (ctx.parameters.safety && this._bulkSafetyEditor) {
this._openBulkEditor(this._bulkSafetyEditor);
+ } else if (ctx.parameters.relations && this._bulkAddRelationEditor) {
+ this._openBulkEditor(this._bulkAddRelationEditor);
} else if (ctx.parameters.delete && this._bulkDeleteEditor) {
this._openBulkEditor(this._bulkDeleteEditor);
}
+ if (ctx.parameters.metrics && this._metricsBlockNode) {
+ this._toggleMetricsBlock(true);
+ }
}
get _formNode() {
@@ -256,6 +323,30 @@ class PostsHeaderView extends events.EventTarget {
return this._hostNode.querySelector("form [name=search-text]");
}
+ get _randomizeButtonNode() {
+ return this._hostNode.querySelector("#randomize-button");
+ }
+
+ get _shortcutButtonNodes() {
+ return this._hostNode.querySelectorAll("form .query-shortcut");
+ }
+
+ get _bulkEditBtnHolderNode() {
+ return this._hostNode.querySelector(".bulk-edit-btn-holder");
+ }
+
+ get _bulkEditOpenButtonNode() {
+ return this._hostNode.querySelector(".bulk-edit-btn.open");
+ }
+
+ get _bulkEditCloseButtonNode() {
+ return this._hostNode.querySelector(".bulk-edit-btn.close");
+ }
+
+ get _bulkEditBlockNode() {
+ return this._hostNode.querySelector(".bulk-edit-block");
+ }
+
get _bulkEditTagsNode() {
return this._hostNode.querySelector(".bulk-edit-tags");
}
@@ -264,15 +355,54 @@ class PostsHeaderView extends events.EventTarget {
return this._hostNode.querySelector(".bulk-edit-safety");
}
+ get _bulkAddRelationNode() {
+ return this._hostNode.querySelector(".bulk-add-relation");
+ }
+
get _bulkEditDeleteNode() {
return this._hostNode.querySelector(".bulk-edit-delete");
}
+ get _metricsButtonHolderNode() {
+ return this._hostNode.querySelector(".metrics-btn-holder");
+ }
+
+ get _metricsOpenButtonNode() {
+ return this._hostNode.querySelector(".metrics-btn.open");
+ }
+
+ get _metricsCloseButtonNode() {
+ return this._hostNode.querySelector(".metrics-btn.close");
+ }
+
+ get _metricsBlockNode() {
+ return this._hostNode.querySelector(".metrics-block");
+ }
+
+ _evtOpenBulkEditBtnClick(e) {
+ e.preventDefault();
+ this._toggleBulkEditBlock(true);
+ this._navigate();
+ }
+
+ _evtCloseBulkEditBtnClick(e) {
+ e.preventDefault();
+ this._toggleBulkEditBlock(false);
+ this._closeAndShowAllBulkEditors();
+ this._navigate();
+ }
+
_openBulkEditor(editor) {
editor.toggleOpen(true);
+ this._toggleBulkEditBlock(true);
this._hideBulkEditorsExcept(editor);
}
+ _toggleBulkEditBlock(open) {
+ this._bulkEditBtnHolderNode.classList.toggle("opened", open);
+ this._bulkEditBlockNode.classList.toggle("hidden", !open);
+ }
+
_hideBulkEditorsExcept(editor) {
for (let otherEditor of this._bulkEditors) {
if (otherEditor !== editor) {
@@ -289,6 +419,23 @@ class PostsHeaderView extends events.EventTarget {
}
}
+ _evtOpenMetricsBtnClick(e) {
+ e.preventDefault();
+ this._toggleMetricsBlock(true);
+ this._navigate();
+ }
+
+ _evtCloseMetricsBtnClick(e) {
+ e.preventDefault();
+ this._toggleMetricsBlock(false);
+ this._navigate();
+ }
+
+ _toggleMetricsBlock(open) {
+ this._metricsButtonHolderNode.classList.toggle("opened", open);
+ this._metricsBlockNode.classList.toggle("hidden", !open);
+ }
+
_evtSafetyButtonClick(e, url) {
e.preventDefault();
e.target.classList.toggle("disabled");
@@ -309,14 +456,67 @@ class PostsHeaderView extends events.EventTarget {
);
}
+ _setupQueryShortcutButton(btn, query) {
+ const term = btn.getAttribute("data-term");
+ const selectedContent = btn.querySelector(".term-selected");
+ const unselectedContent = btn.querySelector(".term-unselected");
+ const termInUse = (query || "").includes(term);
+ if (termInUse) {
+ selectedContent.style.display = "inline-block";
+ unselectedContent.style.display = "none";
+ } else {
+ selectedContent.style.display = "none";
+ unselectedContent.style.display = "inline-block";
+ }
+ }
+
+ _evtToggleQueryShortcut(e) {
+ e.preventDefault();
+ const term = e.currentTarget.getAttribute("data-term");
+ let query = this._ctx.parameters.query || "";
+ if (query.includes(term)) {
+ query = query.replace(" " + term, "");
+ query = query.replace(term, "");
+ } else {
+ query += " " + term;
+ }
+ this._queryInputNode.value = query;
+ this.dispatchEvent(
+ new CustomEvent("navigate", {
+ detail: {
+ parameters: Object.assign({}, this._ctx.parameters, {
+ query: query,
+ tag: null,
+ offset: 0,
+ }),
+ },
+ })
+ );
+ }
+
_evtFormSubmit(e) {
e.preventDefault();
this._navigate();
+ if (this._metricControl) {
+ this._metricControl.refreshQuery(this._queryInputNode.value);
+ }
+ }
+ _evtRandomizeButtonClick(e) {
+ e.preventDefault();
+ if (!this._queryInputNode.value.includes("sort:random")) {
+ this._queryInputNode.value += " sort:random";
+ }
+ this._ctx.parameters.cachenumber = Math.round(Math.random() * 1000);
+ this._navigate();
}
_navigate() {
this._autoCompleteControl.hide();
- let parameters = { query: this._queryInputNode.value };
+ let parameters = {
+ query: this._queryInputNode.value,
+ cachenumber: this._ctx.parameters.cachenumber,
+ metrics: this._ctx.parameters.metrics
+ };
// convert falsy values to an empty string "" so that we can correctly compare with the current query
const prevQuery = this._ctx.parameters.query
@@ -334,6 +534,10 @@ class PostsHeaderView extends events.EventTarget {
this._bulkSafetyEditor && this._bulkSafetyEditor.opened
? "1"
: null;
+ parameters.relations =
+ this._bulkAddRelationEditor && this._bulkAddRelationEditor.opened
+ ? this._ctx.parameters.relations || " "
+ : null;
parameters.delete =
this._bulkDeleteEditor && this._bulkDeleteEditor.opened
? "1"
diff --git a/client/js/views/posts_page_view.js b/client/js/views/posts_page_view.js
index c4b1988..463468a 100644
--- a/client/js/views/posts_page_view.js
+++ b/client/js/views/posts_page_view.js
@@ -1,6 +1,7 @@
"use strict";
const events = require("../events.js");
+const tags = require("../tags.js");
const views = require("../util/views.js");
const template = views.getTemplate("posts-page");
@@ -31,6 +32,13 @@ class PostsPageView extends events.EventTarget {
);
}
+ const relationFlipperNode = this._getRelationFlipperNode(listItemNode);
+ if (relationFlipperNode) {
+ relationFlipperNode.addEventListener("click", e =>
+ this._evtBulkAddRelationClick(e, post)
+ );
+ }
+
const safetyFlipperNode = this._getSafetyFlipperNode(listItemNode);
if (safetyFlipperNode) {
for (let linkNode of safetyFlipperNode.querySelectorAll("a")) {
@@ -63,6 +71,10 @@ class PostsPageView extends events.EventTarget {
return listItemNode.querySelector(".safety-flipper");
}
+ _getRelationFlipperNode(listItemNode) {
+ return listItemNode.querySelector(".relation-flipper");
+ }
+
_getDeleteFlipperNode(listItemNode) {
return listItemNode.querySelector(".delete-flipper");
}
@@ -92,6 +104,23 @@ class PostsPageView extends events.EventTarget {
);
}
+ _evtBulkAddRelationClick(e, post) {
+ e.preventDefault();
+ const linkNode = e.target;
+ if (linkNode.getAttribute("data-disabled")) {
+ return;
+ }
+ linkNode.setAttribute("data-disabled", true);
+ this.dispatchEvent(
+ new CustomEvent(
+ linkNode.classList.contains("related") ? "removeRelation" : "addRelation",
+ {
+ detail: { post: post }
+ }
+ )
+ );
+ }
+
_evtBulkEditSafetyClick(e, post) {
e.preventDefault();
const linkNode = e.target;
@@ -133,7 +162,8 @@ class PostsPageView extends events.EventTarget {
if (tagFlipperNode) {
let tagged = true;
for (let tag of this._ctx.bulkEdit.tags) {
- tagged &= post.tags.isTaggedWith(tag);
+ let tagData = tags.parseTagAndCategory(tag);
+ tagged &= post.tags.isTaggedWith(tagData.name);
}
tagFlipperNode.classList.toggle("tagged", tagged);
}
@@ -149,6 +179,12 @@ class PostsPageView extends events.EventTarget {
}
}
+ const relationFlipperNode = this._getRelationFlipperNode(listItemNode);
+ if (relationFlipperNode) {
+ let related = this._ctx.parameters.relations.includes(post.id);
+ relationFlipperNode.classList.toggle("related", related);
+ }
+
const deleteFlipperNode = this._getDeleteFlipperNode(listItemNode);
if (deleteFlipperNode) {
deleteFlipperNode.classList.toggle(
diff --git a/client/js/views/settings_view.js b/client/js/views/settings_view.js
index b7f69d5..fba4cb6 100644
--- a/client/js/views/settings_view.js
+++ b/client/js/views/settings_view.js
@@ -42,10 +42,16 @@ class SettingsView extends events.EventTarget {
tagSuggestions: this._find("tag-suggestions").checked,
autoplayVideos: this._find("autoplay-videos").checked,
postsPerPage: this._find("posts-per-page").value,
+ similarPosts: this._find("similar-posts").value,
tagUnderscoresAsSpaces: this._find("underscores-as-spaces")
.checked,
darkTheme: this._find("dark-theme").checked,
postFlow: this._find("post-flow").checked,
+ uploadSafety: this._safetyButtonNodes.length ?
+ Array.from(this._safetyButtonNodes)
+ .filter(node => node.checked)[0]
+ .value.toLowerCase() :
+ undefined,
},
})
);
@@ -55,6 +61,10 @@ class SettingsView extends events.EventTarget {
return this._hostNode.querySelector("form");
}
+ get _safetyButtonNodes() {
+ return this._formNode.querySelectorAll(".uploadSafety input");
+ }
+
_find(nodeName) {
return this._formNode.querySelector("[name=" + nodeName + "]");
}
diff --git a/client/js/views/tag_metric_view.js b/client/js/views/tag_metric_view.js
new file mode 100644
index 0000000..d7cb67f
--- /dev/null
+++ b/client/js/views/tag_metric_view.js
@@ -0,0 +1,97 @@
+'use strict';
+
+const events = require('../events.js');
+const api = require('../api.js');
+const views = require('../util/views.js');
+const Metric = require('../models/metric.js');
+
+const template = views.getTemplate('tag-metric');
+
+class TagMetricView extends events.EventTarget {
+ constructor(ctx) {
+ super();
+
+ this._tag = ctx.tag;
+ this._hostNode = ctx.hostNode;
+
+ if (ctx.tag.metric) {
+ ctx.metricMin = ctx.tag.metric.min;
+ ctx.metricMax = ctx.tag.metric.max;
+ } else {
+ // default new values
+ ctx.metricMin = 0;
+ ctx.metricMax = 10;
+ }
+
+ views.replaceContent(this._hostNode, template(ctx));
+
+ this._formNode.addEventListener('submit', e => this._evtSubmit(e));
+ if (this._deleteButtonNode) {
+ this._deleteButtonNode.addEventListener('click', e => this._evtDelete(e));
+ }
+ }
+
+ _evtSubmit(e) {
+ e.preventDefault();
+ this.dispatchEvent(new CustomEvent('submit', {
+ detail: {
+ tag: this._tag,
+ metricMin: this._minFieldNode.value,
+ metricMax: this._maxFieldNode.value,
+ },
+ }));
+ }
+
+ _evtDelete(e) {
+ e.preventDefault();
+ if (!this._deleteConfirmationNode.checked) {
+ this.showError('Please confirm deletion.')
+ } else {
+ this.dispatchEvent(new CustomEvent('delete', {
+ detail: {tag: this._tag},
+ }));
+ }
+ }
+
+ clearMessages() {
+ views.clearMessages(this._hostNode);
+ }
+
+ enableForm() {
+ views.enableForm(this._formNode);
+ }
+
+ disableForm() {
+ views.disableForm(this._formNode);
+ }
+
+ showSuccess(message) {
+ views.showSuccess(this._hostNode, message);
+ }
+
+ showError(message) {
+ views.showError(this._hostNode, message);
+ }
+
+ get _formNode() {
+ return this._hostNode.querySelector('form');
+ }
+
+ get _minFieldNode() {
+ return this._formNode.querySelector('input[name=metric-min]');
+ }
+
+ get _maxFieldNode() {
+ return this._formNode.querySelector('input[name=metric-max]');
+ }
+
+ get _deleteConfirmationNode() {
+ return this._formNode.querySelector('input[name=confirm-delete]');
+ }
+
+ get _deleteButtonNode() {
+ return this._formNode.querySelector('input[name=delete]');
+ }
+}
+
+module.exports = TagMetricView;
diff --git a/client/js/views/tag_view.js b/client/js/views/tag_view.js
index d08a8c4..163d701 100644
--- a/client/js/views/tag_view.js
+++ b/client/js/views/tag_view.js
@@ -6,6 +6,7 @@ const misc = require("../util/misc.js");
const TagSummaryView = require("./tag_summary_view.js");
const TagEditView = require("./tag_edit_view.js");
const TagMergeView = require("./tag_merge_view.js");
+const TagMetricView = require("./tag_metric_view.js");
const TagDeleteView = require("./tag_delete_view.js");
const EmptyView = require("../views/empty_view.js");
@@ -51,6 +52,19 @@ class TagView extends events.EventTarget {
this._view = new TagEditView(ctx);
events.proxyEvent(this._view, this, "submit");
}
+
+ } else if (ctx.section === "metric") {
+ const metricExists = this._ctx.tag.metric;
+ if (!metricExists && !this._ctx.canCreateMetric) {
+ this._view = new EmptyView();
+ this._view.showError(
+ "You don\"t have privileges to create metrics.");
+ } else {
+ this._view = new TagMetricView(ctx);
+ events.proxyEvent(this._view, this, "submit", "metricUpdate");
+ events.proxyEvent(this._view, this, "delete", "metricDelete");
+ }
+
} else if (ctx.section === "merge") {
if (!this._ctx.canMerge) {
this._view = new EmptyView();
diff --git a/client/package-lock.json b/client/package-lock.json
index 3aa4ca4..8db5a38 100644
--- a/client/package-lock.json
+++ b/client/package-lock.json
@@ -1,13 +1,13 @@
{
"name": "szurubooru",
- "lockfileVersion": 2,
+ "lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "szurubooru",
"dependencies": {
+ "@fortawesome/fontawesome-free": "^5.1.0",
"dompurify": "^2.0.17",
- "font-awesome": "^4.7.0",
"ios-inner-height": "^1.0.3",
"js-cookie": "^2.2.0",
"marked": "^4.0.10",
@@ -49,6 +49,15 @@
"integrity": "sha512-ZS5w8CpKFinUzOwW3c83oPeVXoNsrLsaCoLtJvAClH135j/R77RuymhiSErhm2lKcwSCIpmvIWSbDkIfAqKQlA==",
"dev": true
},
+ "node_modules/@fortawesome/fontawesome-free": {
+ "version": "5.15.4",
+ "resolved": "https://registry.npmjs.org/@fortawesome/fontawesome-free/-/fontawesome-free-5.15.4.tgz",
+ "integrity": "sha512-eYm8vijH/hpzr/6/1CJ/V/Eb1xQFW2nnUKArb3z+yUWv7HTwj6M7SP957oMjfZjAHU6qpoNc2wQvIxBLWYa/Jg==",
+ "hasInstallScript": true,
+ "engines": {
+ "node": ">=6"
+ }
+ },
"node_modules/@jimp/bmp": {
"version": "0.13.0",
"resolved": "https://registry.npmjs.org/@jimp/bmp/-/bmp-0.13.0.tgz",
@@ -58,6 +67,9 @@
"@babel/runtime": "^7.7.2",
"@jimp/utils": "^0.13.0",
"bmp-js": "^0.1.0"
+ },
+ "peerDependencies": {
+ "@jimp/custom": ">=0.3.5"
}
},
"node_modules/@jimp/core": {
@@ -99,6 +111,9 @@
"@jimp/utils": "^0.13.0",
"gifwrap": "^0.9.2",
"omggif": "^1.0.9"
+ },
+ "peerDependencies": {
+ "@jimp/custom": ">=0.3.5"
}
},
"node_modules/@jimp/jpeg": {
@@ -110,6 +125,9 @@
"@babel/runtime": "^7.7.2",
"@jimp/utils": "^0.13.0",
"jpeg-js": "^0.4.0"
+ },
+ "peerDependencies": {
+ "@jimp/custom": ">=0.3.5"
}
},
"node_modules/@jimp/plugin-blit": {
@@ -120,6 +138,9 @@
"dependencies": {
"@babel/runtime": "^7.7.2",
"@jimp/utils": "^0.13.0"
+ },
+ "peerDependencies": {
+ "@jimp/custom": ">=0.3.5"
}
},
"node_modules/@jimp/plugin-blur": {
@@ -130,6 +151,9 @@
"dependencies": {
"@babel/runtime": "^7.7.2",
"@jimp/utils": "^0.13.0"
+ },
+ "peerDependencies": {
+ "@jimp/custom": ">=0.3.5"
}
},
"node_modules/@jimp/plugin-circle": {
@@ -140,6 +164,9 @@
"dependencies": {
"@babel/runtime": "^7.7.2",
"@jimp/utils": "^0.13.0"
+ },
+ "peerDependencies": {
+ "@jimp/custom": ">=0.3.5"
}
},
"node_modules/@jimp/plugin-color": {
@@ -151,6 +178,9 @@
"@babel/runtime": "^7.7.2",
"@jimp/utils": "^0.13.0",
"tinycolor2": "^1.4.1"
+ },
+ "peerDependencies": {
+ "@jimp/custom": ">=0.3.5"
}
},
"node_modules/@jimp/plugin-contain": {
@@ -161,6 +191,12 @@
"dependencies": {
"@babel/runtime": "^7.7.2",
"@jimp/utils": "^0.13.0"
+ },
+ "peerDependencies": {
+ "@jimp/custom": ">=0.3.5",
+ "@jimp/plugin-blit": ">=0.3.5",
+ "@jimp/plugin-resize": ">=0.3.5",
+ "@jimp/plugin-scale": ">=0.3.5"
}
},
"node_modules/@jimp/plugin-cover": {
@@ -171,6 +207,12 @@
"dependencies": {
"@babel/runtime": "^7.7.2",
"@jimp/utils": "^0.13.0"
+ },
+ "peerDependencies": {
+ "@jimp/custom": ">=0.3.5",
+ "@jimp/plugin-crop": ">=0.3.5",
+ "@jimp/plugin-resize": ">=0.3.5",
+ "@jimp/plugin-scale": ">=0.3.5"
}
},
"node_modules/@jimp/plugin-crop": {
@@ -181,6 +223,9 @@
"dependencies": {
"@babel/runtime": "^7.7.2",
"@jimp/utils": "^0.13.0"
+ },
+ "peerDependencies": {
+ "@jimp/custom": ">=0.3.5"
}
},
"node_modules/@jimp/plugin-displace": {
@@ -191,6 +236,9 @@
"dependencies": {
"@babel/runtime": "^7.7.2",
"@jimp/utils": "^0.13.0"
+ },
+ "peerDependencies": {
+ "@jimp/custom": ">=0.3.5"
}
},
"node_modules/@jimp/plugin-dither": {
@@ -201,6 +249,9 @@
"dependencies": {
"@babel/runtime": "^7.7.2",
"@jimp/utils": "^0.13.0"
+ },
+ "peerDependencies": {
+ "@jimp/custom": ">=0.3.5"
}
},
"node_modules/@jimp/plugin-fisheye": {
@@ -211,6 +262,9 @@
"dependencies": {
"@babel/runtime": "^7.7.2",
"@jimp/utils": "^0.13.0"
+ },
+ "peerDependencies": {
+ "@jimp/custom": ">=0.3.5"
}
},
"node_modules/@jimp/plugin-flip": {
@@ -221,6 +275,10 @@
"dependencies": {
"@babel/runtime": "^7.7.2",
"@jimp/utils": "^0.13.0"
+ },
+ "peerDependencies": {
+ "@jimp/custom": ">=0.3.5",
+ "@jimp/plugin-rotate": ">=0.3.5"
}
},
"node_modules/@jimp/plugin-gaussian": {
@@ -231,6 +289,9 @@
"dependencies": {
"@babel/runtime": "^7.7.2",
"@jimp/utils": "^0.13.0"
+ },
+ "peerDependencies": {
+ "@jimp/custom": ">=0.3.5"
}
},
"node_modules/@jimp/plugin-invert": {
@@ -241,6 +302,9 @@
"dependencies": {
"@babel/runtime": "^7.7.2",
"@jimp/utils": "^0.13.0"
+ },
+ "peerDependencies": {
+ "@jimp/custom": ">=0.3.5"
}
},
"node_modules/@jimp/plugin-mask": {
@@ -251,6 +315,9 @@
"dependencies": {
"@babel/runtime": "^7.7.2",
"@jimp/utils": "^0.13.0"
+ },
+ "peerDependencies": {
+ "@jimp/custom": ">=0.3.5"
}
},
"node_modules/@jimp/plugin-normalize": {
@@ -261,6 +328,9 @@
"dependencies": {
"@babel/runtime": "^7.7.2",
"@jimp/utils": "^0.13.0"
+ },
+ "peerDependencies": {
+ "@jimp/custom": ">=0.3.5"
}
},
"node_modules/@jimp/plugin-print": {
@@ -272,6 +342,10 @@
"@babel/runtime": "^7.7.2",
"@jimp/utils": "^0.13.0",
"load-bmfont": "^1.4.0"
+ },
+ "peerDependencies": {
+ "@jimp/custom": ">=0.3.5",
+ "@jimp/plugin-blit": ">=0.3.5"
}
},
"node_modules/@jimp/plugin-resize": {
@@ -282,6 +356,9 @@
"dependencies": {
"@babel/runtime": "^7.7.2",
"@jimp/utils": "^0.13.0"
+ },
+ "peerDependencies": {
+ "@jimp/custom": ">=0.3.5"
}
},
"node_modules/@jimp/plugin-rotate": {
@@ -292,6 +369,12 @@
"dependencies": {
"@babel/runtime": "^7.7.2",
"@jimp/utils": "^0.13.0"
+ },
+ "peerDependencies": {
+ "@jimp/custom": ">=0.3.5",
+ "@jimp/plugin-blit": ">=0.3.5",
+ "@jimp/plugin-crop": ">=0.3.5",
+ "@jimp/plugin-resize": ">=0.3.5"
}
},
"node_modules/@jimp/plugin-scale": {
@@ -302,6 +385,10 @@
"dependencies": {
"@babel/runtime": "^7.7.2",
"@jimp/utils": "^0.13.0"
+ },
+ "peerDependencies": {
+ "@jimp/custom": ">=0.3.5",
+ "@jimp/plugin-resize": ">=0.3.5"
}
},
"node_modules/@jimp/plugin-shadow": {
@@ -312,6 +399,11 @@
"dependencies": {
"@babel/runtime": "^7.7.2",
"@jimp/utils": "^0.13.0"
+ },
+ "peerDependencies": {
+ "@jimp/custom": ">=0.3.5",
+ "@jimp/plugin-blur": ">=0.3.5",
+ "@jimp/plugin-resize": ">=0.3.5"
}
},
"node_modules/@jimp/plugin-threshold": {
@@ -322,6 +414,11 @@
"dependencies": {
"@babel/runtime": "^7.7.2",
"@jimp/utils": "^0.13.0"
+ },
+ "peerDependencies": {
+ "@jimp/custom": ">=0.3.5",
+ "@jimp/plugin-color": ">=0.8.0",
+ "@jimp/plugin-resize": ">=0.8.0"
}
},
"node_modules/@jimp/plugins": {
@@ -353,6 +450,9 @@
"@jimp/plugin-shadow": "^0.13.0",
"@jimp/plugin-threshold": "^0.13.0",
"timm": "^1.6.1"
+ },
+ "peerDependencies": {
+ "@jimp/custom": ">=0.3.5"
}
},
"node_modules/@jimp/png": {
@@ -364,6 +464,9 @@
"@babel/runtime": "^7.7.2",
"@jimp/utils": "^0.13.0",
"pngjs": "^3.3.3"
+ },
+ "peerDependencies": {
+ "@jimp/custom": ">=0.3.5"
}
},
"node_modules/@jimp/tiff": {
@@ -374,6 +477,9 @@
"dependencies": {
"@babel/runtime": "^7.7.2",
"utif": "^2.0.1"
+ },
+ "peerDependencies": {
+ "@jimp/custom": ">=0.3.5"
}
},
"node_modules/@jimp/types": {
@@ -389,6 +495,9 @@
"@jimp/png": "^0.13.0",
"@jimp/tiff": "^0.13.0",
"timm": "^1.6.1"
+ },
+ "peerDependencies": {
+ "@jimp/custom": ">=0.3.5"
}
},
"node_modules/@jimp/utils": {
@@ -1195,7 +1304,10 @@
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/babelify/-/babelify-8.0.0.tgz",
"integrity": "sha512-xVr63fKEvMWUrrIbqlHYsMcc5Zdw4FSVesAHgkgajyCE1W8gbm9rbMakqavhxKvikGYMhEcqxTwB/gQmQ6lBtw==",
- "dev": true
+ "dev": true,
+ "peerDependencies": {
+ "babel-core": "6 || 7 || ^7.0.0-alpha || ^7.0.0-beta || ^7.0.0-rc"
+ }
},
"node_modules/babylon": {
"version": "6.18.0",
@@ -1671,6 +1783,7 @@
"version": "2.5.7",
"resolved": "https://registry.npmjs.org/core-js/-/core-js-2.5.7.tgz",
"integrity": "sha512-RszJCAxg/PP6uzXVXL6BsxSXx/B05oJAQ2vkJRjyjrEcNVycaqOmNb5OTxZPE3xa5gwZduqza6L9JOCenh/Ecw==",
+ "deprecated": "core-js@<3.23.3 is no longer maintained and not recommended for usage due to the number of issues. Because of the V8 engine whims, feature detection in old core-js versions could cause a slowdown up to 100x even if nothing is polyfilled. Some versions have web compatibility issues. Please, upgrade your dependencies to the actual version of core-js.",
"dev": true
},
"node_modules/core-util-is": {
@@ -2180,14 +2293,6 @@
"node": ">=8"
}
},
- "node_modules/font-awesome": {
- "version": "4.7.0",
- "resolved": "https://registry.npmjs.org/font-awesome/-/font-awesome-4.7.0.tgz",
- "integrity": "sha1-j6jPBBGhoxr9B7BtKQK7n8gVoTM=",
- "engines": {
- "node": ">=0.10.3"
- }
- },
"node_modules/foreach": {
"version": "2.0.5",
"resolved": "https://registry.npmjs.org/foreach/-/foreach-2.0.5.tgz",
@@ -2210,7 +2315,8 @@
"node_modules/formidable": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/formidable/-/formidable-1.2.1.tgz",
- "integrity": "sha512-Fs9VRguL0gqGHkXS5GQiMCr1VhZBxz0JnJs4JmMp/2jL18Fmbzvv7vOFRU+U8TBkHEE/CX1qDXzJplVULgsLeg=="
+ "integrity": "sha512-Fs9VRguL0gqGHkXS5GQiMCr1VhZBxz0JnJs4JmMp/2jL18Fmbzvv7vOFRU+U8TBkHEE/CX1qDXzJplVULgsLeg==",
+ "deprecated": "Please upgrade to latest, formidable@v2 or formidable@v3! Check these notes: https://bit.ly/2ZEqIau"
},
"node_modules/fs.realpath": {
"version": "1.0.0",
@@ -2270,6 +2376,7 @@
"version": "7.1.7",
"resolved": "https://registry.npmjs.org/glob/-/glob-7.1.7.tgz",
"integrity": "sha512-OvD9ENzPLbegENnYP5UUfJIirTg4+XwMWGaQfQTY0JenxNvvIKP3U3/tAQSPIu/lHxXYSZmpXlUHeqAIdKzBLQ==",
+ "deprecated": "Glob versions prior to v9 are no longer supported",
"dev": true,
"dependencies": {
"fs.realpath": "^1.0.0",
@@ -2514,6 +2621,7 @@
"version": "1.0.6",
"resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz",
"integrity": "sha1-Sb1jMdfQLQwJvJEKEHW6gWW1bfk=",
+ "deprecated": "This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.",
"dev": true,
"dependencies": {
"once": "^1.3.0",
@@ -2883,7 +2991,10 @@
"version": "0.0.0",
"resolved": "https://registry.npmjs.org/jsonify/-/jsonify-0.0.0.tgz",
"integrity": "sha1-LHS27kHZPKUbe1qu6PUDYx0lKnM=",
- "dev": true
+ "dev": true,
+ "engines": {
+ "node": "*"
+ }
},
"node_modules/jsonparse": {
"version": "1.3.1",
@@ -3395,6 +3506,7 @@
"version": "2.9.3",
"resolved": "https://registry.npmjs.org/phin/-/phin-2.9.3.tgz",
"integrity": "sha512-CzFr90qM24ju5f88quFC/6qohjC144rehe5n6DH900lgXmUe86+xCKc10ev56gRKC4/BkHUoG4uSiQgBiIXwDA==",
+ "deprecated": "Package no longer supported. Contact Support at https://www.npmjs.com/support for more info.",
"dev": true
},
"node_modules/picomatch": {
@@ -3500,6 +3612,7 @@
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/querystring/-/querystring-0.2.0.tgz",
"integrity": "sha1-sgmEkgO7Jd+CDadW50cAWHhSFiA=",
+ "deprecated": "The querystring API is considered Legacy. new code should use the URLSearchParams API instead.",
"dev": true,
"engines": {
"node": ">=0.4.x"
@@ -3798,6 +3911,7 @@
"version": "0.5.3",
"resolved": "https://registry.npmjs.org/source-map-resolve/-/source-map-resolve-0.5.3.tgz",
"integrity": "sha512-Htz+RnsXWk5+P2slx5Jh3Q66vhQj1Cllm0zvnaY98+NFx+Dv2CF/f5O/t8x+KaNdrdIAsruNzoh/KpialbqAnw==",
+ "deprecated": "See https://github.com/lydell/source-map-resolve#deprecated",
"dev": true,
"dependencies": {
"atob": "^2.1.2",
@@ -3820,6 +3934,7 @@
"version": "0.4.1",
"resolved": "https://registry.npmjs.org/source-map-url/-/source-map-url-0.4.1.tgz",
"integrity": "sha512-cPiFOTLUKvJFIg4SKVScy4ilPPW6rFgMgfuZJPNoDuMs3nC1HbMUycBoJw77xFIp6z1UJQJOfx6C9GMH80DiTw==",
+ "deprecated": "See https://github.com/lydell/source-map-url#deprecated",
"dev": true
},
"node_modules/stream-browserify": {
@@ -3976,6 +4091,7 @@
"version": "3.8.3",
"resolved": "https://registry.npmjs.org/superagent/-/superagent-3.8.3.tgz",
"integrity": "sha512-GLQtLMCoEIK4eDv6OGtkOoSMt3D+oq0y3dsxMuYuDvaNUvuT8eFBuLmfR0iYYzHC1e8hpzC6ZsxbuP6DIalMFA==",
+ "deprecated": "Please upgrade to v9.0.0+ as we have fixed a public vulnerability with formidable dependency. Note that v9.0.0+ requires Node.js v14.18.0+. See https://github.com/ladjs/superagent/pull/1800 for insight. This project is supported and maintained by the team at Forward Email @ https://forwardemail.net",
"dependencies": {
"component-emitter": "^1.2.0",
"cookiejar": "^2.1.0",
@@ -4600,4028 +4716,5 @@
"node": ">=0.4"
}
}
- },
- "dependencies": {
- "@babel/runtime": {
- "version": "7.10.3",
- "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.10.3.tgz",
- "integrity": "sha512-RzGO0RLSdokm9Ipe/YD+7ww8X2Ro79qiXZF3HU9ljrM+qnJmH1Vqth+hbiQZy761LnMJTMitHDuKVYTk3k4dLw==",
- "dev": true,
- "requires": {
- "regenerator-runtime": "^0.13.4"
- },
- "dependencies": {
- "regenerator-runtime": {
- "version": "0.13.5",
- "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.5.tgz",
- "integrity": "sha512-ZS5w8CpKFinUzOwW3c83oPeVXoNsrLsaCoLtJvAClH135j/R77RuymhiSErhm2lKcwSCIpmvIWSbDkIfAqKQlA==",
- "dev": true
- }
- }
- },
- "@jimp/bmp": {
- "version": "0.13.0",
- "resolved": "https://registry.npmjs.org/@jimp/bmp/-/bmp-0.13.0.tgz",
- "integrity": "sha512-7i/XZLoK5JETBKO0VL7qjnr6WDVl1X8mmaUk6Lzq06/veMPC5IwUIZi1JRVAXPEwTf5uUegq0WFnmUS0lVYzFw==",
- "dev": true,
- "requires": {
- "@babel/runtime": "^7.7.2",
- "@jimp/utils": "^0.13.0",
- "bmp-js": "^0.1.0"
- }
- },
- "@jimp/core": {
- "version": "0.13.0",
- "resolved": "https://registry.npmjs.org/@jimp/core/-/core-0.13.0.tgz",
- "integrity": "sha512-BMFEUm5HbRP4yCo4Q23CJFx/v6Yr3trw7rERmS1GKUEooDq9ktApZWWTvWq/vggKyysKX0nQ+KT+FaFD/75Q+Q==",
- "dev": true,
- "requires": {
- "@babel/runtime": "^7.7.2",
- "@jimp/utils": "^0.13.0",
- "any-base": "^1.1.0",
- "buffer": "^5.2.0",
- "exif-parser": "^0.1.12",
- "file-type": "^9.0.0",
- "load-bmfont": "^1.3.1",
- "mkdirp": "^0.5.1",
- "phin": "^2.9.1",
- "pixelmatch": "^4.0.2",
- "tinycolor2": "^1.4.1"
- }
- },
- "@jimp/custom": {
- "version": "0.13.0",
- "resolved": "https://registry.npmjs.org/@jimp/custom/-/custom-0.13.0.tgz",
- "integrity": "sha512-Zir/CHoLRhQDGfPWueCIQbVjVUlayNIUch9fulq4M9V2S+ynHx9BqRn58z8wy+mk8jm1WlpRVhvZ8QUenbL0vg==",
- "dev": true,
- "requires": {
- "@babel/runtime": "^7.7.2",
- "@jimp/core": "^0.13.0"
- }
- },
- "@jimp/gif": {
- "version": "0.13.0",
- "resolved": "https://registry.npmjs.org/@jimp/gif/-/gif-0.13.0.tgz",
- "integrity": "sha512-7FO2Fa9FZluqGt1MM/L8s6P5UEedxrIQT2eBAxzB8Z82YTTSWQXw4bdrZWCwiQjBFZwKTIaULIfw6+TxA/Q2XA==",
- "dev": true,
- "requires": {
- "@babel/runtime": "^7.7.2",
- "@jimp/utils": "^0.13.0",
- "gifwrap": "^0.9.2",
- "omggif": "^1.0.9"
- }
- },
- "@jimp/jpeg": {
- "version": "0.13.0",
- "resolved": "https://registry.npmjs.org/@jimp/jpeg/-/jpeg-0.13.0.tgz",
- "integrity": "sha512-Fol/DxA1lnIzCsNx/CckIEoyWImQHiWPgFAWL5s7VIVaJrEFnnbRqfOxmvr8yWg8mh3hWLeXNcxqA82CKXgg+Q==",
- "dev": true,
- "requires": {
- "@babel/runtime": "^7.7.2",
- "@jimp/utils": "^0.13.0",
- "jpeg-js": "^0.4.0"
- }
- },
- "@jimp/plugin-blit": {
- "version": "0.13.0",
- "resolved": "https://registry.npmjs.org/@jimp/plugin-blit/-/plugin-blit-0.13.0.tgz",
- "integrity": "sha512-roCShFZosJgRMLCLzuOT1pRZysQF/p3avQieZiu9pfg2F9X09f91OauU2Lf3/yOp0TZCWbheqbem9MPlhyED8w==",
- "dev": true,
- "requires": {
- "@babel/runtime": "^7.7.2",
- "@jimp/utils": "^0.13.0"
- }
- },
- "@jimp/plugin-blur": {
- "version": "0.13.0",
- "resolved": "https://registry.npmjs.org/@jimp/plugin-blur/-/plugin-blur-0.13.0.tgz",
- "integrity": "sha512-LeBhQe72bRk2fe2AftcqcDaWgSu6vFD0fxiAYYMy3pHa8pnPAwnw2W3u4bV/gc5XJt6AJzoRyc7WVG2pE4A3gg==",
- "dev": true,
- "requires": {
- "@babel/runtime": "^7.7.2",
- "@jimp/utils": "^0.13.0"
- }
- },
- "@jimp/plugin-circle": {
- "version": "0.13.0",
- "resolved": "https://registry.npmjs.org/@jimp/plugin-circle/-/plugin-circle-0.13.0.tgz",
- "integrity": "sha512-INwIl8zgWnJYXxYkNhIjG8TXg2Q1nh008SDKyC+Pug4ce/XRJC8w/Gk6HS+U9Z2tIO2/zXv473k/JaiwvDMu1w==",
- "dev": true,
- "requires": {
- "@babel/runtime": "^7.7.2",
- "@jimp/utils": "^0.13.0"
- }
- },
- "@jimp/plugin-color": {
- "version": "0.13.0",
- "resolved": "https://registry.npmjs.org/@jimp/plugin-color/-/plugin-color-0.13.0.tgz",
- "integrity": "sha512-e71UDivZdZGOhQRLjDo4a0BKgrH858HJ7zFk7/Yti58LwgeIGjYHhuYc+xQOdnBWPzGSD47TuFX5GXmf/x1nmg==",
- "dev": true,
- "requires": {
- "@babel/runtime": "^7.7.2",
- "@jimp/utils": "^0.13.0",
- "tinycolor2": "^1.4.1"
- }
- },
- "@jimp/plugin-contain": {
- "version": "0.13.0",
- "resolved": "https://registry.npmjs.org/@jimp/plugin-contain/-/plugin-contain-0.13.0.tgz",
- "integrity": "sha512-qPYS+ccMP4mEnf7BB0bcaszUTth8OxeRX0MdMvU6PDEI0nIvVVNwmuI6YtNqqs12PwuYxgPkq6AFenyLyoNP1g==",
- "dev": true,
- "requires": {
- "@babel/runtime": "^7.7.2",
- "@jimp/utils": "^0.13.0"
- }
- },
- "@jimp/plugin-cover": {
- "version": "0.13.0",
- "resolved": "https://registry.npmjs.org/@jimp/plugin-cover/-/plugin-cover-0.13.0.tgz",
- "integrity": "sha512-S2GkbXNgIb0afof/NLLq9IJDZPOcFtu1mc32ngt9S8HzXsNHgRAzONW7cg56bwQ6p0+sz/dS5tB4ctOW/pu/Dw==",
- "dev": true,
- "requires": {
- "@babel/runtime": "^7.7.2",
- "@jimp/utils": "^0.13.0"
- }
- },
- "@jimp/plugin-crop": {
- "version": "0.13.0",
- "resolved": "https://registry.npmjs.org/@jimp/plugin-crop/-/plugin-crop-0.13.0.tgz",
- "integrity": "sha512-Y1Ug3kOzsq72EjLiWQlwkHuvUvdSmFUDtxpyOXh3RxeWF7wmdjH8FvdhPj8hWvFLsDYFgWGaLI4Z6SXOr+N8nA==",
- "dev": true,
- "requires": {
- "@babel/runtime": "^7.7.2",
- "@jimp/utils": "^0.13.0"
- }
- },
- "@jimp/plugin-displace": {
- "version": "0.13.0",
- "resolved": "https://registry.npmjs.org/@jimp/plugin-displace/-/plugin-displace-0.13.0.tgz",
- "integrity": "sha512-c80VIUjIqQoavafthLpYRZdzANCxbOCHzqkFVbZ0kNKJnDDk6fW55mvVW4TJLDToDU81WMlttCmNV0oXcX93gQ==",
- "dev": true,
- "requires": {
- "@babel/runtime": "^7.7.2",
- "@jimp/utils": "^0.13.0"
- }
- },
- "@jimp/plugin-dither": {
- "version": "0.13.0",
- "resolved": "https://registry.npmjs.org/@jimp/plugin-dither/-/plugin-dither-0.13.0.tgz",
- "integrity": "sha512-EUz/y/AaQ00TnaiVLVAXLz8n8Nx7S36lKi4VXPeYy5a5FyzBimxNiKxdITVe9zooN7+H4FP++/xGFGFMpfWtRg==",
- "dev": true,
- "requires": {
- "@babel/runtime": "^7.7.2",
- "@jimp/utils": "^0.13.0"
- }
- },
- "@jimp/plugin-fisheye": {
- "version": "0.13.0",
- "resolved": "https://registry.npmjs.org/@jimp/plugin-fisheye/-/plugin-fisheye-0.13.0.tgz",
- "integrity": "sha512-O7h5pNTk2sYcTKxLvV6+zzUpLx8qzdNl6qiP9x1S0CKy64oZ9IwtK1eR1eLom0YA8tUR7rX5Ra4pB8bhq8Oyqw==",
- "dev": true,
- "requires": {
- "@babel/runtime": "^7.7.2",
- "@jimp/utils": "^0.13.0"
- }
- },
- "@jimp/plugin-flip": {
- "version": "0.13.0",
- "resolved": "https://registry.npmjs.org/@jimp/plugin-flip/-/plugin-flip-0.13.0.tgz",
- "integrity": "sha512-gWk+Q0LmCvupUuWRfoGyETmH/+lJKZuPCeA9K6UHJldq5Cdg/3MrlKUNS1HcPCDXjw+dWDGC8QnNslvMTaY5VQ==",
- "dev": true,
- "requires": {
- "@babel/runtime": "^7.7.2",
- "@jimp/utils": "^0.13.0"
- }
- },
- "@jimp/plugin-gaussian": {
- "version": "0.13.0",
- "resolved": "https://registry.npmjs.org/@jimp/plugin-gaussian/-/plugin-gaussian-0.13.0.tgz",
- "integrity": "sha512-0ctRqbCcLdy8y9IrSIH2McWNPLnEwjoe8qxtqoi51zRsM3z3mwjiTC2q8AWeF0SdIdWwV+YV/eP0494AJqjTsg==",
- "dev": true,
- "requires": {
- "@babel/runtime": "^7.7.2",
- "@jimp/utils": "^0.13.0"
- }
- },
- "@jimp/plugin-invert": {
- "version": "0.13.0",
- "resolved": "https://registry.npmjs.org/@jimp/plugin-invert/-/plugin-invert-0.13.0.tgz",
- "integrity": "sha512-k7TWx/la0MrTcT1dMtncV6I9IuGToRm9Q0ekzfb3k8bHzWRYX4SUtt/WrZ/I+/znD/fGorLtFI057v7mcLJc5A==",
- "dev": true,
- "requires": {
- "@babel/runtime": "^7.7.2",
- "@jimp/utils": "^0.13.0"
- }
- },
- "@jimp/plugin-mask": {
- "version": "0.13.0",
- "resolved": "https://registry.npmjs.org/@jimp/plugin-mask/-/plugin-mask-0.13.0.tgz",
- "integrity": "sha512-U3OdsgtMNpbCYc1lzzu96zdSgyz7BK9eD8IoFHdw4Ma8gCuM8kp9gdBJlKnzOh8eyYvssdCMZOWz9Xbxc5xH9A==",
- "dev": true,
- "requires": {
- "@babel/runtime": "^7.7.2",
- "@jimp/utils": "^0.13.0"
- }
- },
- "@jimp/plugin-normalize": {
- "version": "0.13.0",
- "resolved": "https://registry.npmjs.org/@jimp/plugin-normalize/-/plugin-normalize-0.13.0.tgz",
- "integrity": "sha512-yKOgZSvOxSHNlh+U9NOQH4Drgca0Dwb7DQRk3vj67gvHQC96JafIpGwN+9V4fP89lA3rkItbw6xgN6C/28HEUQ==",
- "dev": true,
- "requires": {
- "@babel/runtime": "^7.7.2",
- "@jimp/utils": "^0.13.0"
- }
- },
- "@jimp/plugin-print": {
- "version": "0.13.0",
- "resolved": "https://registry.npmjs.org/@jimp/plugin-print/-/plugin-print-0.13.0.tgz",
- "integrity": "sha512-Tv7r/1t7z63oLeRuEWw9xbm0G5uuBE54986+BOu8OFaHBpV/BbVHrE7ouApA0mKVZqMZCVjhO6Ph8+uFzRjdOw==",
- "dev": true,
- "requires": {
- "@babel/runtime": "^7.7.2",
- "@jimp/utils": "^0.13.0",
- "load-bmfont": "^1.4.0"
- }
- },
- "@jimp/plugin-resize": {
- "version": "0.13.0",
- "resolved": "https://registry.npmjs.org/@jimp/plugin-resize/-/plugin-resize-0.13.0.tgz",
- "integrity": "sha512-XOo0Skn7aq/aGxV9czFx6EaBUbAsAGCVbAS26fMM0AZ4YAWWUEleKTpHunEo92giIPhvlxeFFjQR2jQ9UcB3uQ==",
- "dev": true,
- "requires": {
- "@babel/runtime": "^7.7.2",
- "@jimp/utils": "^0.13.0"
- }
- },
- "@jimp/plugin-rotate": {
- "version": "0.13.0",
- "resolved": "https://registry.npmjs.org/@jimp/plugin-rotate/-/plugin-rotate-0.13.0.tgz",
- "integrity": "sha512-BaNeh655kF9Rz01ZV+Bkc8hLsHpNu3QnzigruVDjGt9Paoig0EBr+Dgyjje+7eTLu20kyuyxwPUAxLSOiPiraQ==",
- "dev": true,
- "requires": {
- "@babel/runtime": "^7.7.2",
- "@jimp/utils": "^0.13.0"
- }
- },
- "@jimp/plugin-scale": {
- "version": "0.13.0",
- "resolved": "https://registry.npmjs.org/@jimp/plugin-scale/-/plugin-scale-0.13.0.tgz",
- "integrity": "sha512-e/f7lvii+DmRMgYF+uBKQy437f+J66WbL0FcFEataCF/W9UkTIQGeXdECwJSPfqr81SxC5mGbSBbsdbMKChzAQ==",
- "dev": true,
- "requires": {
- "@babel/runtime": "^7.7.2",
- "@jimp/utils": "^0.13.0"
- }
- },
- "@jimp/plugin-shadow": {
- "version": "0.13.0",
- "resolved": "https://registry.npmjs.org/@jimp/plugin-shadow/-/plugin-shadow-0.13.0.tgz",
- "integrity": "sha512-qObtH63dmfPLze5wE8XDRjDsBOUnAfEWib4YbjPXGBZVxeKD7+2oPGemsK56HqC/+rYzIynkbi4MUIV1Q0dGjA==",
- "dev": true,
- "requires": {
- "@babel/runtime": "^7.7.2",
- "@jimp/utils": "^0.13.0"
- }
- },
- "@jimp/plugin-threshold": {
- "version": "0.13.0",
- "resolved": "https://registry.npmjs.org/@jimp/plugin-threshold/-/plugin-threshold-0.13.0.tgz",
- "integrity": "sha512-ACF7jk0ogso+2RK+0EsvBupVfE3IMq39wGFQWgpnHR9Tj12mSO279f6i/H8bcj1ZXmHot22nwLOG0wO4AlAaRg==",
- "dev": true,
- "requires": {
- "@babel/runtime": "^7.7.2",
- "@jimp/utils": "^0.13.0"
- }
- },
- "@jimp/plugins": {
- "version": "0.13.0",
- "resolved": "https://registry.npmjs.org/@jimp/plugins/-/plugins-0.13.0.tgz",
- "integrity": "sha512-onu8GnSnFjLFuFVFq8+aTYFIDfH8kwZuBHeGaDyScPFFn6QMKsPl4TeLzQ5vwIPvcpkADuFFfuAshE4peutjjA==",
- "dev": true,
- "requires": {
- "@babel/runtime": "^7.7.2",
- "@jimp/plugin-blit": "^0.13.0",
- "@jimp/plugin-blur": "^0.13.0",
- "@jimp/plugin-circle": "^0.13.0",
- "@jimp/plugin-color": "^0.13.0",
- "@jimp/plugin-contain": "^0.13.0",
- "@jimp/plugin-cover": "^0.13.0",
- "@jimp/plugin-crop": "^0.13.0",
- "@jimp/plugin-displace": "^0.13.0",
- "@jimp/plugin-dither": "^0.13.0",
- "@jimp/plugin-fisheye": "^0.13.0",
- "@jimp/plugin-flip": "^0.13.0",
- "@jimp/plugin-gaussian": "^0.13.0",
- "@jimp/plugin-invert": "^0.13.0",
- "@jimp/plugin-mask": "^0.13.0",
- "@jimp/plugin-normalize": "^0.13.0",
- "@jimp/plugin-print": "^0.13.0",
- "@jimp/plugin-resize": "^0.13.0",
- "@jimp/plugin-rotate": "^0.13.0",
- "@jimp/plugin-scale": "^0.13.0",
- "@jimp/plugin-shadow": "^0.13.0",
- "@jimp/plugin-threshold": "^0.13.0",
- "timm": "^1.6.1"
- }
- },
- "@jimp/png": {
- "version": "0.13.0",
- "resolved": "https://registry.npmjs.org/@jimp/png/-/png-0.13.0.tgz",
- "integrity": "sha512-9MVU0BLMQKJ6Kaiwjrq6dLDnDktZzeHtxz4qthRHaGOyHLx3RpxmbhaDuK9dDg6NASX3JuXznEhaOP4lqQODpQ==",
- "dev": true,
- "requires": {
- "@babel/runtime": "^7.7.2",
- "@jimp/utils": "^0.13.0",
- "pngjs": "^3.3.3"
- }
- },
- "@jimp/tiff": {
- "version": "0.13.0",
- "resolved": "https://registry.npmjs.org/@jimp/tiff/-/tiff-0.13.0.tgz",
- "integrity": "sha512-8lLGgEmhVRRjzZfn/QgpM3+mijq5ORYqRHtLcqDgcQaUY/q/OU1CxLYX777pozyQ3KIq1O+jyyHZm2xu3RZkPA==",
- "dev": true,
- "requires": {
- "@babel/runtime": "^7.7.2",
- "utif": "^2.0.1"
- }
- },
- "@jimp/types": {
- "version": "0.13.0",
- "resolved": "https://registry.npmjs.org/@jimp/types/-/types-0.13.0.tgz",
- "integrity": "sha512-qGq9qVHiRTgtIy061FSBr9l7OFrSiFLkKyQVnOBndEjwls2XLBKXkMmSD2U3oiHcNuf3ACsDSTIzK3KX/hDHvg==",
- "dev": true,
- "requires": {
- "@babel/runtime": "^7.7.2",
- "@jimp/bmp": "^0.13.0",
- "@jimp/gif": "^0.13.0",
- "@jimp/jpeg": "^0.13.0",
- "@jimp/png": "^0.13.0",
- "@jimp/tiff": "^0.13.0",
- "timm": "^1.6.1"
- }
- },
- "@jimp/utils": {
- "version": "0.13.0",
- "resolved": "https://registry.npmjs.org/@jimp/utils/-/utils-0.13.0.tgz",
- "integrity": "sha512-zA4573jE4FIpBKiYpPGo66JOAGdv/FS/N9fW9GpkbwJeTu12fV+r4R1ARSyt8UEKdE4DMBatBmQC0U2FGZijOA==",
- "dev": true,
- "requires": {
- "@babel/runtime": "^7.7.2",
- "regenerator-runtime": "^0.13.3"
- },
- "dependencies": {
- "regenerator-runtime": {
- "version": "0.13.5",
- "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.5.tgz",
- "integrity": "sha512-ZS5w8CpKFinUzOwW3c83oPeVXoNsrLsaCoLtJvAClH135j/R77RuymhiSErhm2lKcwSCIpmvIWSbDkIfAqKQlA==",
- "dev": true
- }
- }
- },
- "acorn": {
- "version": "7.4.1",
- "resolved": "https://registry.npmjs.org/acorn/-/acorn-7.4.1.tgz",
- "integrity": "sha512-nQyp0o1/mNdbTO1PO6kHkwSrmgZ0MT/jCCpNiwbUjGoRN4dlBhqJtoQuCnEOKzgTVwg0ZWiCoQy6SxMebQVh8A==",
- "dev": true
- },
- "acorn-node": {
- "version": "1.8.2",
- "resolved": "https://registry.npmjs.org/acorn-node/-/acorn-node-1.8.2.tgz",
- "integrity": "sha512-8mt+fslDufLYntIoPAaIMUe/lrbrehIiwmR3t2k9LljIzoigEPF27eLk2hy8zSGzmR/ogr7zbRKINMo1u0yh5A==",
- "dev": true,
- "requires": {
- "acorn": "^7.0.0",
- "acorn-walk": "^7.0.0",
- "xtend": "^4.0.2"
- }
- },
- "acorn-walk": {
- "version": "7.2.0",
- "resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-7.2.0.tgz",
- "integrity": "sha512-OPdCF6GsMIP+Az+aWfAAOEt2/+iVDKE7oy6lJ098aoe59oAmK76qV6Gw60SbZ8jHuG2wH058GF4pLFbYamYrVA==",
- "dev": true
- },
- "ansi-regex": {
- "version": "2.1.1",
- "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-2.1.1.tgz",
- "integrity": "sha1-w7M6te42DYbg5ijwRorn7yfWVN8=",
- "dev": true
- },
- "ansi-styles": {
- "version": "2.2.1",
- "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-2.2.1.tgz",
- "integrity": "sha1-tDLdM1i2NM914eRmQ2gkBTPB3b4=",
- "dev": true
- },
- "any-base": {
- "version": "1.1.0",
- "resolved": "https://registry.npmjs.org/any-base/-/any-base-1.1.0.tgz",
- "integrity": "sha512-uMgjozySS8adZZYePpaWs8cxB9/kdzmpX6SgJZ+wbz1K5eYk5QMYDVJaZKhxyIHUdnnJkfR7SVgStgH7LkGUyg==",
- "dev": true
- },
- "anymatch": {
- "version": "3.1.2",
- "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.2.tgz",
- "integrity": "sha512-P43ePfOAIupkguHUycrc4qJ9kz8ZiuOUijaETwX7THt0Y/GNK7v0aa8rY816xWjZ7rJdA5XdMcpVFTKMq+RvWg==",
- "dev": true,
- "requires": {
- "normalize-path": "^3.0.0",
- "picomatch": "^2.0.4"
- }
- },
- "asn1.js": {
- "version": "4.10.1",
- "resolved": "https://registry.npmjs.org/asn1.js/-/asn1.js-4.10.1.tgz",
- "integrity": "sha512-p32cOF5q0Zqs9uBiONKYLm6BClCoBCM5O9JfeUSlnQLBTxYdTK+pW+nXflm8UkKd2UYlEbYz5qEi0JuZR9ckSw==",
- "dev": true,
- "requires": {
- "bn.js": "^4.0.0",
- "inherits": "^2.0.1",
- "minimalistic-assert": "^1.0.0"
- }
- },
- "assert": {
- "version": "1.4.1",
- "resolved": "https://registry.npmjs.org/assert/-/assert-1.4.1.tgz",
- "integrity": "sha1-mZEtWRg2tab1s0XA8H7vwI/GXZE=",
- "dev": true,
- "requires": {
- "util": "0.10.3"
- },
- "dependencies": {
- "inherits": {
- "version": "2.0.1",
- "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.1.tgz",
- "integrity": "sha1-sX0I0ya0Qj5Wjv9xn5GwscvfafE=",
- "dev": true
- },
- "util": {
- "version": "0.10.3",
- "resolved": "https://registry.npmjs.org/util/-/util-0.10.3.tgz",
- "integrity": "sha1-evsa/lCAUkZInj23/g7TeTNqwPk=",
- "dev": true,
- "requires": {
- "inherits": "2.0.1"
- }
- }
- }
- },
- "asynckit": {
- "version": "0.4.0",
- "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
- "integrity": "sha1-x57Zf380y48robyXkLzDZkdLS3k="
- },
- "atob": {
- "version": "2.1.2",
- "resolved": "https://registry.npmjs.org/atob/-/atob-2.1.2.tgz",
- "integrity": "sha512-Wm6ukoaOGJi/73p/cl2GvLjTI5JM1k/O14isD73YML8StrH/7/lRFgmg8nICZgD3bZZvjwCGxtMOD3wWNAu8cg==",
- "dev": true
- },
- "available-typed-arrays": {
- "version": "1.0.2",
- "resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.2.tgz",
- "integrity": "sha512-XWX3OX8Onv97LMk/ftVyBibpGwY5a8SmuxZPzeOxqmuEqUCOM9ZE+uIaD1VNJ5QnvU2UQusvmKbuM1FR8QWGfQ==",
- "dev": true,
- "requires": {
- "array-filter": "^1.0.0"
- },
- "dependencies": {
- "array-filter": {
- "version": "1.0.0",
- "resolved": "https://registry.npmjs.org/array-filter/-/array-filter-1.0.0.tgz",
- "integrity": "sha1-uveeYubvTCpMC4MSMtr/7CUfnYM=",
- "dev": true
- }
- }
- },
- "babel-code-frame": {
- "version": "6.26.0",
- "resolved": "https://registry.npmjs.org/babel-code-frame/-/babel-code-frame-6.26.0.tgz",
- "integrity": "sha1-Y/1D99weO7fONZR9uP42mj9Yx0s=",
- "dev": true,
- "requires": {
- "chalk": "^1.1.3",
- "esutils": "^2.0.2",
- "js-tokens": "^3.0.2"
- }
- },
- "babel-core": {
- "version": "6.26.3",
- "resolved": "https://registry.npmjs.org/babel-core/-/babel-core-6.26.3.tgz",
- "integrity": "sha512-6jyFLuDmeidKmUEb3NM+/yawG0M2bDZ9Z1qbZP59cyHLz8kYGKYwpJP0UwUKKUiTRNvxfLesJnTedqczP7cTDA==",
- "dev": true,
- "requires": {
- "babel-code-frame": "^6.26.0",
- "babel-generator": "^6.26.0",
- "babel-helpers": "^6.24.1",
- "babel-messages": "^6.23.0",
- "babel-register": "^6.26.0",
- "babel-runtime": "^6.26.0",
- "babel-template": "^6.26.0",
- "babel-traverse": "^6.26.0",
- "babel-types": "^6.26.0",
- "babylon": "^6.18.0",
- "convert-source-map": "^1.5.1",
- "debug": "^2.6.9",
- "json5": "^0.5.1",
- "lodash": "^4.17.4",
- "minimatch": "^3.0.4",
- "path-is-absolute": "^1.0.1",
- "private": "^0.1.8",
- "slash": "^1.0.0",
- "source-map": "^0.5.7"
- },
- "dependencies": {
- "debug": {
- "version": "2.6.9",
- "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
- "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==",
- "dev": true,
- "requires": {
- "ms": "2.0.0"
- }
- }
- }
- },
- "babel-generator": {
- "version": "6.26.1",
- "resolved": "https://registry.npmjs.org/babel-generator/-/babel-generator-6.26.1.tgz",
- "integrity": "sha512-HyfwY6ApZj7BYTcJURpM5tznulaBvyio7/0d4zFOeMPUmfxkCjHocCuoLa2SAGzBI8AREcH3eP3758F672DppA==",
- "dev": true,
- "requires": {
- "babel-messages": "^6.23.0",
- "babel-runtime": "^6.26.0",
- "babel-types": "^6.26.0",
- "detect-indent": "^4.0.0",
- "jsesc": "^1.3.0",
- "lodash": "^4.17.4",
- "source-map": "^0.5.7",
- "trim-right": "^1.0.1"
- }
- },
- "babel-helper-builder-binary-assignment-operator-visitor": {
- "version": "6.24.1",
- "resolved": "https://registry.npmjs.org/babel-helper-builder-binary-assignment-operator-visitor/-/babel-helper-builder-binary-assignment-operator-visitor-6.24.1.tgz",
- "integrity": "sha1-zORReto1b0IgvK6KAsKzRvmlZmQ=",
- "dev": true,
- "requires": {
- "babel-helper-explode-assignable-expression": "^6.24.1",
- "babel-runtime": "^6.22.0",
- "babel-types": "^6.24.1"
- }
- },
- "babel-helper-call-delegate": {
- "version": "6.24.1",
- "resolved": "https://registry.npmjs.org/babel-helper-call-delegate/-/babel-helper-call-delegate-6.24.1.tgz",
- "integrity": "sha1-7Oaqzdx25Bw0YfiL/Fdb0Nqi340=",
- "dev": true,
- "requires": {
- "babel-helper-hoist-variables": "^6.24.1",
- "babel-runtime": "^6.22.0",
- "babel-traverse": "^6.24.1",
- "babel-types": "^6.24.1"
- }
- },
- "babel-helper-define-map": {
- "version": "6.26.0",
- "resolved": "https://registry.npmjs.org/babel-helper-define-map/-/babel-helper-define-map-6.26.0.tgz",
- "integrity": "sha1-pfVtq0GiX5fstJjH66ypgZ+Vvl8=",
- "dev": true,
- "requires": {
- "babel-helper-function-name": "^6.24.1",
- "babel-runtime": "^6.26.0",
- "babel-types": "^6.26.0",
- "lodash": "^4.17.4"
- }
- },
- "babel-helper-explode-assignable-expression": {
- "version": "6.24.1",
- "resolved": "https://registry.npmjs.org/babel-helper-explode-assignable-expression/-/babel-helper-explode-assignable-expression-6.24.1.tgz",
- "integrity": "sha1-8luCz33BBDPFX3BZLVdGQArCLKo=",
- "dev": true,
- "requires": {
- "babel-runtime": "^6.22.0",
- "babel-traverse": "^6.24.1",
- "babel-types": "^6.24.1"
- }
- },
- "babel-helper-function-name": {
- "version": "6.24.1",
- "resolved": "https://registry.npmjs.org/babel-helper-function-name/-/babel-helper-function-name-6.24.1.tgz",
- "integrity": "sha1-00dbjAPtmCQqJbSDUasYOZ01gKk=",
- "dev": true,
- "requires": {
- "babel-helper-get-function-arity": "^6.24.1",
- "babel-runtime": "^6.22.0",
- "babel-template": "^6.24.1",
- "babel-traverse": "^6.24.1",
- "babel-types": "^6.24.1"
- }
- },
- "babel-helper-get-function-arity": {
- "version": "6.24.1",
- "resolved": "https://registry.npmjs.org/babel-helper-get-function-arity/-/babel-helper-get-function-arity-6.24.1.tgz",
- "integrity": "sha1-j3eCqpNAfEHTqlCQj4mwMbG2hT0=",
- "dev": true,
- "requires": {
- "babel-runtime": "^6.22.0",
- "babel-types": "^6.24.1"
- }
- },
- "babel-helper-hoist-variables": {
- "version": "6.24.1",
- "resolved": "https://registry.npmjs.org/babel-helper-hoist-variables/-/babel-helper-hoist-variables-6.24.1.tgz",
- "integrity": "sha1-HssnaJydJVE+rbyZFKc/VAi+enY=",
- "dev": true,
- "requires": {
- "babel-runtime": "^6.22.0",
- "babel-types": "^6.24.1"
- }
- },
- "babel-helper-optimise-call-expression": {
- "version": "6.24.1",
- "resolved": "https://registry.npmjs.org/babel-helper-optimise-call-expression/-/babel-helper-optimise-call-expression-6.24.1.tgz",
- "integrity": "sha1-96E0J7qfc/j0+pk8VKl4gtEkQlc=",
- "dev": true,
- "requires": {
- "babel-runtime": "^6.22.0",
- "babel-types": "^6.24.1"
- }
- },
- "babel-helper-regex": {
- "version": "6.26.0",
- "resolved": "https://registry.npmjs.org/babel-helper-regex/-/babel-helper-regex-6.26.0.tgz",
- "integrity": "sha1-MlxZ+QL4LyS3T6zu0DY5VPZJXnI=",
- "dev": true,
- "requires": {
- "babel-runtime": "^6.26.0",
- "babel-types": "^6.26.0",
- "lodash": "^4.17.4"
- }
- },
- "babel-helper-remap-async-to-generator": {
- "version": "6.24.1",
- "resolved": "https://registry.npmjs.org/babel-helper-remap-async-to-generator/-/babel-helper-remap-async-to-generator-6.24.1.tgz",
- "integrity": "sha1-XsWBgnrXI/7N04HxySg5BnbkVRs=",
- "dev": true,
- "requires": {
- "babel-helper-function-name": "^6.24.1",
- "babel-runtime": "^6.22.0",
- "babel-template": "^6.24.1",
- "babel-traverse": "^6.24.1",
- "babel-types": "^6.24.1"
- }
- },
- "babel-helper-replace-supers": {
- "version": "6.24.1",
- "resolved": "https://registry.npmjs.org/babel-helper-replace-supers/-/babel-helper-replace-supers-6.24.1.tgz",
- "integrity": "sha1-v22/5Dk40XNpohPKiov3S2qQqxo=",
- "dev": true,
- "requires": {
- "babel-helper-optimise-call-expression": "^6.24.1",
- "babel-messages": "^6.23.0",
- "babel-runtime": "^6.22.0",
- "babel-template": "^6.24.1",
- "babel-traverse": "^6.24.1",
- "babel-types": "^6.24.1"
- }
- },
- "babel-helpers": {
- "version": "6.24.1",
- "resolved": "https://registry.npmjs.org/babel-helpers/-/babel-helpers-6.24.1.tgz",
- "integrity": "sha1-NHHenK7DiOXIUOWX5Yom3fN2ArI=",
- "dev": true,
- "requires": {
- "babel-runtime": "^6.22.0",
- "babel-template": "^6.24.1"
- }
- },
- "babel-messages": {
- "version": "6.23.0",
- "resolved": "https://registry.npmjs.org/babel-messages/-/babel-messages-6.23.0.tgz",
- "integrity": "sha1-8830cDhYA1sqKVHG7F7fbGLyYw4=",
- "dev": true,
- "requires": {
- "babel-runtime": "^6.22.0"
- }
- },
- "babel-plugin-check-es2015-constants": {
- "version": "6.22.0",
- "resolved": "https://registry.npmjs.org/babel-plugin-check-es2015-constants/-/babel-plugin-check-es2015-constants-6.22.0.tgz",
- "integrity": "sha1-NRV7EBQm/S/9PaP3XH0ekYNbv4o=",
- "dev": true,
- "requires": {
- "babel-runtime": "^6.22.0"
- }
- },
- "babel-plugin-syntax-async-functions": {
- "version": "6.13.0",
- "resolved": "https://registry.npmjs.org/babel-plugin-syntax-async-functions/-/babel-plugin-syntax-async-functions-6.13.0.tgz",
- "integrity": "sha1-ytnK0RkbWtY0vzCuCHI5HgZHvpU=",
- "dev": true
- },
- "babel-plugin-syntax-exponentiation-operator": {
- "version": "6.13.0",
- "resolved": "https://registry.npmjs.org/babel-plugin-syntax-exponentiation-operator/-/babel-plugin-syntax-exponentiation-operator-6.13.0.tgz",
- "integrity": "sha1-nufoM3KQ2pUoggGmpX9BcDF4MN4=",
- "dev": true
- },
- "babel-plugin-syntax-trailing-function-commas": {
- "version": "6.22.0",
- "resolved": "https://registry.npmjs.org/babel-plugin-syntax-trailing-function-commas/-/babel-plugin-syntax-trailing-function-commas-6.22.0.tgz",
- "integrity": "sha1-ugNgk3+NBuQBgKQ/4NVhb/9TLPM=",
- "dev": true
- },
- "babel-plugin-transform-async-to-generator": {
- "version": "6.24.1",
- "resolved": "https://registry.npmjs.org/babel-plugin-transform-async-to-generator/-/babel-plugin-transform-async-to-generator-6.24.1.tgz",
- "integrity": "sha1-ZTbjeK/2yx1VF6wOQOs+n8jQh2E=",
- "dev": true,
- "requires": {
- "babel-helper-remap-async-to-generator": "^6.24.1",
- "babel-plugin-syntax-async-functions": "^6.8.0",
- "babel-runtime": "^6.22.0"
- }
- },
- "babel-plugin-transform-es2015-arrow-functions": {
- "version": "6.22.0",
- "resolved": "https://registry.npmjs.org/babel-plugin-transform-es2015-arrow-functions/-/babel-plugin-transform-es2015-arrow-functions-6.22.0.tgz",
- "integrity": "sha1-RSaSy3EdX3ncf4XkQM5BufJE0iE=",
- "dev": true,
- "requires": {
- "babel-runtime": "^6.22.0"
- }
- },
- "babel-plugin-transform-es2015-block-scoped-functions": {
- "version": "6.22.0",
- "resolved": "https://registry.npmjs.org/babel-plugin-transform-es2015-block-scoped-functions/-/babel-plugin-transform-es2015-block-scoped-functions-6.22.0.tgz",
- "integrity": "sha1-u8UbSflk1wy42OC5ToICRs46YUE=",
- "dev": true,
- "requires": {
- "babel-runtime": "^6.22.0"
- }
- },
- "babel-plugin-transform-es2015-block-scoping": {
- "version": "6.26.0",
- "resolved": "https://registry.npmjs.org/babel-plugin-transform-es2015-block-scoping/-/babel-plugin-transform-es2015-block-scoping-6.26.0.tgz",
- "integrity": "sha1-1w9SmcEwjQXBL0Y4E7CgnnOxiV8=",
- "dev": true,
- "requires": {
- "babel-runtime": "^6.26.0",
- "babel-template": "^6.26.0",
- "babel-traverse": "^6.26.0",
- "babel-types": "^6.26.0",
- "lodash": "^4.17.4"
- }
- },
- "babel-plugin-transform-es2015-classes": {
- "version": "6.24.1",
- "resolved": "https://registry.npmjs.org/babel-plugin-transform-es2015-classes/-/babel-plugin-transform-es2015-classes-6.24.1.tgz",
- "integrity": "sha1-WkxYpQyclGHlZLSyo7+ryXolhNs=",
- "dev": true,
- "requires": {
- "babel-helper-define-map": "^6.24.1",
- "babel-helper-function-name": "^6.24.1",
- "babel-helper-optimise-call-expression": "^6.24.1",
- "babel-helper-replace-supers": "^6.24.1",
- "babel-messages": "^6.23.0",
- "babel-runtime": "^6.22.0",
- "babel-template": "^6.24.1",
- "babel-traverse": "^6.24.1",
- "babel-types": "^6.24.1"
- }
- },
- "babel-plugin-transform-es2015-computed-properties": {
- "version": "6.24.1",
- "resolved": "https://registry.npmjs.org/babel-plugin-transform-es2015-computed-properties/-/babel-plugin-transform-es2015-computed-properties-6.24.1.tgz",
- "integrity": "sha1-b+Ko0WiV1WNPTNmZttNICjCBWbM=",
- "dev": true,
- "requires": {
- "babel-runtime": "^6.22.0",
- "babel-template": "^6.24.1"
- }
- },
- "babel-plugin-transform-es2015-destructuring": {
- "version": "6.23.0",
- "resolved": "https://registry.npmjs.org/babel-plugin-transform-es2015-destructuring/-/babel-plugin-transform-es2015-destructuring-6.23.0.tgz",
- "integrity": "sha1-mXux8auWf2gtKwh2/jWNYOdlxW0=",
- "dev": true,
- "requires": {
- "babel-runtime": "^6.22.0"
- }
- },
- "babel-plugin-transform-es2015-duplicate-keys": {
- "version": "6.24.1",
- "resolved": "https://registry.npmjs.org/babel-plugin-transform-es2015-duplicate-keys/-/babel-plugin-transform-es2015-duplicate-keys-6.24.1.tgz",
- "integrity": "sha1-c+s9MQypaePvnskcU3QabxV2Qj4=",
- "dev": true,
- "requires": {
- "babel-runtime": "^6.22.0",
- "babel-types": "^6.24.1"
- }
- },
- "babel-plugin-transform-es2015-for-of": {
- "version": "6.23.0",
- "resolved": "https://registry.npmjs.org/babel-plugin-transform-es2015-for-of/-/babel-plugin-transform-es2015-for-of-6.23.0.tgz",
- "integrity": "sha1-9HyVsrYT3x0+zC/bdXNiPHUkhpE=",
- "dev": true,
- "requires": {
- "babel-runtime": "^6.22.0"
- }
- },
- "babel-plugin-transform-es2015-function-name": {
- "version": "6.24.1",
- "resolved": "https://registry.npmjs.org/babel-plugin-transform-es2015-function-name/-/babel-plugin-transform-es2015-function-name-6.24.1.tgz",
- "integrity": "sha1-g0yJhTvDaxrw86TF26qU/Y6sqos=",
- "dev": true,
- "requires": {
- "babel-helper-function-name": "^6.24.1",
- "babel-runtime": "^6.22.0",
- "babel-types": "^6.24.1"
- }
- },
- "babel-plugin-transform-es2015-literals": {
- "version": "6.22.0",
- "resolved": "https://registry.npmjs.org/babel-plugin-transform-es2015-literals/-/babel-plugin-transform-es2015-literals-6.22.0.tgz",
- "integrity": "sha1-T1SgLWzWbPkVKAAZox0xklN3yi4=",
- "dev": true,
- "requires": {
- "babel-runtime": "^6.22.0"
- }
- },
- "babel-plugin-transform-es2015-modules-amd": {
- "version": "6.24.1",
- "resolved": "https://registry.npmjs.org/babel-plugin-transform-es2015-modules-amd/-/babel-plugin-transform-es2015-modules-amd-6.24.1.tgz",
- "integrity": "sha1-Oz5UAXI5hC1tGcMBHEvS8AoA0VQ=",
- "dev": true,
- "requires": {
- "babel-plugin-transform-es2015-modules-commonjs": "^6.24.1",
- "babel-runtime": "^6.22.0",
- "babel-template": "^6.24.1"
- }
- },
- "babel-plugin-transform-es2015-modules-commonjs": {
- "version": "6.26.2",
- "resolved": "https://registry.npmjs.org/babel-plugin-transform-es2015-modules-commonjs/-/babel-plugin-transform-es2015-modules-commonjs-6.26.2.tgz",
- "integrity": "sha512-CV9ROOHEdrjcwhIaJNBGMBCodN+1cfkwtM1SbUHmvyy35KGT7fohbpOxkE2uLz1o6odKK2Ck/tz47z+VqQfi9Q==",
- "dev": true,
- "requires": {
- "babel-plugin-transform-strict-mode": "^6.24.1",
- "babel-runtime": "^6.26.0",
- "babel-template": "^6.26.0",
- "babel-types": "^6.26.0"
- }
- },
- "babel-plugin-transform-es2015-modules-systemjs": {
- "version": "6.24.1",
- "resolved": "https://registry.npmjs.org/babel-plugin-transform-es2015-modules-systemjs/-/babel-plugin-transform-es2015-modules-systemjs-6.24.1.tgz",
- "integrity": "sha1-/4mhQrkRmpBhlfXxBuzzBdlAfSM=",
- "dev": true,
- "requires": {
- "babel-helper-hoist-variables": "^6.24.1",
- "babel-runtime": "^6.22.0",
- "babel-template": "^6.24.1"
- }
- },
- "babel-plugin-transform-es2015-modules-umd": {
- "version": "6.24.1",
- "resolved": "https://registry.npmjs.org/babel-plugin-transform-es2015-modules-umd/-/babel-plugin-transform-es2015-modules-umd-6.24.1.tgz",
- "integrity": "sha1-rJl+YoXNGO1hdq22B9YCNErThGg=",
- "dev": true,
- "requires": {
- "babel-plugin-transform-es2015-modules-amd": "^6.24.1",
- "babel-runtime": "^6.22.0",
- "babel-template": "^6.24.1"
- }
- },
- "babel-plugin-transform-es2015-object-super": {
- "version": "6.24.1",
- "resolved": "https://registry.npmjs.org/babel-plugin-transform-es2015-object-super/-/babel-plugin-transform-es2015-object-super-6.24.1.tgz",
- "integrity": "sha1-JM72muIcuDp/hgPa0CH1cusnj40=",
- "dev": true,
- "requires": {
- "babel-helper-replace-supers": "^6.24.1",
- "babel-runtime": "^6.22.0"
- }
- },
- "babel-plugin-transform-es2015-parameters": {
- "version": "6.24.1",
- "resolved": "https://registry.npmjs.org/babel-plugin-transform-es2015-parameters/-/babel-plugin-transform-es2015-parameters-6.24.1.tgz",
- "integrity": "sha1-V6w1GrScrxSpfNE7CfZv3wpiXys=",
- "dev": true,
- "requires": {
- "babel-helper-call-delegate": "^6.24.1",
- "babel-helper-get-function-arity": "^6.24.1",
- "babel-runtime": "^6.22.0",
- "babel-template": "^6.24.1",
- "babel-traverse": "^6.24.1",
- "babel-types": "^6.24.1"
- }
- },
- "babel-plugin-transform-es2015-shorthand-properties": {
- "version": "6.24.1",
- "resolved": "https://registry.npmjs.org/babel-plugin-transform-es2015-shorthand-properties/-/babel-plugin-transform-es2015-shorthand-properties-6.24.1.tgz",
- "integrity": "sha1-JPh11nIch2YbvZmkYi5R8U3jiqA=",
- "dev": true,
- "requires": {
- "babel-runtime": "^6.22.0",
- "babel-types": "^6.24.1"
- }
- },
- "babel-plugin-transform-es2015-spread": {
- "version": "6.22.0",
- "resolved": "https://registry.npmjs.org/babel-plugin-transform-es2015-spread/-/babel-plugin-transform-es2015-spread-6.22.0.tgz",
- "integrity": "sha1-1taKmfia7cRTbIGlQujdnxdG+NE=",
- "dev": true,
- "requires": {
- "babel-runtime": "^6.22.0"
- }
- },
- "babel-plugin-transform-es2015-sticky-regex": {
- "version": "6.24.1",
- "resolved": "https://registry.npmjs.org/babel-plugin-transform-es2015-sticky-regex/-/babel-plugin-transform-es2015-sticky-regex-6.24.1.tgz",
- "integrity": "sha1-AMHNsaynERLN8M9hJsLta0V8zbw=",
- "dev": true,
- "requires": {
- "babel-helper-regex": "^6.24.1",
- "babel-runtime": "^6.22.0",
- "babel-types": "^6.24.1"
- }
- },
- "babel-plugin-transform-es2015-template-literals": {
- "version": "6.22.0",
- "resolved": "https://registry.npmjs.org/babel-plugin-transform-es2015-template-literals/-/babel-plugin-transform-es2015-template-literals-6.22.0.tgz",
- "integrity": "sha1-qEs0UPfp+PH2g51taH2oS7EjbY0=",
- "dev": true,
- "requires": {
- "babel-runtime": "^6.22.0"
- }
- },
- "babel-plugin-transform-es2015-typeof-symbol": {
- "version": "6.23.0",
- "resolved": "https://registry.npmjs.org/babel-plugin-transform-es2015-typeof-symbol/-/babel-plugin-transform-es2015-typeof-symbol-6.23.0.tgz",
- "integrity": "sha1-3sCfHN3/lLUqxz1QXITfWdzOs3I=",
- "dev": true,
- "requires": {
- "babel-runtime": "^6.22.0"
- }
- },
- "babel-plugin-transform-es2015-unicode-regex": {
- "version": "6.24.1",
- "resolved": "https://registry.npmjs.org/babel-plugin-transform-es2015-unicode-regex/-/babel-plugin-transform-es2015-unicode-regex-6.24.1.tgz",
- "integrity": "sha1-04sS9C6nMj9yk4fxinxa4frrNek=",
- "dev": true,
- "requires": {
- "babel-helper-regex": "^6.24.1",
- "babel-runtime": "^6.22.0",
- "regexpu-core": "^2.0.0"
- }
- },
- "babel-plugin-transform-exponentiation-operator": {
- "version": "6.24.1",
- "resolved": "https://registry.npmjs.org/babel-plugin-transform-exponentiation-operator/-/babel-plugin-transform-exponentiation-operator-6.24.1.tgz",
- "integrity": "sha1-KrDJx/MJj6SJB3cruBP+QejeOg4=",
- "dev": true,
- "requires": {
- "babel-helper-builder-binary-assignment-operator-visitor": "^6.24.1",
- "babel-plugin-syntax-exponentiation-operator": "^6.8.0",
- "babel-runtime": "^6.22.0"
- }
- },
- "babel-plugin-transform-regenerator": {
- "version": "6.26.0",
- "resolved": "https://registry.npmjs.org/babel-plugin-transform-regenerator/-/babel-plugin-transform-regenerator-6.26.0.tgz",
- "integrity": "sha1-4HA2lvveJ/Cj78rPi03KL3s6jy8=",
- "dev": true,
- "requires": {
- "regenerator-transform": "^0.10.0"
- }
- },
- "babel-plugin-transform-strict-mode": {
- "version": "6.24.1",
- "resolved": "https://registry.npmjs.org/babel-plugin-transform-strict-mode/-/babel-plugin-transform-strict-mode-6.24.1.tgz",
- "integrity": "sha1-1fr3qleKZbvlkc9e2uBKDGcCB1g=",
- "dev": true,
- "requires": {
- "babel-runtime": "^6.22.0",
- "babel-types": "^6.24.1"
- }
- },
- "babel-polyfill": {
- "version": "6.26.0",
- "resolved": "https://registry.npmjs.org/babel-polyfill/-/babel-polyfill-6.26.0.tgz",
- "integrity": "sha1-N5k3q8Z9eJWXCtxiHyhM2WbPIVM=",
- "dev": true,
- "requires": {
- "babel-runtime": "^6.26.0",
- "core-js": "^2.5.0",
- "regenerator-runtime": "^0.10.5"
- },
- "dependencies": {
- "regenerator-runtime": {
- "version": "0.10.5",
- "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.10.5.tgz",
- "integrity": "sha1-M2w+/BIgrc7dosn6tntaeVWjNlg=",
- "dev": true
- }
- }
- },
- "babel-preset-env": {
- "version": "1.7.0",
- "resolved": "https://registry.npmjs.org/babel-preset-env/-/babel-preset-env-1.7.0.tgz",
- "integrity": "sha512-9OR2afuKDneX2/q2EurSftUYM0xGu4O2D9adAhVfADDhrYDaxXV0rBbevVYoY9n6nyX1PmQW/0jtpJvUNr9CHg==",
- "dev": true,
- "requires": {
- "babel-plugin-check-es2015-constants": "^6.22.0",
- "babel-plugin-syntax-trailing-function-commas": "^6.22.0",
- "babel-plugin-transform-async-to-generator": "^6.22.0",
- "babel-plugin-transform-es2015-arrow-functions": "^6.22.0",
- "babel-plugin-transform-es2015-block-scoped-functions": "^6.22.0",
- "babel-plugin-transform-es2015-block-scoping": "^6.23.0",
- "babel-plugin-transform-es2015-classes": "^6.23.0",
- "babel-plugin-transform-es2015-computed-properties": "^6.22.0",
- "babel-plugin-transform-es2015-destructuring": "^6.23.0",
- "babel-plugin-transform-es2015-duplicate-keys": "^6.22.0",
- "babel-plugin-transform-es2015-for-of": "^6.23.0",
- "babel-plugin-transform-es2015-function-name": "^6.22.0",
- "babel-plugin-transform-es2015-literals": "^6.22.0",
- "babel-plugin-transform-es2015-modules-amd": "^6.22.0",
- "babel-plugin-transform-es2015-modules-commonjs": "^6.23.0",
- "babel-plugin-transform-es2015-modules-systemjs": "^6.23.0",
- "babel-plugin-transform-es2015-modules-umd": "^6.23.0",
- "babel-plugin-transform-es2015-object-super": "^6.22.0",
- "babel-plugin-transform-es2015-parameters": "^6.23.0",
- "babel-plugin-transform-es2015-shorthand-properties": "^6.22.0",
- "babel-plugin-transform-es2015-spread": "^6.22.0",
- "babel-plugin-transform-es2015-sticky-regex": "^6.22.0",
- "babel-plugin-transform-es2015-template-literals": "^6.22.0",
- "babel-plugin-transform-es2015-typeof-symbol": "^6.23.0",
- "babel-plugin-transform-es2015-unicode-regex": "^6.22.0",
- "babel-plugin-transform-exponentiation-operator": "^6.22.0",
- "babel-plugin-transform-regenerator": "^6.22.0",
- "browserslist": "^3.2.6",
- "invariant": "^2.2.2",
- "semver": "^5.3.0"
- }
- },
- "babel-register": {
- "version": "6.26.0",
- "resolved": "https://registry.npmjs.org/babel-register/-/babel-register-6.26.0.tgz",
- "integrity": "sha1-btAhFz4vy0htestFxgCahW9kcHE=",
- "dev": true,
- "requires": {
- "babel-core": "^6.26.0",
- "babel-runtime": "^6.26.0",
- "core-js": "^2.5.0",
- "home-or-tmp": "^2.0.0",
- "lodash": "^4.17.4",
- "mkdirp": "^0.5.1",
- "source-map-support": "^0.4.15"
- }
- },
- "babel-runtime": {
- "version": "6.26.0",
- "resolved": "https://registry.npmjs.org/babel-runtime/-/babel-runtime-6.26.0.tgz",
- "integrity": "sha1-llxwWGaOgrVde/4E/yM3vItWR/4=",
- "dev": true,
- "requires": {
- "core-js": "^2.4.0",
- "regenerator-runtime": "^0.11.0"
- }
- },
- "babel-template": {
- "version": "6.26.0",
- "resolved": "https://registry.npmjs.org/babel-template/-/babel-template-6.26.0.tgz",
- "integrity": "sha1-3gPi0WOWsGn0bdn/+FIfsaDjXgI=",
- "dev": true,
- "requires": {
- "babel-runtime": "^6.26.0",
- "babel-traverse": "^6.26.0",
- "babel-types": "^6.26.0",
- "babylon": "^6.18.0",
- "lodash": "^4.17.4"
- }
- },
- "babel-traverse": {
- "version": "6.26.0",
- "resolved": "https://registry.npmjs.org/babel-traverse/-/babel-traverse-6.26.0.tgz",
- "integrity": "sha1-RqnL1+3MYsjlwGTi0tjQ9ANXZu4=",
- "dev": true,
- "requires": {
- "babel-code-frame": "^6.26.0",
- "babel-messages": "^6.23.0",
- "babel-runtime": "^6.26.0",
- "babel-types": "^6.26.0",
- "babylon": "^6.18.0",
- "debug": "^2.6.8",
- "globals": "^9.18.0",
- "invariant": "^2.2.2",
- "lodash": "^4.17.4"
- },
- "dependencies": {
- "debug": {
- "version": "2.6.9",
- "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
- "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==",
- "dev": true,
- "requires": {
- "ms": "2.0.0"
- }
- }
- }
- },
- "babel-types": {
- "version": "6.26.0",
- "resolved": "https://registry.npmjs.org/babel-types/-/babel-types-6.26.0.tgz",
- "integrity": "sha1-o7Bz+Uq0nrb6Vc1lInozQ4BjJJc=",
- "dev": true,
- "requires": {
- "babel-runtime": "^6.26.0",
- "esutils": "^2.0.2",
- "lodash": "^4.17.4",
- "to-fast-properties": "^1.0.3"
- }
- },
- "babelify": {
- "version": "8.0.0",
- "resolved": "https://registry.npmjs.org/babelify/-/babelify-8.0.0.tgz",
- "integrity": "sha512-xVr63fKEvMWUrrIbqlHYsMcc5Zdw4FSVesAHgkgajyCE1W8gbm9rbMakqavhxKvikGYMhEcqxTwB/gQmQ6lBtw==",
- "dev": true
- },
- "babylon": {
- "version": "6.18.0",
- "resolved": "https://registry.npmjs.org/babylon/-/babylon-6.18.0.tgz",
- "integrity": "sha512-q/UEjfGJ2Cm3oKV71DJz9d25TPnq5rhBVL2Q4fA5wcC3jcrdn7+SssEybFIxwAvvP+YCsCYNKughoF33GxgycQ==",
- "dev": true
- },
- "balanced-match": {
- "version": "1.0.0",
- "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.0.tgz",
- "integrity": "sha1-ibTRmasr7kneFk6gK4nORi1xt2c=",
- "dev": true
- },
- "base64-js": {
- "version": "1.3.0",
- "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.3.0.tgz",
- "integrity": "sha512-ccav/yGvoa80BQDljCxsmmQ3Xvx60/UpBIij5QN21W3wBi/hhIC9OoO+KLpu9IJTS9j4DRVJ3aDDF9cMSoa2lw==",
- "dev": true
- },
- "binary-extensions": {
- "version": "2.2.0",
- "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.2.0.tgz",
- "integrity": "sha512-jDctJ/IVQbZoJykoeHbhXpOlNBqGNcwXJKJog42E5HDPUwQTSdjCHdihjj0DlnheQ7blbT6dHOafNAiS8ooQKA==",
- "dev": true
- },
- "bmp-js": {
- "version": "0.1.0",
- "resolved": "https://registry.npmjs.org/bmp-js/-/bmp-js-0.1.0.tgz",
- "integrity": "sha1-4Fpj95amwf8l9Hcex62twUjAcjM=",
- "dev": true
- },
- "bn.js": {
- "version": "4.11.8",
- "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.11.8.tgz",
- "integrity": "sha512-ItfYfPLkWHUjckQCk8xC+LwxgK8NYcXywGigJgSwOP8Y2iyWT4f2vsZnoOXTTbo+o5yXmIUJ4gn5538SO5S3gA==",
- "dev": true
- },
- "boolbase": {
- "version": "1.0.0",
- "resolved": "https://registry.npmjs.org/boolbase/-/boolbase-1.0.0.tgz",
- "integrity": "sha1-aN/1++YMUes3cl6p4+0xDcwed24=",
- "dev": true
- },
- "brace-expansion": {
- "version": "1.1.11",
- "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz",
- "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==",
- "dev": true,
- "requires": {
- "balanced-match": "^1.0.0",
- "concat-map": "0.0.1"
- }
- },
- "braces": {
- "version": "3.0.2",
- "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.2.tgz",
- "integrity": "sha512-b8um+L1RzM3WDSzvhm6gIz1yfTbBt6YTlcEKAvsmqCZZFw46z626lVj9j1yEPW33H5H+lBQpZMP1k8l+78Ha0A==",
- "dev": true,
- "requires": {
- "fill-range": "^7.0.1"
- }
- },
- "brorand": {
- "version": "1.1.0",
- "resolved": "https://registry.npmjs.org/brorand/-/brorand-1.1.0.tgz",
- "integrity": "sha1-EsJe/kCkXjwyPrhnWgoM5XsiNx8=",
- "dev": true
- },
- "browser-pack": {
- "version": "6.1.0",
- "resolved": "https://registry.npmjs.org/browser-pack/-/browser-pack-6.1.0.tgz",
- "integrity": "sha512-erYug8XoqzU3IfcU8fUgyHqyOXqIE4tUTTQ+7mqUjQlvnXkOO6OlT9c/ZoJVHYoAaqGxr09CN53G7XIsO4KtWA==",
- "dev": true,
- "requires": {
- "combine-source-map": "~0.8.0",
- "defined": "^1.0.0",
- "JSONStream": "^1.0.3",
- "safe-buffer": "^5.1.1",
- "through2": "^2.0.0",
- "umd": "^3.0.0"
- }
- },
- "browser-resolve": {
- "version": "1.11.3",
- "resolved": "https://registry.npmjs.org/browser-resolve/-/browser-resolve-1.11.3.tgz",
- "integrity": "sha512-exDi1BYWB/6raKHmDTCicQfTkqwN5fioMFV4j8BsfMU4R2DK/QfZfK7kOVkmWCNANf0snkBzqGqAJBao9gZMdQ==",
- "dev": true,
- "requires": {
- "resolve": "1.1.7"
- },
- "dependencies": {
- "resolve": {
- "version": "1.1.7",
- "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.1.7.tgz",
- "integrity": "sha1-IDEU2CrSxe2ejgQRs5ModeiJ6Xs=",
- "dev": true
- }
- }
- },
- "browserify": {
- "version": "16.2.2",
- "resolved": "https://registry.npmjs.org/browserify/-/browserify-16.2.2.tgz",
- "integrity": "sha512-fMES05wq1Oukts6ksGUU2TMVHHp06LyQt0SIwbXIHm7waSrQmNBZePsU0iM/4f94zbvb/wHma+D1YrdzWYnF/A==",
- "dev": true,
- "requires": {
- "assert": "^1.4.0",
- "browser-pack": "^6.0.1",
- "browser-resolve": "^1.11.0",
- "browserify-zlib": "~0.2.0",
- "buffer": "^5.0.2",
- "cached-path-relative": "^1.0.0",
- "concat-stream": "^1.6.0",
- "console-browserify": "^1.1.0",
- "constants-browserify": "~1.0.0",
- "crypto-browserify": "^3.0.0",
- "defined": "^1.0.0",
- "deps-sort": "^2.0.0",
- "domain-browser": "^1.2.0",
- "duplexer2": "~0.1.2",
- "events": "^2.0.0",
- "glob": "^7.1.0",
- "has": "^1.0.0",
- "htmlescape": "^1.1.0",
- "https-browserify": "^1.0.0",
- "inherits": "~2.0.1",
- "insert-module-globals": "^7.0.0",
- "JSONStream": "^1.0.3",
- "labeled-stream-splicer": "^2.0.0",
- "mkdirp": "^0.5.0",
- "module-deps": "^6.0.0",
- "os-browserify": "~0.3.0",
- "parents": "^1.0.1",
- "path-browserify": "~0.0.0",
- "process": "~0.11.0",
- "punycode": "^1.3.2",
- "querystring-es3": "~0.2.0",
- "read-only-stream": "^2.0.0",
- "readable-stream": "^2.0.2",
- "resolve": "^1.1.4",
- "shasum": "^1.0.0",
- "shell-quote": "^1.6.1",
- "stream-browserify": "^2.0.0",
- "stream-http": "^2.0.0",
- "string_decoder": "^1.1.1",
- "subarg": "^1.0.0",
- "syntax-error": "^1.1.1",
- "through2": "^2.0.0",
- "timers-browserify": "^1.0.1",
- "tty-browserify": "0.0.1",
- "url": "~0.11.0",
- "util": "~0.10.1",
- "vm-browserify": "^1.0.0",
- "xtend": "^4.0.0"
- }
- },
- "browserify-aes": {
- "version": "1.2.0",
- "resolved": "https://registry.npmjs.org/browserify-aes/-/browserify-aes-1.2.0.tgz",
- "integrity": "sha512-+7CHXqGuspUn/Sl5aO7Ea0xWGAtETPXNSAjHo48JfLdPWcMng33Xe4znFvQweqc/uzk5zSOI3H52CYnjCfb5hA==",
- "dev": true,
- "requires": {
- "buffer-xor": "^1.0.3",
- "cipher-base": "^1.0.0",
- "create-hash": "^1.1.0",
- "evp_bytestokey": "^1.0.3",
- "inherits": "^2.0.1",
- "safe-buffer": "^5.0.1"
- }
- },
- "browserify-cipher": {
- "version": "1.0.1",
- "resolved": "https://registry.npmjs.org/browserify-cipher/-/browserify-cipher-1.0.1.tgz",
- "integrity": "sha512-sPhkz0ARKbf4rRQt2hTpAHqn47X3llLkUGn+xEJzLjwY8LRs2p0v7ljvI5EyoRO/mexrNunNECisZs+gw2zz1w==",
- "dev": true,
- "requires": {
- "browserify-aes": "^1.0.4",
- "browserify-des": "^1.0.0",
- "evp_bytestokey": "^1.0.0"
- }
- },
- "browserify-des": {
- "version": "1.0.1",
- "resolved": "https://registry.npmjs.org/browserify-des/-/browserify-des-1.0.1.tgz",
- "integrity": "sha512-zy0Cobe3hhgpiOM32Tj7KQ3Vl91m0njwsjzZQK1L+JDf11dzP9qIvjreVinsvXrgfjhStXwUWAEpB9D7Gwmayw==",
- "dev": true,
- "requires": {
- "cipher-base": "^1.0.1",
- "des.js": "^1.0.0",
- "inherits": "^2.0.1"
- }
- },
- "browserify-rsa": {
- "version": "4.0.1",
- "resolved": "https://registry.npmjs.org/browserify-rsa/-/browserify-rsa-4.0.1.tgz",
- "integrity": "sha1-IeCr+vbyApzy+vsTNWenAdQTVSQ=",
- "dev": true,
- "requires": {
- "bn.js": "^4.1.0",
- "randombytes": "^2.0.1"
- }
- },
- "browserify-sign": {
- "version": "4.0.4",
- "resolved": "https://registry.npmjs.org/browserify-sign/-/browserify-sign-4.0.4.tgz",
- "integrity": "sha1-qk62jl17ZYuqa/alfmMMvXqT0pg=",
- "dev": true,
- "requires": {
- "bn.js": "^4.1.1",
- "browserify-rsa": "^4.0.0",
- "create-hash": "^1.1.0",
- "create-hmac": "^1.1.2",
- "elliptic": "^6.0.0",
- "inherits": "^2.0.1",
- "parse-asn1": "^5.0.0"
- }
- },
- "browserify-zlib": {
- "version": "0.2.0",
- "resolved": "https://registry.npmjs.org/browserify-zlib/-/browserify-zlib-0.2.0.tgz",
- "integrity": "sha512-Z942RysHXmJrhqk88FmKBVq/v5tqmSkDz7p54G/MGyjMnCFFnC79XWNbg+Vta8W6Wb2qtSZTSxIGkJrRpCFEiA==",
- "dev": true,
- "requires": {
- "pako": "~1.0.5"
- }
- },
- "browserslist": {
- "version": "3.2.8",
- "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-3.2.8.tgz",
- "integrity": "sha512-WHVocJYavUwVgVViC0ORikPHQquXwVh939TaelZ4WDqpWgTX/FsGhl/+P4qBUAGcRvtOgDgC+xftNWWp2RUTAQ==",
- "dev": true,
- "requires": {
- "caniuse-lite": "^1.0.30000844",
- "electron-to-chromium": "^1.3.47"
- }
- },
- "buffer": {
- "version": "5.2.1",
- "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.2.1.tgz",
- "integrity": "sha512-c+Ko0loDaFfuPWiL02ls9Xd3GO3cPVmUobQ6t3rXNUk304u6hGq+8N/kFi+QEIKhzK3uwolVhLzszmfLmMLnqg==",
- "dev": true,
- "requires": {
- "base64-js": "^1.0.2",
- "ieee754": "^1.1.4"
- }
- },
- "buffer-equal": {
- "version": "0.0.1",
- "resolved": "https://registry.npmjs.org/buffer-equal/-/buffer-equal-0.0.1.tgz",
- "integrity": "sha1-kbx0sR6kBbyRa8aqkI+q+ltKrEs=",
- "dev": true
- },
- "buffer-from": {
- "version": "1.1.0",
- "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.0.tgz",
- "integrity": "sha512-c5mRlguI/Pe2dSZmpER62rSCu0ryKmWddzRYsuXc50U2/g8jMOulc31VZMa4mYx31U5xsmSOpDCgH88Vl9cDGQ==",
- "dev": true
- },
- "buffer-xor": {
- "version": "1.0.3",
- "resolved": "https://registry.npmjs.org/buffer-xor/-/buffer-xor-1.0.3.tgz",
- "integrity": "sha1-JuYe0UIvtw3ULm42cp7VHYVf6Nk=",
- "dev": true
- },
- "builtin-status-codes": {
- "version": "3.0.0",
- "resolved": "https://registry.npmjs.org/builtin-status-codes/-/builtin-status-codes-3.0.0.tgz",
- "integrity": "sha1-hZgoeOIbmOHGZCXgPQF0eI9Wnug=",
- "dev": true
- },
- "cached-path-relative": {
- "version": "1.1.0",
- "resolved": "https://registry.npmjs.org/cached-path-relative/-/cached-path-relative-1.1.0.tgz",
- "integrity": "sha512-WF0LihfemtesFcJgO7xfOoOcnWzY/QHR4qeDqV44jPU3HTI54+LnfXK3SA27AVVGCdZFgjjFFaqUA9Jx7dMJZA==",
- "dev": true
- },
- "call-bind": {
- "version": "1.0.2",
- "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.2.tgz",
- "integrity": "sha512-7O+FbCihrB5WGbFYesctwmTKae6rOiIzmz1icreWJ+0aA7LJfuqhEso2T9ncpcFtzMQtzXf2QGGueWJGTYsqrA==",
- "requires": {
- "function-bind": "^1.1.1",
- "get-intrinsic": "^1.0.2"
- }
- },
- "camel-case": {
- "version": "3.0.0",
- "resolved": "https://registry.npmjs.org/camel-case/-/camel-case-3.0.0.tgz",
- "integrity": "sha1-yjw2iKTpzzpM2nd9xNy8cTJJz3M=",
- "dev": true,
- "requires": {
- "no-case": "^2.2.0",
- "upper-case": "^1.1.1"
- }
- },
- "caniuse-lite": {
- "version": "1.0.30000864",
- "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30000864.tgz",
- "integrity": "sha512-8fuGh8n3MIQ7oBkO/ck7J4LXhV5Sz5aLyFmfpChWpK+rJhqYrOsGDdbBVDdyKIRBWamZpy6iM4OmLCFVudOOhg==",
- "dev": true
- },
- "chalk": {
- "version": "1.1.3",
- "resolved": "https://registry.npmjs.org/chalk/-/chalk-1.1.3.tgz",
- "integrity": "sha1-qBFcVeSnAv5NFQq9OHKCKn4J/Jg=",
- "dev": true,
- "requires": {
- "ansi-styles": "^2.2.1",
- "escape-string-regexp": "^1.0.2",
- "has-ansi": "^2.0.0",
- "strip-ansi": "^3.0.0",
- "supports-color": "^2.0.0"
- }
- },
- "chokidar": {
- "version": "3.5.1",
- "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.5.1.tgz",
- "integrity": "sha512-9+s+Od+W0VJJzawDma/gvBNQqkTiqYTWLuZoyAsivsI4AaWTCzHG06/TMjsf1cYe9Cb97UCEhjz7HvnPk2p/tw==",
- "dev": true,
- "requires": {
- "anymatch": "~3.1.1",
- "braces": "~3.0.2",
- "fsevents": "~2.3.1",
- "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"
- }
- },
- "cipher-base": {
- "version": "1.0.4",
- "resolved": "https://registry.npmjs.org/cipher-base/-/cipher-base-1.0.4.tgz",
- "integrity": "sha512-Kkht5ye6ZGmwv40uUDZztayT2ThLQGfnj/T71N/XzeZeo3nf8foyW7zGTsPYkEya3m5f3cAypH+qe7YOrM1U2Q==",
- "dev": true,
- "requires": {
- "inherits": "^2.0.1",
- "safe-buffer": "^5.0.1"
- }
- },
- "clean-css": {
- "version": "4.1.11",
- "resolved": "https://registry.npmjs.org/clean-css/-/clean-css-4.1.11.tgz",
- "integrity": "sha1-Ls3xRaujj1R0DybO/Q/z4D4SXWo=",
- "dev": true,
- "requires": {
- "source-map": "0.5.x"
- }
- },
- "combine-source-map": {
- "version": "0.8.0",
- "resolved": "https://registry.npmjs.org/combine-source-map/-/combine-source-map-0.8.0.tgz",
- "integrity": "sha1-pY0N8ELBhvz4IqjoAV9UUNLXmos=",
- "dev": true,
- "requires": {
- "convert-source-map": "~1.1.0",
- "inline-source-map": "~0.6.0",
- "lodash.memoize": "~3.0.3",
- "source-map": "~0.5.3"
- },
- "dependencies": {
- "convert-source-map": {
- "version": "1.1.3",
- "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-1.1.3.tgz",
- "integrity": "sha1-SCnId+n+SbMWHzvzZziI4gRpmGA=",
- "dev": true
- }
- }
- },
- "combined-stream": {
- "version": "1.0.6",
- "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.6.tgz",
- "integrity": "sha1-cj599ugBrFYTETp+RFqbactjKBg=",
- "requires": {
- "delayed-stream": "~1.0.0"
- }
- },
- "commander": {
- "version": "2.16.0",
- "resolved": "https://registry.npmjs.org/commander/-/commander-2.16.0.tgz",
- "integrity": "sha512-sVXqklSaotK9at437sFlFpyOcJonxe0yST/AG9DkQKUdIE6IqGIMv4SfAQSKaJbSdVEJYItASCrBiVQHq1HQew==",
- "dev": true
- },
- "component-emitter": {
- "version": "1.2.1",
- "resolved": "https://registry.npmjs.org/component-emitter/-/component-emitter-1.2.1.tgz",
- "integrity": "sha1-E3kY1teCg/ffemt8WmPhQOaUJeY="
- },
- "concat-map": {
- "version": "0.0.1",
- "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz",
- "integrity": "sha1-2Klr13/Wjfd5OnMDajug1UBdR3s=",
- "dev": true
- },
- "concat-stream": {
- "version": "1.6.2",
- "resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-1.6.2.tgz",
- "integrity": "sha512-27HBghJxjiZtIk3Ycvn/4kbJk/1uZuJFfuPEns6LaEvpvG1f0hTea8lilrouyo9mVc2GWdcEZ8OLoGmSADlrCw==",
- "dev": true,
- "requires": {
- "buffer-from": "^1.0.0",
- "inherits": "^2.0.3",
- "readable-stream": "^2.2.2",
- "typedarray": "^0.0.6"
- }
- },
- "console-browserify": {
- "version": "1.1.0",
- "resolved": "https://registry.npmjs.org/console-browserify/-/console-browserify-1.1.0.tgz",
- "integrity": "sha1-8CQcRXMKn8YyOyBtvzjtx0HQuxA=",
- "dev": true,
- "requires": {
- "date-now": "^0.1.4"
- }
- },
- "constants-browserify": {
- "version": "1.0.0",
- "resolved": "https://registry.npmjs.org/constants-browserify/-/constants-browserify-1.0.0.tgz",
- "integrity": "sha1-wguW2MYXdIqvHBYCF2DNJ/y4y3U=",
- "dev": true
- },
- "convert-source-map": {
- "version": "1.5.1",
- "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-1.5.1.tgz",
- "integrity": "sha1-uCeAl7m8IpNl3lxiz1/K7YtVmeU=",
- "dev": true
- },
- "cookiejar": {
- "version": "2.1.4",
- "resolved": "https://registry.npmjs.org/cookiejar/-/cookiejar-2.1.4.tgz",
- "integrity": "sha512-LDx6oHrK+PhzLKJU9j5S7/Y3jM/mUHvD/DeI1WQmJn652iPC5Y4TBzC9l+5OMOXlyTTA+SmVUPm0HQUwpD5Jqw=="
- },
- "core-js": {
- "version": "2.5.7",
- "resolved": "https://registry.npmjs.org/core-js/-/core-js-2.5.7.tgz",
- "integrity": "sha512-RszJCAxg/PP6uzXVXL6BsxSXx/B05oJAQ2vkJRjyjrEcNVycaqOmNb5OTxZPE3xa5gwZduqza6L9JOCenh/Ecw==",
- "dev": true
- },
- "core-util-is": {
- "version": "1.0.2",
- "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.2.tgz",
- "integrity": "sha1-tf1UIgqivFq1eqtxQMlAdUUDwac="
- },
- "create-ecdh": {
- "version": "4.0.3",
- "resolved": "https://registry.npmjs.org/create-ecdh/-/create-ecdh-4.0.3.tgz",
- "integrity": "sha512-GbEHQPMOswGpKXM9kCWVrremUcBmjteUaQ01T9rkKCPDXfUHX0IoP9LpHYo2NPFampa4e+/pFDc3jQdxrxQLaw==",
- "dev": true,
- "requires": {
- "bn.js": "^4.1.0",
- "elliptic": "^6.0.0"
- }
- },
- "create-hash": {
- "version": "1.2.0",
- "resolved": "https://registry.npmjs.org/create-hash/-/create-hash-1.2.0.tgz",
- "integrity": "sha512-z00bCGNHDG8mHAkP7CtT1qVu+bFQUPjYq/4Iv3C3kWjTFV10zIjfSoeqXo9Asws8gwSHDGj/hl2u4OGIjapeCg==",
- "dev": true,
- "requires": {
- "cipher-base": "^1.0.1",
- "inherits": "^2.0.1",
- "md5.js": "^1.3.4",
- "ripemd160": "^2.0.1",
- "sha.js": "^2.4.0"
- }
- },
- "create-hmac": {
- "version": "1.1.7",
- "resolved": "https://registry.npmjs.org/create-hmac/-/create-hmac-1.1.7.tgz",
- "integrity": "sha512-MJG9liiZ+ogc4TzUwuvbER1JRdgvUFSB5+VR/g5h82fGaIRWMWddtKBHi7/sVhfjQZ6SehlyhvQYrcYkaUIpLg==",
- "dev": true,
- "requires": {
- "cipher-base": "^1.0.3",
- "create-hash": "^1.1.0",
- "inherits": "^2.0.1",
- "ripemd160": "^2.0.0",
- "safe-buffer": "^5.0.1",
- "sha.js": "^2.4.8"
- }
- },
- "crypto-browserify": {
- "version": "3.12.0",
- "resolved": "https://registry.npmjs.org/crypto-browserify/-/crypto-browserify-3.12.0.tgz",
- "integrity": "sha512-fz4spIh+znjO2VjL+IdhEpRJ3YN6sMzITSBijk6FK2UvTqruSQW+/cCZTSNsMiZNvUeq0CqurF+dAbyiGOY6Wg==",
- "dev": true,
- "requires": {
- "browserify-cipher": "^1.0.0",
- "browserify-sign": "^4.0.0",
- "create-ecdh": "^4.0.0",
- "create-hash": "^1.1.0",
- "create-hmac": "^1.1.0",
- "diffie-hellman": "^5.0.0",
- "inherits": "^2.0.1",
- "pbkdf2": "^3.0.3",
- "public-encrypt": "^4.0.0",
- "randombytes": "^2.0.0",
- "randomfill": "^1.0.3"
- }
- },
- "css": {
- "version": "2.2.4",
- "resolved": "https://registry.npmjs.org/css/-/css-2.2.4.tgz",
- "integrity": "sha512-oUnjmWpy0niI3x/mPL8dVEI1l7MnG3+HHyRPHf+YFSbK+svOhXpmSOcDURUh2aOCgl2grzrOPt1nHLuCVFULLw==",
- "dev": true,
- "requires": {
- "inherits": "^2.0.3",
- "source-map": "^0.6.1",
- "source-map-resolve": "^0.5.2",
- "urix": "^0.1.0"
- },
- "dependencies": {
- "source-map": {
- "version": "0.6.1",
- "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz",
- "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==",
- "dev": true
- }
- }
- },
- "css-parse": {
- "version": "2.0.0",
- "resolved": "https://registry.npmjs.org/css-parse/-/css-parse-2.0.0.tgz",
- "integrity": "sha1-pGjuZnwW2BzPBcWMONKpfHgNv9Q=",
- "dev": true,
- "requires": {
- "css": "^2.0.0"
- }
- },
- "css-select": {
- "version": "2.1.0",
- "resolved": "https://registry.npmjs.org/css-select/-/css-select-2.1.0.tgz",
- "integrity": "sha512-Dqk7LQKpwLoH3VovzZnkzegqNSuAziQyNZUcrdDM401iY+R5NkGBXGmtO05/yaXQziALuPogeG0b7UAgjnTJTQ==",
- "dev": true,
- "requires": {
- "boolbase": "^1.0.0",
- "css-what": "^3.2.1",
- "domutils": "^1.7.0",
- "nth-check": "^1.0.2"
- }
- },
- "css-tree": {
- "version": "1.0.0-alpha.29",
- "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-1.0.0-alpha.29.tgz",
- "integrity": "sha512-sRNb1XydwkW9IOci6iB2xmy8IGCj6r/fr+JWitvJ2JxQRPzN3T4AGGVWCMlVmVwM1gtgALJRmGIlWv5ppnGGkg==",
- "dev": true,
- "requires": {
- "mdn-data": "~1.1.0",
- "source-map": "^0.5.3"
- }
- },
- "css-what": {
- "version": "3.4.2",
- "resolved": "https://registry.npmjs.org/css-what/-/css-what-3.4.2.tgz",
- "integrity": "sha512-ACUm3L0/jiZTqfzRM3Hi9Q8eZqd6IK37mMWPLz9PJxkLWllYeRf+EHUSHYEtFop2Eqytaq1FizFVh7XfBnXCDQ==",
- "dev": true
- },
- "csso": {
- "version": "3.5.1",
- "resolved": "https://registry.npmjs.org/csso/-/csso-3.5.1.tgz",
- "integrity": "sha512-vrqULLffYU1Q2tLdJvaCYbONStnfkfimRxXNaGjxMldI0C7JPBC4rB1RyjhfdZ4m1frm8pM9uRPKH3d2knZ8gg==",
- "dev": true,
- "requires": {
- "css-tree": "1.0.0-alpha.29"
- }
- },
- "date-now": {
- "version": "0.1.4",
- "resolved": "https://registry.npmjs.org/date-now/-/date-now-0.1.4.tgz",
- "integrity": "sha1-6vQ5/U1ISK105cx9vvIAZyueNFs=",
- "dev": true
- },
- "debug": {
- "version": "3.1.0",
- "resolved": "https://registry.npmjs.org/debug/-/debug-3.1.0.tgz",
- "integrity": "sha512-OX8XqP7/1a9cqkxYw2yXss15f26NKWBpDXQd0/uK/KPqdQhxbPa994hnzjcE2VqQpDslf55723cKPUOGSmMY3g==",
- "requires": {
- "ms": "2.0.0"
- }
- },
- "decode-uri-component": {
- "version": "0.2.2",
- "resolved": "https://registry.npmjs.org/decode-uri-component/-/decode-uri-component-0.2.2.tgz",
- "integrity": "sha512-FqUYQ+8o158GyGTrMFJms9qh3CqTKvAqgqsTnkLI8sKu0028orqBhxNMFkFen0zGyg6epACD32pjVk58ngIErQ==",
- "dev": true
- },
- "define-properties": {
- "version": "1.1.3",
- "resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.1.3.tgz",
- "integrity": "sha512-3MqfYKj2lLzdMSf8ZIZE/V+Zuy+BgD6f164e8K2w7dgnpKArBDerGYpM46IYYcjnkdPNMjPk9A6VFB8+3SKlXQ==",
- "dev": true,
- "requires": {
- "object-keys": "^1.0.12"
- }
- },
- "defined": {
- "version": "1.0.0",
- "resolved": "https://registry.npmjs.org/defined/-/defined-1.0.0.tgz",
- "integrity": "sha1-yY2bzvdWdBiOEQlpFRGZ45sfppM=",
- "dev": true
- },
- "delayed-stream": {
- "version": "1.0.0",
- "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
- "integrity": "sha1-3zrhmayt+31ECqrgsp4icrJOxhk="
- },
- "deps-sort": {
- "version": "2.0.1",
- "resolved": "https://registry.npmjs.org/deps-sort/-/deps-sort-2.0.1.tgz",
- "integrity": "sha512-1orqXQr5po+3KI6kQb9A4jnXT1PBwggGl2d7Sq2xsnOeI9GPcE/tGcF9UiSZtZBM7MukY4cAh7MemS6tZYipfw==",
- "dev": true,
- "requires": {
- "JSONStream": "^1.0.3",
- "shasum-object": "^1.0.0",
- "subarg": "^1.0.0",
- "through2": "^2.0.0"
- }
- },
- "des.js": {
- "version": "1.0.0",
- "resolved": "https://registry.npmjs.org/des.js/-/des.js-1.0.0.tgz",
- "integrity": "sha1-wHTS4qpqipoH29YfmhXCzYPsjsw=",
- "dev": true,
- "requires": {
- "inherits": "^2.0.1",
- "minimalistic-assert": "^1.0.0"
- }
- },
- "detect-indent": {
- "version": "4.0.0",
- "resolved": "https://registry.npmjs.org/detect-indent/-/detect-indent-4.0.0.tgz",
- "integrity": "sha1-920GQ1LN9Docts5hnE7jqUdd4gg=",
- "dev": true,
- "requires": {
- "repeating": "^2.0.0"
- }
- },
- "detective": {
- "version": "5.2.0",
- "resolved": "https://registry.npmjs.org/detective/-/detective-5.2.0.tgz",
- "integrity": "sha512-6SsIx+nUUbuK0EthKjv0zrdnajCCXVYGmbYYiYjFVpzcjwEs/JMDZ8tPRG29J/HhN56t3GJp2cGSWDRjjot8Pg==",
- "dev": true,
- "requires": {
- "acorn-node": "^1.6.1",
- "defined": "^1.0.0",
- "minimist": "^1.1.1"
- }
- },
- "diffie-hellman": {
- "version": "5.0.3",
- "resolved": "https://registry.npmjs.org/diffie-hellman/-/diffie-hellman-5.0.3.tgz",
- "integrity": "sha512-kqag/Nl+f3GwyK25fhUMYj81BUOrZ9IuJsjIcDE5icNM9FJHAVm3VcUDxdLPoQtTuUylWm6ZIknYJwwaPxsUzg==",
- "dev": true,
- "requires": {
- "bn.js": "^4.1.0",
- "miller-rabin": "^4.0.0",
- "randombytes": "^2.0.0"
- }
- },
- "dom-converter": {
- "version": "0.2.0",
- "resolved": "https://registry.npmjs.org/dom-converter/-/dom-converter-0.2.0.tgz",
- "integrity": "sha512-gd3ypIPfOMr9h5jIKq8E3sHOTCjeirnl0WK5ZdS1AW0Odt0b1PaWaHdJ4Qk4klv+YB9aJBS7mESXjFoDQPu6DA==",
- "dev": true,
- "requires": {
- "utila": "~0.4"
- }
- },
- "dom-serializer": {
- "version": "0.2.2",
- "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-0.2.2.tgz",
- "integrity": "sha512-2/xPb3ORsQ42nHYiSunXkDjPLBaEj/xTwUO4B7XCZQTRk7EBtTOPaygh10YAAh2OI1Qrp6NWfpAhzswj0ydt9g==",
- "dev": true,
- "requires": {
- "domelementtype": "^2.0.1",
- "entities": "^2.0.0"
- },
- "dependencies": {
- "domelementtype": {
- "version": "2.2.0",
- "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.2.0.tgz",
- "integrity": "sha512-DtBMo82pv1dFtUmHyr48beiuq792Sxohr+8Hm9zoxklYPfa6n0Z3Byjj2IV7bmr2IyqClnqEQhfgHJJ5QF0R5A==",
- "dev": true
- }
- }
- },
- "dom-walk": {
- "version": "0.1.2",
- "resolved": "https://registry.npmjs.org/dom-walk/-/dom-walk-0.1.2.tgz",
- "integrity": "sha512-6QvTW9mrGeIegrFXdtQi9pk7O/nSK6lSdXW2eqUspN5LWD7UTji2Fqw5V2YLjBpHEoU9Xl/eUWNpDeZvoyOv2w==",
- "dev": true
- },
- "domain-browser": {
- "version": "1.2.0",
- "resolved": "https://registry.npmjs.org/domain-browser/-/domain-browser-1.2.0.tgz",
- "integrity": "sha512-jnjyiM6eRyZl2H+W8Q/zLMA481hzi0eszAaBUzIVnmYVDBbnLxVNnfu1HgEBvCbL+71FrxMl3E6lpKH7Ge3OXA==",
- "dev": true
- },
- "domelementtype": {
- "version": "1.3.1",
- "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-1.3.1.tgz",
- "integrity": "sha512-BSKB+TSpMpFI/HOxCNr1O8aMOTZ8hT3pM3GQ0w/mWRmkhEDSFJkkyzz4XQsBV44BChwGkrDfMyjVD0eA2aFV3w==",
- "dev": true
- },
- "domhandler": {
- "version": "2.4.2",
- "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-2.4.2.tgz",
- "integrity": "sha512-JiK04h0Ht5u/80fdLMCEmV4zkNh2BcoMFBmZ/91WtYZ8qVXSKjiw7fXMgFPnHcSZgOo3XdinHvmnDUeMf5R4wA==",
- "dev": true,
- "requires": {
- "domelementtype": "1"
- }
- },
- "dompurify": {
- "version": "2.0.17",
- "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-2.0.17.tgz",
- "integrity": "sha512-nNwwJfW55r8akD8MSFz6k75bzyT2y6JEa1O3JrZFBf+Y5R9JXXU4OsRl0B9hKoPgHTw2b7ER5yJ5Md97MMUJPg=="
- },
- "domutils": {
- "version": "1.7.0",
- "resolved": "https://registry.npmjs.org/domutils/-/domutils-1.7.0.tgz",
- "integrity": "sha512-Lgd2XcJ/NjEw+7tFvfKxOzCYKZsdct5lczQ2ZaQY8Djz7pfAD3Gbp8ySJWtreII/vDlMVmxwa6pHmdxIYgttDg==",
- "dev": true,
- "requires": {
- "dom-serializer": "0",
- "domelementtype": "1"
- }
- },
- "duplexer2": {
- "version": "0.1.4",
- "resolved": "https://registry.npmjs.org/duplexer2/-/duplexer2-0.1.4.tgz",
- "integrity": "sha1-ixLauHjA1p4+eJEFFmKjL8a93ME=",
- "dev": true,
- "requires": {
- "readable-stream": "^2.0.2"
- }
- },
- "electron-to-chromium": {
- "version": "1.3.51",
- "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.3.51.tgz",
- "integrity": "sha1-akK0nar38ipbN7mR2vlJ8029ubU=",
- "dev": true
- },
- "elliptic": {
- "version": "6.5.4",
- "resolved": "https://registry.npmjs.org/elliptic/-/elliptic-6.5.4.tgz",
- "integrity": "sha512-iLhC6ULemrljPZb+QutR5TQGB+pdW6KGD5RSegS+8sorOZT+rdQFbsQFJgvN3eRqNALqJer4oQ16YvJHlU8hzQ==",
- "dev": true,
- "requires": {
- "bn.js": "^4.11.9",
- "brorand": "^1.1.0",
- "hash.js": "^1.0.0",
- "hmac-drbg": "^1.0.1",
- "inherits": "^2.0.4",
- "minimalistic-assert": "^1.0.1",
- "minimalistic-crypto-utils": "^1.0.1"
- },
- "dependencies": {
- "bn.js": {
- "version": "4.12.0",
- "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.0.tgz",
- "integrity": "sha512-c98Bf3tPniI+scsdk237ku1Dc3ujXQTSgyiPUDEOe7tRkhrqridvh8klBv0HCEso1OLOYcHuCv/cS6DNxKH+ZA==",
- "dev": true
- },
- "inherits": {
- "version": "2.0.4",
- "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
- "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
- "dev": true
- }
- }
- },
- "entities": {
- "version": "2.2.0",
- "resolved": "https://registry.npmjs.org/entities/-/entities-2.2.0.tgz",
- "integrity": "sha512-p92if5Nz619I0w+akJrLZH0MX0Pb5DX39XOwQTtXSdQQOaYH03S1uIQp4mhOZtAXrxq4ViO67YTiLBo2638o9A==",
- "dev": true
- },
- "es-abstract": {
- "version": "1.18.0",
- "resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.18.0.tgz",
- "integrity": "sha512-LJzK7MrQa8TS0ja2w3YNLzUgJCGPdPOV1yVvezjNnS89D+VR08+Szt2mz3YB2Dck/+w5tfIq/RoUAFqJJGM2yw==",
- "dev": true,
- "requires": {
- "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.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.2",
- "string.prototype.trimend": "^1.0.4",
- "string.prototype.trimstart": "^1.0.4",
- "unbox-primitive": "^1.0.0"
- }
- },
- "es-to-primitive": {
- "version": "1.2.1",
- "resolved": "https://registry.npmjs.org/es-to-primitive/-/es-to-primitive-1.2.1.tgz",
- "integrity": "sha512-QCOllgZJtaUo9miYBcLChTUaHNjJF3PYs1VidD7AwiEj1kYxKeQTctLAezAOH5ZKRH0g2IgPn6KwB4IT8iRpvA==",
- "dev": true,
- "requires": {
- "is-callable": "^1.1.4",
- "is-date-object": "^1.0.1",
- "is-symbol": "^1.0.2"
- }
- },
- "escape-string-regexp": {
- "version": "1.0.5",
- "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz",
- "integrity": "sha1-G2HAViGQqN/2rjuyzwIAyhMLhtQ=",
- "dev": true
- },
- "esutils": {
- "version": "2.0.2",
- "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.2.tgz",
- "integrity": "sha1-Cr9PHKpbyx96nYrMbepPqqBLrJs=",
- "dev": true
- },
- "events": {
- "version": "2.1.0",
- "resolved": "https://registry.npmjs.org/events/-/events-2.1.0.tgz",
- "integrity": "sha512-3Zmiobend8P9DjmKAty0Era4jV8oJ0yGYe2nJJAxgymF9+N8F2m0hhZiMoWtcfepExzNKZumFU3ksdQbInGWCg==",
- "dev": true
- },
- "evp_bytestokey": {
- "version": "1.0.3",
- "resolved": "https://registry.npmjs.org/evp_bytestokey/-/evp_bytestokey-1.0.3.tgz",
- "integrity": "sha512-/f2Go4TognH/KvCISP7OUsHn85hT9nUkxxA9BEWxFn+Oj9o8ZNLm/40hdlgSLyuOimsrTKLUMEorQexp/aPQeA==",
- "dev": true,
- "requires": {
- "md5.js": "^1.3.4",
- "safe-buffer": "^5.1.1"
- }
- },
- "exif-parser": {
- "version": "0.1.12",
- "resolved": "https://registry.npmjs.org/exif-parser/-/exif-parser-0.1.12.tgz",
- "integrity": "sha1-WKnS1ywCwfbwKg70qRZicrd2CSI=",
- "dev": true
- },
- "extend": {
- "version": "3.0.2",
- "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz",
- "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g=="
- },
- "fast-safe-stringify": {
- "version": "2.0.7",
- "resolved": "https://registry.npmjs.org/fast-safe-stringify/-/fast-safe-stringify-2.0.7.tgz",
- "integrity": "sha512-Utm6CdzT+6xsDk2m8S6uL8VHxNwI6Jub+e9NYTcAms28T84pTa25GJQV9j0CY0N1rM8hK4x6grpF2BQf+2qwVA==",
- "dev": true
- },
- "file-type": {
- "version": "9.0.0",
- "resolved": "https://registry.npmjs.org/file-type/-/file-type-9.0.0.tgz",
- "integrity": "sha512-Qe/5NJrgIOlwijpq3B7BEpzPFcgzggOTagZmkXQY4LA6bsXKTUstK7Wp12lEJ/mLKTpvIZxmIuRcLYWT6ov9lw==",
- "dev": true
- },
- "fill-range": {
- "version": "7.0.1",
- "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.0.1.tgz",
- "integrity": "sha512-qOo9F+dMUmC2Lcb4BbVvnKJxTPjCm+RRpe4gDuGrzkL7mEVl/djYSu2OdQ2Pa302N4oqkSg9ir6jaLWJ2USVpQ==",
- "dev": true,
- "requires": {
- "to-regex-range": "^5.0.1"
- }
- },
- "font-awesome": {
- "version": "4.7.0",
- "resolved": "https://registry.npmjs.org/font-awesome/-/font-awesome-4.7.0.tgz",
- "integrity": "sha1-j6jPBBGhoxr9B7BtKQK7n8gVoTM="
- },
- "foreach": {
- "version": "2.0.5",
- "resolved": "https://registry.npmjs.org/foreach/-/foreach-2.0.5.tgz",
- "integrity": "sha1-C+4AUBiusmDQo6865ljdATbsG5k=",
- "dev": true
- },
- "form-data": {
- "version": "2.3.2",
- "resolved": "https://registry.npmjs.org/form-data/-/form-data-2.3.2.tgz",
- "integrity": "sha1-SXBJi+YEwgwAXU9cI67NIda0kJk=",
- "requires": {
- "asynckit": "^0.4.0",
- "combined-stream": "1.0.6",
- "mime-types": "^2.1.12"
- }
- },
- "formidable": {
- "version": "1.2.1",
- "resolved": "https://registry.npmjs.org/formidable/-/formidable-1.2.1.tgz",
- "integrity": "sha512-Fs9VRguL0gqGHkXS5GQiMCr1VhZBxz0JnJs4JmMp/2jL18Fmbzvv7vOFRU+U8TBkHEE/CX1qDXzJplVULgsLeg=="
- },
- "fs.realpath": {
- "version": "1.0.0",
- "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz",
- "integrity": "sha1-FQStJSMVjKpA20onh8sBQRmU6k8=",
- "dev": true
- },
- "fsevents": {
- "version": "2.3.2",
- "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
- "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
- "dev": true,
- "optional": true
- },
- "function-bind": {
- "version": "1.1.1",
- "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.1.tgz",
- "integrity": "sha512-yIovAzMX49sF8Yl58fSCWJ5svSLuaibPxXQJFLmBObTuCr0Mf1KiPopGM9NiFjiYBCbfaa2Fh6breQ6ANVTI0A=="
- },
- "get-assigned-identifiers": {
- "version": "1.2.0",
- "resolved": "https://registry.npmjs.org/get-assigned-identifiers/-/get-assigned-identifiers-1.2.0.tgz",
- "integrity": "sha512-mBBwmeGTrxEMO4pMaaf/uUEFHnYtwr8FTe8Y/mer4rcV/bye0qGm6pw1bGZFGStxC5O76c5ZAVBGnqHmOaJpdQ==",
- "dev": true
- },
- "get-intrinsic": {
- "version": "1.1.1",
- "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.1.1.tgz",
- "integrity": "sha512-kWZrnVM42QCiEA2Ig1bG8zjoIMOgxWwYCEeNdwY6Tv/cOSeGpcoX4pXHfKUxNKVoArnrEr2e9srnAxxGIraS9Q==",
- "requires": {
- "function-bind": "^1.1.1",
- "has": "^1.0.3",
- "has-symbols": "^1.0.1"
- }
- },
- "gifwrap": {
- "version": "0.9.2",
- "resolved": "https://registry.npmjs.org/gifwrap/-/gifwrap-0.9.2.tgz",
- "integrity": "sha512-fcIswrPaiCDAyO8xnWvHSZdWChjKXUanKKpAiWWJ/UTkEi/aYKn5+90e7DE820zbEaVR9CE2y4z9bzhQijZ0BA==",
- "dev": true,
- "requires": {
- "image-q": "^1.1.1",
- "omggif": "^1.0.10"
- }
- },
- "glob": {
- "version": "7.1.7",
- "resolved": "https://registry.npmjs.org/glob/-/glob-7.1.7.tgz",
- "integrity": "sha512-OvD9ENzPLbegENnYP5UUfJIirTg4+XwMWGaQfQTY0JenxNvvIKP3U3/tAQSPIu/lHxXYSZmpXlUHeqAIdKzBLQ==",
- "dev": true,
- "requires": {
- "fs.realpath": "^1.0.0",
- "inflight": "^1.0.4",
- "inherits": "2",
- "minimatch": "^3.0.4",
- "once": "^1.3.0",
- "path-is-absolute": "^1.0.0"
- }
- },
- "glob-parent": {
- "version": "5.1.2",
- "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz",
- "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==",
- "dev": true,
- "requires": {
- "is-glob": "^4.0.1"
- }
- },
- "global": {
- "version": "4.3.2",
- "resolved": "https://registry.npmjs.org/global/-/global-4.3.2.tgz",
- "integrity": "sha1-52mJJopsdMOJCLEwWxD8DjlOnQ8=",
- "dev": true,
- "requires": {
- "min-document": "^2.19.0",
- "process": "~0.5.1"
- },
- "dependencies": {
- "process": {
- "version": "0.5.2",
- "resolved": "https://registry.npmjs.org/process/-/process-0.5.2.tgz",
- "integrity": "sha1-FjjYqONML0QKkduVq5rrZ3/Bhc8=",
- "dev": true
- }
- }
- },
- "globals": {
- "version": "9.18.0",
- "resolved": "https://registry.npmjs.org/globals/-/globals-9.18.0.tgz",
- "integrity": "sha512-S0nG3CLEQiY/ILxqtztTWH/3iRRdyBLw6KMDxnKMchrtbj2OFmehVh0WUCfW3DUrIgx/qFrJPICrq4Z4sTR9UQ==",
- "dev": true
- },
- "has": {
- "version": "1.0.3",
- "resolved": "https://registry.npmjs.org/has/-/has-1.0.3.tgz",
- "integrity": "sha512-f2dvO0VU6Oej7RkWJGrehjbzMAjFp5/VKPp5tTpWIV4JHHZK1/BxbFRtf/siA2SWTe09caDmVtYYzWEIbBS4zw==",
- "requires": {
- "function-bind": "^1.1.1"
- }
- },
- "has-ansi": {
- "version": "2.0.0",
- "resolved": "https://registry.npmjs.org/has-ansi/-/has-ansi-2.0.0.tgz",
- "integrity": "sha1-NPUEnOHs3ysGSa8+8k5F7TVBbZE=",
- "dev": true,
- "requires": {
- "ansi-regex": "^2.0.0"
- }
- },
- "has-bigints": {
- "version": "1.0.1",
- "resolved": "https://registry.npmjs.org/has-bigints/-/has-bigints-1.0.1.tgz",
- "integrity": "sha512-LSBS2LjbNBTf6287JEbEzvJgftkF5qFkmCo9hDRpAzKhUOlJ+hx8dd4USs00SgsUNwc4617J9ki5YtEClM2ffA==",
- "dev": true
- },
- "has-symbols": {
- "version": "1.0.2",
- "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.0.2.tgz",
- "integrity": "sha512-chXa79rL/UC2KlX17jo3vRGz0azaWEx5tGqZg5pO3NUyEJVB17dMruQlzCCOfUvElghKcm5194+BCRvi2Rv/Gw=="
- },
- "hash-base": {
- "version": "3.0.4",
- "resolved": "https://registry.npmjs.org/hash-base/-/hash-base-3.0.4.tgz",
- "integrity": "sha1-X8hoaEfs1zSZQDMZprCj8/auSRg=",
- "dev": true,
- "requires": {
- "inherits": "^2.0.1",
- "safe-buffer": "^5.0.1"
- }
- },
- "hash.js": {
- "version": "1.1.4",
- "resolved": "https://registry.npmjs.org/hash.js/-/hash.js-1.1.4.tgz",
- "integrity": "sha512-A6RlQvvZEtFS5fLU43IDu0QUmBy+fDO9VMdTXvufKwIkt/rFfvICAViCax5fbDO4zdNzaC3/27ZhKUok5bAJyw==",
- "dev": true,
- "requires": {
- "inherits": "^2.0.3",
- "minimalistic-assert": "^1.0.0"
- }
- },
- "he": {
- "version": "1.1.1",
- "resolved": "https://registry.npmjs.org/he/-/he-1.1.1.tgz",
- "integrity": "sha1-k0EP0hsAlzUVH4howvJx80J+I/0=",
- "dev": true
- },
- "hmac-drbg": {
- "version": "1.0.1",
- "resolved": "https://registry.npmjs.org/hmac-drbg/-/hmac-drbg-1.0.1.tgz",
- "integrity": "sha1-0nRXAQJabHdabFRXk+1QL8DGSaE=",
- "dev": true,
- "requires": {
- "hash.js": "^1.0.3",
- "minimalistic-assert": "^1.0.0",
- "minimalistic-crypto-utils": "^1.0.1"
- }
- },
- "home-or-tmp": {
- "version": "2.0.0",
- "resolved": "https://registry.npmjs.org/home-or-tmp/-/home-or-tmp-2.0.0.tgz",
- "integrity": "sha1-42w/LSyufXRqhX440Y1fMqeILbg=",
- "dev": true,
- "requires": {
- "os-homedir": "^1.0.0",
- "os-tmpdir": "^1.0.1"
- }
- },
- "html-minifier": {
- "version": "3.5.18",
- "resolved": "https://registry.npmjs.org/html-minifier/-/html-minifier-3.5.18.tgz",
- "integrity": "sha512-sczoq/9zeXiKZMj8tsQzHJE7EyjrpMHvblTLuh9o8h5923a6Ts5uQ/3YdY+xIqJYRjzHQPlrHjfjh0BtwPJG0g==",
- "dev": true,
- "requires": {
- "camel-case": "3.0.x",
- "clean-css": "4.1.x",
- "commander": "2.16.x",
- "he": "1.1.x",
- "param-case": "2.1.x",
- "relateurl": "0.2.x",
- "uglify-js": "3.4.x"
- }
- },
- "htmlescape": {
- "version": "1.1.1",
- "resolved": "https://registry.npmjs.org/htmlescape/-/htmlescape-1.1.1.tgz",
- "integrity": "sha1-OgPtwiFLyjtmQko+eVk0lQnLA1E=",
- "dev": true
- },
- "htmlparser2": {
- "version": "3.10.1",
- "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-3.10.1.tgz",
- "integrity": "sha512-IgieNijUMbkDovyoKObU1DUhm1iwNYE/fuifEoEHfd1oZKZDaONBSkal7Y01shxsM49R4XaMdGez3WnF9UfiCQ==",
- "dev": true,
- "requires": {
- "domelementtype": "^1.3.1",
- "domhandler": "^2.3.0",
- "domutils": "^1.5.1",
- "entities": "^1.1.1",
- "inherits": "^2.0.1",
- "readable-stream": "^3.1.1"
- },
- "dependencies": {
- "entities": {
- "version": "1.1.2",
- "resolved": "https://registry.npmjs.org/entities/-/entities-1.1.2.tgz",
- "integrity": "sha512-f2LZMYl1Fzu7YSBKg+RoROelpOaNrcGmE9AZubeDfrCEia483oW4MI4VyFd5VNHIgQ/7qm1I0wUHK1eJnn2y2w==",
- "dev": true
- },
- "readable-stream": {
- "version": "3.6.0",
- "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.0.tgz",
- "integrity": "sha512-BViHy7LKeTz4oNnkcLJ+lVSL6vpiFeX6/d3oSH8zCW7UxP2onchk+vTGB143xuFjHS3deTgkKoXXymXqymiIdA==",
- "dev": true,
- "requires": {
- "inherits": "^2.0.3",
- "string_decoder": "^1.1.1",
- "util-deprecate": "^1.0.1"
- }
- }
- }
- },
- "https-browserify": {
- "version": "1.0.0",
- "resolved": "https://registry.npmjs.org/https-browserify/-/https-browserify-1.0.0.tgz",
- "integrity": "sha1-7AbBDgo0wPL68Zn3/X/Hj//QPHM=",
- "dev": true
- },
- "ieee754": {
- "version": "1.1.12",
- "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.1.12.tgz",
- "integrity": "sha512-GguP+DRY+pJ3soyIiGPTvdiVXjZ+DbXOxGpXn3eMvNW4x4irjqXm4wHKscC+TfxSJ0yw/S1F24tqdMNsMZTiLA==",
- "dev": true
- },
- "image-q": {
- "version": "1.1.1",
- "resolved": "https://registry.npmjs.org/image-q/-/image-q-1.1.1.tgz",
- "integrity": "sha1-/IQJlmRGC5DKhi2TALa/u7+/gFY=",
- "dev": true
- },
- "inflight": {
- "version": "1.0.6",
- "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz",
- "integrity": "sha1-Sb1jMdfQLQwJvJEKEHW6gWW1bfk=",
- "dev": true,
- "requires": {
- "once": "^1.3.0",
- "wrappy": "1"
- }
- },
- "inherits": {
- "version": "2.0.3",
- "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.3.tgz",
- "integrity": "sha1-Yzwsg+PaQqUC9SRmAiSA9CCCYd4="
- },
- "inline-source-map": {
- "version": "0.6.2",
- "resolved": "https://registry.npmjs.org/inline-source-map/-/inline-source-map-0.6.2.tgz",
- "integrity": "sha1-+Tk0ccGKedFyT4Y/o4tYY3Ct4qU=",
- "dev": true,
- "requires": {
- "source-map": "~0.5.3"
- }
- },
- "insert-module-globals": {
- "version": "7.2.1",
- "resolved": "https://registry.npmjs.org/insert-module-globals/-/insert-module-globals-7.2.1.tgz",
- "integrity": "sha512-ufS5Qq9RZN+Bu899eA9QCAYThY+gGW7oRkmb0vC93Vlyu/CFGcH0OYPEjVkDXA5FEbTt1+VWzdoOD3Ny9N+8tg==",
- "dev": true,
- "requires": {
- "acorn-node": "^1.5.2",
- "combine-source-map": "^0.8.0",
- "concat-stream": "^1.6.1",
- "is-buffer": "^1.1.0",
- "JSONStream": "^1.0.3",
- "path-is-absolute": "^1.0.1",
- "process": "~0.11.0",
- "through2": "^2.0.0",
- "undeclared-identifiers": "^1.1.2",
- "xtend": "^4.0.0"
- }
- },
- "invariant": {
- "version": "2.2.4",
- "resolved": "https://registry.npmjs.org/invariant/-/invariant-2.2.4.tgz",
- "integrity": "sha512-phJfQVBuaJM5raOpJjSfkiD6BpbCE4Ns//LaXl6wGYtUBY83nWS6Rf9tXm2e8VaK60JEjYldbPif/A2B1C2gNA==",
- "dev": true,
- "requires": {
- "loose-envify": "^1.0.0"
- }
- },
- "ios-inner-height": {
- "version": "1.0.3",
- "resolved": "https://registry.npmjs.org/ios-inner-height/-/ios-inner-height-1.0.3.tgz",
- "integrity": "sha512-GayJWoFxYHDx/gkfz4nIxNdsqB3nAJQHKV5pDBvig6he8+NxBSYxN+D7oarbqZfW2p6uera3q9NDr4Jgdafiog=="
- },
- "is-arguments": {
- "version": "1.1.0",
- "resolved": "https://registry.npmjs.org/is-arguments/-/is-arguments-1.1.0.tgz",
- "integrity": "sha512-1Ij4lOMPl/xB5kBDn7I+b2ttPMKa8szhEIrXDuXQD/oe3HJLTLhqhgGspwgyGd6MOywBUqVvYicF72lkgDnIHg==",
- "dev": true,
- "requires": {
- "call-bind": "^1.0.0"
- }
- },
- "is-bigint": {
- "version": "1.0.2",
- "resolved": "https://registry.npmjs.org/is-bigint/-/is-bigint-1.0.2.tgz",
- "integrity": "sha512-0JV5+SOCQkIdzjBK9buARcV804Ddu7A0Qet6sHi3FimE9ne6m4BGQZfRn+NZiXbBk4F4XmHfDZIipLj9pX8dSA==",
- "dev": true
- },
- "is-binary-path": {
- "version": "2.1.0",
- "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz",
- "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==",
- "dev": true,
- "requires": {
- "binary-extensions": "^2.0.0"
- }
- },
- "is-boolean-object": {
- "version": "1.1.1",
- "resolved": "https://registry.npmjs.org/is-boolean-object/-/is-boolean-object-1.1.1.tgz",
- "integrity": "sha512-bXdQWkECBUIAcCkeH1unwJLIpZYaa5VvuygSyS/c2lf719mTKZDU5UdDRlpd01UjADgmW8RfqaP+mRaVPdr/Ng==",
- "dev": true,
- "requires": {
- "call-bind": "^1.0.2"
- }
- },
- "is-buffer": {
- "version": "1.1.6",
- "resolved": "https://registry.npmjs.org/is-buffer/-/is-buffer-1.1.6.tgz",
- "integrity": "sha512-NcdALwpXkTm5Zvvbk7owOUSvVvBKDgKP5/ewfXEznmQFfs4ZRmanOeKBTjRVjka3QFoN6XJ+9F3USqfHqTaU5w==",
- "dev": true
- },
- "is-callable": {
- "version": "1.2.3",
- "resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.2.3.tgz",
- "integrity": "sha512-J1DcMe8UYTBSrKezuIUTUwjXsho29693unXM2YhJUTR2txK/eG47bvNa/wipPFmZFgr/N6f1GA66dv0mEyTIyQ==",
- "dev": true
- },
- "is-core-module": {
- "version": "2.3.0",
- "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.3.0.tgz",
- "integrity": "sha512-xSphU2KG9867tsYdLD4RWQ1VqdFl4HTO9Thf3I/3dLEfr0dbPTWKsuCKrgqMljg4nPE+Gq0VCnzT3gr0CyBmsw==",
- "dev": true,
- "requires": {
- "has": "^1.0.3"
- }
- },
- "is-date-object": {
- "version": "1.0.4",
- "resolved": "https://registry.npmjs.org/is-date-object/-/is-date-object-1.0.4.tgz",
- "integrity": "sha512-/b4ZVsG7Z5XVtIxs/h9W8nvfLgSAyKYdtGWQLbqy6jA1icmgjf8WCoTKgeS4wy5tYaPePouzFMANbnj94c2Z+A==",
- "dev": true
- },
- "is-extglob": {
- "version": "2.1.1",
- "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz",
- "integrity": "sha1-qIwCU1eR8C7TfHahueqXc8gz+MI=",
- "dev": true
- },
- "is-finite": {
- "version": "1.0.2",
- "resolved": "https://registry.npmjs.org/is-finite/-/is-finite-1.0.2.tgz",
- "integrity": "sha1-zGZ3aVYCvlUO8R6LSqYwU0K20Ko=",
- "dev": true,
- "requires": {
- "number-is-nan": "^1.0.0"
- }
- },
- "is-function": {
- "version": "1.0.2",
- "resolved": "https://registry.npmjs.org/is-function/-/is-function-1.0.2.tgz",
- "integrity": "sha512-lw7DUp0aWXYg+CBCN+JKkcE0Q2RayZnSvnZBlwgxHBQhqt5pZNVy4Ri7H9GmmXkdu7LUthszM+Tor1u/2iBcpQ==",
- "dev": true
- },
- "is-generator-function": {
- "version": "1.0.9",
- "resolved": "https://registry.npmjs.org/is-generator-function/-/is-generator-function-1.0.9.tgz",
- "integrity": "sha512-ZJ34p1uvIfptHCN7sFTjGibB9/oBg17sHqzDLfuwhvmN/qLVvIQXRQ8licZQ35WJ8KuEQt/etnnzQFI9C9Ue/A==",
- "dev": true
- },
- "is-glob": {
- "version": "4.0.1",
- "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.1.tgz",
- "integrity": "sha512-5G0tKtBTFImOqDnLB2hG6Bp2qcKEFduo4tZu9MT/H6NQv/ghhy30o55ufafxJ/LdH79LLs2Kfrn85TLKyA7BUg==",
- "dev": true,
- "requires": {
- "is-extglob": "^2.1.1"
- }
- },
- "is-negative-zero": {
- "version": "2.0.1",
- "resolved": "https://registry.npmjs.org/is-negative-zero/-/is-negative-zero-2.0.1.tgz",
- "integrity": "sha512-2z6JzQvZRa9A2Y7xC6dQQm4FSTSTNWjKIYYTt4246eMTJmIo0Q+ZyOsU66X8lxK1AbB92dFeglPLrhwpeRKO6w==",
- "dev": true
- },
- "is-number": {
- "version": "7.0.0",
- "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz",
- "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==",
- "dev": true
- },
- "is-number-object": {
- "version": "1.0.5",
- "resolved": "https://registry.npmjs.org/is-number-object/-/is-number-object-1.0.5.tgz",
- "integrity": "sha512-RU0lI/n95pMoUKu9v1BZP5MBcZuNSVJkMkAG2dJqC4z2GlkGUNeH68SuHuBKBD/XFe+LHZ+f9BKkLET60Niedw==",
- "dev": true
- },
- "is-regex": {
- "version": "1.1.3",
- "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.1.3.tgz",
- "integrity": "sha512-qSVXFz28HM7y+IWX6vLCsexdlvzT1PJNFSBuaQLQ5o0IEw8UDYW6/2+eCMVyIsbM8CNLX2a/QWmSpyxYEHY7CQ==",
- "dev": true,
- "requires": {
- "call-bind": "^1.0.2",
- "has-symbols": "^1.0.2"
- }
- },
- "is-string": {
- "version": "1.0.6",
- "resolved": "https://registry.npmjs.org/is-string/-/is-string-1.0.6.tgz",
- "integrity": "sha512-2gdzbKUuqtQ3lYNrUTQYoClPhm7oQu4UdpSZMp1/DGgkHBT8E2Z1l0yMdb6D4zNAxwDiMv8MdulKROJGNl0Q0w==",
- "dev": true
- },
- "is-symbol": {
- "version": "1.0.4",
- "resolved": "https://registry.npmjs.org/is-symbol/-/is-symbol-1.0.4.tgz",
- "integrity": "sha512-C/CPBqKWnvdcxqIARxyOh4v1UUEOCHpgDa0WYgpKDFMszcrPcffg5uhwSgPCLD2WWxmq6isisz87tzT01tuGhg==",
- "dev": true,
- "requires": {
- "has-symbols": "^1.0.2"
- }
- },
- "is-typed-array": {
- "version": "1.1.5",
- "resolved": "https://registry.npmjs.org/is-typed-array/-/is-typed-array-1.1.5.tgz",
- "integrity": "sha512-S+GRDgJlR3PyEbsX/Fobd9cqpZBuvUS+8asRqYDMLCb2qMzt1oz5m5oxQCxOgUDxiWsOVNi4yaF+/uvdlHlYug==",
- "dev": true,
- "requires": {
- "available-typed-arrays": "^1.0.2",
- "call-bind": "^1.0.2",
- "es-abstract": "^1.18.0-next.2",
- "foreach": "^2.0.5",
- "has-symbols": "^1.0.1"
- }
- },
- "isarray": {
- "version": "1.0.0",
- "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz",
- "integrity": "sha1-u5NdSFgsuhaMBoNJV6VKPgcSTxE="
- },
- "jimp": {
- "version": "0.13.0",
- "resolved": "https://registry.npmjs.org/jimp/-/jimp-0.13.0.tgz",
- "integrity": "sha512-N/iG8L7Qe+AcHhrgcL0m7PTP/14iybmSIuOqCDvuel9gcIKEzxbbGuPCJVMchwXzusc2E7h9UjO9LZDfXb/09w==",
- "dev": true,
- "requires": {
- "@babel/runtime": "^7.7.2",
- "@jimp/custom": "^0.13.0",
- "@jimp/plugins": "^0.13.0",
- "@jimp/types": "^0.13.0",
- "regenerator-runtime": "^0.13.3"
- },
- "dependencies": {
- "regenerator-runtime": {
- "version": "0.13.5",
- "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.5.tgz",
- "integrity": "sha512-ZS5w8CpKFinUzOwW3c83oPeVXoNsrLsaCoLtJvAClH135j/R77RuymhiSErhm2lKcwSCIpmvIWSbDkIfAqKQlA==",
- "dev": true
- }
- }
- },
- "jpeg-js": {
- "version": "0.4.4",
- "resolved": "https://registry.npmjs.org/jpeg-js/-/jpeg-js-0.4.4.tgz",
- "integrity": "sha512-WZzeDOEtTOBK4Mdsar0IqEU5sMr3vSV2RqkAIzUEV2BHnUfKGyswWFPFwK5EeDo93K3FohSHbLAjj0s1Wzd+dg==",
- "dev": true
- },
- "js-cookie": {
- "version": "2.2.0",
- "resolved": "https://registry.npmjs.org/js-cookie/-/js-cookie-2.2.0.tgz",
- "integrity": "sha1-Gywnmm7s44ChIWi5JIUmWzWx7/s="
- },
- "js-tokens": {
- "version": "3.0.2",
- "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-3.0.2.tgz",
- "integrity": "sha1-mGbfOVECEw449/mWvOtlRDIJwls=",
- "dev": true
- },
- "jsesc": {
- "version": "1.3.0",
- "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-1.3.0.tgz",
- "integrity": "sha1-RsP+yMGJKxKwgz25vHYiF226s0s=",
- "dev": true
- },
- "json-stable-stringify": {
- "version": "0.0.1",
- "resolved": "https://registry.npmjs.org/json-stable-stringify/-/json-stable-stringify-0.0.1.tgz",
- "integrity": "sha1-YRwj6BTbN1Un34URk9tZ3Sryf0U=",
- "dev": true,
- "requires": {
- "jsonify": "~0.0.0"
- }
- },
- "json5": {
- "version": "0.5.1",
- "resolved": "https://registry.npmjs.org/json5/-/json5-0.5.1.tgz",
- "integrity": "sha1-Hq3nrMASA0rYTiOWdn6tn6VJWCE=",
- "dev": true
- },
- "jsonify": {
- "version": "0.0.0",
- "resolved": "https://registry.npmjs.org/jsonify/-/jsonify-0.0.0.tgz",
- "integrity": "sha1-LHS27kHZPKUbe1qu6PUDYx0lKnM=",
- "dev": true
- },
- "jsonparse": {
- "version": "1.3.1",
- "resolved": "https://registry.npmjs.org/jsonparse/-/jsonparse-1.3.1.tgz",
- "integrity": "sha1-P02uSpH6wxX3EGL4UhzCOfE2YoA=",
- "dev": true
- },
- "JSONStream": {
- "version": "1.3.3",
- "resolved": "https://registry.npmjs.org/JSONStream/-/JSONStream-1.3.3.tgz",
- "integrity": "sha512-3Sp6WZZ/lXl+nTDoGpGWHEpTnnC6X5fnkolYZR6nwIfzbxxvA8utPWe1gCt7i0m9uVGsSz2IS8K8mJ7HmlduMg==",
- "dev": true,
- "requires": {
- "jsonparse": "^1.2.0",
- "through": ">=2.2.7 <3"
- }
- },
- "labeled-stream-splicer": {
- "version": "2.0.1",
- "resolved": "https://registry.npmjs.org/labeled-stream-splicer/-/labeled-stream-splicer-2.0.1.tgz",
- "integrity": "sha512-MC94mHZRvJ3LfykJlTUipBqenZz1pacOZEMhhQ8dMGcDHs0SBE5GbsavUXV7YtP3icBW17W0Zy1I0lfASmo9Pg==",
- "dev": true,
- "requires": {
- "inherits": "^2.0.1",
- "isarray": "^2.0.4",
- "stream-splicer": "^2.0.0"
- },
- "dependencies": {
- "isarray": {
- "version": "2.0.4",
- "resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.4.tgz",
- "integrity": "sha512-GMxXOiUirWg1xTKRipM0Ek07rX+ubx4nNVElTJdNLYmNO/2YrDkgJGw9CljXn+r4EWiDQg/8lsRdHyg2PJuUaA==",
- "dev": true
- }
- }
- },
- "load-bmfont": {
- "version": "1.4.0",
- "resolved": "https://registry.npmjs.org/load-bmfont/-/load-bmfont-1.4.0.tgz",
- "integrity": "sha512-kT63aTAlNhZARowaNYcY29Fn/QYkc52M3l6V1ifRcPewg2lvUZDAj7R6dXjOL9D0sict76op3T5+odumDSF81g==",
- "dev": true,
- "requires": {
- "buffer-equal": "0.0.1",
- "mime": "^1.3.4",
- "parse-bmfont-ascii": "^1.0.3",
- "parse-bmfont-binary": "^1.0.5",
- "parse-bmfont-xml": "^1.1.4",
- "phin": "^2.9.1",
- "xhr": "^2.0.1",
- "xtend": "^4.0.0"
- }
- },
- "lodash": {
- "version": "4.17.21",
- "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz",
- "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==",
- "dev": true
- },
- "lodash.memoize": {
- "version": "3.0.4",
- "resolved": "https://registry.npmjs.org/lodash.memoize/-/lodash.memoize-3.0.4.tgz",
- "integrity": "sha1-LcvSwofLwKVcxCMovQxzYVDVPj8=",
- "dev": true
- },
- "loose-envify": {
- "version": "1.3.1",
- "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.3.1.tgz",
- "integrity": "sha1-0aitM/qc4OcT1l/dCsi3SNR4yEg=",
- "dev": true,
- "requires": {
- "js-tokens": "^3.0.0"
- }
- },
- "lower-case": {
- "version": "1.1.4",
- "resolved": "https://registry.npmjs.org/lower-case/-/lower-case-1.1.4.tgz",
- "integrity": "sha1-miyr0bno4K6ZOkv31YdcOcQujqw=",
- "dev": true
- },
- "marked": {
- "version": "4.0.10",
- "resolved": "https://registry.npmjs.org/marked/-/marked-4.0.10.tgz",
- "integrity": "sha512-+QvuFj0nGgO970fySghXGmuw+Fd0gD2x3+MqCWLIPf5oxdv1Ka6b2q+z9RP01P/IaKPMEramy+7cNy/Lw8c3hw=="
- },
- "md5.js": {
- "version": "1.3.4",
- "resolved": "https://registry.npmjs.org/md5.js/-/md5.js-1.3.4.tgz",
- "integrity": "sha1-6b296UogpawYsENA/Fdk1bCdkB0=",
- "dev": true,
- "requires": {
- "hash-base": "^3.0.0",
- "inherits": "^2.0.1"
- }
- },
- "mdn-data": {
- "version": "1.1.4",
- "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-1.1.4.tgz",
- "integrity": "sha512-FSYbp3lyKjyj3E7fMl6rYvUdX0FBXaluGqlFoYESWQlyUTq8R+wp0rkFxoYFqZlHCvsUXGjyJmLQSnXToYhOSA==",
- "dev": true
- },
- "methods": {
- "version": "1.1.2",
- "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz",
- "integrity": "sha1-VSmk1nZUE07cxSZmVoNbD4Ua/O4="
- },
- "miller-rabin": {
- "version": "4.0.1",
- "resolved": "https://registry.npmjs.org/miller-rabin/-/miller-rabin-4.0.1.tgz",
- "integrity": "sha512-115fLhvZVqWwHPbClyntxEVfVDfl9DLLTuJvq3g2O/Oxi8AiNouAHvDSzHS0viUJc+V5vm3eq91Xwqn9dp4jRA==",
- "dev": true,
- "requires": {
- "bn.js": "^4.0.0",
- "brorand": "^1.0.1"
- }
- },
- "mime": {
- "version": "1.6.0",
- "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz",
- "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg=="
- },
- "mime-db": {
- "version": "1.33.0",
- "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.33.0.tgz",
- "integrity": "sha512-BHJ/EKruNIqJf/QahvxwQZXKygOQ256myeN/Ew+THcAa5q+PjyTTMMeNQC4DZw5AwfvelsUrA6B67NKMqXDbzQ=="
- },
- "mime-types": {
- "version": "2.1.18",
- "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.18.tgz",
- "integrity": "sha512-lc/aahn+t4/SWV/qcmumYjymLsWfN3ELhpmVuUFjgsORruuZPVSwAQryq+HHGvO/SI2KVX26bx+En+zhM8g8hQ==",
- "requires": {
- "mime-db": "~1.33.0"
- }
- },
- "min-document": {
- "version": "2.19.0",
- "resolved": "https://registry.npmjs.org/min-document/-/min-document-2.19.0.tgz",
- "integrity": "sha1-e9KC4/WELtKVu3SM3Z8f+iyCRoU=",
- "dev": true,
- "requires": {
- "dom-walk": "^0.1.0"
- }
- },
- "minimalistic-assert": {
- "version": "1.0.1",
- "resolved": "https://registry.npmjs.org/minimalistic-assert/-/minimalistic-assert-1.0.1.tgz",
- "integrity": "sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A==",
- "dev": true
- },
- "minimalistic-crypto-utils": {
- "version": "1.0.1",
- "resolved": "https://registry.npmjs.org/minimalistic-crypto-utils/-/minimalistic-crypto-utils-1.0.1.tgz",
- "integrity": "sha1-9sAMHAsIIkblxNmd+4x8CDsrWCo=",
- "dev": true
- },
- "minimatch": {
- "version": "3.0.4",
- "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.0.4.tgz",
- "integrity": "sha512-yJHVQEhyqPLUTgt9B83PXu6W3rx4MvvHvSUvToogpwoGDOUQ+yDrR0HRot+yOCdCO7u4hX3pWft6kWBBcqh0UA==",
- "dev": true,
- "requires": {
- "brace-expansion": "^1.1.7"
- }
- },
- "minimist": {
- "version": "1.2.6",
- "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.6.tgz",
- "integrity": "sha512-Jsjnk4bw3YJqYzbdyBiNsPWHPfO++UGG749Cxs6peCu5Xg4nrena6OVxOYxrQTqww0Jmwt+Ref8rggumkTLz9Q==",
- "dev": true
- },
- "mkdirp": {
- "version": "0.5.5",
- "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.5.tgz",
- "integrity": "sha512-NKmAlESf6jMGym1++R0Ra7wvhV+wFW63FaSOFPwRahvea0gMUcGUhVeAg/0BC0wiv9ih5NYPB1Wn1UEI1/L+xQ==",
- "dev": true,
- "requires": {
- "minimist": "^1.2.5"
- }
- },
- "mkdirp-classic": {
- "version": "0.5.3",
- "resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz",
- "integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==",
- "dev": true
- },
- "module-deps": {
- "version": "6.2.3",
- "resolved": "https://registry.npmjs.org/module-deps/-/module-deps-6.2.3.tgz",
- "integrity": "sha512-fg7OZaQBcL4/L+AK5f4iVqf9OMbCclXfy/znXRxTVhJSeW5AIlS9AwheYwDaXM3lVW7OBeaeUEY3gbaC6cLlSA==",
- "dev": true,
- "requires": {
- "browser-resolve": "^2.0.0",
- "cached-path-relative": "^1.0.2",
- "concat-stream": "~1.6.0",
- "defined": "^1.0.0",
- "detective": "^5.2.0",
- "duplexer2": "^0.1.2",
- "inherits": "^2.0.1",
- "JSONStream": "^1.0.3",
- "parents": "^1.0.0",
- "readable-stream": "^2.0.2",
- "resolve": "^1.4.0",
- "stream-combiner2": "^1.1.1",
- "subarg": "^1.0.0",
- "through2": "^2.0.0",
- "xtend": "^4.0.0"
- },
- "dependencies": {
- "browser-resolve": {
- "version": "2.0.0",
- "resolved": "https://registry.npmjs.org/browser-resolve/-/browser-resolve-2.0.0.tgz",
- "integrity": "sha512-7sWsQlYL2rGLy2IWm8WL8DCTJvYLc/qlOnsakDac87SOoCd16WLsaAMdCiAqsTNHIe+SXfaqyxyo6THoWqs8WQ==",
- "dev": true,
- "requires": {
- "resolve": "^1.17.0"
- }
- }
- }
- },
- "mousetrap": {
- "version": "1.6.2",
- "resolved": "https://registry.npmjs.org/mousetrap/-/mousetrap-1.6.2.tgz",
- "integrity": "sha512-jDjhi7wlHwdO6q6DS7YRmSHcuI+RVxadBkLt3KHrhd3C2b+w5pKefg3oj5beTcHZyVFA9Aksf+yEE1y5jxUjVA=="
- },
- "ms": {
- "version": "2.0.0",
- "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
- "integrity": "sha1-VgiurfwAvmwpAd9fmGF4jeDVl8g="
- },
- "no-case": {
- "version": "2.3.2",
- "resolved": "https://registry.npmjs.org/no-case/-/no-case-2.3.2.tgz",
- "integrity": "sha512-rmTZ9kz+f3rCvK2TD1Ue/oZlns7OGoIWP4fc3llxxRXlOkHKoWPPWJOfFYpITabSow43QJbRIoHQXtt10VldyQ==",
- "dev": true,
- "requires": {
- "lower-case": "^1.1.1"
- }
- },
- "normalize-path": {
- "version": "3.0.0",
- "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz",
- "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==",
- "dev": true
- },
- "nprogress": {
- "version": "0.2.0",
- "resolved": "https://registry.npmjs.org/nprogress/-/nprogress-0.2.0.tgz",
- "integrity": "sha1-y480xTIT2JVyP8urkH6UIq28r7E="
- },
- "nth-check": {
- "version": "1.0.2",
- "resolved": "https://registry.npmjs.org/nth-check/-/nth-check-1.0.2.tgz",
- "integrity": "sha512-WeBOdju8SnzPN5vTUJYxYUxLeXpCaVP5i5e0LF8fg7WORF2Wd7wFX/pk0tYZk7s8T+J7VLy0Da6J1+wCT0AtHg==",
- "dev": true,
- "requires": {
- "boolbase": "~1.0.0"
- }
- },
- "number-is-nan": {
- "version": "1.0.1",
- "resolved": "https://registry.npmjs.org/number-is-nan/-/number-is-nan-1.0.1.tgz",
- "integrity": "sha1-CXtgK1NCKlIsGvuHkDGDNpQaAR0=",
- "dev": true
- },
- "object-inspect": {
- "version": "1.10.3",
- "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.10.3.tgz",
- "integrity": "sha512-e5mCJlSH7poANfC8z8S9s9S2IN5/4Zb3aZ33f5s8YqoazCFzNLloLU8r5VCG+G7WoqLvAAZoVMcy3tp/3X0Plw=="
- },
- "object-keys": {
- "version": "1.1.1",
- "resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz",
- "integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==",
- "dev": true
- },
- "object.assign": {
- "version": "4.1.2",
- "resolved": "https://registry.npmjs.org/object.assign/-/object.assign-4.1.2.tgz",
- "integrity": "sha512-ixT2L5THXsApyiUPYKmW+2EHpXXe5Ii3M+f4e+aJFAHao5amFRW6J0OO6c/LU8Be47utCx2GL89hxGB6XSmKuQ==",
- "dev": true,
- "requires": {
- "call-bind": "^1.0.0",
- "define-properties": "^1.1.3",
- "has-symbols": "^1.0.1",
- "object-keys": "^1.1.1"
- }
- },
- "omggif": {
- "version": "1.0.10",
- "resolved": "https://registry.npmjs.org/omggif/-/omggif-1.0.10.tgz",
- "integrity": "sha512-LMJTtvgc/nugXj0Vcrrs68Mn2D1r0zf630VNtqtpI1FEO7e+O9FP4gqs9AcnBaSEeoHIPm28u6qgPR0oyEpGSw==",
- "dev": true
- },
- "once": {
- "version": "1.4.0",
- "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",
- "integrity": "sha1-WDsap3WWHUsROsF9nFC6753Xa9E=",
- "dev": true,
- "requires": {
- "wrappy": "1"
- }
- },
- "os-browserify": {
- "version": "0.3.0",
- "resolved": "https://registry.npmjs.org/os-browserify/-/os-browserify-0.3.0.tgz",
- "integrity": "sha1-hUNzx/XCMVkU/Jv8a9gjj92h7Cc=",
- "dev": true
- },
- "os-homedir": {
- "version": "1.0.2",
- "resolved": "https://registry.npmjs.org/os-homedir/-/os-homedir-1.0.2.tgz",
- "integrity": "sha1-/7xJiDNuDoM94MFox+8VISGqf7M=",
- "dev": true
- },
- "os-tmpdir": {
- "version": "1.0.2",
- "resolved": "https://registry.npmjs.org/os-tmpdir/-/os-tmpdir-1.0.2.tgz",
- "integrity": "sha1-u+Z0BseaqFxc/sdm/lc0VV36EnQ=",
- "dev": true
- },
- "outpipe": {
- "version": "1.1.1",
- "resolved": "https://registry.npmjs.org/outpipe/-/outpipe-1.1.1.tgz",
- "integrity": "sha1-UM+GFjZeh+Ax4ppeyTOaPaRyX6I=",
- "dev": true,
- "requires": {
- "shell-quote": "^1.4.2"
- }
- },
- "pako": {
- "version": "1.0.6",
- "resolved": "https://registry.npmjs.org/pako/-/pako-1.0.6.tgz",
- "integrity": "sha512-lQe48YPsMJAig+yngZ87Lus+NF+3mtu7DVOBu6b/gHO1YpKwIj5AWjZ/TOS7i46HD/UixzWb1zeWDZfGZ3iYcg==",
- "dev": true
- },
- "param-case": {
- "version": "2.1.1",
- "resolved": "https://registry.npmjs.org/param-case/-/param-case-2.1.1.tgz",
- "integrity": "sha1-35T9jPZTHs915r75oIWPvHK+Ikc=",
- "dev": true,
- "requires": {
- "no-case": "^2.2.0"
- }
- },
- "parents": {
- "version": "1.0.1",
- "resolved": "https://registry.npmjs.org/parents/-/parents-1.0.1.tgz",
- "integrity": "sha1-/t1NK/GTp3dF/nHjcdc8MwfZx1E=",
- "dev": true,
- "requires": {
- "path-platform": "~0.11.15"
- }
- },
- "parse-asn1": {
- "version": "5.1.1",
- "resolved": "https://registry.npmjs.org/parse-asn1/-/parse-asn1-5.1.1.tgz",
- "integrity": "sha512-KPx7flKXg775zZpnp9SxJlz00gTd4BmJ2yJufSc44gMCRrRQ7NSzAcSJQfifuOLgW6bEi+ftrALtsgALeB2Adw==",
- "dev": true,
- "requires": {
- "asn1.js": "^4.0.0",
- "browserify-aes": "^1.0.0",
- "create-hash": "^1.1.0",
- "evp_bytestokey": "^1.0.0",
- "pbkdf2": "^3.0.3"
- }
- },
- "parse-bmfont-ascii": {
- "version": "1.0.6",
- "resolved": "https://registry.npmjs.org/parse-bmfont-ascii/-/parse-bmfont-ascii-1.0.6.tgz",
- "integrity": "sha1-Eaw8P/WPfCAgqyJ2kHkQjU36AoU=",
- "dev": true
- },
- "parse-bmfont-binary": {
- "version": "1.0.6",
- "resolved": "https://registry.npmjs.org/parse-bmfont-binary/-/parse-bmfont-binary-1.0.6.tgz",
- "integrity": "sha1-0Di0dtPp3Z2x4RoLDlOiJ5K2kAY=",
- "dev": true
- },
- "parse-bmfont-xml": {
- "version": "1.1.4",
- "resolved": "https://registry.npmjs.org/parse-bmfont-xml/-/parse-bmfont-xml-1.1.4.tgz",
- "integrity": "sha512-bjnliEOmGv3y1aMEfREMBJ9tfL3WR0i0CKPj61DnSLaoxWR3nLrsQrEbCId/8rF4NyRF0cCqisSVXyQYWM+mCQ==",
- "dev": true,
- "requires": {
- "xml-parse-from-string": "^1.0.0",
- "xml2js": "^0.4.5"
- }
- },
- "parse-headers": {
- "version": "2.0.3",
- "resolved": "https://registry.npmjs.org/parse-headers/-/parse-headers-2.0.3.tgz",
- "integrity": "sha512-QhhZ+DCCit2Coi2vmAKbq5RGTRcQUOE2+REgv8vdyu7MnYx2eZztegqtTx99TZ86GTIwqiy3+4nQTWZ2tgmdCA==",
- "dev": true
- },
- "path-browserify": {
- "version": "0.0.1",
- "resolved": "https://registry.npmjs.org/path-browserify/-/path-browserify-0.0.1.tgz",
- "integrity": "sha512-BapA40NHICOS+USX9SN4tyhq+A2RrN/Ws5F0Z5aMHDp98Fl86lX8Oti8B7uN93L4Ifv4fHOEA+pQw87gmMO/lQ==",
- "dev": true
- },
- "path-is-absolute": {
- "version": "1.0.1",
- "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz",
- "integrity": "sha1-F0uSaHNVNP+8es5r9TpanhtcX18=",
- "dev": true
- },
- "path-parse": {
- "version": "1.0.7",
- "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz",
- "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==",
- "dev": true
- },
- "path-platform": {
- "version": "0.11.15",
- "resolved": "https://registry.npmjs.org/path-platform/-/path-platform-0.11.15.tgz",
- "integrity": "sha1-6GQhf3TDaFDwhSt43Hv31KVyG/I=",
- "dev": true
- },
- "pbkdf2": {
- "version": "3.0.16",
- "resolved": "https://registry.npmjs.org/pbkdf2/-/pbkdf2-3.0.16.tgz",
- "integrity": "sha512-y4CXP3thSxqf7c0qmOF+9UeOTrifiVTIM+u7NWlq+PRsHbr7r7dpCmvzrZxa96JJUNi0Y5w9VqG5ZNeCVMoDcA==",
- "dev": true,
- "requires": {
- "create-hash": "^1.1.2",
- "create-hmac": "^1.1.4",
- "ripemd160": "^2.0.1",
- "safe-buffer": "^5.0.1",
- "sha.js": "^2.4.8"
- }
- },
- "phin": {
- "version": "2.9.3",
- "resolved": "https://registry.npmjs.org/phin/-/phin-2.9.3.tgz",
- "integrity": "sha512-CzFr90qM24ju5f88quFC/6qohjC144rehe5n6DH900lgXmUe86+xCKc10ev56gRKC4/BkHUoG4uSiQgBiIXwDA==",
- "dev": true
- },
- "picomatch": {
- "version": "2.2.3",
- "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.2.3.tgz",
- "integrity": "sha512-KpELjfwcCDUb9PeigTs2mBJzXUPzAuP2oPcA989He8Rte0+YUAjw1JVedDhuTKPkHjSYzMN3npC9luThGYEKdg==",
- "dev": true
- },
- "pixelmatch": {
- "version": "4.0.2",
- "resolved": "https://registry.npmjs.org/pixelmatch/-/pixelmatch-4.0.2.tgz",
- "integrity": "sha1-j0fc7FARtHe2fbA8JDvB8wheiFQ=",
- "dev": true,
- "requires": {
- "pngjs": "^3.0.0"
- }
- },
- "pngjs": {
- "version": "3.4.0",
- "resolved": "https://registry.npmjs.org/pngjs/-/pngjs-3.4.0.tgz",
- "integrity": "sha512-NCrCHhWmnQklfH4MtJMRjZ2a8c80qXeMlQMv2uVp9ISJMTt562SbGd6n2oq0PaPgKm7Z6pL9E2UlLIhC+SHL3w==",
- "dev": true
- },
- "pretty-error": {
- "version": "3.0.3",
- "resolved": "https://registry.npmjs.org/pretty-error/-/pretty-error-3.0.3.tgz",
- "integrity": "sha512-nFB0BMeWNJA4YfmrgqPhOH3UQjMQZASZ2ueBfmlyqpVy9+ExLcmwXL/Iu4Wb9pbt/cubQXK4ir8IZUnE8EwFnw==",
- "dev": true,
- "requires": {
- "lodash": "^4.17.20",
- "renderkid": "^2.0.5"
- }
- },
- "private": {
- "version": "0.1.8",
- "resolved": "https://registry.npmjs.org/private/-/private-0.1.8.tgz",
- "integrity": "sha512-VvivMrbvd2nKkiG38qjULzlc+4Vx4wm/whI9pQD35YrARNnhxeiRktSOhSukRLFNlzg6Br/cJPet5J/u19r/mg==",
- "dev": true
- },
- "process": {
- "version": "0.11.10",
- "resolved": "https://registry.npmjs.org/process/-/process-0.11.10.tgz",
- "integrity": "sha1-czIwDoQBYb2j5podHZGn1LwW8YI=",
- "dev": true
- },
- "process-nextick-args": {
- "version": "2.0.0",
- "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.0.tgz",
- "integrity": "sha512-MtEC1TqN0EU5nephaJ4rAtThHtC86dNN9qCuEhtshvpVBkAW5ZO7BASN9REnF9eoXGcRub+pFuKEpOHE+HbEMw=="
- },
- "public-encrypt": {
- "version": "4.0.2",
- "resolved": "https://registry.npmjs.org/public-encrypt/-/public-encrypt-4.0.2.tgz",
- "integrity": "sha512-4kJ5Esocg8X3h8YgJsKAuoesBgB7mqH3eowiDzMUPKiRDDE7E/BqqZD1hnTByIaAFiwAw246YEltSq7tdrOH0Q==",
- "dev": true,
- "requires": {
- "bn.js": "^4.1.0",
- "browserify-rsa": "^4.0.0",
- "create-hash": "^1.1.0",
- "parse-asn1": "^5.0.0",
- "randombytes": "^2.0.1"
- }
- },
- "punycode": {
- "version": "1.4.1",
- "resolved": "https://registry.npmjs.org/punycode/-/punycode-1.4.1.tgz",
- "integrity": "sha1-wNWmOycYgArY4esPpSachN1BhF4=",
- "dev": true
- },
- "qs": {
- "version": "6.11.0",
- "resolved": "https://registry.npmjs.org/qs/-/qs-6.11.0.tgz",
- "integrity": "sha512-MvjoMCJwEarSbUYk5O+nmoSzSutSsTwF85zcHPQ9OrlFoZOYIjaqBAJIqIXjptyD5vThxGq52Xu/MaJzRkIk4Q==",
- "requires": {
- "side-channel": "^1.0.4"
- }
- },
- "querystring": {
- "version": "0.2.0",
- "resolved": "https://registry.npmjs.org/querystring/-/querystring-0.2.0.tgz",
- "integrity": "sha1-sgmEkgO7Jd+CDadW50cAWHhSFiA=",
- "dev": true
- },
- "querystring-es3": {
- "version": "0.2.1",
- "resolved": "https://registry.npmjs.org/querystring-es3/-/querystring-es3-0.2.1.tgz",
- "integrity": "sha1-nsYfeQSYdXB9aUFFlv2Qek1xHnM=",
- "dev": true
- },
- "randombytes": {
- "version": "2.0.6",
- "resolved": "https://registry.npmjs.org/randombytes/-/randombytes-2.0.6.tgz",
- "integrity": "sha512-CIQ5OFxf4Jou6uOKe9t1AOgqpeU5fd70A8NPdHSGeYXqXsPe6peOwI0cUl88RWZ6sP1vPMV3avd/R6cZ5/sP1A==",
- "dev": true,
- "requires": {
- "safe-buffer": "^5.1.0"
- }
- },
- "randomfill": {
- "version": "1.0.4",
- "resolved": "https://registry.npmjs.org/randomfill/-/randomfill-1.0.4.tgz",
- "integrity": "sha512-87lcbR8+MhcWcUiQ+9e+Rwx8MyR2P7qnt15ynUlbm3TU/fjbgz4GsvfSUDTemtCCtVCqb4ZcEFlyPNTh9bBTLw==",
- "dev": true,
- "requires": {
- "randombytes": "^2.0.5",
- "safe-buffer": "^5.1.0"
- }
- },
- "read-only-stream": {
- "version": "2.0.0",
- "resolved": "https://registry.npmjs.org/read-only-stream/-/read-only-stream-2.0.0.tgz",
- "integrity": "sha1-JyT9aoET1zdkrCiNQ4YnDB2/F/A=",
- "dev": true,
- "requires": {
- "readable-stream": "^2.0.2"
- }
- },
- "readable-stream": {
- "version": "2.3.6",
- "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.6.tgz",
- "integrity": "sha512-tQtKA9WIAhBF3+VLAseyMqZeBjW0AHJoxOtYqSUZNJxauErmLbVm2FW1y+J/YA9dUrAC39ITejlZWhVIwawkKw==",
- "requires": {
- "core-util-is": "~1.0.0",
- "inherits": "~2.0.3",
- "isarray": "~1.0.0",
- "process-nextick-args": "~2.0.0",
- "safe-buffer": "~5.1.1",
- "string_decoder": "~1.1.1",
- "util-deprecate": "~1.0.1"
- }
- },
- "readdirp": {
- "version": "3.5.0",
- "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.5.0.tgz",
- "integrity": "sha512-cMhu7c/8rdhkHXWsY+osBhfSy0JikwpHK/5+imo+LpeasTF8ouErHrlYkwT0++njiyuDvc7OFY5T3ukvZ8qmFQ==",
- "dev": true,
- "requires": {
- "picomatch": "^2.2.1"
- }
- },
- "regenerate": {
- "version": "1.4.0",
- "resolved": "https://registry.npmjs.org/regenerate/-/regenerate-1.4.0.tgz",
- "integrity": "sha512-1G6jJVDWrt0rK99kBjvEtziZNCICAuvIPkSiUFIQxVP06RCVpq3dmDo2oi6ABpYaDYaTRr67BEhL8r1wgEZZKg==",
- "dev": true
- },
- "regenerator-runtime": {
- "version": "0.11.1",
- "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.11.1.tgz",
- "integrity": "sha512-MguG95oij0fC3QV3URf4V2SDYGJhJnJGqvIIgdECeODCT98wSWDAJ94SSuVpYQUoTcGUIL6L4yNB7j1DFFHSBg==",
- "dev": true
- },
- "regenerator-transform": {
- "version": "0.10.1",
- "resolved": "https://registry.npmjs.org/regenerator-transform/-/regenerator-transform-0.10.1.tgz",
- "integrity": "sha512-PJepbvDbuK1xgIgnau7Y90cwaAmO/LCLMI2mPvaXq2heGMR3aWW5/BQvYrhJ8jgmQjXewXvBjzfqKcVOmhjZ6Q==",
- "dev": true,
- "requires": {
- "babel-runtime": "^6.18.0",
- "babel-types": "^6.19.0",
- "private": "^0.1.6"
- }
- },
- "regexpu-core": {
- "version": "2.0.0",
- "resolved": "https://registry.npmjs.org/regexpu-core/-/regexpu-core-2.0.0.tgz",
- "integrity": "sha1-SdA4g3uNz4v6W5pCE5k45uoq4kA=",
- "dev": true,
- "requires": {
- "regenerate": "^1.2.1",
- "regjsgen": "^0.2.0",
- "regjsparser": "^0.1.4"
- }
- },
- "regjsgen": {
- "version": "0.2.0",
- "resolved": "https://registry.npmjs.org/regjsgen/-/regjsgen-0.2.0.tgz",
- "integrity": "sha1-bAFq3qxVT3WCP+N6wFuS1aTtsfc=",
- "dev": true
- },
- "regjsparser": {
- "version": "0.1.5",
- "resolved": "https://registry.npmjs.org/regjsparser/-/regjsparser-0.1.5.tgz",
- "integrity": "sha1-fuj4Tcb6eS0/0K4ijSS9lJ6tIFw=",
- "dev": true,
- "requires": {
- "jsesc": "~0.5.0"
- },
- "dependencies": {
- "jsesc": {
- "version": "0.5.0",
- "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-0.5.0.tgz",
- "integrity": "sha1-597mbjXW/Bb3EP6R1c9p9w8IkR0=",
- "dev": true
- }
- }
- },
- "relateurl": {
- "version": "0.2.7",
- "resolved": "https://registry.npmjs.org/relateurl/-/relateurl-0.2.7.tgz",
- "integrity": "sha1-VNvzd+UUQKypCkzSdGANP/LYiKk=",
- "dev": true
- },
- "renderkid": {
- "version": "2.0.5",
- "resolved": "https://registry.npmjs.org/renderkid/-/renderkid-2.0.5.tgz",
- "integrity": "sha512-ccqoLg+HLOHq1vdfYNm4TBeaCDIi1FLt3wGojTDSvdewUv65oTmI3cnT2E4hRjl1gzKZIPK+KZrXzlUYKnR+vQ==",
- "dev": true,
- "requires": {
- "css-select": "^2.0.2",
- "dom-converter": "^0.2",
- "htmlparser2": "^3.10.1",
- "lodash": "^4.17.20",
- "strip-ansi": "^3.0.0"
- }
- },
- "repeating": {
- "version": "2.0.1",
- "resolved": "https://registry.npmjs.org/repeating/-/repeating-2.0.1.tgz",
- "integrity": "sha1-UhTFOpJtNVJwdSf7q0FdvAjQbdo=",
- "dev": true,
- "requires": {
- "is-finite": "^1.0.0"
- }
- },
- "resolve": {
- "version": "1.20.0",
- "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.20.0.tgz",
- "integrity": "sha512-wENBPt4ySzg4ybFQW2TT1zMQucPK95HSh/nq2CFTZVOGut2+pQvSsgtda4d26YrYcr067wjbmzOG8byDPBX63A==",
- "dev": true,
- "requires": {
- "is-core-module": "^2.2.0",
- "path-parse": "^1.0.6"
- }
- },
- "resolve-url": {
- "version": "0.2.1",
- "resolved": "https://registry.npmjs.org/resolve-url/-/resolve-url-0.2.1.tgz",
- "integrity": "sha1-LGN/53yJOv0qZj/iGqkIAGjiBSo=",
- "dev": true
- },
- "ripemd160": {
- "version": "2.0.2",
- "resolved": "https://registry.npmjs.org/ripemd160/-/ripemd160-2.0.2.tgz",
- "integrity": "sha512-ii4iagi25WusVoiC4B4lq7pbXfAp3D9v5CwfkY33vffw2+pkDjY1D8GaN7spsxvCSx8dkPqOZCEZyfxcmJG2IA==",
- "dev": true,
- "requires": {
- "hash-base": "^3.0.0",
- "inherits": "^2.0.1"
- }
- },
- "safe-buffer": {
- "version": "5.1.2",
- "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz",
- "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g=="
- },
- "safer-buffer": {
- "version": "2.1.2",
- "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
- "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
- "dev": true
- },
- "sax": {
- "version": "1.2.4",
- "resolved": "https://registry.npmjs.org/sax/-/sax-1.2.4.tgz",
- "integrity": "sha512-NqVDv9TpANUjFm0N8uM5GxL36UgKi9/atZw+x7YFnQ8ckwFGKrl4xX4yWtrey3UJm5nP1kUbnYgLopqWNSRhWw==",
- "dev": true
- },
- "semver": {
- "version": "5.5.0",
- "resolved": "https://registry.npmjs.org/semver/-/semver-5.5.0.tgz",
- "integrity": "sha512-4SJ3dm0WAwWy/NVeioZh5AntkdJoWKxHxcmyP622fOkgHa4z3R0TdBJICINyaSDE6uNwVc8gZr+ZinwZAH4xIA==",
- "dev": true
- },
- "sha.js": {
- "version": "2.4.11",
- "resolved": "https://registry.npmjs.org/sha.js/-/sha.js-2.4.11.tgz",
- "integrity": "sha512-QMEp5B7cftE7APOjk5Y6xgrbWu+WkLVQwk8JNjZ8nKRciZaByEW6MubieAiToS7+dwvrjGhH8jRXz3MVd0AYqQ==",
- "dev": true,
- "requires": {
- "inherits": "^2.0.1",
- "safe-buffer": "^5.0.1"
- }
- },
- "shasum": {
- "version": "1.0.2",
- "resolved": "https://registry.npmjs.org/shasum/-/shasum-1.0.2.tgz",
- "integrity": "sha1-5wEjENj0F/TetXEhUOVni4euVl8=",
- "dev": true,
- "requires": {
- "json-stable-stringify": "~0.0.0",
- "sha.js": "~2.4.4"
- }
- },
- "shasum-object": {
- "version": "1.0.0",
- "resolved": "https://registry.npmjs.org/shasum-object/-/shasum-object-1.0.0.tgz",
- "integrity": "sha512-Iqo5rp/3xVi6M4YheapzZhhGPVs0yZwHj7wvwQ1B9z8H6zk+FEnI7y3Teq7qwnekfEhu8WmG2z0z4iWZaxLWVg==",
- "dev": true,
- "requires": {
- "fast-safe-stringify": "^2.0.7"
- }
- },
- "shell-quote": {
- "version": "1.7.3",
- "resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.7.3.tgz",
- "integrity": "sha512-Vpfqwm4EnqGdlsBFNmHhxhElJYrdfcxPThu+ryKS5J8L/fhAwLazFZtq+S+TWZ9ANj2piSQLGj6NQg+lKPmxrw==",
- "dev": true
- },
- "side-channel": {
- "version": "1.0.4",
- "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.0.4.tgz",
- "integrity": "sha512-q5XPytqFEIKHkGdiMIrY10mvLRvnQh42/+GoBlFW3b2LXLE2xxJpZFdm94we0BaoV3RwJyGqg5wS7epxTv0Zvw==",
- "requires": {
- "call-bind": "^1.0.0",
- "get-intrinsic": "^1.0.2",
- "object-inspect": "^1.9.0"
- }
- },
- "simple-concat": {
- "version": "1.0.0",
- "resolved": "https://registry.npmjs.org/simple-concat/-/simple-concat-1.0.0.tgz",
- "integrity": "sha1-c0TLuLbib7J9ZrL8hvn21Zl1IcY=",
- "dev": true
- },
- "slash": {
- "version": "1.0.0",
- "resolved": "https://registry.npmjs.org/slash/-/slash-1.0.0.tgz",
- "integrity": "sha1-xB8vbDn8FtHNF61LXYlhFK5HDVU=",
- "dev": true
- },
- "source-map": {
- "version": "0.5.7",
- "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.5.7.tgz",
- "integrity": "sha1-igOdLRAh0i0eoUyA2OpGi6LvP8w=",
- "dev": true
- },
- "source-map-resolve": {
- "version": "0.5.3",
- "resolved": "https://registry.npmjs.org/source-map-resolve/-/source-map-resolve-0.5.3.tgz",
- "integrity": "sha512-Htz+RnsXWk5+P2slx5Jh3Q66vhQj1Cllm0zvnaY98+NFx+Dv2CF/f5O/t8x+KaNdrdIAsruNzoh/KpialbqAnw==",
- "dev": true,
- "requires": {
- "atob": "^2.1.2",
- "decode-uri-component": "^0.2.0",
- "resolve-url": "^0.2.1",
- "source-map-url": "^0.4.0",
- "urix": "^0.1.0"
- }
- },
- "source-map-support": {
- "version": "0.4.18",
- "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.4.18.tgz",
- "integrity": "sha512-try0/JqxPLF9nOjvSta7tVondkP5dwgyLDjVoyMDlmjugT2lRZ1OfsrYTkCd2hkDnJTKRbO/Rl3orm8vlsUzbA==",
- "dev": true,
- "requires": {
- "source-map": "^0.5.6"
- }
- },
- "source-map-url": {
- "version": "0.4.1",
- "resolved": "https://registry.npmjs.org/source-map-url/-/source-map-url-0.4.1.tgz",
- "integrity": "sha512-cPiFOTLUKvJFIg4SKVScy4ilPPW6rFgMgfuZJPNoDuMs3nC1HbMUycBoJw77xFIp6z1UJQJOfx6C9GMH80DiTw==",
- "dev": true
- },
- "stream-browserify": {
- "version": "2.0.1",
- "resolved": "https://registry.npmjs.org/stream-browserify/-/stream-browserify-2.0.1.tgz",
- "integrity": "sha1-ZiZu5fm9uZQKTkUUyvtDu3Hlyds=",
- "dev": true,
- "requires": {
- "inherits": "~2.0.1",
- "readable-stream": "^2.0.2"
- }
- },
- "stream-combiner2": {
- "version": "1.1.1",
- "resolved": "https://registry.npmjs.org/stream-combiner2/-/stream-combiner2-1.1.1.tgz",
- "integrity": "sha1-+02KFCDqNidk4hrUeAOXvry0HL4=",
- "dev": true,
- "requires": {
- "duplexer2": "~0.1.0",
- "readable-stream": "^2.0.2"
- }
- },
- "stream-http": {
- "version": "2.8.3",
- "resolved": "https://registry.npmjs.org/stream-http/-/stream-http-2.8.3.tgz",
- "integrity": "sha512-+TSkfINHDo4J+ZobQLWiMouQYB+UVYFttRA94FpEzzJ7ZdqcL4uUUQ7WkdkI4DSozGmgBUE/a47L+38PenXhUw==",
- "dev": true,
- "requires": {
- "builtin-status-codes": "^3.0.0",
- "inherits": "^2.0.1",
- "readable-stream": "^2.3.6",
- "to-arraybuffer": "^1.0.0",
- "xtend": "^4.0.0"
- }
- },
- "stream-splicer": {
- "version": "2.0.0",
- "resolved": "https://registry.npmjs.org/stream-splicer/-/stream-splicer-2.0.0.tgz",
- "integrity": "sha1-G2O+Q4oTPktnHMGTUZdgAXWRDYM=",
- "dev": true,
- "requires": {
- "inherits": "^2.0.1",
- "readable-stream": "^2.0.2"
- }
- },
- "string_decoder": {
- "version": "1.1.1",
- "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz",
- "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==",
- "requires": {
- "safe-buffer": "~5.1.0"
- }
- },
- "string.prototype.trimend": {
- "version": "1.0.4",
- "resolved": "https://registry.npmjs.org/string.prototype.trimend/-/string.prototype.trimend-1.0.4.tgz",
- "integrity": "sha512-y9xCjw1P23Awk8EvTpcyL2NIr1j7wJ39f+k6lvRnSMz+mz9CGz9NYPelDk42kOz6+ql8xjfK8oYzy3jAP5QU5A==",
- "dev": true,
- "requires": {
- "call-bind": "^1.0.2",
- "define-properties": "^1.1.3"
- }
- },
- "string.prototype.trimstart": {
- "version": "1.0.4",
- "resolved": "https://registry.npmjs.org/string.prototype.trimstart/-/string.prototype.trimstart-1.0.4.tgz",
- "integrity": "sha512-jh6e984OBfvxS50tdY2nRZnoC5/mLFKOREQfw8t5yytkoUsJRNxvI/E39qu1sD0OtWI3OC0XgKSmcWwziwYuZw==",
- "dev": true,
- "requires": {
- "call-bind": "^1.0.2",
- "define-properties": "^1.1.3"
- }
- },
- "strip-ansi": {
- "version": "3.0.1",
- "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-3.0.1.tgz",
- "integrity": "sha1-ajhfuIU9lS1f8F0Oiq+UJ43GPc8=",
- "dev": true,
- "requires": {
- "ansi-regex": "^2.0.0"
- }
- },
- "stylus": {
- "version": "0.54.8",
- "resolved": "https://registry.npmjs.org/stylus/-/stylus-0.54.8.tgz",
- "integrity": "sha512-vr54Or4BZ7pJafo2mpf0ZcwA74rpuYCZbxrHBsH8kbcXOwSfvBFwsRfpGO5OD5fhG5HDCFW737PKaawI7OqEAg==",
- "dev": true,
- "requires": {
- "css-parse": "~2.0.0",
- "debug": "~3.1.0",
- "glob": "^7.1.6",
- "mkdirp": "~1.0.4",
- "safer-buffer": "^2.1.2",
- "sax": "~1.2.4",
- "semver": "^6.3.0",
- "source-map": "^0.7.3"
- },
- "dependencies": {
- "mkdirp": {
- "version": "1.0.4",
- "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-1.0.4.tgz",
- "integrity": "sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==",
- "dev": true
- },
- "semver": {
- "version": "6.3.0",
- "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.0.tgz",
- "integrity": "sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw==",
- "dev": true
- },
- "source-map": {
- "version": "0.7.3",
- "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.7.3.tgz",
- "integrity": "sha512-CkCj6giN3S+n9qrYiBTX5gystlENnRW5jZeNLHpe6aue+SrHcG5VYwujhW9s4dY31mEGsxBDrHR6oI69fTXsaQ==",
- "dev": true
- }
- }
- },
- "subarg": {
- "version": "1.0.0",
- "resolved": "https://registry.npmjs.org/subarg/-/subarg-1.0.0.tgz",
- "integrity": "sha1-9izxdYHplrSPyWVpn1TAauJouNI=",
- "dev": true,
- "requires": {
- "minimist": "^1.1.0"
- }
- },
- "superagent": {
- "version": "3.8.3",
- "resolved": "https://registry.npmjs.org/superagent/-/superagent-3.8.3.tgz",
- "integrity": "sha512-GLQtLMCoEIK4eDv6OGtkOoSMt3D+oq0y3dsxMuYuDvaNUvuT8eFBuLmfR0iYYzHC1e8hpzC6ZsxbuP6DIalMFA==",
- "requires": {
- "component-emitter": "^1.2.0",
- "cookiejar": "^2.1.0",
- "debug": "^3.1.0",
- "extend": "^3.0.0",
- "form-data": "^2.3.1",
- "formidable": "^1.2.0",
- "methods": "^1.1.1",
- "mime": "^1.4.1",
- "qs": "^6.5.1",
- "readable-stream": "^2.3.5"
- }
- },
- "supports-color": {
- "version": "2.0.0",
- "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-2.0.0.tgz",
- "integrity": "sha1-U10EXOa2Nj+kARcIRimZXp3zJMc=",
- "dev": true
- },
- "syntax-error": {
- "version": "1.4.0",
- "resolved": "https://registry.npmjs.org/syntax-error/-/syntax-error-1.4.0.tgz",
- "integrity": "sha512-YPPlu67mdnHGTup2A8ff7BC2Pjq0e0Yp/IyTFN03zWO0RcK07uLcbi7C2KpGR2FvWbaB0+bfE27a+sBKebSo7w==",
- "dev": true,
- "requires": {
- "acorn-node": "^1.2.0"
- }
- },
- "terser": {
- "version": "4.8.1",
- "resolved": "https://registry.npmjs.org/terser/-/terser-4.8.1.tgz",
- "integrity": "sha512-4GnLC0x667eJG0ewJTa6z/yXrbLGv80D9Ru6HIpCQmO+Q4PfEtBFi0ObSckqwL6VyQv/7ENJieXHo2ANmdQwgw==",
- "dev": true,
- "requires": {
- "commander": "^2.20.0",
- "source-map": "~0.6.1",
- "source-map-support": "~0.5.12"
- },
- "dependencies": {
- "commander": {
- "version": "2.20.3",
- "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz",
- "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==",
- "dev": true
- },
- "source-map": {
- "version": "0.6.1",
- "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz",
- "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==",
- "dev": true
- },
- "source-map-support": {
- "version": "0.5.21",
- "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.21.tgz",
- "integrity": "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==",
- "dev": true,
- "requires": {
- "buffer-from": "^1.0.0",
- "source-map": "^0.6.0"
- }
- }
- }
- },
- "through": {
- "version": "2.3.8",
- "resolved": "https://registry.npmjs.org/through/-/through-2.3.8.tgz",
- "integrity": "sha1-DdTJ/6q8NXlgsbckEV1+Doai4fU=",
- "dev": true
- },
- "through2": {
- "version": "2.0.3",
- "resolved": "https://registry.npmjs.org/through2/-/through2-2.0.3.tgz",
- "integrity": "sha1-AARWmzfHx0ujnEPzzteNGtlBQL4=",
- "dev": true,
- "requires": {
- "readable-stream": "^2.1.5",
- "xtend": "~4.0.1"
- }
- },
- "timers-browserify": {
- "version": "1.4.2",
- "resolved": "https://registry.npmjs.org/timers-browserify/-/timers-browserify-1.4.2.tgz",
- "integrity": "sha1-ycWLV1voQHN1y14kYtrO50NZ9B0=",
- "dev": true,
- "requires": {
- "process": "~0.11.0"
- }
- },
- "timm": {
- "version": "1.6.2",
- "resolved": "https://registry.npmjs.org/timm/-/timm-1.6.2.tgz",
- "integrity": "sha512-IH3DYDL1wMUwmIlVmMrmesw5lZD6N+ZOAFWEyLrtpoL9Bcrs9u7M/vyOnHzDD2SMs4irLkVjqxZbHrXStS/Nmw==",
- "dev": true
- },
- "tinycolor2": {
- "version": "1.4.1",
- "resolved": "https://registry.npmjs.org/tinycolor2/-/tinycolor2-1.4.1.tgz",
- "integrity": "sha1-9PrTM0R7wLB9TcjpIJ2POaisd+g=",
- "dev": true
- },
- "to-arraybuffer": {
- "version": "1.0.1",
- "resolved": "https://registry.npmjs.org/to-arraybuffer/-/to-arraybuffer-1.0.1.tgz",
- "integrity": "sha1-fSKbH8xjfkZsoIEYCDanqr/4P0M=",
- "dev": true
- },
- "to-fast-properties": {
- "version": "1.0.3",
- "resolved": "https://registry.npmjs.org/to-fast-properties/-/to-fast-properties-1.0.3.tgz",
- "integrity": "sha1-uDVx+k2MJbguIxsG46MFXeTKGkc=",
- "dev": true
- },
- "to-regex-range": {
- "version": "5.0.1",
- "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
- "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==",
- "dev": true,
- "requires": {
- "is-number": "^7.0.0"
- }
- },
- "trim-right": {
- "version": "1.0.1",
- "resolved": "https://registry.npmjs.org/trim-right/-/trim-right-1.0.1.tgz",
- "integrity": "sha1-yy4SAwZ+DI3h9hQJS5/kVwTqYAM=",
- "dev": true
- },
- "tty-browserify": {
- "version": "0.0.1",
- "resolved": "https://registry.npmjs.org/tty-browserify/-/tty-browserify-0.0.1.tgz",
- "integrity": "sha512-C3TaO7K81YvjCgQH9Q1S3R3P3BtN3RIM8n+OvX4il1K1zgE8ZhI0op7kClgkxtutIE8hQrcrHBXvIheqKUUCxw==",
- "dev": true
- },
- "typedarray": {
- "version": "0.0.6",
- "resolved": "https://registry.npmjs.org/typedarray/-/typedarray-0.0.6.tgz",
- "integrity": "sha1-hnrHTjhkGHsdPUfZlqeOxciDB3c=",
- "dev": true
- },
- "uglify-js": {
- "version": "3.4.3",
- "resolved": "https://registry.npmjs.org/uglify-js/-/uglify-js-3.4.3.tgz",
- "integrity": "sha512-RbOgGjF04sFUNSi8xGOTB9AmtVmMmVVAL5a7lxIgJ8urejJen+priq0ooRIHHa8AXI/dSvNF9yYMz9OP4PhybQ==",
- "dev": true,
- "requires": {
- "commander": "~2.16.0",
- "source-map": "~0.6.1"
- },
- "dependencies": {
- "source-map": {
- "version": "0.6.1",
- "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz",
- "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==",
- "dev": true
- }
- }
- },
- "umd": {
- "version": "3.0.3",
- "resolved": "https://registry.npmjs.org/umd/-/umd-3.0.3.tgz",
- "integrity": "sha512-4IcGSufhFshvLNcMCV80UnQVlZ5pMOC8mvNPForqwA4+lzYQuetTESLDQkeLmihq8bRcnpbQa48Wb8Lh16/xow==",
- "dev": true
- },
- "unbox-primitive": {
- "version": "1.0.1",
- "resolved": "https://registry.npmjs.org/unbox-primitive/-/unbox-primitive-1.0.1.tgz",
- "integrity": "sha512-tZU/3NqK3dA5gpE1KtyiJUrEB0lxnGkMFHptJ7q6ewdZ8s12QrODwNbhIJStmJkd1QDXa1NRA8aF2A1zk/Ypyw==",
- "dev": true,
- "requires": {
- "function-bind": "^1.1.1",
- "has-bigints": "^1.0.1",
- "has-symbols": "^1.0.2",
- "which-boxed-primitive": "^1.0.2"
- }
- },
- "undeclared-identifiers": {
- "version": "1.1.2",
- "resolved": "https://registry.npmjs.org/undeclared-identifiers/-/undeclared-identifiers-1.1.2.tgz",
- "integrity": "sha512-13EaeocO4edF/3JKime9rD7oB6QI8llAGhgn5fKOPyfkJbRb6NFv9pYV6dFEmpa4uRjKeBqLZP8GpuzqHlKDMQ==",
- "dev": true,
- "requires": {
- "acorn-node": "^1.3.0",
- "get-assigned-identifiers": "^1.2.0",
- "simple-concat": "^1.0.0",
- "xtend": "^4.0.1"
- }
- },
- "underscore": {
- "version": "1.12.1",
- "resolved": "https://registry.npmjs.org/underscore/-/underscore-1.12.1.tgz",
- "integrity": "sha512-hEQt0+ZLDVUMhebKxL4x1BTtDY7bavVofhZ9KZ4aI26X9SRaE+Y3m83XUL1UP2jn8ynjndwCCpEHdUG+9pP1Tw==",
- "dev": true
- },
- "upper-case": {
- "version": "1.1.3",
- "resolved": "https://registry.npmjs.org/upper-case/-/upper-case-1.1.3.tgz",
- "integrity": "sha1-9rRQHC7EzdJrp4vnIilh3ndiFZg=",
- "dev": true
- },
- "urix": {
- "version": "0.1.0",
- "resolved": "https://registry.npmjs.org/urix/-/urix-0.1.0.tgz",
- "integrity": "sha1-2pN/emLiH+wf0Y1Js1wpNQZ6bHI=",
- "dev": true
- },
- "url": {
- "version": "0.11.0",
- "resolved": "https://registry.npmjs.org/url/-/url-0.11.0.tgz",
- "integrity": "sha1-ODjpfPxgUh63PFJajlW/3Z4uKPE=",
- "dev": true,
- "requires": {
- "punycode": "1.3.2",
- "querystring": "0.2.0"
- },
- "dependencies": {
- "punycode": {
- "version": "1.3.2",
- "resolved": "https://registry.npmjs.org/punycode/-/punycode-1.3.2.tgz",
- "integrity": "sha1-llOgNvt8HuQjQvIyXM7v6jkmxI0=",
- "dev": true
- }
- }
- },
- "utif": {
- "version": "2.0.1",
- "resolved": "https://registry.npmjs.org/utif/-/utif-2.0.1.tgz",
- "integrity": "sha512-Z/S1fNKCicQTf375lIP9G8Sa1H/phcysstNrrSdZKj1f9g58J4NMgb5IgiEZN9/nLMPDwF0W7hdOe9Qq2IYoLg==",
- "dev": true,
- "requires": {
- "pako": "^1.0.5"
- }
- },
- "util": {
- "version": "0.10.4",
- "resolved": "https://registry.npmjs.org/util/-/util-0.10.4.tgz",
- "integrity": "sha512-0Pm9hTQ3se5ll1XihRic3FDIku70C+iHUdT/W926rSgHV5QgXsYbKZN8MSC3tJtSkhuROzvsQjAaFENRXr+19A==",
- "dev": true,
- "requires": {
- "inherits": "2.0.3"
- }
- },
- "util-deprecate": {
- "version": "1.0.2",
- "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
- "integrity": "sha1-RQ1Nyfpw3nMnYvvS1KKJgUGaDM8="
- },
- "utila": {
- "version": "0.4.0",
- "resolved": "https://registry.npmjs.org/utila/-/utila-0.4.0.tgz",
- "integrity": "sha1-ihagXURWV6Oupe7MWxKk+lN5dyw=",
- "dev": true
- },
- "vm-browserify": {
- "version": "1.1.0",
- "resolved": "https://registry.npmjs.org/vm-browserify/-/vm-browserify-1.1.0.tgz",
- "integrity": "sha512-iq+S7vZJE60yejDYM0ek6zg308+UZsdtPExWP9VZoCFCz1zkJoXFnAX7aZfd/ZwrkidzdUZL0C/ryW+JwAiIGw==",
- "dev": true
- },
- "watchify": {
- "version": "4.0.0",
- "resolved": "https://registry.npmjs.org/watchify/-/watchify-4.0.0.tgz",
- "integrity": "sha512-2Z04dxwoOeNxa11qzWumBTgSAohTC0+ScuY7XMenPnH+W2lhTcpEOJP4g2EIG/SWeLadPk47x++Yh+8BqPM/lA==",
- "dev": true,
- "requires": {
- "anymatch": "^3.1.0",
- "browserify": "^17.0.0",
- "chokidar": "^3.4.0",
- "defined": "^1.0.0",
- "outpipe": "^1.1.0",
- "through2": "^4.0.2",
- "xtend": "^4.0.2"
- },
- "dependencies": {
- "browser-resolve": {
- "version": "2.0.0",
- "resolved": "https://registry.npmjs.org/browser-resolve/-/browser-resolve-2.0.0.tgz",
- "integrity": "sha512-7sWsQlYL2rGLy2IWm8WL8DCTJvYLc/qlOnsakDac87SOoCd16WLsaAMdCiAqsTNHIe+SXfaqyxyo6THoWqs8WQ==",
- "dev": true,
- "requires": {
- "resolve": "^1.17.0"
- }
- },
- "browserify": {
- "version": "17.0.0",
- "resolved": "https://registry.npmjs.org/browserify/-/browserify-17.0.0.tgz",
- "integrity": "sha512-SaHqzhku9v/j6XsQMRxPyBrSP3gnwmE27gLJYZgMT2GeK3J0+0toN+MnuNYDfHwVGQfLiMZ7KSNSIXHemy905w==",
- "dev": true,
- "requires": {
- "assert": "^1.4.0",
- "browser-pack": "^6.0.1",
- "browser-resolve": "^2.0.0",
- "browserify-zlib": "~0.2.0",
- "buffer": "~5.2.1",
- "cached-path-relative": "^1.0.0",
- "concat-stream": "^1.6.0",
- "console-browserify": "^1.1.0",
- "constants-browserify": "~1.0.0",
- "crypto-browserify": "^3.0.0",
- "defined": "^1.0.0",
- "deps-sort": "^2.0.1",
- "domain-browser": "^1.2.0",
- "duplexer2": "~0.1.2",
- "events": "^3.0.0",
- "glob": "^7.1.0",
- "has": "^1.0.0",
- "htmlescape": "^1.1.0",
- "https-browserify": "^1.0.0",
- "inherits": "~2.0.1",
- "insert-module-globals": "^7.2.1",
- "JSONStream": "^1.0.3",
- "labeled-stream-splicer": "^2.0.0",
- "mkdirp-classic": "^0.5.2",
- "module-deps": "^6.2.3",
- "os-browserify": "~0.3.0",
- "parents": "^1.0.1",
- "path-browserify": "^1.0.0",
- "process": "~0.11.0",
- "punycode": "^1.3.2",
- "querystring-es3": "~0.2.0",
- "read-only-stream": "^2.0.0",
- "readable-stream": "^2.0.2",
- "resolve": "^1.1.4",
- "shasum-object": "^1.0.0",
- "shell-quote": "^1.6.1",
- "stream-browserify": "^3.0.0",
- "stream-http": "^3.0.0",
- "string_decoder": "^1.1.1",
- "subarg": "^1.0.0",
- "syntax-error": "^1.1.1",
- "through2": "^2.0.0",
- "timers-browserify": "^1.0.1",
- "tty-browserify": "0.0.1",
- "url": "~0.11.0",
- "util": "~0.12.0",
- "vm-browserify": "^1.0.0",
- "xtend": "^4.0.0"
- },
- "dependencies": {
- "through2": {
- "version": "2.0.5",
- "resolved": "https://registry.npmjs.org/through2/-/through2-2.0.5.tgz",
- "integrity": "sha512-/mrRod8xqpA+IHSLyGCQ2s8SPHiCDEeQJSep1jqLYeEUClOFG2Qsh+4FU6G9VeqpZnGW/Su8LQGc4YKni5rYSQ==",
- "dev": true,
- "requires": {
- "readable-stream": "~2.3.6",
- "xtend": "~4.0.1"
- }
- }
- }
- },
- "events": {
- "version": "3.3.0",
- "resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz",
- "integrity": "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==",
- "dev": true
- },
- "inherits": {
- "version": "2.0.4",
- "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
- "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
- "dev": true
- },
- "path-browserify": {
- "version": "1.0.1",
- "resolved": "https://registry.npmjs.org/path-browserify/-/path-browserify-1.0.1.tgz",
- "integrity": "sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==",
- "dev": true
- },
- "stream-browserify": {
- "version": "3.0.0",
- "resolved": "https://registry.npmjs.org/stream-browserify/-/stream-browserify-3.0.0.tgz",
- "integrity": "sha512-H73RAHsVBapbim0tU2JwwOiXUj+fikfiaoYAKHF3VJfA0pe2BCzkhAHBlLG6REzE+2WNZcxOXjK7lkso+9euLA==",
- "dev": true,
- "requires": {
- "inherits": "~2.0.4",
- "readable-stream": "^3.5.0"
- },
- "dependencies": {
- "readable-stream": {
- "version": "3.6.0",
- "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.0.tgz",
- "integrity": "sha512-BViHy7LKeTz4oNnkcLJ+lVSL6vpiFeX6/d3oSH8zCW7UxP2onchk+vTGB143xuFjHS3deTgkKoXXymXqymiIdA==",
- "dev": true,
- "requires": {
- "inherits": "^2.0.3",
- "string_decoder": "^1.1.1",
- "util-deprecate": "^1.0.1"
- }
- }
- }
- },
- "stream-http": {
- "version": "3.2.0",
- "resolved": "https://registry.npmjs.org/stream-http/-/stream-http-3.2.0.tgz",
- "integrity": "sha512-Oq1bLqisTyK3TSCXpPbT4sdeYNdmyZJv1LxpEm2vu1ZhK89kSE5YXwZc3cWk0MagGaKriBh9mCFbVGtO+vY29A==",
- "dev": true,
- "requires": {
- "builtin-status-codes": "^3.0.0",
- "inherits": "^2.0.4",
- "readable-stream": "^3.6.0",
- "xtend": "^4.0.2"
- },
- "dependencies": {
- "readable-stream": {
- "version": "3.6.0",
- "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.0.tgz",
- "integrity": "sha512-BViHy7LKeTz4oNnkcLJ+lVSL6vpiFeX6/d3oSH8zCW7UxP2onchk+vTGB143xuFjHS3deTgkKoXXymXqymiIdA==",
- "dev": true,
- "requires": {
- "inherits": "^2.0.3",
- "string_decoder": "^1.1.1",
- "util-deprecate": "^1.0.1"
- }
- }
- }
- },
- "through2": {
- "version": "4.0.2",
- "resolved": "https://registry.npmjs.org/through2/-/through2-4.0.2.tgz",
- "integrity": "sha512-iOqSav00cVxEEICeD7TjLB1sueEL+81Wpzp2bY17uZjZN0pWZPuo4suZ/61VujxmqSGFfgOcNuTZ85QJwNZQpw==",
- "dev": true,
- "requires": {
- "readable-stream": "3"
- },
- "dependencies": {
- "readable-stream": {
- "version": "3.6.0",
- "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.0.tgz",
- "integrity": "sha512-BViHy7LKeTz4oNnkcLJ+lVSL6vpiFeX6/d3oSH8zCW7UxP2onchk+vTGB143xuFjHS3deTgkKoXXymXqymiIdA==",
- "dev": true,
- "requires": {
- "inherits": "^2.0.3",
- "string_decoder": "^1.1.1",
- "util-deprecate": "^1.0.1"
- }
- }
- }
- },
- "util": {
- "version": "0.12.3",
- "resolved": "https://registry.npmjs.org/util/-/util-0.12.3.tgz",
- "integrity": "sha512-I8XkoQwE+fPQEhy9v012V+TSdH2kp9ts29i20TaaDUXsg7x/onePbhFJUExBfv/2ay1ZOp/Vsm3nDlmnFGSAog==",
- "dev": true,
- "requires": {
- "inherits": "^2.0.3",
- "is-arguments": "^1.0.4",
- "is-generator-function": "^1.0.7",
- "is-typed-array": "^1.1.3",
- "safe-buffer": "^5.1.2",
- "which-typed-array": "^1.1.2"
- }
- }
- }
- },
- "which-boxed-primitive": {
- "version": "1.0.2",
- "resolved": "https://registry.npmjs.org/which-boxed-primitive/-/which-boxed-primitive-1.0.2.tgz",
- "integrity": "sha512-bwZdv0AKLpplFY2KZRX6TvyuN7ojjr7lwkg6ml0roIy9YeuSr7JS372qlNW18UQYzgYK9ziGcerWqZOmEn9VNg==",
- "dev": true,
- "requires": {
- "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-typed-array": {
- "version": "1.1.4",
- "resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.4.tgz",
- "integrity": "sha512-49E0SpUe90cjpoc7BOJwyPHRqSAd12c10Qm2amdEZrJPCY2NDxaW01zHITrem+rnETY3dwrbH3UUrUwagfCYDA==",
- "dev": true,
- "requires": {
- "available-typed-arrays": "^1.0.2",
- "call-bind": "^1.0.0",
- "es-abstract": "^1.18.0-next.1",
- "foreach": "^2.0.5",
- "function-bind": "^1.1.1",
- "has-symbols": "^1.0.1",
- "is-typed-array": "^1.1.3"
- }
- },
- "wrappy": {
- "version": "1.0.2",
- "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
- "integrity": "sha1-tSQ9jz7BqjXxNkYFvA0QNuMKtp8=",
- "dev": true
- },
- "ws": {
- "version": "7.4.6",
- "resolved": "https://registry.npmjs.org/ws/-/ws-7.4.6.tgz",
- "integrity": "sha512-YmhHDO4MzaDLB+M9ym/mDA5z0naX8j7SIlT8f8z+I0VtzsRbekxEutHSme7NPS2qE8StCYQNUnfWdXta/Yu85A==",
- "dev": true,
- "requires": {}
- },
- "xhr": {
- "version": "2.5.0",
- "resolved": "https://registry.npmjs.org/xhr/-/xhr-2.5.0.tgz",
- "integrity": "sha512-4nlO/14t3BNUZRXIXfXe+3N6w3s1KoxcJUUURctd64BLRe67E4gRwp4PjywtDY72fXpZ1y6Ch0VZQRY/gMPzzQ==",
- "dev": true,
- "requires": {
- "global": "~4.3.0",
- "is-function": "^1.0.1",
- "parse-headers": "^2.0.0",
- "xtend": "^4.0.0"
- }
- },
- "xml-parse-from-string": {
- "version": "1.0.1",
- "resolved": "https://registry.npmjs.org/xml-parse-from-string/-/xml-parse-from-string-1.0.1.tgz",
- "integrity": "sha1-qQKekp09vN7RafPG4oI42VpdWig=",
- "dev": true
- },
- "xml2js": {
- "version": "0.4.23",
- "resolved": "https://registry.npmjs.org/xml2js/-/xml2js-0.4.23.tgz",
- "integrity": "sha512-ySPiMjM0+pLDftHgXY4By0uswI3SPKLDw/i3UXbnO8M/p28zqexCUoPmQFrYD+/1BzhGJSs2i1ERWKJAtiLrug==",
- "dev": true,
- "requires": {
- "sax": ">=0.6.0",
- "xmlbuilder": "~11.0.0"
- }
- },
- "xmlbuilder": {
- "version": "11.0.1",
- "resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-11.0.1.tgz",
- "integrity": "sha512-fDlsI/kFEx7gLvbecc0/ohLG50fugQp8ryHzMTuW9vSa1GJ0XYWKnhsUx7oie3G98+r56aTQIUB4kht42R3JvA==",
- "dev": true
- },
- "xtend": {
- "version": "4.0.2",
- "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz",
- "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==",
- "dev": true
- }
}
}
diff --git a/client/package.json b/client/package.json
index 76376f8..6daba6b 100644
--- a/client/package.json
+++ b/client/package.json
@@ -8,7 +8,7 @@
},
"dependencies": {
"dompurify": "^2.0.17",
- "font-awesome": "^4.7.0",
+ "@fortawesome/fontawesome-free": "^5.1.0",
"ios-inner-height": "^1.0.3",
"js-cookie": "^2.2.0",
"marked": "^4.0.10",
diff --git a/docker-compose.test.yml b/docker-compose.test.yml
new file mode 100644
index 0000000..0444c69
--- /dev/null
+++ b/docker-compose.test.yml
@@ -0,0 +1,23 @@
+services:
+ test-server:
+ build:
+ context: ./server
+ target: testing
+ environment:
+ POSTGRES_HOST: test-sql
+ POSTGRES_USER: your_postgres_user
+ POSTGRES_PASSWORD: your_postgres_password
+ depends_on:
+ - test-sql
+ volumes:
+ - "${MOUNT_DATA}:/data"
+ - "./server/config.yaml:/opt/app/config.yaml"
+
+ test-sql:
+ image: postgres:11-alpine
+ restart: unless-stopped
+ environment:
+ POSTGRES_USER: your_postgres_user
+ POSTGRES_PASSWORD: your_postgres_password
+ volumes:
+ - "${MOUNT_SQL}:/var/lib/postgresql/data" \ No newline at end of file
diff --git a/docker-compose.yml b/docker-compose.yml
index 4fd677d..dc7ca69 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -5,7 +5,9 @@
services:
server:
- image: szurubooru/server:latest
+ # image: szurubooru/server:latest
+ build:
+ context: ./server
depends_on:
- sql
environment:
@@ -25,7 +27,11 @@ services:
- "./server/config.yaml:/opt/app/config.yaml"
client:
- image: szurubooru/client:latest
+ # image: szurubooru/client:latest
+ build:
+ context: ./client
+ args:
+ BUILD_INFO: dev build
depends_on:
- server
environment:
diff --git a/server/config.yaml.dist b/server/config.yaml.dist
index b55afad..9a41295 100644
--- a/server/config.yaml.dist
+++ b/server/config.yaml.dist
@@ -117,6 +117,7 @@ privileges:
'posts:favorite': regular
'posts:bulk-edit:tags': power
'posts:bulk-edit:safety': power
+ 'posts:view:similar': regular
'posts:bulk-edit:delete': power
'tags:create': regular
@@ -139,6 +140,12 @@ privileges:
'tag_categories:delete': moderator
'tag_categories:set_default': moderator
+ 'metrics:create': power
+ 'metrics:edit:bounds': power
+ 'metrics:edit:posts': regular
+ 'metrics:list': regular
+ 'metrics:delete': moderator
+
'pools:create': regular
'pools:edit:names': power
'pools:edit:category': power
diff --git a/server/szurubooru/api/__init__.py b/server/szurubooru/api/__init__.py
index d9b7ecb..99c9524 100644
--- a/server/szurubooru/api/__init__.py
+++ b/server/szurubooru/api/__init__.py
@@ -1,5 +1,6 @@
import szurubooru.api.comment_api
import szurubooru.api.info_api
+import szurubooru.api.metric_api
import szurubooru.api.password_reset_api
import szurubooru.api.pool_api
import szurubooru.api.pool_category_api
diff --git a/server/szurubooru/api/metric_api.py b/server/szurubooru/api/metric_api.py
new file mode 100644
index 0000000..2bba66f
--- /dev/null
+++ b/server/szurubooru/api/metric_api.py
@@ -0,0 +1,94 @@
+from math import ceil
+from typing import Optional, List, Dict
+from szurubooru import db, model, search, rest
+from szurubooru.func import (
+ auth, metrics, snapshots, serialization, tags, versions
+)
+
+
+_search_executor_config = search.configs.PostMetricSearchConfig()
+_search_executor = search.Executor(_search_executor_config)
+
+
+def _serialize_metric(
+ ctx: rest.Context, metric: model.Metric) -> rest.Response:
+ return metrics.serialize_metric(
+ metric, options=serialization.get_serialization_options(ctx)
+ )
+
+
+def _serialize_post_metric(
+ ctx: rest.Context, post_metric: model.PostMetric) -> rest.Response:
+ return metrics.serialize_post_metric(
+ post_metric, options=serialization.get_serialization_options(ctx)
+ )
+
+
+def _get_metric(params: Dict[str, str]) -> model.Metric:
+ return metrics.get_metric_by_tag_name(params["tag_name"])
+
+
+@rest.routes.get("/metrics/?")
+def get_metrics(
+ ctx: rest.Context, params: Dict[str, str] = {}) -> rest.Response:
+ auth.verify_privilege(ctx.user, "metrics:list")
+ all_metrics = metrics.get_all_metrics()
+ return {
+ "results": [_serialize_metric(ctx, metric) for metric in all_metrics]
+ }
+
+
+@rest.routes.post("/metrics/?")
+def create_metric(
+ ctx: rest.Context, params: Dict[str, str] = {}) -> rest.Response:
+ auth.verify_privilege(ctx.user, "metrics:create")
+ tag_name = ctx.get_param_as_string("tag_name")
+ tag = tags.get_tag_by_name(tag_name)
+ min = ctx.get_param_as_float("min")
+ max = ctx.get_param_as_float("max")
+
+ metric = metrics.create_metric(tag, min, max)
+ ctx.session.flush()
+ # snapshots.create(metric, ctx.user)
+ ctx.session.commit()
+ return _serialize_metric(ctx, metric)
+
+
+@rest.routes.delete("/metric/(?P<tag_name>.+)")
+def delete_metric(ctx: rest.Context, params: Dict[str, str]) -> rest.Response:
+ metric = _get_metric(params)
+ versions.verify_version(metric, ctx)
+ auth.verify_privilege(ctx.user, "metrics:delete")
+ # snapshots.delete(metric, ctx.user)
+ metrics.delete_metric(metric)
+ ctx.session.commit()
+ return {}
+
+
+@rest.routes.get("/post-metrics/?")
+def get_post_metrics(
+ ctx: rest.Context, params: Dict[str, str] = {}) -> rest.Response:
+ auth.verify_privilege(ctx.user, "metrics:list")
+ return _search_executor.execute_and_serialize(
+ ctx, lambda post_metric: _serialize_post_metric(ctx, post_metric))
+
+
+@rest.routes.get("/post-metrics/median/(?P<tag_name>.+)")
+def get_post_metrics_median(
+ ctx: rest.Context, params: Dict[str, str] = {}) -> rest.Response:
+ auth.verify_privilege(ctx.user, "metrics:list")
+ metric = _get_metric(params)
+ tag_name = params["tag_name"]
+ query_text = ctx.get_param_as_string(
+ "query",
+ default="%s:%f..%f" % (tag_name, metric.min, metric.max))
+ total_count = _search_executor.count(query_text)
+ offset = ceil(total_count/2) - 1
+ _, results = _search_executor.execute(query_text, offset, 1)
+ return {
+ "query": query_text,
+ "offset": offset,
+ "limit": 1,
+ "total": len(results),
+ "results": list([_serialize_post_metric(ctx, pm) for pm in results])
+ }
diff --git a/server/szurubooru/api/post_api.py b/server/szurubooru/api/post_api.py
index 7883f5e..4e89f54 100644
--- a/server/szurubooru/api/post_api.py
+++ b/server/szurubooru/api/post_api.py
@@ -1,3 +1,4 @@
+from math import ceil
from datetime import datetime
from typing import Dict, List, Optional
@@ -5,13 +6,15 @@ from szurubooru import db, errors, model, rest, search
from szurubooru.func import (
auth,
favorites,
+ metrics,
mime,
posts,
scores,
serialization,
+ similar,
snapshots,
tags,
- versions,
+ versions, image_hash,
)
_search_executor_config = search.configs.PostSearchConfig()
@@ -167,6 +170,14 @@ def update_post(ctx: rest.Context, params: Dict[str, str]) -> rest.Response:
if ctx.has_file("thumbnail"):
auth.verify_privilege(ctx.user, "posts:edit:thumbnail")
posts.update_post_thumbnail(post, ctx.get_file("thumbnail"))
+ if ctx.has_param("metrics"):
+ auth.verify_privilege(ctx.user, "metrics:edit:posts")
+ metrics.update_or_create_post_metrics(
+ post, ctx.get_param_as_list("metrics"))
+ if ctx.has_param("metricRanges"):
+ auth.verify_privilege(ctx.user, "metrics:edit:posts")
+ metrics.update_or_create_post_metric_ranges(
+ post, ctx.get_param_as_list("metricRanges"))
post.last_edit_time = datetime.utcnow()
ctx.session.flush()
snapshots.modify(post, ctx.user)
@@ -310,3 +321,72 @@ def get_posts_by_image(
for distance, post in lookalikes
],
}
+
+
+@rest.routes.get("/post/(?P<post_id>[^/]+)/reverse-search/?")
+def get_posts_lookalikes(
+ ctx: rest.Context, params: Dict[str, str] = {}
+) -> rest.Response:
+ auth.verify_privilege(ctx.user, "posts:reverse_search")
+ limit = ctx.get_param_as_int("limit", default=10, min=1, max=100)
+ threshold = ctx.get_param_as_float("threshold", default=1, min=0, max=100)
+ query_text = ctx.get_param_as_string("query", default="")
+ post_id = _get_post_id(params)
+ post = posts.get_post_by_id(post_id)
+ if post.signature is None:
+ return {"similarPosts": []}
+
+ sig = image_hash.unpack_signature(post.signature.signature)
+ # limit + 1 because the original post will be excluded
+ lookalikes = posts.search_by_signature(sig, limit + 1, threshold, query_text)
+ # exclude the original post:
+ lookalikes = filter(lambda la: la[1].post_id != post_id, lookalikes)
+ lookalikes = sorted(lookalikes, key=lambda la: la[0])
+ return {
+ "similarPosts": [
+ {
+ "distance": distance,
+ "post": _serialize_post(ctx, post),
+ }
+ for distance, post in lookalikes
+ ],
+ }
+
+
+@rest.routes.get("/posts/median/?")
+def get_posts_median(
+ ctx: rest.Context, _params: Dict[str, str] = {}
+) -> rest.Response:
+ auth.verify_privilege(ctx.user, "posts:list")
+ _search_executor_config.user = ctx.user
+ query_text = ctx.get_param_as_string("query", default="")
+ total_count = _search_executor.count(query_text)
+ offset = ceil(total_count / 2) - 1
+ _, results = _search_executor.execute(query_text, offset, 1)
+ return {
+ "query": query_text,
+ "offset": offset,
+ "limit": 1,
+ "total": len(results),
+ "results": list([_serialize_post(ctx, post) for post in results])
+ }
+
+
+@rest.routes.get("/post/(?P<post_id>[^/]+)/similar-by-tags/?")
+def get_posts_similar_by_tags(
+ ctx: rest.Context, params: Dict[str, str]
+) -> rest.Response:
+ auth.verify_privilege(ctx.user, "posts:view:similar")
+ _search_executor_config.user = ctx.user
+ query_text = ctx.get_param_as_string("query", default="")
+ post_id = _get_post_id(params)
+ post = posts.get_post_by_id(post_id)
+ limit = ctx.get_param_as_int("limit", default=10, min=1, max=100)
+ results = similar.find_similar_posts(post, limit, query_text)
+ return {
+ "query": query_text,
+ "limit": limit,
+ "results": list([
+ posts.serialize_micro_post(result, ctx.user) for result in results
+ ])
+ }
diff --git a/server/szurubooru/api/tag_api.py b/server/szurubooru/api/tag_api.py
index 6b4c807..5f54324 100644
--- a/server/szurubooru/api/tag_api.py
+++ b/server/szurubooru/api/tag_api.py
@@ -2,7 +2,14 @@ from datetime import datetime
from typing import Dict, List, Optional
from szurubooru import db, model, rest, search
-from szurubooru.func import auth, serialization, snapshots, tags, versions
+from szurubooru.func import (
+ auth,
+ metrics,
+ serialization,
+ snapshots,
+ tags,
+ versions
+)
_search_executor = search.Executor(search.configs.TagSearchConfig())
@@ -93,6 +100,14 @@ def update_tag(ctx: rest.Context, params: Dict[str, str]) -> rest.Response:
implications = ctx.get_param_as_string_list("implications")
_create_if_needed(implications, ctx.user)
tags.update_tag_implications(tag, implications)
+ if ctx.has_param("metric"):
+ auth.verify_privilege(ctx.user, "metrics:edit:bounds")
+ new_metric = metrics.update_or_create_metric(tag, ctx.get_param("metric"))
+ if new_metric is not None:
+ auth.verify_privilege(ctx.user, "metrics:create")
+ db.session.flush()
+ # snapshots.create(new_metric, ctx.user)
+
tag.last_edit_time = datetime.utcnow()
ctx.session.flush()
snapshots.modify(tag, ctx.user)
diff --git a/server/szurubooru/func/metrics.py b/server/szurubooru/func/metrics.py
new file mode 100644
index 0000000..944c4d5
--- /dev/null
+++ b/server/szurubooru/func/metrics.py
@@ -0,0 +1,273 @@
+import sqlalchemy as sa
+from typing import Any, Optional, List, Dict, Callable
+from szurubooru import db, model, errors, rest
+from szurubooru.func import serialization, tags, util, versions
+
+
+class MetricDoesNotExistsError(errors.ValidationError):
+ pass
+
+
+class MetricAlreadyExistsError(errors.ValidationError):
+ pass
+
+
+class InvalidMetricError(errors.ValidationError):
+ pass
+
+
+class PostMissingTagError(errors.ValidationError):
+ pass
+
+
+class MetricValueOutOfRangeError(errors.ValidationError):
+ pass
+
+
+class MetricSerializer(serialization.BaseSerializer):
+ def __init__(self, metric: model.Metric):
+ self.metric = metric
+
+ def _serializers(self) -> Dict[str, Callable[[], Any]]:
+ return {
+ "version": lambda: self.metric.version,
+ "min": lambda: self.metric.min,
+ "max": lambda: self.metric.max,
+ "exact_count": lambda: self.metric.post_metric_count,
+ "range_count": lambda: self.metric.post_metric_range_count,
+ "tag": lambda: tags.serialize_tag(self.metric.tag, [
+ "names", "category", "description", "usages"])
+ }
+
+
+class PostMetricSerializer(serialization.BaseSerializer):
+ def __init__(self, post_metric: model.PostMetric):
+ self.post_metric = post_metric
+
+ def _serializers(self) -> Dict[str, Callable[[], Any]]:
+ return {
+ "tag_name": lambda: self.post_metric.metric.tag_name,
+ "post_id": lambda: self.post_metric.post_id,
+ "value": lambda: self.post_metric.value,
+ }
+
+
+class PostMetricRangeSerializer(serialization.BaseSerializer):
+ def __init__(self, post_metric_range: model.PostMetricRange):
+ self.post_metric_range = post_metric_range
+
+ def _serializers(self) -> Dict[str, Callable[[], Any]]:
+ return {
+ "tag_name": lambda: self.post_metric_range.metric.tag_name,
+ "post_id": lambda: self.post_metric_range.post_id,
+ "low": lambda: self.post_metric_range.low,
+ "high": lambda: self.post_metric_range.high,
+ }
+
+
+def serialize_metric(
+ metric: model.Metric,
+ options: List[str] = []) -> Optional[rest.Response]:
+ if not metric:
+ return None
+ return MetricSerializer(metric).serialize(options)
+
+
+def serialize_post_metric(
+ post_metric: model.PostMetric,
+ options: List[str] = []) -> Optional[rest.Response]:
+ if not post_metric:
+ return None
+ return PostMetricSerializer(post_metric).serialize(options)
+
+
+def serialize_post_metric_range(
+ post_metric_range: model.PostMetricRange,
+ options: List[str] = []) -> Optional[rest.Response]:
+ if not post_metric_range:
+ return None
+ return PostMetricRangeSerializer(post_metric_range).serialize(options)
+
+
+def try_get_metric_by_tag_name(tag_name: str) -> Optional[model.Metric]:
+ return (
+ db.session
+ .query(model.Metric)
+ .filter(sa.func.lower(model.Metric.tag_name) == tag_name.lower())
+ .one_or_none())
+
+
+def get_metric_by_tag_name(tag_name: str) -> model.Metric:
+ metric = try_get_metric_by_tag_name(tag_name)
+ if not metric:
+ raise MetricDoesNotExistsError("Metric %r not found." % tag_name)
+ return metric
+
+
+def get_all_metrics() -> List[model.Metric]:
+ return db.session.query(model.Metric).all()
+
+
+def get_all_metric_tag_names() -> List[str]:
+ return [
+ tag_name.name for tag_name in util.flatten_list(
+ [metric.tag.names for metric in get_all_metrics()]
+ )
+ ]
+
+
+def try_get_post_metric(
+ post: model.Post,
+ metric: model.Metric) -> Optional[model.PostMetric]:
+ return (
+ db.session
+ .query(model.PostMetric)
+ .filter(model.PostMetric.metric == metric)
+ .filter(model.PostMetric.post == post)
+ .one_or_none())
+
+
+def try_get_post_metric_range(
+ post: model.Post,
+ metric: model.Metric) -> Optional[model.PostMetricRange]:
+ return (
+ db.session
+ .query(model.PostMetricRange)
+ .filter(model.PostMetricRange.metric == metric)
+ .filter(model.PostMetricRange.post == post)
+ .one_or_none())
+
+
+def create_metric(
+ tag: model.Tag,
+ min: float,
+ max: float) -> model.Metric:
+ assert tag
+ if tag.metric:
+ raise MetricAlreadyExistsError("Tag already has a metric.")
+ if min >= max:
+ raise InvalidMetricError("Metric min(%r) >= max(%r)" % (min, max))
+ metric = model.Metric(tag=tag, min=min, max=max)
+ db.session.add(metric)
+ return metric
+
+
+def update_or_create_metric(
+ tag: model.Tag,
+ metric_data: Any) -> Optional[model.Metric]:
+ assert tag
+ for field in ("min", "max"):
+ if field not in metric_data:
+ raise InvalidMetricError("Metric is missing %r field." % field)
+
+ min, max = metric_data["min"], metric_data["max"]
+ if min >= max:
+ raise InvalidMetricError("Metric min(%r) >= max(%r)" % (min, max))
+ if tag.metric:
+ tag.metric.min = min
+ tag.metric.max = max
+ versions.bump_version(tag.metric)
+ return None
+ else:
+ return create_metric(tag=tag, min=min, max=max)
+
+
+def update_or_create_post_metric(
+ post: model.Post,
+ metric: model.Metric,
+ value: float) -> model.PostMetric:
+ assert post
+ assert metric
+ if metric.tag not in post.tags:
+ raise PostMissingTagError(
+ "Post doesn\"t have this tag.")
+ if value < metric.min or value > metric.max:
+ raise MetricValueOutOfRangeError(
+ "Metric value %r out of range." % value)
+ post_metric = try_get_post_metric(post, metric)
+ if not post_metric:
+ post_metric = model.PostMetric(post=post, metric=metric, value=value)
+ db.session.add(post_metric)
+ else:
+ post_metric.value = value
+ versions.bump_version(post_metric)
+ return post_metric
+
+
+def update_or_create_post_metrics(post: model.Post, metrics_data: Any) -> None:
+ """
+ Overwrites any existing post metrics, deletes other existing post metrics.
+ """
+ assert post
+ post.metrics = []
+ for metric_data in metrics_data:
+ for field in ("tag_name", "value"):
+ if field not in metric_data:
+ raise InvalidMetricError("Metric is missing %r field." % field)
+ value = float(metric_data["value"])
+ tag_name = metric_data["tag_name"]
+ tag = tags.get_tag_by_name(tag_name)
+ if not tag.metric:
+ raise MetricDoesNotExistsError(
+ "Tag %r has no metric." % tag_name)
+ post_metric = update_or_create_post_metric(post, tag.metric, value)
+ post.metrics.append(post_metric)
+
+
+def update_or_create_post_metric_range(
+ post: model.Post,
+ metric: model.Metric,
+ low: float,
+ high: float) -> model.PostMetricRange:
+ assert post
+ assert metric
+ if metric.tag not in post.tags:
+ raise PostMissingTagError(
+ "Post doesn\"t have this tag.")
+ for value in (low, high):
+ if value < metric.min or value > metric.max:
+ raise MetricValueOutOfRangeError(
+ "Metric value %r out of range." % value)
+ if low >= high:
+ raise InvalidMetricError(
+ "Metric range low(%r) >= high(%r)" % (low, high))
+ post_metric_range = try_get_post_metric_range(post, metric)
+ if not post_metric_range:
+ post_metric_range = model.PostMetricRange(
+ post=post, metric=metric, low=low, high=high)
+ db.session.add(post_metric_range)
+ else:
+ post_metric_range.low = low
+ post_metric_range.high = high
+ versions.bump_version(post_metric_range)
+ return post_metric_range
+
+
+def update_or_create_post_metric_ranges(
+ post: model.Post,
+ metric_ranges_data: Any) -> None:
+ """
+ Overwrites any existing post metrics, deletes other existing post metrics.
+ """
+ assert post
+ post.metric_ranges = []
+ for metric_data in metric_ranges_data:
+ for field in ("tag_name", "low", "high"):
+ if field not in metric_data:
+ raise InvalidMetricError(
+ "Metric range is missing %r field." % field)
+ low = float(metric_data["low"])
+ high = float(metric_data["high"])
+ tag_name = metric_data["tag_name"]
+ tag = tags.get_tag_by_name(tag_name)
+ if not tag.metric:
+ raise MetricDoesNotExistsError(
+ "Tag %r has no metric." % tag_name)
+ post_metric_range = update_or_create_post_metric_range(
+ post, tag.metric, low, high)
+ post.metric_ranges.append(post_metric_range)
+
+
+def delete_metric(metric: model.Metric) -> None:
+ assert metric
+ db.session.delete(metric)
diff --git a/server/szurubooru/func/posts.py b/server/szurubooru/func/posts.py
index be2259c..a7c111a 100644
--- a/server/szurubooru/func/posts.py
+++ b/server/szurubooru/func/posts.py
@@ -1,5 +1,6 @@
import hmac
import logging
+import re
from datetime import datetime
from typing import Any, Callable, Dict, List, Optional, Tuple
@@ -11,6 +12,7 @@ from szurubooru.func import (
files,
image_hash,
images,
+ metrics,
mime,
pools,
scores,
@@ -20,6 +22,8 @@ from szurubooru.func import (
users,
util,
)
+from szurubooru.func.image_hash import NpMatrix
+from szurubooru.search import parser, criteria
logger = logging.getLogger(__name__)
@@ -198,6 +202,8 @@ class PostSerializer(serialization.BaseSerializer):
"hasCustomThumbnail": self.serialize_has_custom_thumbnail,
"notes": self.serialize_notes,
"comments": self.serialize_comments,
+ "metrics": self.serialize_metrics,
+ "metricRanges": self.serialize_metric_ranges,
"pools": self.serialize_pools,
}
@@ -255,6 +261,10 @@ class PostSerializer(serialization.BaseSerializer):
"names": [name.name for name in tag.names],
"category": tag.category.name,
"usages": tag.post_count,
+ "metric": {
+ "min": tag.metric.min,
+ "max": tag.metric.max
+ } if tag.metric else None,
}
for tag in tags.sort_tags(self.post.tags)
]
@@ -344,6 +354,24 @@ class PostSerializer(serialization.BaseSerializer):
)
]
+ def serialize_metrics(self) -> Any:
+ return [
+ metrics.serialize_post_metric(metric)
+ for metric in sorted(
+ self.post.metrics,
+ key=lambda metric: metric.metric.tag_name
+ )
+ ]
+
+ def serialize_metric_ranges(self) -> Any:
+ return [
+ metrics.serialize_post_metric_range(metric_range)
+ for metric_range in sorted(
+ self.post.metric_ranges,
+ key=lambda metric_range: metric_range.metric.tag_name
+ )
+ ]
+
def serialize_post(
post: Optional[model.Post], auth_user: model.User, options: List[str] = []
@@ -929,8 +957,16 @@ def search_by_image_exact(image_content: bytes) -> Optional[model.Post]:
def search_by_image(image_content: bytes) -> List[Tuple[float, model.Post]]:
query_signature = image_hash.generate_signature(image_content)
- query_words = image_hash.generate_words(query_signature)
+ return search_by_signature(query_signature)
+
+def search_by_signature(
+ signature: NpMatrix,
+ limit: int = 100,
+ distance_cutoff: float = image_hash.DISTANCE_CUTOFF,
+ query_text: str = ''
+) -> List[Tuple[float, model.Post]]:
+ query_words = image_hash.generate_words(signature)
"""
The unnest function is used here to expand one row containing the 'words'
array into multiple rows each containing a singular word.
@@ -939,15 +975,33 @@ def search_by_image(image_content: bytes) -> List[Tuple[float, model.Post]]:
https://www.postgresql.org/docs/9.2/functions-array.html
"""
- dbquery = """
- SELECT s.post_id, s.signature, count(a.query) AS score
- FROM post_signature AS s, unnest(s.words, :q) AS a(word, query)
- WHERE a.word = a.query
- GROUP BY s.post_id
- ORDER BY score DESC LIMIT 100;
- """
+ # optimization: don't join if safety is not queried:
+ if len(query_text) > 0:
+ dbquery = """
+ SELECT s.post_id, s.signature, count(a.query) AS score
+ FROM post_signature AS s
+ CROSS JOIN unnest(s.words, :q) AS a(word, query)
+ INNER JOIN post ON post.id = s.post_id
+ WHERE a.word = a.query
+ AND post.safety in :safety
+ GROUP BY s.post_id
+ ORDER BY score DESC LIMIT :limit;
+ """
+ else:
+ dbquery = """
+ SELECT s.post_id, s.signature, count(a.query) AS score
+ FROM post_signature AS s, unnest(s.words, :q) AS a(word, query)
+ WHERE a.word = a.query
+ GROUP BY s.post_id
+ ORDER BY score DESC LIMIT :limit;
+ """
+ allowed_rating = _get_safety_list(query_text)
- candidates = db.session.execute(dbquery, {"q": query_words})
+ candidates = db.session.execute(dbquery, {
+ "q": query_words,
+ "limit": limit,
+ "safety": tuple(allowed_rating),
+ })
data = tuple(
zip(
*[
@@ -958,13 +1012,51 @@ def search_by_image(image_content: bytes) -> List[Tuple[float, model.Post]]:
)
if data:
candidate_post_ids, sigarray = data
- distances = image_hash.normalized_distance(sigarray, query_signature)
+ distances = image_hash.normalized_distance(sigarray, signature)
return [
(distance, try_get_post_by_id(candidate_post_id))
for candidate_post_id, distance in zip(
candidate_post_ids, distances
)
- if distance < image_hash.DISTANCE_CUTOFF
+ if distance < distance_cutoff
]
else:
return []
+
+
+def _get_safety_list(query_text: str = '') -> List[str]:
+ """Will output a list of safety options matched by the query"""
+ # TODO(hunternif): searching by signature should be done in executor,
+ # together with all other tokens, but as a quick fix for safety rating,
+ # we can parse it here.
+ # Assuming format: -rating:safe,sketchy,unsafe
+ query_parser = parser.Parser()
+ search_query = query_parser.parse(query_text)
+ safety_map = util.flip(SAFETY_MAP)
+ allowed = []
+ disallowed = []
+
+ def process_safety(safety_value: str):
+ safety = safety_map.get(safety_value, None)
+ if safety:
+ if token.negated:
+ disallowed.append(safety)
+ else:
+ allowed.append(safety)
+
+ for token in search_query.named_tokens:
+ if token.name == "rating":
+ criterion = token.criterion
+ if isinstance(criterion, criteria.PlainCriterion):
+ process_safety(criterion.value)
+ elif isinstance(criterion, criteria.ArrayCriterion):
+ for value in criterion.values:
+ process_safety(value)
+
+ if len(allowed) == 0:
+ allowed = [
+ model.Post.SAFETY_SAFE,
+ model.Post.SAFETY_SKETCHY,
+ model.Post.SAFETY_UNSAFE,
+ ]
+ return [x for x in allowed if x not in disallowed] \ No newline at end of file
diff --git a/server/szurubooru/func/similar.py b/server/szurubooru/func/similar.py
new file mode 100644
index 0000000..a19b7d1
--- /dev/null
+++ b/server/szurubooru/func/similar.py
@@ -0,0 +1,32 @@
+from typing import List
+
+import sqlalchemy as sa
+
+from szurubooru import db, model, search
+
+_search_executor_config = search.configs.PostSearchConfig()
+_search_executor = search.Executor(_search_executor_config)
+
+
+# TODO(hunternif): this ignores the query, e.g. rating.
+# (But we're actually using a "similar" search query on the client anyway.)
+def find_similar_posts(
+ source_post: model.Post, limit: int, query_text: str = ''
+) -> List[model.Post]:
+ post_alias = sa.orm.aliased(model.Post)
+ pt_alias = sa.orm.aliased(model.PostTag)
+ result = (
+ db.session.query(post_alias)
+ .join(pt_alias, pt_alias.post_id == post_alias.post_id)
+ .filter(
+ sa.sql.or_(
+ pt_alias.tag_id == tag.tag_id for tag in source_post.tags
+ )
+ )
+ .filter(pt_alias.post_id != source_post.post_id)
+ .group_by(post_alias.post_id)
+ .order_by(sa.func.count(pt_alias.tag_id).desc())
+ .order_by(post_alias.post_id.desc())
+ .limit(limit)
+ )
+ return result
diff --git a/server/szurubooru/func/tags.py b/server/szurubooru/func/tags.py
index 28a2a76..4981c5d 100644
--- a/server/szurubooru/func/tags.py
+++ b/server/szurubooru/func/tags.py
@@ -98,6 +98,7 @@ class TagSerializer(serialization.BaseSerializer):
"usages": self.serialize_usages,
"suggestions": self.serialize_suggestions,
"implications": self.serialize_implications,
+ "metric": self.serialize_metric,
}
def serialize_names(self) -> Any:
@@ -133,6 +134,16 @@ class TagSerializer(serialization.BaseSerializer):
for relation in sort_tags(self.tag.implications)
]
+ def serialize_metric(self) -> Any:
+ if not self.tag.metric:
+ return None
+ else:
+ return {
+ "version": self.tag.metric.version,
+ "min": self.tag.metric.min,
+ "max": self.tag.metric.max,
+ }
+
def serialize_tag(
tag: model.Tag, options: List[str] = []
@@ -241,6 +252,8 @@ def merge_tags(source_tag: model.Tag, target_tag: model.Tag) -> None:
assert target_tag
if source_tag.tag_id == target_tag.tag_id:
raise InvalidTagRelationError("Cannot merge tag with itself.")
+ if source_tag.metric or target_tag.metric:
+ raise InvalidTagRelationError("Cannot merge tags with metrics.")
def merge_posts(source_tag_id: int, target_tag_id: int) -> None:
alias1 = model.PostTag
diff --git a/server/szurubooru/func/util.py b/server/szurubooru/func/util.py
index 453e121..dc5ced0 100644
--- a/server/szurubooru/func/util.py
+++ b/server/szurubooru/func/util.py
@@ -62,6 +62,10 @@ def unalias_dict(source: List[Tuple[List[str], T]]) -> Dict[str, T]:
return output_dict
+def flatten_list(source: List[List[T]]) -> List[T]:
+ return [item for sublist in source for item in sublist]
+
+
def get_md5(source: Union[str, bytes]) -> str:
if not isinstance(source, bytes):
source = source.encode("utf-8")
diff --git a/server/szurubooru/migrations/versions/0061c5c3299f_postmetric_depends_on_posttag.py b/server/szurubooru/migrations/versions/0061c5c3299f_postmetric_depends_on_posttag.py
new file mode 100644
index 0000000..be8fc08
--- /dev/null
+++ b/server/szurubooru/migrations/versions/0061c5c3299f_postmetric_depends_on_posttag.py
@@ -0,0 +1,35 @@
+'''
+PostMetric depends on PostTag
+
+Revision ID: 0061c5c3299f
+Created at: 2019-04-20 14:02:23.229492
+'''
+
+import sqlalchemy as sa
+from alembic import op
+
+
+revision = '0061c5c3299f'
+down_revision = 'aae2050fb28c'
+branch_labels = None
+depends_on = None
+
+
+def upgrade():
+ op.create_foreign_key(
+ 'post_metric_post_tag_fkey', 'post_metric', 'post_tag',
+ ['post_id', 'tag_id'], ['post_id', 'tag_id'],
+ ondelete='cascade')
+ op.create_foreign_key(
+ 'post_metric_range_post_tag_fkey', 'post_metric_range', 'post_tag',
+ ['post_id', 'tag_id'], ['post_id', 'tag_id'],
+ ondelete='cascade')
+
+
+def downgrade():
+ op.drop_constraint(
+ 'post_metric_post_tag_fkey', 'post_metric',
+ type_='foreignKey')
+ op.drop_constraint(
+ 'post_metric_range_post_tag_fkey', 'post_metric',
+ type_='foreignKey')
diff --git a/server/szurubooru/migrations/versions/3c1f0316fa7f_resize_post_columns.py b/server/szurubooru/migrations/versions/3c1f0316fa7f_resize_post_columns.py
index 17e30d5..c668baf 100644
--- a/server/szurubooru/migrations/versions/3c1f0316fa7f_resize_post_columns.py
+++ b/server/szurubooru/migrations/versions/3c1f0316fa7f_resize_post_columns.py
@@ -9,7 +9,7 @@ import sqlalchemy as sa
from alembic import op
revision = "3c1f0316fa7f"
-down_revision = "1cd4c7b22846"
+down_revision = "0061c5c3299f"
branch_labels = None
depends_on = None
diff --git a/server/szurubooru/migrations/versions/51ac43760440_create_metric_tables.py b/server/szurubooru/migrations/versions/51ac43760440_create_metric_tables.py
new file mode 100644
index 0000000..dd47bc7
--- /dev/null
+++ b/server/szurubooru/migrations/versions/51ac43760440_create_metric_tables.py
@@ -0,0 +1,50 @@
+'''
+Create metric tables
+
+Revision ID: 51ac43760440
+Created at: 2019-04-16 17:38:47.176916
+'''
+
+import sqlalchemy as sa
+from alembic import op
+
+
+revision = '51ac43760440'
+down_revision = '1cd4c7b22846'
+branch_labels = None
+depends_on = None
+
+
+def upgrade():
+ op.create_table(
+ 'metric',
+ sa.Column('tag_id', sa.Integer(), nullable=False),
+ sa.Column('min', sa.Float(), nullable=False),
+ sa.Column('max', sa.Float(), nullable=False),
+ sa.ForeignKeyConstraint(['tag_id'], ['tag.id']),
+ sa.PrimaryKeyConstraint('tag_id'))
+
+ op.create_table(
+ 'post_metric',
+ sa.Column('post_id', sa.Integer(), nullable=False),
+ sa.Column('tag_id', sa.Integer(), nullable=False),
+ sa.Column('value', sa.Float(), nullable=False),
+ sa.ForeignKeyConstraint(['post_id'], ['post.id']),
+ sa.ForeignKeyConstraint(['tag_id'], ['metric.tag_id']),
+ sa.PrimaryKeyConstraint('post_id', 'tag_id'))
+
+ op.create_table(
+ 'post_metric_range',
+ sa.Column('post_id', sa.Integer(), nullable=False),
+ sa.Column('tag_id', sa.Integer(), nullable=False),
+ sa.Column('low', sa.Float(), nullable=False),
+ sa.Column('high', sa.Float(), nullable=False),
+ sa.ForeignKeyConstraint(['post_id'], ['post.id']),
+ sa.ForeignKeyConstraint(['tag_id'], ['metric.tag_id']),
+ sa.PrimaryKeyConstraint('post_id', 'tag_id'))
+
+
+def downgrade():
+ op.drop_table('post_metric_range')
+ op.drop_table('post_metric')
+ op.drop_table('metric')
diff --git a/server/szurubooru/migrations/versions/aae2050fb28c_add_version_to_metric_tables.py b/server/szurubooru/migrations/versions/aae2050fb28c_add_version_to_metric_tables.py
new file mode 100644
index 0000000..c8be348
--- /dev/null
+++ b/server/szurubooru/migrations/versions/aae2050fb28c_add_version_to_metric_tables.py
@@ -0,0 +1,32 @@
+'''
+Add version to metric tables
+
+Revision ID: aae2050fb28c
+Created at: 2019-04-16 22:15:03.656192
+'''
+
+import sqlalchemy as sa
+from alembic import op
+
+
+revision = 'aae2050fb28c'
+down_revision = '51ac43760440'
+branch_labels = None
+depends_on = None
+
+tables = ['metric', 'post_metric', 'post_metric_range']
+
+
+def upgrade():
+ for table in tables:
+ op.add_column(table, sa.Column('version', sa.Integer(), nullable=True))
+ op.execute(
+ sa.table(table, sa.column('version'))
+ .update()
+ .values(version=1))
+ op.alter_column(table, 'version', nullable=False)
+
+
+def downgrade():
+ for table in tables:
+ op.drop_column(table, 'version')
diff --git a/server/szurubooru/model/__init__.py b/server/szurubooru/model/__init__.py
index 21a178e..2befe74 100644
--- a/server/szurubooru/model/__init__.py
+++ b/server/szurubooru/model/__init__.py
@@ -1,6 +1,7 @@
import szurubooru.model.util
from szurubooru.model.base import Base
from szurubooru.model.comment import Comment, CommentScore
+from szurubooru.model.metric import Metric, PostMetric, PostMetricRange
from szurubooru.model.pool import Pool, PoolName, PoolPost
from szurubooru.model.pool_category import PoolCategory
from szurubooru.model.post import (
diff --git a/server/szurubooru/model/metric.py b/server/szurubooru/model/metric.py
new file mode 100644
index 0000000..530165a
--- /dev/null
+++ b/server/szurubooru/model/metric.py
@@ -0,0 +1,141 @@
+import sqlalchemy as sa
+from szurubooru.model.base import Base
+from szurubooru.model.post import PostTag
+from szurubooru.model.tag import TagName
+
+
+class PostMetric(Base):
+ __tablename__ = 'post_metric'
+
+ post_id = sa.Column(
+ 'post_id',
+ sa.Integer,
+ sa.ForeignKey('post.id'),
+ primary_key=True,
+ nullable=False,
+ index=True)
+ tag_id = sa.Column(
+ 'tag_id',
+ sa.Integer,
+ sa.ForeignKey('metric.tag_id'),
+ primary_key=True,
+ nullable=False,
+ index=True)
+ version = sa.Column('version', sa.Integer, default=1, nullable=False)
+ value = sa.Column('value', sa.Float, nullable=False, index=True)
+
+ post = sa.orm.relationship('Post')
+ metric = sa.orm.relationship('Metric', back_populates='post_metrics')
+
+ __table_args__ = (sa.ForeignKeyConstraint(
+ (post_id, tag_id),
+ (PostTag.post_id, PostTag.tag_id),
+ ondelete='cascade'),
+ )
+ __mapper_args__ = {
+ 'version_id_col': version,
+ 'version_id_generator': False,
+ # when deleting tag or post, cascade will ensure this post metric is
+ # also deleted, but sqlalchemy will try to delete it twice because of
+ # the cascade on foreign key into PostTag. This silences the error:
+ 'confirm_deleted_rows': False,
+ }
+
+
+class PostMetricRange(Base):
+ """
+ Could be a metric in the process of finding its exact value, e.g. by sorting.
+ It has upper and lower boundaries that will converge at the final value.
+ """
+ __tablename__ = 'post_metric_range'
+
+ post_id = sa.Column(
+ 'post_id',
+ sa.Integer,
+ sa.ForeignKey('post.id'),
+ primary_key=True,
+ nullable=False,
+ index=True)
+ tag_id = sa.Column(
+ 'tag_id',
+ sa.Integer,
+ sa.ForeignKey('metric.tag_id'),
+ primary_key=True,
+ nullable=False,
+ index=True)
+ version = sa.Column('version', sa.Integer, default=1, nullable=False)
+ low = sa.Column('low', sa.Float, nullable=False)
+ high = sa.Column('high', sa.Float, nullable=False)
+
+ post = sa.orm.relationship('Post')
+ metric = sa.orm.relationship('Metric', back_populates='post_metric_ranges')
+
+ __table_args__ = (sa.ForeignKeyConstraint(
+ (post_id, tag_id),
+ (PostTag.post_id, PostTag.tag_id),
+ ondelete='cascade'),
+ )
+ __mapper_args__ = {
+ 'version_id_col': version,
+ 'version_id_generator': False,
+ # when deleting tag or post, cascade will ensure this post metric is
+ # also deleted, but sqlalchemy will try to delete it twice because of
+ # the cascade on foreign key into PostTag. This silences the error:
+ 'confirm_deleted_rows': False,
+ }
+
+
+class Metric(Base):
+ """
+ Must be attached to a tag, tag_id is primary key.
+ """
+ __tablename__ = 'metric'
+
+ tag_id = sa.Column(
+ 'tag_id',
+ sa.Integer,
+ sa.ForeignKey('tag.id'),
+ primary_key=True,
+ nullable=False,
+ index=True)
+ version = sa.Column('version', sa.Integer, default=1, nullable=False)
+ min = sa.Column('min', sa.Float, nullable=False)
+ max = sa.Column('max', sa.Float, nullable=False)
+
+ tag = sa.orm.relationship('Tag')
+ post_metrics = sa.orm.relationship(
+ 'PostMetric', back_populates='metric', cascade='all, delete-orphan')
+ post_metric_ranges = sa.orm.relationship(
+ 'PostMetricRange', back_populates='metric', cascade='all, delete-orphan')
+
+ tag_name = sa.orm.column_property(
+ (
+ sa.sql.expression.select([TagName.name])
+ .where(TagName.tag_id == tag_id)
+ .order_by(TagName.order)
+ .limit(1)
+ .as_scalar()
+ ))
+
+ post_metric_count = sa.orm.column_property(
+ (
+ sa.sql.expression.select(
+ [sa.sql.expression.func.count(PostMetric.post_id)])
+ .where(PostMetric.tag_id == tag_id)
+ .correlate_except(PostMetric)
+ ),
+ deferred=True)
+
+ post_metric_range_count = sa.orm.column_property(
+ (
+ sa.sql.expression.select(
+ [sa.sql.expression.func.count(PostMetricRange.post_id)])
+ .where(PostMetricRange.tag_id == tag_id)
+ .correlate_except(PostMetricRange)
+ ),
+ deferred=True)
+
+ __mapper_args__ = {
+ 'version_id_col': version,
+ 'version_id_generator': False,
+ }
diff --git a/server/szurubooru/model/post.py b/server/szurubooru/model/post.py
index 49e748d..deb3cc8 100644
--- a/server/szurubooru/model/post.py
+++ b/server/szurubooru/model/post.py
@@ -253,6 +253,12 @@ class Post(Base):
"PostNote", cascade="all, delete-orphan", lazy="joined"
)
comments = sa.orm.relationship("Comment", cascade="all, delete-orphan")
+ metrics = sa.orm.relationship(
+ "PostMetric", cascade="all, delete-orphan", lazy="joined"
+ )
+ metric_ranges = sa.orm.relationship(
+ "PostMetricRange", cascade="all, delete-orphan", lazy="joined"
+ )
_pools = sa.orm.relationship(
"PoolPost",
cascade="all,delete-orphan",
diff --git a/server/szurubooru/model/tag.py b/server/szurubooru/model/tag.py
index 61dbf83..65dabb1 100644
--- a/server/szurubooru/model/tag.py
+++ b/server/szurubooru/model/tag.py
@@ -110,6 +110,11 @@ class Tag(Base):
secondaryjoin=tag_id == TagImplication.child_id,
lazy="joined",
)
+ metric = sa.orm.relationship(
+ "Metric",
+ uselist=False,
+ cascade="all, delete-orphan"
+ )
post_count = sa.orm.column_property(
sa.sql.expression.select(
diff --git a/server/szurubooru/rest/context.py b/server/szurubooru/rest/context.py
index 40ba0bc..a75ca60 100644
--- a/server/szurubooru/rest/context.py
+++ b/server/szurubooru/rest/context.py
@@ -78,6 +78,9 @@ class Context:
def has_param(self, name: str) -> bool:
return name in self._params
+ def get_param(self, name: str) -> Any:
+ return self._params[name]
+
def get_param_as_list(
self, name: str, default: Union[object, List[Any]] = MISSING
) -> List[Any]:
@@ -176,6 +179,32 @@ class Context:
"Parameter %r must be an integer value." % name
)
+ def get_param_as_float(
+ self,
+ name: str,
+ default: Union[object, float] = MISSING,
+ min: Optional[float] = None,
+ max: Optional[float] = None) -> float:
+ if name not in self._params:
+ if default is not MISSING:
+ return cast(float, default)
+ raise errors.MissingRequiredParameterError(
+ "Required parameter %r is missing." % name)
+ value = self._params[name]
+ try:
+ value = float(value)
+ if min is not None and value < min:
+ raise errors.InvalidParameterError(
+ "Parameter %r must be at least %r." % (name, min))
+ if max is not None and value > max:
+ raise errors.InvalidParameterError(
+ "Parameter %r may not exceed %r." % (name, max))
+ return value
+ except (ValueError, TypeError):
+ pass
+ raise errors.InvalidParameterError(
+ "Parameter %r must be a float value." % name)
+
def get_param_as_bool(
self, name: str, default: Union[object, bool] = MISSING
) -> bool:
diff --git a/server/szurubooru/search/configs/__init__.py b/server/szurubooru/search/configs/__init__.py
index c721813..72bd5bc 100644
--- a/server/szurubooru/search/configs/__init__.py
+++ b/server/szurubooru/search/configs/__init__.py
@@ -1,4 +1,5 @@
from .comment_search_config import CommentSearchConfig
+from .post_metric_search_config import PostMetricSearchConfig
from .pool_search_config import PoolSearchConfig
from .post_search_config import PostSearchConfig
from .snapshot_search_config import SnapshotSearchConfig
diff --git a/server/szurubooru/search/configs/post_metric_search_config.py b/server/szurubooru/search/configs/post_metric_search_config.py
new file mode 100644
index 0000000..0cdb1ea
--- /dev/null
+++ b/server/szurubooru/search/configs/post_metric_search_config.py
@@ -0,0 +1,44 @@
+from typing import Dict
+
+import sqlalchemy as sa
+
+from szurubooru import db, model
+from szurubooru.func import metrics, util
+from szurubooru.search.configs import util as search_util
+from szurubooru.search.configs.base_search_config import (
+ BaseSearchConfig, Filter)
+from szurubooru.search.typing import SaQuery
+
+
+class PostMetricSearchConfig(BaseSearchConfig):
+ def __init__(self) -> None:
+ self.all_metric_names = []
+
+ def refresh_metrics(self) -> None:
+ self.all_metric_names = metrics.get_all_metric_tag_names()
+
+ def create_filter_query(self, _disable_eager_loads: bool) -> SaQuery:
+ self.refresh_metrics()
+ return db.session.query(model.PostMetric).options(sa.orm.lazyload('*'))
+
+ def create_count_query(self, disable_eager_loads: bool) -> SaQuery:
+ return self.create_filter_query(disable_eager_loads)
+
+ def create_around_query(self) -> SaQuery:
+ return self.create_filter_query()
+
+ def finalize_query(self, query: SaQuery) -> SaQuery:
+ return query.order_by(model.PostMetric.value.asc())
+
+ @property
+ def anonymous_filter(self) -> Filter:
+ return search_util.create_subquery_filter(
+ model.PostMetric.tag_id,
+ model.TagName.tag_id,
+ model.TagName.name,
+ search_util.create_str_filter)
+
+ @property
+ def named_filters(self) -> Dict[str, Filter]:
+ num_filter = search_util.create_float_filter(model.PostMetric.value)
+ return {tag_name: num_filter for tag_name in self.all_metric_names}
diff --git a/server/szurubooru/search/configs/post_search_config.py b/server/szurubooru/search/configs/post_search_config.py
index 8c2b5b9..9065539 100644
--- a/server/szurubooru/search/configs/post_search_config.py
+++ b/server/szurubooru/search/configs/post_search_config.py
@@ -3,7 +3,7 @@ from typing import Any, Dict, Optional, Tuple
import sqlalchemy as sa
from szurubooru import db, errors, model
-from szurubooru.func import auth, util
+from szurubooru.func import auth, metrics, util
from szurubooru.search import criteria, tokens
from szurubooru.search.configs import util as search_util
from szurubooru.search.configs.base_search_config import (
@@ -122,6 +122,103 @@ def _pool_filter(
)(query, criterion, negated)
+# includes the given post itself, also applies sort
+def _similar_filter(
+ query: SaQuery, criterion: Optional[criteria.BaseCriterion], negated: bool
+) -> SaQuery:
+ assert criterion
+ filter_func_tag = search_util.create_num_filter(model.PostTag.post_id)
+ pt_alias = sa.orm.aliased(model.PostTag)
+
+ # subquery for tags of the given post (post id in criterion)
+ tag_query = db.session.query(model.PostTag.tag_id)
+ tag_query = filter_func_tag(tag_query, criterion, False)
+ tag_query = tag_query.subquery("source_tags")
+
+ if negated:
+ # negated query runs normally, doesn't apply sort
+ subquery = (
+ db.session.query(pt_alias.post_id)
+ .filter(pt_alias.tag_id.in_(tag_query))
+ .group_by(pt_alias.post_id)
+ .subquery("similar_posts")
+ )
+ expr = model.Post.post_id.in_(subquery)
+ return query.filter(~expr)
+ else:
+ # direct query applies sort
+ subquery = query.subquery("main_query")
+ return (
+ db.session.query(model.Post)
+ .join(pt_alias, pt_alias.post_id == model.Post.post_id)
+ .filter(pt_alias.tag_id.in_(tag_query))
+ .group_by(model.Post.post_id)
+ .join(subquery, pt_alias.post_id == subquery.c.id)
+ .order_by(sa.func.count(pt_alias.tag_id).desc())
+ )
+
+
+def _create_metric_num_filter(name: str):
+ def wrapper(
+ query: SaQuery,
+ criterion: Optional[criteria.BaseCriterion],
+ negated: bool,
+ ) -> SaQuery:
+ assert criterion
+ t = sa.orm.aliased(model.TagName)
+ pm = sa.orm.aliased(model.PostMetric)
+ expr = t.name == name
+ expr = expr & search_util.apply_num_criterion_to_column(
+ pm.value, criterion, search_util.float_transformer
+ )
+ if negated:
+ expr = ~expr
+ ret = (
+ query.join(pm, pm.post_id == model.Post.post_id)
+ .join(t, t.tag_id == pm.tag_id)
+ .filter(expr)
+ )
+ return ret
+
+ return wrapper
+
+
+def _metric_presence_filter(
+ query: SaQuery,
+ criterion: Optional[criteria.BaseCriterion],
+ negated: bool,
+) -> SaQuery:
+ assert criterion
+ t = sa.orm.aliased(model.TagName)
+ tag_name_filter = search_util.apply_str_criterion_to_column(
+ t.name, criterion
+ )
+ pm = sa.orm.aliased(model.PostMetric)
+ subquery = (
+ db.session.query(pm.post_id)
+ .join(t, t.tag_id == pm.tag_id)
+ .filter(tag_name_filter)
+ .subquery()
+ )
+ post_filter = model.Post.post_id.in_(subquery)
+ if negated:
+ post_filter = ~post_filter
+ return query.filter(post_filter)
+
+
+def _create_metric_sort_column(metric_name: str):
+ t = sa.orm.aliased(model.TagName)
+ pm = sa.orm.aliased(model.PostMetric)
+ ret = (
+ db.session.query(pm.value)
+ .filter(pm.post_id == model.Post.post_id)
+ .join(t, t.tag_id == pm.tag_id)
+ .filter(t.name == metric_name)
+ .as_scalar()
+ )
+ return ret
+
+
def _category_filter(
query: SaQuery, criterion: Optional[criteria.BaseCriterion], negated: bool
) -> SaQuery:
@@ -162,6 +259,10 @@ def _safety_filter(
class PostSearchConfig(BaseSearchConfig):
def __init__(self) -> None:
self.user = None # type: Optional[model.User]
+ self.all_metric_names = []
+
+ def refresh_metrics(self) -> None:
+ self.all_metric_names = metrics.get_all_metric_tag_names()
def on_search_query_parsed(self, search_query: SearchQuery) -> SaQuery:
new_special_tokens = []
@@ -188,9 +289,11 @@ class PostSearchConfig(BaseSearchConfig):
search_query.special_tokens = new_special_tokens
def create_around_query(self) -> SaQuery:
+ self.refresh_metrics()
return db.session.query(model.Post).options(sa.orm.lazyload("*"))
def create_filter_query(self, disable_eager_loads: bool) -> SaQuery:
+ self.refresh_metrics()
strategy = (
sa.orm.lazyload if disable_eager_loads else sa.orm.subqueryload
)
@@ -217,7 +320,9 @@ class PostSearchConfig(BaseSearchConfig):
return db.session.query(model.Post)
def finalize_query(self, query: SaQuery) -> SaQuery:
- if self.user and not auth.has_privilege(self.user, "posts:list:unsafe"):
+ if self.user and not auth.has_privilege(
+ self.user, "posts:list:unsafe"
+ ):
# exclude unsafe posts:
query = _safety_filter(
query,
@@ -244,221 +349,268 @@ class PostSearchConfig(BaseSearchConfig):
@property
def named_filters(self) -> Dict[str, Filter]:
- return util.unalias_dict(
- [
- (["id"], search_util.create_num_filter(model.Post.post_id)),
- (
- ["tag"],
- search_util.create_subquery_filter(
- model.Post.post_id,
- model.PostTag.post_id,
- model.TagName.name,
- search_util.create_str_filter,
- lambda subquery: subquery.join(model.Tag).join(
- model.TagName
+ filters = {
+ "metric-" + name: _create_metric_num_filter(name)
+ for name in self.all_metric_names
+ }
+ filters.update(
+ util.unalias_dict(
+ [
+ (
+ ["id"],
+ search_util.create_num_filter(model.Post.post_id),
+ ),
+ (
+ ["tag"],
+ search_util.create_subquery_filter(
+ model.Post.post_id,
+ model.PostTag.post_id,
+ model.TagName.name,
+ search_util.create_str_filter,
+ lambda subquery: subquery.join(model.Tag).join(
+ model.TagName
+ ),
),
),
- ),
- (["score"], search_util.create_num_filter(model.Post.score)),
- (["uploader", "upload", "submit"], _user_filter),
- (
- ["comment"],
- search_util.create_subquery_filter(
- model.Post.post_id,
- model.Comment.post_id,
- model.User.name,
- search_util.create_str_filter,
- lambda subquery: subquery.join(model.User),
+ (["metric"], _metric_presence_filter),
+ (
+ ["score"],
+ search_util.create_num_filter(model.Post.score),
),
- ),
- (
- ["fav"],
- search_util.create_subquery_filter(
- model.Post.post_id,
- model.PostFavorite.post_id,
- model.User.name,
- search_util.create_str_filter,
- lambda subquery: subquery.join(model.User),
+ (["uploader", "upload", "submit"], _user_filter),
+ (
+ ["comment"],
+ search_util.create_subquery_filter(
+ model.Post.post_id,
+ model.Comment.post_id,
+ model.User.name,
+ search_util.create_str_filter,
+ lambda subquery: subquery.join(model.User),
+ ),
),
- ),
- (["liked"], _create_score_filter(1)),
- (["disliked"], _create_score_filter(-1)),
- (
- ["source"],
- search_util.create_str_filter(
- model.Post.source, _source_transformer
+ (
+ ["fav"],
+ search_util.create_subquery_filter(
+ model.Post.post_id,
+ model.PostFavorite.post_id,
+ model.User.name,
+ search_util.create_str_filter,
+ lambda subquery: subquery.join(model.User),
+ ),
),
- ),
- (
- ["tag-count"],
- search_util.create_num_filter(model.Post.tag_count),
- ),
- (
- ["comment-count"],
- search_util.create_num_filter(model.Post.comment_count),
- ),
- (
- ["fav-count"],
- search_util.create_num_filter(model.Post.favorite_count),
- ),
- (
- ["note-count"],
- search_util.create_num_filter(model.Post.note_count),
- ),
- (
- ["relation-count"],
- search_util.create_num_filter(model.Post.relation_count),
- ),
- (
- ["feature-count"],
- search_util.create_num_filter(model.Post.feature_count),
- ),
- (
- ["type"],
- search_util.create_str_filter(
- model.Post.type, _type_transformer
+ (["liked"], _create_score_filter(1)),
+ (["disliked"], _create_score_filter(-1)),
+ (
+ ["source"],
+ search_util.create_str_filter(
+ model.Post.source, _source_transformer
+ ),
),
- ),
- (
- ["content-checksum", "sha1"],
- search_util.create_str_filter(model.Post.checksum),
- ),
- (
- ["md5"],
- search_util.create_str_filter(model.Post.checksum_md5),
- ),
- (
- ["file-size"],
- search_util.create_num_filter(model.Post.file_size),
- ),
- (
- ["image-width", "width"],
- search_util.create_num_filter(model.Post.canvas_width),
- ),
- (
- ["image-height", "height"],
- search_util.create_num_filter(model.Post.canvas_height),
- ),
- (
- ["image-area", "area"],
- search_util.create_num_filter(model.Post.canvas_area),
- ),
- (
- ["image-aspect-ratio", "image-ar", "aspect-ratio", "ar"],
- search_util.create_num_filter(
- model.Post.canvas_aspect_ratio,
- transformer=search_util.float_transformer,
+ (
+ ["tag-count"],
+ search_util.create_num_filter(model.Post.tag_count),
),
- ),
- (
- ["creation-date", "creation-time", "date", "time"],
- search_util.create_date_filter(model.Post.creation_time),
- ),
- (
- [
- "last-edit-date",
- "last-edit-time",
- "edit-date",
- "edit-time",
- ],
- search_util.create_date_filter(model.Post.last_edit_time),
- ),
- (
- ["comment-date", "comment-time"],
- search_util.create_date_filter(
- model.Post.last_comment_creation_time
+ (
+ ["comment-count"],
+ search_util.create_num_filter(
+ model.Post.comment_count
+ ),
),
- ),
- (
- ["fav-date", "fav-time"],
- search_util.create_date_filter(
- model.Post.last_favorite_time
+ (
+ ["fav-count"],
+ search_util.create_num_filter(
+ model.Post.favorite_count
+ ),
),
- ),
- (
- ["feature-date", "feature-time"],
- search_util.create_date_filter(
- model.Post.last_feature_time
+ (
+ ["note-count"],
+ search_util.create_num_filter(model.Post.note_count),
),
- ),
- (["safety", "rating"], _safety_filter),
- (["note-text"], _note_filter),
- (
- ["flag"],
- search_util.create_str_filter(
- model.Post.flags_string, _flag_transformer
+ (
+ ["relation-count"],
+ search_util.create_num_filter(
+ model.Post.relation_count
+ ),
),
- ),
- (["pool"], _pool_filter),
- (["category"], _category_filter),
- ]
+ (
+ ["feature-count"],
+ search_util.create_num_filter(
+ model.Post.feature_count
+ ),
+ ),
+ (
+ ["type"],
+ search_util.create_str_filter(
+ model.Post.type, _type_transformer
+ ),
+ ),
+ (
+ ["content-checksum", "sha1"],
+ search_util.create_str_filter(model.Post.checksum),
+ ),
+ (
+ ["md5"],
+ search_util.create_str_filter(model.Post.checksum_md5),
+ ),
+ (
+ ["file-size"],
+ search_util.create_num_filter(model.Post.file_size),
+ ),
+ (
+ ["image-width", "width"],
+ search_util.create_num_filter(model.Post.canvas_width),
+ ),
+ (
+ ["image-height", "height"],
+ search_util.create_num_filter(
+ model.Post.canvas_height
+ ),
+ ),
+ (
+ ["image-area", "area"],
+ search_util.create_num_filter(model.Post.canvas_area),
+ ),
+ (
+ [
+ "image-aspect-ratio",
+ "image-ar",
+ "aspect-ratio",
+ "ar",
+ ],
+ search_util.create_num_filter(
+ model.Post.canvas_aspect_ratio,
+ transformer=search_util.float_transformer,
+ ),
+ ),
+ (
+ ["creation-date", "creation-time", "date", "time"],
+ search_util.create_date_filter(
+ model.Post.creation_time
+ ),
+ ),
+ (
+ [
+ "last-edit-date",
+ "last-edit-time",
+ "edit-date",
+ "edit-time",
+ ],
+ search_util.create_date_filter(
+ model.Post.last_edit_time
+ ),
+ ),
+ (
+ ["comment-date", "comment-time"],
+ search_util.create_date_filter(
+ model.Post.last_comment_creation_time
+ ),
+ ),
+ (
+ ["fav-date", "fav-time"],
+ search_util.create_date_filter(
+ model.Post.last_favorite_time
+ ),
+ ),
+ (
+ ["feature-date", "feature-time"],
+ search_util.create_date_filter(
+ model.Post.last_feature_time
+ ),
+ ),
+ (["safety", "rating"], _safety_filter),
+ (["note-text"], _note_filter),
+ (
+ ["flag"],
+ search_util.create_str_filter(
+ model.Post.flags_string, _flag_transformer
+ ),
+ ),
+ (["pool"], _pool_filter),
+ (["similar"], _similar_filter),
+ (["category"], _category_filter),
+ ]
+ )
)
+ return filters
@property
def sort_columns(self) -> Dict[str, Tuple[SaColumn, str]]:
- return util.unalias_dict(
- [
- (
- ["random"],
- (sa.sql.expression.func.random(), self.SORT_NONE),
- ),
- (["id"], (model.Post.post_id, self.SORT_DESC)),
- (["score"], (model.Post.score, self.SORT_DESC)),
- (["tag-count"], (model.Post.tag_count, self.SORT_DESC)),
- (
- ["comment-count"],
- (model.Post.comment_count, self.SORT_DESC),
- ),
- (["fav-count"], (model.Post.favorite_count, self.SORT_DESC)),
- (["note-count"], (model.Post.note_count, self.SORT_DESC)),
- (
- ["relation-count"],
- (model.Post.relation_count, self.SORT_DESC),
- ),
- (
- ["feature-count"],
- (model.Post.feature_count, self.SORT_DESC),
- ),
- (["file-size"], (model.Post.file_size, self.SORT_DESC)),
- (
- ["image-width", "width"],
- (model.Post.canvas_width, self.SORT_DESC),
- ),
- (
- ["image-height", "height"],
- (model.Post.canvas_height, self.SORT_DESC),
- ),
- (
- ["image-area", "area"],
- (model.Post.canvas_area, self.SORT_DESC),
- ),
- (
- ["creation-date", "creation-time", "date", "time"],
- (model.Post.creation_time, self.SORT_DESC),
- ),
- (
- [
- "last-edit-date",
- "last-edit-time",
- "edit-date",
- "edit-time",
- ],
- (model.Post.last_edit_time, self.SORT_DESC),
- ),
- (
- ["comment-date", "comment-time"],
- (model.Post.last_comment_creation_time, self.SORT_DESC),
- ),
- (
- ["fav-date", "fav-time"],
- (model.Post.last_favorite_time, self.SORT_DESC),
- ),
- (
- ["feature-date", "feature-time"],
- (model.Post.last_feature_time, self.SORT_DESC),
- ),
- ]
+ filters = {
+ "metric-" + name: (_create_metric_sort_column(name), self.SORT_ASC)
+ for name in self.all_metric_names
+ }
+ filters.update(
+ util.unalias_dict(
+ [
+ (
+ ["random"],
+ (sa.sql.expression.func.random(), self.SORT_NONE),
+ ),
+ (["id"], (model.Post.post_id, self.SORT_DESC)),
+ (["score"], (model.Post.score, self.SORT_DESC)),
+ (["tag-count"], (model.Post.tag_count, self.SORT_DESC)),
+ (
+ ["comment-count"],
+ (model.Post.comment_count, self.SORT_DESC),
+ ),
+ (
+ ["fav-count"],
+ (model.Post.favorite_count, self.SORT_DESC),
+ ),
+ (["note-count"], (model.Post.note_count, self.SORT_DESC)),
+ (
+ ["relation-count"],
+ (model.Post.relation_count, self.SORT_DESC),
+ ),
+ (
+ ["feature-count"],
+ (model.Post.feature_count, self.SORT_DESC),
+ ),
+ (["file-size"], (model.Post.file_size, self.SORT_DESC)),
+ (
+ ["image-width", "width"],
+ (model.Post.canvas_width, self.SORT_DESC),
+ ),
+ (
+ ["image-height", "height"],
+ (model.Post.canvas_height, self.SORT_DESC),
+ ),
+ (
+ ["image-area", "area"],
+ (model.Post.canvas_area, self.SORT_DESC),
+ ),
+ (
+ ["creation-date", "creation-time", "date", "time"],
+ (model.Post.creation_time, self.SORT_DESC),
+ ),
+ (
+ [
+ "last-edit-date",
+ "last-edit-time",
+ "edit-date",
+ "edit-time",
+ ],
+ (model.Post.last_edit_time, self.SORT_DESC),
+ ),
+ (
+ ["comment-date", "comment-time"],
+ (
+ model.Post.last_comment_creation_time,
+ self.SORT_DESC,
+ ),
+ ),
+ (
+ ["fav-date", "fav-time"],
+ (model.Post.last_favorite_time, self.SORT_DESC),
+ ),
+ (
+ ["feature-date", "feature-time"],
+ (model.Post.last_feature_time, self.SORT_DESC),
+ ),
+ ]
+ )
)
+ return filters
@property
def special_filters(self) -> Dict[str, Filter]:
diff --git a/server/szurubooru/search/configs/util.py b/server/szurubooru/search/configs/util.py
index 58e6ebe..659f546 100644
--- a/server/szurubooru/search/configs/util.py
+++ b/server/szurubooru/search/configs/util.py
@@ -118,6 +118,10 @@ def create_num_filter(
return wrapper
+def create_float_filter(column: Any) -> SaQuery:
+ return create_num_filter(column, float_transformer)
+
+
def apply_str_criterion_to_column(
column: SaColumn,
criterion: criteria.BaseCriterion,
diff --git a/server/szurubooru/search/executor.py b/server/szurubooru/search/executor.py
index a5ef962..5302b14 100644
--- a/server/szurubooru/search/executor.py
+++ b/server/szurubooru/search/executor.py
@@ -31,6 +31,8 @@ class Executor:
Class for search parsing and execution. Handles plaintext parsing and
delegates sqlalchemy filter decoration to SearchConfig instances.
"""
+ AROUND_NEXT = "up"
+ AROUND_PREV = "down"
def __init__(self, search_config: BaseSearchConfig) -> None:
self.config = search_config
@@ -38,30 +40,29 @@ class Executor:
def get_around(
self, query_text: str, entity_id: int
- ) -> Tuple[model.Base, model.Base]:
+ ) -> Tuple[model.Base, model.Base, model.Base]:
search_query = self.parser.parse(query_text)
self.config.on_search_query_parsed(search_query)
- filter_query = self.config.create_around_query().options(
- sa.orm.lazyload("*")
- )
- filter_query = self._prepare_db_query(
- filter_query, search_query, False
- )
+ filter_query = (
+ self.config
+ .create_around_query()
+ .options(sa.orm.lazyload("*")))
prev_filter_query = (
- filter_query.filter(self.config.id_column > entity_id)
- .order_by(None)
- .order_by(sa.func.abs(self.config.id_column - entity_id).asc())
- .limit(1)
- )
+ self._prepare_sorted_around_query(
+ filter_query, search_query, entity_id, self.AROUND_PREV
+ ).limit(1))
next_filter_query = (
- filter_query.filter(self.config.id_column < entity_id)
- .order_by(None)
- .order_by(sa.func.abs(self.config.id_column - entity_id).asc())
- .limit(1)
- )
+ self._prepare_sorted_around_query(
+ filter_query, search_query, entity_id, self.AROUND_NEXT
+ ).limit(1))
+ # random post
+ if "sort:random" not in query_text:
+ query_text = "sort:random " + query_text
+ count, random_entities = self.execute(query_text, 0, 1)
return (
prev_filter_query.one_or_none(),
next_filter_query.one_or_none(),
+ random_entities[0] if random_entities else None
)
def get_around_and_serialize(
@@ -76,6 +77,7 @@ class Executor:
return {
"prev": serializer(entities[0]),
"next": serializer(entities[1]),
+ "random": serializer(entities[2]),
}
def execute(
@@ -94,7 +96,7 @@ class Executor:
disable_eager_loads = True
key = (id(self.config), hash(search_query), offset, limit)
- if cache.has(key):
+ if not disable_eager_loads and cache.has(key):
return cache.get(key)
filter_query = self.config.create_filter_query(disable_eager_loads)
@@ -131,6 +133,20 @@ class Executor:
"results": list([serializer(entity) for entity in entities]),
}
+ def count(self, query_text:str) -> int:
+ search_query = self.parser.parse(query_text)
+ self.config.on_search_query_parsed(search_query)
+ count_query = self.config.create_count_query(True)
+ count_query = count_query.options(sa.orm.lazyload("*"))
+ count_query = self._prepare_db_query(count_query, search_query, False)
+ count_statement = (
+ count_query
+ .statement
+ .with_only_columns([sa.func.count()])
+ .order_by(None))
+ count = db.session.execute(count_statement).scalar()
+ return count
+
def _prepare_db_query(
self, db_query: SaQuery, search_query: SearchQuery, use_sort: bool
) -> SaQuery:
@@ -192,3 +208,75 @@ class Executor:
db_query = self.config.finalize_query(db_query)
return db_query
+
+ def _prepare_sorted_around_query(
+ self,
+ db_query: SaQuery,
+ search_query: SearchQuery,
+ entity_id: int,
+ direction: str):
+ db_query = self._prepare_db_query(db_query, search_query, False)
+ db_query = db_query.order_by(None)
+ found_sort_column = False
+
+ for sort_token in search_query.sort_tokens:
+ if sort_token.name == "random":
+ continue
+ if sort_token.name not in self.config.sort_columns:
+ raise errors.SearchError(
+ "Unknown sort token: %r. "
+ "Available sort tokens: %r." % (
+ sort_token.name,
+ _format_dict_keys(self.config.sort_columns)))
+ column, default_order = (
+ self.config.sort_columns[sort_token.name])
+ order = _get_order(sort_token.order, default_order)
+
+ # the order column may be joined, so we need to query its value:
+ column_query = (
+ db.session.query(self.config.id_column, column)
+ .options(sa.orm.lazyload("*")))
+ column_query = (
+ # empty search query because we already know entity id
+ self._prepare_db_query(column_query, SearchQuery(), False)
+ .filter(self.config.id_column == entity_id))
+ id, column_value = column_query.one_or_none()
+ # it's possible that this entity doesn't have the column
+ if not column_value:
+ continue
+ found_sort_column = True
+
+ if order == sort_token.SORT_ASC:
+ if direction == self.AROUND_NEXT:
+ db_query = (
+ db_query
+ .order_by(column.asc())
+ .filter(column > column_value))
+ elif direction == self.AROUND_PREV:
+ db_query = (
+ db_query
+ .order_by(column.desc())
+ .filter(column < column_value))
+ elif order == sort_token.SORT_DESC:
+ if direction == self.AROUND_NEXT:
+ db_query = (
+ db_query
+ .order_by(column.desc())
+ .filter(column < column_value))
+ elif direction == self.AROUND_PREV:
+ db_query = (
+ db_query
+ .order_by(column.asc())
+ .filter(column > column_value))
+
+ if not found_sort_column:
+ # no sorting, use default sorting by id
+ if direction == self.AROUND_NEXT:
+ db_query = db_query.filter(self.config.id_column < entity_id)
+ elif direction == self.AROUND_PREV:
+ db_query = db_query.filter(self.config.id_column > entity_id)
+ db_query = db_query.order_by(
+ sa.func.abs(self.config.id_column - entity_id).asc())
+ return db_query
+
+ return db_query
diff --git a/server/szurubooru/tests/api/test_metric_retrieving.py b/server/szurubooru/tests/api/test_metric_retrieving.py
new file mode 100644
index 0000000..81e29cd
--- /dev/null
+++ b/server/szurubooru/tests/api/test_metric_retrieving.py
@@ -0,0 +1,63 @@
+from szurubooru import api, db, model
+
+import pytest
+
+
+@pytest.fixture(autouse=True)
+def inject_config(config_injector):
+ config_injector(
+ {
+ "privileges": {
+ "metrics:list": model.User.RANK_REGULAR,
+ },
+ }
+ )
+
+@pytest.mark.parametrize('query,expected_value', [
+ ('', 5),
+ ('mytag:0..', 5),
+ ('mytag:..10', 5),
+ ('mytag:0..10', 5),
+ ('mytag:2..8', 5),
+ ('mytag:0..8', 4),
+ ('mytag:0..6', 4),
+ ('mytag:0..5.5', 4),
+ ('mytag:0..4', 1),
+ ('mytag:1..4', 1),
+ ('mytag:2..3', None),
+])
+def test_median(
+ query,
+ expected_value,
+ tag_factory,
+ post_factory,
+ metric_factory,
+ post_metric_factory,
+ context_factory,
+ user_factory):
+ tag = tag_factory(names=['mytag'])
+ post1 = post_factory(tags=[tag])
+ post4 = post_factory(tags=[tag])
+ post5 = post_factory(tags=[tag])
+ post6 = post_factory(tags=[tag])
+ post10 = post_factory(tags=[tag])
+ metric = metric_factory(tag=tag, min=0, max=10)
+ pm1 = post_metric_factory(metric=metric, post=post1, value=1)
+ pm4 = post_metric_factory(metric=metric, post=post4, value=4)
+ pm5 = post_metric_factory(metric=metric, post=post5, value=5)
+ pm6 = post_metric_factory(metric=metric, post=post6, value=6)
+ pm10 = post_metric_factory(metric=metric, post=post10, value=10)
+ db.session.add_all([tag, metric, pm1, pm4, pm5, pm6, pm10,
+ post1, post4, post5, post6, post10])
+ db.session.flush()
+ response = api.metric_api.get_post_metrics_median(
+ context_factory(
+ params={'query': query},
+ user=user_factory(rank=model.User.RANK_REGULAR)),
+ {'tag_name': 'mytag'})
+ if not expected_value:
+ assert response['total'] == 0
+ assert len(response['results']) == 0
+ else:
+ assert response['total'] == 1
+ assert response['results'][0]['value'] == expected_value
diff --git a/server/szurubooru/tests/api/test_post_retrieving.py b/server/szurubooru/tests/api/test_post_retrieving.py
index a40ab0e..b2d5c8e 100644
--- a/server/szurubooru/tests/api/test_post_retrieving.py
+++ b/server/szurubooru/tests/api/test_post_retrieving.py
@@ -11,6 +11,8 @@ from szurubooru.func import posts
def inject_config(config_injector):
config_injector(
{
+ "data_url": "http://example.com/",
+ "secret": "test",
"privileges": {
"posts:list": model.User.RANK_REGULAR,
"posts:view": model.User.RANK_REGULAR,
@@ -131,6 +133,53 @@ def test_trying_to_retrieve_single_without_privileges(
)
+@pytest.mark.parametrize(
+ "query,expected_id",
+ [
+ ("sort:id,asc", 2),
+ ("sort:id,asc id:2..", 2),
+ ("sort:id,desc id:2..", 3),
+ ("sort:id,asc id:3..", 3),
+ ("sort:id,desc id:3..", 3),
+ ("sort:id id:4..", None),
+ ("sort:tag-count", 3),
+ ("sort:tag-count,asc id:..2", 1),
+ ("sort:tag-count,desc id:..2", 2),
+ ],
+)
+def test_median(
+ query,
+ expected_id,
+ post_factory,
+ tag_factory,
+ context_factory,
+ user_factory,
+):
+ tag1 = tag_factory()
+ tag2 = tag_factory()
+ tag3 = tag_factory()
+ post1 = post_factory(id=1, tags=[tag1])
+ post2 = post_factory(id=2, tags=[tag1, tag2, tag3])
+ post3 = post_factory(id=3, tags=[tag1, tag2])
+ db.session.add_all([tag1, tag2, tag3, post1, post2, post3])
+ db.session.flush()
+ with patch("szurubooru.func.comments.serialize_comment"), patch(
+ "szurubooru.func.users.serialize_micro_user"
+ ), patch("szurubooru.func.posts.files.has"):
+ response = api.post_api.get_posts_median(
+ context_factory(
+ params={"query": query},
+ user=user_factory(rank=model.User.RANK_REGULAR),
+ )
+ )
+ if not expected_id:
+ assert response["total"] == 0
+ assert len(response["results"]) == 0
+ else:
+ assert response["total"] == 1
+ assert response["results"][0]["id"] == expected_id
+
+
def test_trying_to_retrieve_unsafe_without_privileges(
user_factory, context_factory, post_factory, config_injector
):
diff --git a/server/szurubooru/tests/api/test_post_updating.py b/server/szurubooru/tests/api/test_post_updating.py
index e4a606d..7d830c9 100644
--- a/server/szurubooru/tests/api/test_post_updating.py
+++ b/server/szurubooru/tests/api/test_post_updating.py
@@ -4,7 +4,7 @@ from unittest.mock import patch
import pytest
from szurubooru import api, db, errors, model
-from szurubooru.func import net, posts, snapshots, tags
+from szurubooru.func import metrics, net, posts, snapshots, tags
@pytest.fixture(autouse=True)
@@ -21,6 +21,7 @@ def inject_config(config_injector):
"posts:edit:flags": model.User.RANK_REGULAR,
"posts:edit:thumbnail": model.User.RANK_REGULAR,
"tags:create": model.User.RANK_MODERATOR,
+ "metrics:edit:posts": model.User.RANK_REGULAR,
"uploads:use_downloader": model.User.RANK_REGULAR,
},
"allow_broken_uploads": False,
@@ -54,6 +55,10 @@ def test_post_updating(
"szurubooru.func.posts.serialize_post"
), patch(
"szurubooru.func.snapshots.modify"
+ ), patch(
+ "szurubooru.func.metrics.update_or_create_post_metrics"
+ ), patch(
+ "szurubooru.func.metrics.update_or_create_post_metric_ranges"
), fake_datetime(
"1997-01-01"
):
@@ -69,6 +74,8 @@ def test_post_updating(
"source": "source",
"notes": ["note1", "note2"],
"flags": ["flag1", "flag2"],
+ "metrics": [{"tag_name": "tag1", "value": 1.2}],
+ "metricRanges": [{"tag_name": "tag2", "low": 1, "high": 2}],
},
files={
"content": "post-content",
@@ -99,6 +106,10 @@ def test_post_updating(
post, auth_user, options=[]
)
snapshots.modify.assert_called_once_with(post, auth_user)
+ metrics.update_or_create_post_metrics.assert_called_once_with(
+ post, [{"tag_name": "tag1", "value": 1.2}])
+ metrics.update_or_create_post_metric_ranges.assert_called_once_with(
+ post, [{"tag_name": "tag2", "low": 1, "high": 2}])
assert post.last_edit_time == datetime(1997, 1, 1)
@@ -184,6 +195,8 @@ def test_trying_to_update_non_existing(context_factory, user_factory):
({}, {"flags": "..."}),
({"content": "..."}, {}),
({"thumbnail": "..."}, {}),
+ ({}, {"metrics": "..."}),
+ ({}, {"metricRanges": "..."}),
],
)
def test_trying_to_update_field_without_privileges(
diff --git a/server/szurubooru/tests/api/test_tag_updating.py b/server/szurubooru/tests/api/test_tag_updating.py
index be5f485..66939a4 100644
--- a/server/szurubooru/tests/api/test_tag_updating.py
+++ b/server/szurubooru/tests/api/test_tag_updating.py
@@ -3,7 +3,7 @@ from unittest.mock import patch
import pytest
from szurubooru import api, db, errors, model
-from szurubooru.func import snapshots, tags
+from szurubooru.func import metrics, snapshots, tags
@pytest.fixture(autouse=True)
@@ -17,6 +17,8 @@ def inject_config(config_injector):
"tags:edit:description": model.User.RANK_REGULAR,
"tags:edit:suggestions": model.User.RANK_REGULAR,
"tags:edit:implications": model.User.RANK_REGULAR,
+ "metrics:create": model.User.RANK_REGULAR,
+ "metrics:edit:bounds": model.User.RANK_REGULAR,
},
}
)
@@ -40,6 +42,8 @@ def test_simple_updating(user_factory, tag_factory, context_factory):
), patch(
"szurubooru.func.tags.serialize_tag"
), patch(
+ "szurubooru.func.metrics.update_or_create_metric"
+ ), patch(
"szurubooru.func.snapshots.modify"
):
tags.get_or_create_tags_by_names.return_value = ([], [])
@@ -53,6 +57,7 @@ def test_simple_updating(user_factory, tag_factory, context_factory):
"description": "desc",
"suggestions": ["sug1", "sug2"],
"implications": ["imp1", "imp2"],
+ "metric": {"min": -1, "max": 1},
},
user=auth_user,
),
@@ -70,6 +75,8 @@ def test_simple_updating(user_factory, tag_factory, context_factory):
tag, ["imp1", "imp2"]
)
tags.serialize_tag.assert_called_once_with(tag, options=[])
+ metrics.update_or_create_metric.assert_called_once_with(
+ tag, {"min": -1, "max": 1})
snapshots.modify.assert_called_once_with(tag, auth_user)
@@ -128,6 +135,7 @@ def test_trying_to_update_non_existing(user_factory, context_factory):
{"category": "whatever"},
{"suggestions": ["whatever"]},
{"implications": ["whatever"]},
+ {"metric": ["whatever"]},
],
)
def test_trying_to_update_without_privileges(
@@ -145,6 +153,20 @@ def test_trying_to_update_without_privileges(
)
+def test_trying_to_create_metric_without_privileges(
+ user_factory, tag_factory, context_factory
+):
+ db.session.add(tag_factory(names=["tag"]))
+ db.session.commit()
+ with pytest.raises(errors.AuthError):
+ api.tag_api.update_tag(
+ context_factory(
+ params={"metric": {"min": 0, "max": 10}, **{"version": 1}},
+ user=user_factory(rank=model.User.RANK_ANONYMOUS)),
+ {"tag_name": "tag"}
+ )
+
+
@pytest.mark.parametrize("type", ["suggestions", "implications"])
def test_trying_to_create_tags_without_privileges(
config_injector, context_factory, tag_factory, user_factory, type
diff --git a/server/szurubooru/tests/conftest.py b/server/szurubooru/tests/conftest.py
index 280987c..45113f5 100644
--- a/server/szurubooru/tests/conftest.py
+++ b/server/szurubooru/tests/conftest.py
@@ -151,7 +151,7 @@ def tag_category_factory():
@pytest.fixture
def tag_factory():
- def factory(names=None, category=None):
+ def factory(names=None, category=None, metric=None):
if not category:
category = model.TagCategory(get_unique_name())
db.session.add(category)
@@ -161,6 +161,8 @@ def tag_factory():
tag.names.append(model.TagName(name, i))
tag.category = category
tag.creation_time = datetime(1996, 1, 1)
+ if metric:
+ tag.metric = metric
return tag
return factory
@@ -173,6 +175,7 @@ def post_factory():
safety=model.Post.SAFETY_SAFE,
type=model.Post.TYPE_IMAGE,
checksum="...",
+ tags=[],
):
post = model.Post()
post.post_id = id
@@ -182,6 +185,7 @@ def post_factory():
post.flags = []
post.mime_type = "application/octet-stream"
post.creation_time = datetime(1996, 1, 1)
+ post.tags = tags
return post
return factory
@@ -286,6 +290,53 @@ def pool_post_factory(pool_factory, post_factory):
@pytest.fixture
+def metric_factory(tag_factory):
+ def factory(tag=None, min=0, max=10):
+ if not tag:
+ tag = tag_factory()
+ return model.Metric(tag=tag, min=min, max=max)
+ return factory
+
+
+@pytest.fixture
+def post_metric_factory(post_factory, tag_factory, metric_factory):
+ def factory(post=None, metric=None, value=None, tag=None, tag_name=None):
+ if not post:
+ post = post_factory()
+ if tag_name:
+ tag = tag_factory(names=[tag_name])
+ if tag:
+ metric = metric_factory(tag=tag)
+ elif not metric:
+ metric = metric_factory()
+ if not value:
+ value = (metric.min + metric.max)/2
+ return model.PostMetric(post=post, metric=metric, value=value)
+ return factory
+
+
+@pytest.fixture
+def post_metric_range_factory(post_factory, tag_factory, metric_factory):
+ def factory(post=None, metric=None, low=None, high=None, tag=None,
+ tag_name=None):
+ if not post:
+ post = post_factory()
+ if tag_name:
+ tag = tag_factory(names=[tag_name])
+ if tag:
+ metric = metric_factory(tag=tag)
+ elif not metric:
+ metric = metric_factory()
+ if not low:
+ low = metric.min
+ if not high:
+ high = metric.max
+ return model.PostMetricRange(
+ post=post, metric=metric, low=low, high=high)
+ return factory
+
+
+@pytest.fixture
def read_asset():
def get(path):
path = os.path.join(os.path.dirname(__file__), "assets", path)
diff --git a/server/szurubooru/tests/func/test_metrics.py b/server/szurubooru/tests/func/test_metrics.py
new file mode 100644
index 0000000..c33be62
--- /dev/null
+++ b/server/szurubooru/tests/func/test_metrics.py
@@ -0,0 +1,459 @@
+import pytest
+from szurubooru import db, model
+from szurubooru.func import metrics
+
+
+def test_serialize_metric(tag_category_factory, tag_factory):
+ cat = tag_category_factory(name="cat")
+ tag = tag_factory(names=["tag1"], category=cat)
+ metric = model.Metric(tag=tag, min=1, max=2)
+ db.session.add(metric)
+ db.session.flush()
+ result = metrics.serialize_metric(metric)
+ assert result == {
+ "version": 1,
+ "min": 1,
+ "max": 2,
+ "exact_count": 0,
+ "range_count": 0,
+ "tag": {
+ "names": ["tag1"],
+ "category": "cat",
+ "description": None,
+ "usages": 0,
+ },
+ }
+
+
+def test_serialize_post_metric(post_factory, tag_factory, metric_factory):
+ tag = tag_factory(names=["mytag"])
+ post = post_factory(id=456, tags=[tag])
+ metric = metric_factory(tag)
+ post_metric = model.PostMetric(post=post, metric=metric, value=-12.3)
+ db.session.add_all([post, tag, metric, post_metric])
+ db.session.flush()
+ result = metrics.serialize_post_metric(post_metric)
+ assert result == {
+ "tag_name": "mytag",
+ "post_id": 456,
+ "value": -12.3,
+ }
+
+
+def test_serialize_post_metric_range(post_factory, tag_factory, metric_factory):
+ tag = tag_factory(names=["mytag"])
+ post = post_factory(id=456, tags=[tag])
+ metric = metric_factory(tag)
+ post_metric_range = model.PostMetricRange(
+ post=post, metric=metric, low=-1.2, high=3.4)
+ db.session.add_all([post, tag, metric, post_metric_range])
+ db.session.flush()
+ result = metrics.serialize_post_metric_range(post_metric_range)
+ assert result == {
+ "tag_name": "mytag",
+ "post_id": 456,
+ "low": -1.2,
+ "high": 3.4
+ }
+
+
+def test_try_get_metric_by_tag_name(tag_factory, metric_factory):
+ tag = tag_factory(names=["mytag"])
+ metric = metric_factory(tag)
+ db.session.add_all([tag, metric])
+ db.session.flush()
+ assert metrics.try_get_metric_by_tag_name("unknown") is None
+ assert metrics.try_get_metric_by_tag_name("mytag") is metric
+
+
+def test_try_get_post_metric(
+ post_factory, metric_factory, post_metric_factory):
+ metric1 = metric_factory()
+ metric2 = metric_factory()
+ post = post_factory(tags=[metric1.tag, metric2.tag])
+ post_metric = post_metric_factory(post=post, metric=metric1)
+ db.session.add_all([post, metric1, metric2, post_metric])
+ db.session.flush()
+ assert metrics.try_get_post_metric(post, metric2) is None
+ assert metrics.try_get_post_metric(post, metric1) is post_metric
+
+
+def test_try_get_post_metric_range(
+ post_factory, metric_factory, post_metric_range_factory):
+ metric1 = metric_factory()
+ metric2 = metric_factory()
+ post = post_factory(tags=[metric1.tag, metric2.tag])
+ post_metric_range = post_metric_range_factory(post=post, metric=metric1)
+ db.session.add_all([post, metric1, metric2, post_metric_range])
+ db.session.flush()
+ assert metrics.try_get_post_metric_range(post, metric2) is None
+ assert metrics.try_get_post_metric_range(post, metric1) is post_metric_range
+
+
+def test_get_all_metrics(metric_factory):
+ metric1 = metric_factory()
+ metric2 = metric_factory()
+ metric3 = metric_factory()
+ db.session.add_all([metric1, metric2, metric3])
+ db.session.flush()
+ all_metrics = metrics.get_all_metrics()
+ assert len(all_metrics) == 3
+ assert metric1 in all_metrics
+ assert metric2 in all_metrics
+ assert metric3 in all_metrics
+
+
+def test_get_all_metric_tag_names(tag_factory, metric_factory):
+ tag1 = tag_factory(names=["abc", "def"])
+ tag2 = tag_factory(names=["ghi"])
+ metric1 = metric_factory(tag=tag1)
+ metric2 = metric_factory(tag=tag2)
+ db.session.add_all([metric1, metric2])
+ db.session.flush()
+ assert metrics.get_all_metric_tag_names() == ["abc", "def", "ghi"]
+
+
+def test_create_metric(tag_factory):
+ tag = tag_factory()
+ db.session.add(tag)
+ new_metric = metrics.create_metric(tag, 1, 2)
+ assert new_metric is not None
+ db.session.flush()
+ assert tag.metric is not None
+ assert tag.metric.min == 1
+ assert tag.metric.max == 2
+
+
+def test_create_metric_with_existing_metric(tag_factory):
+ tag = tag_factory()
+ tag.metric = model.Metric()
+ with pytest.raises(metrics.MetricAlreadyExistsError):
+ metrics.create_metric(tag, 1, 2)
+
+
+def test_create_metric_with_invalid_params(tag_factory):
+ tag = tag_factory()
+ with pytest.raises(metrics.InvalidMetricError):
+ metrics.create_metric(tag, 2, 1)
+
+
+def test_update_or_create_metric(tag_factory):
+ tag = tag_factory()
+ db.session.add(tag)
+ new_metric = metrics.update_or_create_metric(tag, {"min": 1, "max": 2})
+ assert new_metric is not None
+ db.session.flush()
+ assert tag.metric is not None
+ assert tag.metric.min == 1
+ assert tag.metric.max == 2
+ assert tag.metric.version == 1
+
+ new_metric = metrics.update_or_create_metric(tag, {"min": 3, "max": 4})
+ assert new_metric is None
+ db.session.flush()
+ assert tag.metric.min == 3
+ assert tag.metric.max == 4
+ assert tag.metric.version == 2
+
+
+@pytest.mark.parametrize("params", [
+ {"min": 1}, {"max": 2}, {"min": 2, "max": 1}
+])
+def test_update_or_create_metric_with_invalid_params(tag_factory, params):
+ tag = tag_factory()
+ with pytest.raises(metrics.InvalidMetricError):
+ metrics.update_or_create_metric(tag, params)
+
+
+# Post metrics
+
+def test_update_or_create_post_metric_without_tag(post_factory, metric_factory):
+ post = post_factory()
+ metric = metric_factory()
+ with pytest.raises(metrics.PostMissingTagError):
+ metrics.update_or_create_post_metric(post, metric, 1.5)
+
+
+def test_update_or_create_post_metric_with_value_out_of_range(
+ post_factory, metric_factory):
+ metric = metric_factory()
+ post = post_factory(tags=[metric.tag])
+ with pytest.raises(metrics.MetricValueOutOfRangeError):
+ metrics.update_or_create_post_metric(post, metric, -99)
+
+
+def test_update_or_create_post_metric_create(post_factory, metric_factory):
+ metric = metric_factory()
+ post = post_factory(tags=[metric.tag])
+ db.session.add(metric)
+ db.session.flush()
+ post_metric = metrics.update_or_create_post_metric(post, metric, 1.5)
+ assert post_metric.value == 1.5
+
+
+def test_update_or_create_post_metric_update(post_factory, metric_factory):
+ metric = metric_factory()
+ post1 = post_factory(tags=[metric.tag])
+ post2 = post_factory(tags=[metric.tag])
+ post_metric1 = model.PostMetric(post=post1, metric=metric, value=1.2)
+ post_metric2 = model.PostMetric(post=post2, metric=metric, value=5.6)
+ db.session.add_all([post1, post2, post_metric1, post_metric2])
+ db.session.flush()
+ assert post_metric1.version == 1
+ assert post_metric2.version == 1
+
+ metrics.update_or_create_post_metric(post1, metric, 3.4)
+ db.session.flush()
+
+ assert db.session.query(model.PostMetric).count() == 2
+ assert post_metric1.value == 3.4
+ assert post_metric1.version == 2
+ assert post_metric2.value == 5.6
+ assert post_metric2.version == 1
+
+
+def test_update_or_create_post_metrics_missing_tag(
+ post_factory, tag_factory, metric_factory):
+ post = post_factory()
+ tag = tag_factory(names=["tag1"])
+ metric = metric_factory(tag)
+ db.session.add(metric)
+ db.session.flush()
+ data = [{"tag_name": "tag1", "value": 1.5}]
+ with pytest.raises(metrics.PostMissingTagError):
+ metrics.update_or_create_post_metrics(post, data)
+
+
+@pytest.mark.parametrize("params", [
+ [{}],
+ [{"tag_name": "tag"}],
+ [{"value": 1.5}]
+])
+def test_update_or_create_post_metrics_with_missing_fields(
+ params, post_factory):
+ post = post_factory()
+ with pytest.raises(metrics.InvalidMetricError):
+ metrics.update_or_create_post_metrics(post, params)
+
+
+def test_update_or_create_post_metrics_with_invalid_tag(
+ post_factory, tag_factory):
+ tag = tag_factory(names=["tag1"])
+ post = post_factory(tags=[tag])
+ db.session.add(tag)
+ db.session.flush()
+ data = [{"tag_name": "tag1", "value": 2}]
+ with pytest.raises(metrics.MetricDoesNotExistsError):
+ metrics.update_or_create_post_metrics(post, data)
+
+
+def test_update_or_create_post_metrics(
+ post_factory, tag_factory, metric_factory):
+ tag1 = tag_factory(names=["tag1"])
+ tag2 = tag_factory(names=["tag2"])
+ post = post_factory(tags=[tag1, tag2])
+ metric1 = metric_factory(tag1)
+ metric2 = metric_factory(tag2)
+ db.session.add_all([metric1, metric2])
+ db.session.flush()
+
+ data = [
+ {"tag_name": "tag1", "value": 1.2},
+ {"tag_name": "tag2", "value": 3.4},
+ ]
+ metrics.update_or_create_post_metrics(post, data)
+ db.session.flush()
+
+ assert len(post.metrics) == 2
+ assert post.metrics[0].value == 1.2
+ assert post.metrics[1].value == 3.4
+
+
+def test_update_or_create_post_metrics_with_trim(
+ post_factory, tag_factory, metric_factory, post_metric_factory):
+ tag1 = tag_factory(names=["tag1"])
+ tag2 = tag_factory(names=["tag2"])
+ post = post_factory(tags=[tag1, tag2])
+ metric1 = metric_factory(tag1)
+ metric2 = metric_factory(tag2)
+ post_metric = post_metric_factory(post=post, metric=metric1, value=1.2)
+ db.session.add_all([post, tag1, tag2, metric1, metric2, post_metric])
+ db.session.flush()
+ assert len(post.metrics) == 1
+ assert post.metrics[0].metric == metric1
+ assert post.metrics[0].value == 1.2
+
+ data = [
+ {"tag_name": "tag2", "value": 3.4},
+ ]
+ metrics.update_or_create_post_metrics(post, data)
+ db.session.flush()
+
+ assert len(post.metrics) == 1
+ assert post.metrics[0].metric == metric2
+ assert post.metrics[0].value == 3.4
+
+
+# Post metric ranges
+
+def test_update_or_create_post_metric_range_without_tag(
+ post_factory, metric_factory):
+ post = post_factory()
+ metric = metric_factory()
+ with pytest.raises(metrics.PostMissingTagError):
+ metrics.update_or_create_post_metric_range(post, metric, 2, 3)
+
+
+@pytest.mark.parametrize("low, high", [
+ (-99, 1), (1, 99),
+])
+def test_update_or_create_post_metric_range_with_values_out_of_range(
+ low, high, post_factory, metric_factory):
+ metric = metric_factory()
+ post = post_factory(tags=[metric.tag])
+ with pytest.raises(metrics.MetricValueOutOfRangeError):
+ metrics.update_or_create_post_metric_range(post, metric, low, high)
+
+
+def test_update_or_create_post_metric_range_create(
+ post_factory, metric_factory):
+ metric = metric_factory()
+ post = post_factory(tags=[metric.tag])
+ db.session.add(metric)
+ db.session.flush()
+ post_metric_range = metrics.update_or_create_post_metric_range(
+ post, metric, 2, 3)
+ assert post_metric_range.low == 2
+ assert post_metric_range.high == 3
+
+
+def test_update_or_create_post_metric_range_update(
+ post_factory, metric_factory):
+ metric = metric_factory()
+ post = post_factory(tags=[metric.tag])
+ post_metric_range = model.PostMetricRange(
+ post=post, metric=metric, low=2, high=3)
+ db.session.add(post_metric_range)
+ db.session.flush()
+ assert post_metric_range.version == 1
+
+ metrics.update_or_create_post_metric_range(post, metric, 4, 5)
+ db.session.flush()
+
+ assert post_metric_range.low == 4
+ assert post_metric_range.high == 5
+ assert post_metric_range.version == 2
+
+
+def test_update_or_create_post_metric_ranges_missing_tag(
+ post_factory, tag_factory, metric_factory):
+ post = post_factory()
+ tag = tag_factory(names=["tag1"])
+ metric = metric_factory(tag)
+ db.session.add(metric)
+ db.session.flush()
+ data = [{"tag_name": "tag1", "low": 2, "high": 3}]
+ with pytest.raises(metrics.PostMissingTagError):
+ metrics.update_or_create_post_metric_ranges(post, data)
+
+
+@pytest.mark.parametrize("params", [
+ [{}],
+ [{"tag_name": "tag"}],
+ [{"tag_name": "tag", "low": 2}],
+ [{"low": 2, "high": 3}],
+])
+def test_update_or_create_post_metric_ranges_with_missing_fields(
+ params, post_factory, tag_factory):
+ tag = tag_factory(names=["tag"])
+ post = post_factory(tags=[tag])
+ with pytest.raises(metrics.InvalidMetricError):
+ metrics.update_or_create_post_metric_ranges(post, params)
+
+
+def test_update_or_create_post_metric_ranges_with_invalid_tag(
+ post_factory, tag_factory):
+ tag = tag_factory(names=["tag1"])
+ post = post_factory(tags=[tag])
+ db.session.add(tag)
+ db.session.flush()
+ data = [{"tag_name": "tag1", "low": 2, "high": 3}]
+ with pytest.raises(metrics.MetricDoesNotExistsError):
+ metrics.update_or_create_post_metric_ranges(post, data)
+
+
+def test_update_or_create_post_metric_ranges_with_invalid_values(
+ post_factory, tag_factory, metric_factory):
+ tag = tag_factory(names=["tag1"])
+ post = post_factory(tags=[tag])
+ metric = metric_factory(tag=tag)
+ db.session.add_all([metric, tag])
+ db.session.flush()
+ data = [
+ {"tag_name": "tag1", "low": 4, "high": 2},
+ ]
+ with pytest.raises(metrics.InvalidMetricError):
+ metrics.update_or_create_post_metric_ranges(post, data)
+
+
+def test_update_or_create_post_metric_ranges(
+ post_factory, tag_factory, metric_factory):
+ tag1 = tag_factory(names=["tag1"])
+ tag2 = tag_factory(names=["tag2"])
+ post = post_factory(tags=[tag1, tag2])
+ metric1 = metric_factory(tag1)
+ metric2 = metric_factory(tag2)
+ db.session.add_all([metric1, metric2])
+ db.session.flush()
+
+ data = [
+ {"tag_name": "tag1", "low": 2, "high": 3},
+ {"tag_name": "tag2", "low": 4, "high": 5},
+ ]
+ metrics.update_or_create_post_metric_ranges(post, data)
+ db.session.flush()
+
+ assert len(post.metric_ranges) == 2
+ assert post.metric_ranges[0].low == 2
+ assert post.metric_ranges[0].high == 3
+ assert post.metric_ranges[1].low == 4
+ assert post.metric_ranges[1].high == 5
+
+
+def test_update_or_create_post_metric_ranges_with_trim(
+ post_factory, tag_factory, metric_factory, post_metric_range_factory):
+ tag1 = tag_factory(names=["tag1"])
+ tag2 = tag_factory(names=["tag2"])
+ post = post_factory(tags=[tag1, tag2])
+ metric1 = metric_factory(tag1)
+ metric2 = metric_factory(tag2)
+ post_metric_range = post_metric_range_factory(
+ post=post, metric=metric1, low=1, high=2)
+ db.session.add_all([post, tag1, tag2, metric1, metric2, post_metric_range])
+ db.session.flush()
+ assert len(post.metric_ranges) == 1
+ assert post.metric_ranges[0].metric == metric1
+ assert post.metric_ranges[0].low == 1
+ assert post.metric_ranges[0].high == 2
+
+ data = [
+ {"tag_name": "tag2", "low": 3, "high": 4},
+ ]
+ metrics.update_or_create_post_metric_ranges(post, data)
+ db.session.flush()
+
+ assert len(post.metric_ranges) == 1
+ assert post.metric_ranges[0].metric == metric2
+ assert post.metric_ranges[0].low == 3
+ assert post.metric_ranges[0].high == 4
+
+
+def test_delete_metric(metric_factory):
+ metric1 = metric_factory()
+ metric2 = metric_factory()
+ db.session.add_all([metric1, metric2])
+ db.session.flush()
+ assert db.session.query(model.Metric).count() == 2
+ metrics.delete_metric(metric2)
+ db.session.flush()
+ assert db.session.query(model.Metric).count() == 1
diff --git a/server/szurubooru/tests/func/test_posts.py b/server/szurubooru/tests/func/test_posts.py
index fa1b3bb..168a74a 100644
--- a/server/szurubooru/tests/func/test_posts.py
+++ b/server/szurubooru/tests/func/test_posts.py
@@ -8,13 +8,13 @@ from szurubooru import db, model
from szurubooru.func import (
comments,
files,
- image_hash,
images,
posts,
tags,
users,
util,
)
+from szurubooru.func.posts import _get_safety_list
@pytest.mark.parametrize(
@@ -101,6 +101,9 @@ def test_serialize_post(
comment_factory,
tag_factory,
tag_category_factory,
+ metric_factory,
+ post_metric_factory,
+ post_metric_range_factory,
pool_factory,
pool_category_factory,
config_injector,
@@ -122,14 +125,23 @@ def test_serialize_post(
post.post_id = 1
post.creation_time = datetime(1997, 1, 1)
post.last_edit_time = datetime(1998, 1, 1)
- post.tags = [
- tag_factory(
- names=["tag1", "tag2"],
- category=tag_category_factory("test-cat1"),
- ),
- tag_factory(
- names=["tag3"], category=tag_category_factory("test-cat2")
- ),
+ tag1 = tag_factory(
+ names=["tag1", "tag2"],
+ category=tag_category_factory("test-cat1")
+ )
+ tag1.metric = metric_factory(tag=tag1, min=-2.5, max=2.5)
+ tag3 = tag_factory(
+ names=["tag3"],
+ category=tag_category_factory("test-cat2")
+ )
+ post.tags = [tag1, tag3]
+ post.metrics = [
+ post_metric_factory(post=post, metric=tag1.metric, value=-1.2)
+ ]
+ post.metric_ranges = [
+ post_metric_range_factory(
+ post=post, metric=tag1.metric, low=2, high=3
+ )
]
post.safety = model.Post.SAFETY_SAFE
post.source = "4gag"
@@ -233,11 +245,16 @@ def test_serialize_post(
"names": ["tag1", "tag2"],
"category": "test-cat1",
"usages": 1,
+ "metric": {
+ "min": -2.5,
+ "max": 2.5
+ },
},
{
"names": ["tag3"],
"category": "test-cat2",
"usages": 1,
+ "metric": None,
},
],
"relations": [],
@@ -273,6 +290,21 @@ def test_serialize_post(
"hasCustomThumbnail": True,
"mimeType": "image/jpeg",
"comments": ["commenter1", "commenter2"],
+ "metrics": [
+ {
+ "tag_name": "tag1",
+ "post_id": 1,
+ "value": -1.2
+ }
+ ],
+ "metricRanges": [
+ {
+ "tag_name": "tag1",
+ "post_id": 1,
+ "low": 2,
+ "high": 3
+ }
+ ],
}
@@ -1221,3 +1253,12 @@ def test_search_by_image(post_factory, config_injector, read_asset):
result2 = posts.search_by_image(read_asset("png.png"))
assert not result2
+
+
+def test_get_safety_list():
+ assert _get_safety_list('') == ['safe', 'sketchy', 'unsafe']
+ assert _get_safety_list('abc') == ['safe', 'sketchy', 'unsafe']
+ assert _get_safety_list('abc rating:lol -def') ==\
+ ['safe', 'sketchy', 'unsafe']
+ assert _get_safety_list('abc -rating:sketchy,lol def') == ['safe', 'unsafe']
+ assert _get_safety_list('rating:safe,unsafe -rating:safe') == ['unsafe']
diff --git a/server/szurubooru/tests/func/test_similar.py b/server/szurubooru/tests/func/test_similar.py
new file mode 100644
index 0000000..5346cb0
--- /dev/null
+++ b/server/szurubooru/tests/func/test_similar.py
@@ -0,0 +1,60 @@
+import pytest
+from szurubooru import db
+from szurubooru.func import similar
+
+
+@pytest.fixture
+def verify_posts():
+ def verify(actual_posts, expected_posts):
+ actual_post_ids = list([p.post_id for p in actual_posts])
+ expected_post_ids = list([p.post_id for p in expected_posts])
+ assert actual_post_ids == expected_post_ids
+
+ return verify
+
+
+def test_find_similar_posts(post_factory, tag_factory, verify_posts):
+ tagA = tag_factory(names=["a"])
+ tagB = tag_factory(names=["b"])
+ tagC = tag_factory(names=["c"])
+ postA = post_factory(id=1, tags=[tagA])
+ postAB = post_factory(id=2, tags=[tagA, tagB])
+ postAC = post_factory(id=3, tags=[tagA, tagC])
+ postABC = post_factory(id=4, tags=[tagA, tagB, tagC])
+ postBC = post_factory(id=5, tags=[tagB, tagC])
+ db.session.add_all([tagA, tagB, tagC, postA, postAB, postAC, postABC, postBC])
+ db.session.flush()
+
+ results = similar.find_similar_posts(postBC, 10)
+ verify_posts(results, [postABC, postAC, postAB])
+
+ results = similar.find_similar_posts(postBC, 2)
+ verify_posts(results, [postABC, postAC])
+
+ results = similar.find_similar_posts(postABC, 10)
+ verify_posts(results, [postBC, postAC, postAB, postA])
+
+ results = similar.find_similar_posts(postA, 10)
+ verify_posts(results, [postABC, postAC, postAB]) # sorted by id
+
+ results = similar.find_similar_posts(postAB, 10)
+ verify_posts(results, [postABC, postBC, postAC, postA])
+
+ results = similar.find_similar_posts(postAC, 10)
+ verify_posts(results, [postABC, postBC, postAB, postA])
+
+
+def test_find_similar_posts_with_limit(post_factory, tag_factory, verify_posts):
+ tagA = tag_factory(names=["a"])
+ tagB = tag_factory(names=["b"])
+ tagC = tag_factory(names=["c"])
+ tagD = tag_factory(names=["d"])
+ tagE = tag_factory(names=["e"])
+ postA = post_factory(id=111, tags=[tagA])
+ postAB = post_factory(id=112, tags=[tagA, tagB])
+ postABCDE = post_factory(id=113, tags=[tagA, tagB, tagC, tagD, tagE])
+ db.session.add_all([tagA, tagB, tagC, tagD, tagE, postA, postAB, postABCDE])
+ db.session.flush()
+
+ results = similar.find_similar_posts(postABCDE, 10)
+ verify_posts(results, [postAB, postA])
diff --git a/server/szurubooru/tests/func/test_tags.py b/server/szurubooru/tests/func/test_tags.py
index 60df122..79376f4 100644
--- a/server/szurubooru/tests/func/test_tags.py
+++ b/server/szurubooru/tests/func/test_tags.py
@@ -66,7 +66,12 @@ def test_serialize_tag_when_empty():
assert tags.serialize_tag(None, None) is None
-def test_serialize_tag(post_factory, tag_factory, tag_category_factory):
+def test_serialize_tag(
+ post_factory,
+ tag_factory,
+ tag_category_factory,
+ metric_factory,
+):
cat = tag_category_factory(name="cat")
tag = tag_factory(names=["tag1", "tag2"], category=cat)
# tag.tag_id = 1
@@ -81,6 +86,8 @@ def test_serialize_tag(post_factory, tag_factory, tag_category_factory):
]
tag.last_edit_time = datetime(1998, 1, 1)
+ tag.metric = metric_factory(tag, min=1.5, max=10)
+
post1 = post_factory()
post1.tags = [tag]
post2 = post_factory()
@@ -106,6 +113,11 @@ def test_serialize_tag(post_factory, tag_factory, tag_category_factory):
{"names": ["impl1"], "category": "cat", "usages": 0},
{"names": ["impl2"], "category": "cat", "usages": 0},
],
+ "metric": {
+ "version": 1,
+ "min": 1.5,
+ "max": 10
+ },
"usages": 2,
}
@@ -318,6 +330,22 @@ def test_merge_tags_with_itself(tag_factory):
tags.merge_tags(source_tag, source_tag)
+def test_merge_tags_with_metrics(tag_factory, metric_factory):
+ tag_with_metric1 = tag_factory()
+ tag_with_metric2 = tag_factory()
+ tag_no_metric = tag_factory()
+ tag_with_metric1.metric = metric_factory()
+ tag_with_metric2.metric = metric_factory()
+ db.session.add_all([tag_no_metric, tag_with_metric1, tag_with_metric2])
+ db.session.flush()
+ with pytest.raises(tags.InvalidTagRelationError):
+ tags.merge_tags(tag_no_metric, tag_with_metric2)
+ with pytest.raises(tags.InvalidTagRelationError):
+ tags.merge_tags(tag_with_metric1, tag_no_metric)
+ with pytest.raises(tags.InvalidTagRelationError):
+ tags.merge_tags(tag_with_metric1, tag_with_metric2)
+
+
def test_merge_tags_moves_usages(tag_factory, post_factory):
source_tag = tag_factory(names=["source"])
target_tag = tag_factory(names=["target"])
diff --git a/server/szurubooru/tests/model/test_metric.py b/server/szurubooru/tests/model/test_metric.py
new file mode 100644
index 0000000..cf0fa56
--- /dev/null
+++ b/server/szurubooru/tests/model/test_metric.py
@@ -0,0 +1,235 @@
+from szurubooru import db, model
+
+import pytest
+
+@pytest.fixture(autouse=True)
+def inject_config(config_injector):
+ config_injector(
+ {"secret": "secret", "data_dir": "", "delete_source_files": False}
+ )
+
+def test_saving_metric(post_factory, tag_factory):
+ tag = tag_factory()
+ post = post_factory(tags=[tag])
+ metric = model.Metric(tag=tag, min=1., max=10.)
+ post_metric = model.PostMetric(metric=metric, post=post, value=5.5)
+ post_metric_range = model.PostMetricRange(metric=metric, post=post,
+ low=2., high=8.)
+ db.session.add_all([post, tag, metric, post_metric, post_metric_range])
+ db.session.commit()
+
+ assert metric.tag_id is not None
+ assert post_metric.tag_id is not None
+ assert post_metric.post_id is not None
+ assert post_metric_range.tag_id is not None
+ assert post_metric_range.post_id is not None
+ assert tag.metric.tag_id == tag.tag_id
+ assert tag.metric.min == 1.
+ assert tag.metric.max == 10.
+
+ metric = (
+ db.session
+ .query(model.Metric)
+ .filter(model.Metric.tag_id == tag.tag_id)
+ .one())
+ assert metric.min == 1.
+ assert metric.max == 10.
+
+ post_metric = (
+ db.session
+ .query(model.PostMetric)
+ .filter(model.PostMetric.tag_id == tag.tag_id and
+ model.PostMetric.post_id == post.post_id)
+ .one())
+ assert post_metric.value == 5.5
+
+ post_metric_range = (
+ db.session
+ .query(model.PostMetricRange)
+ .filter(model.PostMetricRange.tag_id == tag.tag_id and
+ model.PostMetricRange.post_id == post.post_id)
+ .one())
+ assert post_metric_range.low == 2.
+ assert post_metric_range.high == 8.
+
+ tag = (
+ db.session
+ .query(model.Tag)
+ .filter(model.Tag.tag_id == metric.tag_id)
+ .one())
+ assert tag.metric == metric
+
+
+def test_cascade_delete_metric(post_factory, tag_factory):
+ tag = tag_factory()
+ post1 = post_factory(tags=[tag])
+ post2 = post_factory(tags=[tag])
+ metric = model.Metric(tag=tag, min=1., max=10.)
+ post_metric1 = model.PostMetric(metric=metric, post=post1, value=2.3)
+ post_metric2 = model.PostMetric(metric=metric, post=post2, value=4.5)
+ post_metric_range = model.PostMetricRange(
+ metric=metric, post=post2, low=2, high=8)
+ db.session.add_all([post1, post2, tag, metric, post_metric1, post_metric2,
+ post_metric_range])
+ db.session.flush()
+
+ assert not db.session.dirty
+ assert db.session.query(model.Post).count() == 2
+ assert db.session.query(model.Tag).count() == 1
+ assert db.session.query(model.Metric).count() == 1
+ assert db.session.query(model.PostMetric).count() == 2
+ assert db.session.query(model.PostMetricRange).count() == 1
+
+ db.session.delete(metric)
+ db.session.commit()
+
+ assert not db.session.dirty
+ assert db.session.query(model.Post).count() == 2
+ assert db.session.query(model.Tag).count() == 1
+ assert db.session.query(model.Metric).count() == 0
+ assert db.session.query(model.PostMetric).count() == 0
+ assert db.session.query(model.PostMetricRange).count() == 0
+
+
+def test_cascade_delete_tag(post_factory, tag_factory):
+ tag1 = tag_factory()
+ tag2 = tag_factory()
+ post = post_factory(tags=[tag1, tag2])
+ metric1 = model.Metric(tag=tag1, min=1., max=10.)
+ metric2 = model.Metric(tag=tag2, min=2., max=20.)
+ post_metric1 = model.PostMetric(metric=metric1, post=post, value=2.3)
+ post_metric2 = model.PostMetric(metric=metric2, post=post, value=4.5)
+ post_metric_range1 = model.PostMetricRange(
+ metric=metric1, post=post, low=2, high=8)
+ post_metric_range2 = model.PostMetricRange(
+ metric=metric2, post=post, low=2, high=8)
+ db.session.add_all([post, tag1, tag2, metric1, metric2, post_metric1,
+ post_metric2, post_metric_range1, post_metric_range2])
+ db.session.commit()
+
+ assert not db.session.dirty
+ assert db.session.query(model.Post).count() == 1
+ assert db.session.query(model.Tag).count() == 2
+ assert db.session.query(model.Metric).count() == 2
+ assert db.session.query(model.PostMetric).count() == 2
+ assert db.session.query(model.PostMetricRange).count() == 2
+
+ db.session.delete(tag2)
+ db.session.commit()
+
+ assert not db.session.dirty
+ assert db.session.query(model.Post).count() == 1
+ assert db.session.query(model.Tag).count() == 1
+ assert db.session.query(model.Metric).count() == 1
+ assert db.session.query(model.PostMetric).count() == 1
+ assert db.session.query(model.PostMetricRange).count() == 1
+
+
+def test_cascade_delete_post(post_factory, tag_factory):
+ tag = tag_factory()
+ post1 = post_factory(tags=[tag])
+ post2 = post_factory(tags=[tag])
+ metric = model.Metric(tag=tag, min=1., max=10.)
+ post_metric1 = model.PostMetric(metric=metric, post=post1, value=2.3)
+ post_metric2 = model.PostMetric(metric=metric, post=post2, value=4.5)
+ post_metric_range1 = model.PostMetricRange(
+ metric=metric, post=post1, low=2, high=8)
+ post_metric_range2 = model.PostMetricRange(
+ metric=metric, post=post2, low=2, high=8)
+ db.session.add_all([post1, post2, tag, metric, post_metric1, post_metric2,
+ post_metric_range1, post_metric_range2])
+ db.session.commit()
+
+ assert not db.session.dirty
+ assert db.session.query(model.Post).count() == 2
+ assert db.session.query(model.Tag).count() == 1
+ assert db.session.query(model.Metric).count() == 1
+ assert db.session.query(model.PostMetric).count() == 2
+ assert db.session.query(model.PostMetricRange).count() == 2
+
+ db.session.delete(post2)
+ db.session.commit()
+
+ assert not db.session.dirty
+ assert db.session.query(model.Post).count() == 1
+ assert db.session.query(model.Tag).count() == 1
+ assert db.session.query(model.Metric).count() == 1
+ assert db.session.query(model.PostMetric).count() == 1
+ assert db.session.query(model.PostMetricRange).count() == 1
+
+
+def test_delete_post_metric_no_cascade(
+ post_factory, tag_factory, metric_factory,
+ post_metric_factory, post_metric_range_factory):
+ tag = tag_factory()
+ post = post_factory(tags=[tag])
+ metric = metric_factory(tag=tag)
+ post_metric = post_metric_factory(post=post, metric=metric)
+ post_metric_range = post_metric_range_factory(post=post, metric=metric)
+ db.session.add(metric)
+ db.session.commit()
+ assert len(metric.post_metrics) == 1
+
+ db.session.delete(post_metric)
+ db.session.delete(post_metric_range)
+ db.session.commit()
+ assert len(metric.post_metrics) == 0
+ assert len(metric.post_metric_ranges) == 0
+
+
+def test_tag_without_metric(tag_factory):
+ tag = tag_factory(names=['mytag'])
+ assert tag.metric is None
+ db.session.add(tag)
+ db.session.commit()
+ tag = (
+ db.session
+ .query(model.Tag)
+ .join(model.TagName)
+ .filter(model.TagName.name == 'mytag')
+ .one())
+ assert tag.metric is None
+
+
+def test_metric_counts(post_factory, metric_factory):
+ metric = metric_factory()
+ post1 = post_factory(tags=[metric.tag])
+ post2 = post_factory(tags=[metric.tag])
+ post_metric1 = model.PostMetric(post=post1, metric=metric, value=1.2)
+ post_metric2 = model.PostMetric(post=post2, metric=metric, value=3.4)
+ post_metric_range = model.PostMetricRange(post=post1, metric=metric, low=5.6, high=7.8)
+ db.session.add_all([metric, post_metric1, post_metric2, post_metric_range])
+ db.session.flush()
+ assert metric.post_metric_count == 2
+ assert metric.post_metric_range_count == 1
+
+
+def test_cascade_on_remove_tag_from_post(
+ post_factory, tag_factory, metric_factory,
+ post_metric_factory, post_metric_range_factory):
+ tag = tag_factory()
+ post = post_factory(tags=[tag])
+ metric = metric_factory(tag=tag)
+ post_metric = post_metric_factory(post=post, metric=metric)
+ post_metric_range = post_metric_range_factory(post=post, metric=metric)
+ db.session.add_all([post, tag, metric, post_metric, post_metric_range])
+ db.session.commit()
+
+ assert not db.session.dirty
+ assert db.session.query(model.Post).count() == 1
+ assert db.session.query(model.Tag).count() == 1
+ assert db.session.query(model.PostTag).count() == 1
+ assert db.session.query(model.Metric).count() == 1
+ assert db.session.query(model.PostMetric).count() == 1
+ assert db.session.query(model.PostMetricRange).count() == 1
+
+ post.tags.clear()
+ db.session.commit()
+
+ assert not db.session.dirty
+ assert db.session.query(model.Post).count() == 1
+ assert db.session.query(model.Tag).count() == 1
+ assert db.session.query(model.PostTag).count() == 0
+ assert db.session.query(model.Metric).count() == 1
+ assert db.session.query(model.PostMetric).count() == 0
+ assert db.session.query(model.PostMetricRange).count() == 0
diff --git a/server/szurubooru/tests/search/configs/test_post_metric_search_config.py b/server/szurubooru/tests/search/configs/test_post_metric_search_config.py
new file mode 100644
index 0000000..4eba809
--- /dev/null
+++ b/server/szurubooru/tests/search/configs/test_post_metric_search_config.py
@@ -0,0 +1,89 @@
+import pytest
+from szurubooru import db, model, errors, search
+
+
+@pytest.fixture
+def executor():
+ return search.Executor(search.configs.PostMetricSearchConfig())
+
+
+@pytest.fixture
+def verify_unpaged(executor):
+ def verify(input, expected_values):
+ actual_count, actual_post_metrics = executor.execute(
+ input, offset=0, limit=100)
+ actual_values = ['%s:%r' % (u.metric.tag_name, u.value)
+ for u in actual_post_metrics]
+ assert actual_count == len(expected_values)
+ assert actual_values == expected_values
+ return verify
+
+
+def test_refresh_metrics(tag_factory, metric_factory):
+ tag1 = tag_factory(names=['tag1'])
+ tag2 = tag_factory(names=['tag2'])
+ metric1 = metric_factory(tag1)
+ metric2 = metric_factory(tag2)
+ db.session.add_all([tag1, tag2, metric1, metric2])
+ db.session.flush()
+
+ config = search.configs.PostMetricSearchConfig()
+ config.refresh_metrics()
+
+ assert config.all_metric_names == ['tag1', 'tag2']
+
+
+@pytest.mark.parametrize('input,expected_tag_names', [
+ ('', ['t1:10', 't2:20.5', 't1:30', 't2:40']),
+ ('*', ['t1:10', 't2:20.5', 't1:30', 't2:40']),
+ ('t1', ['t1:10', 't1:30']),
+ ('t2', ['t2:20.5', 't2:40']),
+ ('t*', ['t1:10', 't2:20.5', 't1:30', 't2:40']),
+ ('t1,t2', ['t1:10', 't2:20.5', 't1:30', 't2:40']),
+ ('T1,T2', ['t1:10', 't2:20.5', 't1:30', 't2:40']),
+])
+def test_filter_anonymous(
+ verify_unpaged, input, expected_tag_names,
+ post_factory, tag_factory, metric_factory, post_metric_factory):
+ tag1 = tag_factory(names=['t1'])
+ tag2 = tag_factory(names=['t2'])
+ post1 = post_factory(tags=[tag1, tag2])
+ post2 = post_factory(tags=[tag1, tag2])
+ metric1 = metric_factory(tag1)
+ metric2 = metric_factory(tag2)
+ t1_10 = post_metric_factory(post=post1, metric=metric1, value=10)
+ t1_30 = post_metric_factory(post=post2, metric=metric1, value=30)
+ t2_20 = post_metric_factory(post=post1, metric=metric2, value=20.5)
+ t2_40 = post_metric_factory(post=post2, metric=metric2, value=40)
+ db.session.add_all([tag1, tag2, metric1, metric2,
+ t1_10, t1_30, t2_20, t2_40])
+ db.session.flush()
+ verify_unpaged(input, expected_tag_names)
+
+
+@pytest.mark.parametrize('input,expected_tag_names', [
+ ('t:13', []),
+ ('t:10', ['t:10']),
+ ('t:20.5', ['t:20.5']),
+ ('t:18.6..', ['t:20.5', 't:30', 't:40']),
+ ('t-min:18.6', ['t:20.5', 't:30', 't:40']),
+ ('t:..21.4', ['t:10', 't:20.5']),
+ ('t-max:21.4', ['t:10', 't:20.5']),
+ ('t:17..33', ['t:20.5', 't:30']),
+])
+def test_filter_by_value(
+ verify_unpaged, input, expected_tag_names,
+ post_factory, tag_factory, metric_factory, post_metric_factory):
+ tag = tag_factory(names=['t'])
+ post1 = post_factory(tags=[tag])
+ post2 = post_factory(tags=[tag])
+ post3 = post_factory(tags=[tag])
+ post4 = post_factory(tags=[tag])
+ metric = metric_factory(tag)
+ t1 = post_metric_factory(post=post1, metric=metric, value=10)
+ t2 = post_metric_factory(post=post2, metric=metric, value=30)
+ t3 = post_metric_factory(post=post3, metric=metric, value=20.5)
+ t4 = post_metric_factory(post=post4, metric=metric, value=40)
+ db.session.add_all([tag, metric, t1, t2, t3, t4])
+ db.session.flush()
+ verify_unpaged(input, expected_tag_names)
diff --git a/server/szurubooru/tests/search/configs/test_post_search_config.py b/server/szurubooru/tests/search/configs/test_post_search_config.py
index e726f31..cdd60ec 100644
--- a/server/szurubooru/tests/search/configs/test_post_search_config.py
+++ b/server/szurubooru/tests/search/configs/test_post_search_config.py
@@ -86,6 +86,14 @@ def verify_unpaged(executor):
return verify
+@pytest.fixture
+def verify_around(executor):
+ def verify(input, post_id, expected_prev_id, expected_next_id):
+ actual_prev, actual_next, r = executor.get_around(input, post_id)
+ assert actual_prev.post_id == expected_prev_id
+ assert actual_next.post_id == expected_next_id
+ return verify
+
@pytest.mark.parametrize(
"input,expected_post_ids",
@@ -871,6 +879,157 @@ def test_tumbleweed(
verify_unpaged("-special:tumbleweed", [1, 2, 3])
+@pytest.mark.parametrize("input,expected_post_ids", [
+ ("sort:id,asc metric-a:1..3", [1, 2, 3]),
+ ("sort:id,asc metric-a-min:2", [2, 3]),
+ ("sort:id,asc metric-a:1.5..", [2, 3]),
+ ("sort:id,asc metric-a:1..3 metric-b:2..", [2]),
+ ("sort:id,asc c metric-a:3..", [3]),
+ ("sort:id,asc metric-b:..2", [1, 2]),
+ ("sort:id,asc metric-b:..1.9", [1]),
+ ("sort:metric-a", [1, 2, 3, 5, 4]),
+ ("sort:metric-a,desc", [3, 2, 1, 5, 4]),
+ ("metric-a:1..3 metric-b:1..3 sort:metric-b,desc", [2, 1]),
+ ("metric-a:1..3 sort:metric-b,desc", [2, 1, 3]),
+ ("metric-a:2..3 metric-b:1..3 sort:metric-b,desc", [2]),
+ ("metric-a:2..3 sort:metric-b,desc", [2, 3]),
+ ("sort:id,asc metric:a", [1, 2, 3]),
+ ("sort:id,asc -metric:a", [4, 5]),
+ ("sort:id,asc metric:a -metric:b", [3]),
+])
+def test_metrics(
+ input,
+ expected_post_ids,
+ post_factory,
+ tag_factory,
+ metric_factory,
+ post_metric_factory,
+ post_metric_range_factory,
+ verify_unpaged):
+ tag_a = tag_factory(names=["a"])
+ tag_b = tag_factory(names=["b"])
+ tag_c = tag_factory(names=["c"])
+ post1 = post_factory(id=1, tags=[tag_a, tag_b, tag_c])
+ post2 = post_factory(id=2, tags=[tag_a, tag_b, tag_c])
+ post3 = post_factory(id=3, tags=[tag_a, tag_b, tag_c])
+ post4 = post_factory(id=4, tags=[tag_a, tag_b, tag_c])
+ post5 = post_factory(id=5, tags=[tag_a, tag_b, tag_c])
+ metric_a = metric_factory(tag=tag_a)
+ metric_b = metric_factory(tag=tag_b)
+ metric_c = metric_factory(tag=tag_c)
+ a1 = post_metric_factory(post=post1, metric=metric_a, value=1)
+ b1 = post_metric_factory(post=post1, metric=metric_b, value=1)
+ c1 = post_metric_factory(post=post1, metric=metric_c, value=1)
+ a2 = post_metric_factory(post=post2, metric=metric_a, value=2)
+ b2 = post_metric_factory(post=post2, metric=metric_b, value=2)
+ a3 = post_metric_factory(post=post3, metric=metric_a, value=3)
+ c3 = post_metric_factory(post=post3, metric=metric_c, value=3)
+ r_a4 = post_metric_range_factory(post=post4, metric=metric_a,
+ low=1.5, high=2.5)
+ db.session.add_all([tag_a, tag_b, tag_c,
+ post1, post2, post3, post4, post5,
+ metric_a, metric_b, metric_c,
+ a1, b1, c1, a2, b2, a3, c3, r_a4])
+ db.session.flush()
+ verify_unpaged(input, expected_post_ids, True)
+
+
+@pytest.mark.parametrize("input,expected_prev_id,expected_next_id", [
+ ("", 3, 1), # default order is actually descending
+ ("sort:id,asc", 1, 3),
+ ("sort:id,desc", 3, 1),
+ ("sort:tag-count,asc", 1, 3),
+ ("sort:tag-count,desc", 3, 1),
+ ("metric-a:0..2 sort:metric-a", 3, 1),
+ ("metric-a:0..2 sort:metric-a,desc", 1, 3),
+ ("sort:metric-b", 3, 1),
+])
+def test_around_query(
+ input,
+ expected_prev_id,
+ expected_next_id,
+ post_factory,
+ tag_factory,
+ metric_factory,
+ post_metric_factory,
+ verify_around):
+ tag_a = tag_factory(names=["a"])
+ tag_b = tag_factory(names=["b"])
+ tag_c = tag_factory(names=["c"])
+ tag_d = tag_factory(names=["d"])
+ post1 = post_factory(id=1, tags=[tag_a])
+ post2 = post_factory(id=2, tags=[tag_a, tag_b])
+ post3 = post_factory(id=3, tags=[tag_a, tag_b, tag_c])
+ metric_a = metric_factory(tag=tag_a)
+ metric_b = metric_factory(tag=tag_b)
+ pm1 = post_metric_factory(post=post1, metric=metric_a, value=1.4)
+ pm2 = post_metric_factory(post=post2, metric=metric_a, value=1)
+ pm3 = post_metric_factory(post=post3, metric=metric_a, value=0.3)
+ db.session.add_all([tag_a, tag_b, tag_c,
+ post1, post2, post3,
+ metric_a, metric_b, pm1, pm2, pm3])
+ db.session.add_all([tag_a, tag_b, tag_c, post1, post2, post3])
+ db.session.flush()
+ verify_around(input, 2, expected_prev_id, expected_next_id)
+
+
+@pytest.mark.parametrize("input,expected_post_ids", [
+ ("similar:1", [6, 4, 1]),
+ ("similar:2", [6, 5, 4, 2]),
+ ("similar:3", [6, 5, 3]),
+ ("similar:4", [6, 4, 5, 2, 1]),
+ ("similar:5", [6, 5, 4, 3, 2]),
+ ("similar:6", [6, 5, 4, 3, 2, 1]),
+ ("-similar:1", [5, 3, 2]),
+ ("-similar:2", [3, 1]),
+ ("-similar:3", [4, 2, 1]),
+ ("-similar:4", [3]),
+ ("-similar:5", [1]),
+ ("-similar:6", []),
+ ("similar:4 sort:id,asc", [4, 6, 1, 2, 5]),
+ ("similar:4 b", [6, 4, 5, 2]),
+ ("similar:4 c", [6, 5]),
+])
+def test_filter_by_similar(
+ post_factory, tag_factory, verify_unpaged, input, expected_post_ids
+):
+ tagA = tag_factory(names=["a"])
+ tagB = tag_factory(names=["b"])
+ tagC = tag_factory(names=["c"])
+ postA = post_factory(id=1, tags=[tagA])
+ postB = post_factory(id=2, tags=[tagB])
+ postC = post_factory(id=3, tags=[tagC])
+ postAB = post_factory(id=4, tags=[tagA, tagB])
+ postBC = post_factory(id=5, tags=[tagB, tagC])
+ postABC = post_factory(id=6, tags=[tagA, tagB, tagC])
+ db.session.add_all(
+ [tagA, tagB, tagC, postA, postB, postC, postAB, postBC, postABC]
+ )
+ db.session.flush()
+ verify_unpaged(input, expected_post_ids, True)
+
+
+@pytest.mark.parametrize("input,expected_post_ids", [
+ ("similar:1", [3, 1, 2]),
+ ("similar:2", [3, 2, 1]),
+ ("similar:3", [3, 1, 2]),
+])
+def test_sort_by_similar(
+ post_factory, tag_factory, verify_unpaged, input, expected_post_ids
+):
+ tagA = tag_factory(names=["a"])
+ tagB = tag_factory(names=["b"])
+ tagC = tag_factory(names=["c"])
+ postAB = post_factory(id=1, tags=[tagA, tagB])
+ postA = post_factory(id=2, tags=[tagA])
+ postABC = post_factory(id=3, tags=[tagA, tagB, tagC])
+ db.session.add_all(
+ [tagA, tagB, tagC, postA,postAB, postABC]
+ )
+ db.session.flush()
+ verify_unpaged(input, expected_post_ids, True)
+
+
@pytest.mark.parametrize(
"input,expected_post_ids",
[