diff options
123 files changed, 5367 insertions, 346 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..35b83c2 100644 --- a/client/Dockerfile +++ b/client/Dockerfile @@ -7,7 +7,7 @@ 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} 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..1609870 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 @@ -200,45 +225,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 +344,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..0ff29ec 100644 --- a/client/css/post-main-view.styl +++ b/client/css/post-main-view.styl @@ -15,33 +15,40 @@ 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 + >.sidebar>nav.buttons, >.content 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 @@ -69,9 +76,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 +124,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 +137,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..f170713 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 (screen.width <= 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 (screen.width > 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..ed5e9a4 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,15 +96,24 @@ 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"; } } + _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() { if (window.innerWidth <= 800) { const buttons = document.querySelector(".sidebar > .buttons"); @@ -132,7 +144,10 @@ class PostContentControl { _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..f1f4a99 100644 --- a/client/package-lock.json +++ b/client/package-lock.json @@ -1,6 +1,5 @@ { "name": "szurubooru", - "lockfileVersion": 2, "requires": true, "packages": { "": { @@ -4619,6 +4618,11 @@ } } }, + "@fortawesome/fontawesome-free": { + "version": "5.15.3", + "resolved": "https://registry.npmjs.org/@fortawesome/fontawesome-free/-/fontawesome-free-5.15.3.tgz", + "integrity": "sha512-rFnSUN/QOtnOAgqFRooTA3H57JLDm0QEG/jPdk+tLQNL/eWd+Aok8g3qCI+Q1xuDPWpGW/i9JySpJVsq8Q0s9w==" + }, "@jimp/bmp": { "version": "0.13.0", "resolved": "https://registry.npmjs.org/@jimp/bmp/-/bmp-0.13.0.tgz", @@ -4979,6 +4983,16 @@ } } }, + "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" + } + }, "acorn": { "version": "7.4.1", "resolved": "https://registry.npmjs.org/acorn/-/acorn-7.4.1.tgz", @@ -5822,9 +5836,9 @@ "integrity": "sha512-erYug8XoqzU3IfcU8fUgyHqyOXqIE4tUTTQ+7mqUjQlvnXkOO6OlT9c/ZoJVHYoAaqGxr09CN53G7XIsO4KtWA==", "dev": true, "requires": { + "JSONStream": "^1.0.3", "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" @@ -5853,6 +5867,7 @@ "integrity": "sha512-fMES05wq1Oukts6ksGUU2TMVHHp06LyQt0SIwbXIHm7waSrQmNBZePsU0iM/4f94zbvb/wHma+D1YrdzWYnF/A==", "dev": true, "requires": { + "JSONStream": "^1.0.3", "assert": "^1.4.0", "browser-pack": "^6.0.1", "browser-resolve": "^1.11.0", @@ -5874,7 +5889,6 @@ "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", @@ -6619,11 +6633,6 @@ "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", @@ -6913,11 +6922,11 @@ "integrity": "sha512-ufS5Qq9RZN+Bu899eA9QCAYThY+gGW7oRkmb0vC93Vlyu/CFGcH0OYPEjVkDXA5FEbTt1+VWzdoOD3Ny9N+8tg==", "dev": true, "requires": { + "JSONStream": "^1.0.3", "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", @@ -7167,16 +7176,6 @@ "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", @@ -7350,6 +7349,7 @@ "integrity": "sha512-fg7OZaQBcL4/L+AK5f4iVqf9OMbCclXfy/znXRxTVhJSeW5AIlS9AwheYwDaXM3lVW7OBeaeUEY3gbaC6cLlSA==", "dev": true, "requires": { + "JSONStream": "^1.0.3", "browser-resolve": "^2.0.0", "cached-path-relative": "^1.0.2", "concat-stream": "~1.6.0", @@ -7357,7 +7357,6 @@ "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", @@ -8002,14 +8001,6 @@ "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", @@ -8030,6 +8021,14 @@ "define-properties": "^1.1.3" } }, + "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" + } + }, "strip-ansi": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-3.0.1.tgz", @@ -8376,6 +8375,7 @@ "integrity": "sha512-SaHqzhku9v/j6XsQMRxPyBrSP3gnwmE27gLJYZgMT2GeK3J0+0toN+MnuNYDfHwVGQfLiMZ7KSNSIXHemy905w==", "dev": true, "requires": { + "JSONStream": "^1.0.3", "assert": "^1.4.0", "browser-pack": "^6.0.1", "browser-resolve": "^2.0.0", @@ -8397,7 +8397,6 @@ "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", @@ -8580,8 +8579,7 @@ "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": {} + "dev": true }, "xhr": { "version": "2.5.0", 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.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 193aac3..5dddd72 100644 --- a/server/config.yaml.dist +++ b/server/config.yaml.dist @@ -115,6 +115,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 @@ -137,6 +138,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 daba7f7..4a629fe 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() @@ -165,6 +168,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) @@ -308,3 +319,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 8d4672d..9869843 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 util +from szurubooru.func import 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,98 @@ 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: @@ -153,6 +245,10 @@ def _category_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 = [] @@ -179,9 +275,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 ) @@ -226,7 +324,9 @@ class PostSearchConfig(BaseSearchConfig): @property def named_filters(self) -> Dict[str, Filter]: - return util.unalias_dict( + 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)), ( @@ -241,6 +341,7 @@ class PostSearchConfig(BaseSearchConfig): ), ), ), + (["metric"], _metric_presence_filter), (["score"], search_util.create_num_filter(model.Post.score)), (["uploader", "upload", "submit"], _user_filter), ( @@ -377,13 +478,18 @@ class PostSearchConfig(BaseSearchConfig): ), ), (["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( + filters = {"metric-" + name: + (_create_metric_sort_column(name), self.SORT_ASC) + for name in self.all_metric_names} + filters.update(util.unalias_dict( [ ( ["random"], @@ -445,7 +551,8 @@ class PostSearchConfig(BaseSearchConfig): (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 ac984c2..b64074c 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, @@ -125,3 +127,44 @@ def test_trying_to_retrieve_single_without_privileges( context_factory(user=user_factory(rank=model.User.RANK_ANONYMOUS)), {"post_id": 999}, ) + + +@pytest.mark.parametrize("query,expected_id", [ + ("sort:id,asc", 2), + ("sort:id,asc id:2..", 2), + ("sort:id,desc id:2..", 3), + ("sort:id,asc id:3..", 3), + ("sort:id,desc id:3..", 3), + ("sort:id id:4..", None), + ("sort:tag-count", 3), + ("sort:tag-count,asc id:..2", 1), + ("sort:tag-count,desc id:..2", 2), +]) +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 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 b86fa27..6ab5d52 100644 --- a/server/szurubooru/tests/search/configs/test_post_search_config.py +++ b/server/szurubooru/tests/search/configs/test_post_search_config.py @@ -80,6 +80,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", @@ -865,6 +873,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", [ |