diff options
124 files changed, 5743 insertions, 4577 deletions
@@ -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> edit<% + %><i class='fa fa-edit'></i> edit<% %></a><% %><% } %><% %><% if (ctx.canDeleteComment) { %><% %><a href class='delete'><% - %><i class='fa fa-remove'></i> delete<% + %><i class='fa fa-times'></i> 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] %> <!-- + --></a><!-- + --><span class='metric-bounds' data-pseudo-content= + '<%- ctx.tag.metric.min %> — <%- 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'>—</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 %> — <%- 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 ></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> · - <a href='https://danbooru.donmai.us/posts?tags=md5:<%- ctx.post.checksumMD5 %>'>Danbooru</a> · - <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> · + <a target="_blank" href='https://danbooru.donmai.us/posts?tags=md5:<%- ctx.post.checksumMD5 %>'>Danbooru</a> · + <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…</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", [ |