summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--.gitignore4
-rw-r--r--client/Dockerfile2
-rwxr-xr-xclient/build.js12
-rw-r--r--client/css/colors.styl2
-rw-r--r--client/css/core-forms.styl16
-rw-r--r--client/css/core-general.styl2
-rw-r--r--client/css/expander-control.styl3
-rw-r--r--client/css/metric-sorter-view.styl56
-rw-r--r--client/css/post-content-control.styl3
-rw-r--r--client/css/post-list-view.styl143
-rw-r--r--client/css/post-main-view.styl69
-rw-r--r--client/css/post-metric-input.styl49
-rw-r--r--client/css/post-upload.styl19
-rw-r--r--client/css/settings.styl10
-rw-r--r--client/css/tag-view.styl23
-rw-r--r--client/css/user-registration.styl5
-rw-r--r--client/html/comment.tpl4
-rw-r--r--client/html/compact_metric_list_item.tpl26
-rw-r--r--client/html/compact_post_metric_list_item.tpl32
-rw-r--r--client/html/compact_post_metric_range_list_item.tpl42
-rw-r--r--client/html/endless_pager_page.tpl3
-rw-r--r--client/html/fav.tpl4
-rw-r--r--client/html/help_keyboard.tpl21
-rw-r--r--client/html/metric_header.tpl11
-rw-r--r--client/html/metric_header_item.tpl6
-rw-r--r--client/html/metric_sorter.tpl39
-rw-r--r--client/html/metric_sorter_side.tpl5
-rw-r--r--client/html/post_edit_sidebar.tpl6
-rw-r--r--client/html/post_main.tpl58
-rw-r--r--client/html/post_metric_input.tpl5
-rw-r--r--client/html/post_readonly_sidebar.tpl28
-rw-r--r--client/html/post_upload.tpl13
-rw-r--r--client/html/post_upload_row.tpl12
-rw-r--r--client/html/posts_header.tpl99
-rw-r--r--client/html/posts_page.tpl6
-rw-r--r--client/html/score.tpl6
-rw-r--r--client/html/settings.tpl34
-rw-r--r--client/html/similar_post_item.tpl5
-rw-r--r--client/html/tag.tpl3
-rw-r--r--client/html/tag_input.tpl4
-rw-r--r--client/html/tag_metric.tpl40
-rw-r--r--client/html/user_registration.tpl4
-rw-r--r--client/js/controllers/metric_sorter_contoller.js187
-rw-r--r--client/js/controllers/post_list_controller.js77
-rw-r--r--client/js/controllers/post_main_controller.js23
-rw-r--r--client/js/controllers/post_upload_controller.js38
-rw-r--r--client/js/controllers/tag_controller.js42
-rw-r--r--client/js/controls/expander_control.js4
-rw-r--r--client/js/controls/metric_header_control.js85
-rw-r--r--client/js/controls/post_content_control.js23
-rw-r--r--client/js/controls/post_edit_sidebar_control.js72
-rw-r--r--client/js/controls/post_metric_input_control.js164
-rw-r--r--client/js/controls/post_metric_list_control.js51
-rw-r--r--client/js/controls/post_readonly_sidebar_control.js76
-rw-r--r--client/js/controls/tag_input_control.js64
-rw-r--r--client/js/main.js1
-rw-r--r--client/js/models/abstract_list.js5
-rw-r--r--client/js/models/metric.js88
-rw-r--r--client/js/models/metric_list.js24
-rw-r--r--client/js/models/post.js34
-rw-r--r--client/js/models/post_list.js49
-rw-r--r--client/js/models/post_metric.js39
-rw-r--r--client/js/models/post_metric_list.js24
-rw-r--r--client/js/models/post_metric_range.js43
-rw-r--r--client/js/models/post_metric_range_list.js24
-rw-r--r--client/js/models/settings.js18
-rw-r--r--client/js/models/tag.js33
-rw-r--r--client/js/models/tag_list.js27
-rw-r--r--client/js/router.js10
-rw-r--r--client/js/tags.js43
-rw-r--r--client/js/util/keyboard.js8
-rw-r--r--client/js/util/search.js1
-rw-r--r--client/js/util/touch.js47
-rw-r--r--client/js/util/uri.js11
-rw-r--r--client/js/util/views.js35
-rw-r--r--client/js/views/endless_page_view.js111
-rw-r--r--client/js/views/metric_sorter_view.js138
-rw-r--r--client/js/views/post_main_view.js46
-rw-r--r--client/js/views/post_upload_view.js60
-rw-r--r--client/js/views/posts_header_view.js206
-rw-r--r--client/js/views/posts_page_view.js38
-rw-r--r--client/js/views/settings_view.js10
-rw-r--r--client/js/views/tag_metric_view.js97
-rw-r--r--client/js/views/tag_view.js14
-rw-r--r--client/package-lock.json60
-rw-r--r--client/package.json2
-rw-r--r--docker-compose.yml10
-rw-r--r--server/config.yaml.dist7
-rw-r--r--server/szurubooru/api/__init__.py1
-rw-r--r--server/szurubooru/api/metric_api.py94
-rw-r--r--server/szurubooru/api/post_api.py82
-rw-r--r--server/szurubooru/api/tag_api.py17
-rw-r--r--server/szurubooru/func/metrics.py273
-rw-r--r--server/szurubooru/func/posts.py114
-rw-r--r--server/szurubooru/func/similar.py32
-rw-r--r--server/szurubooru/func/tags.py13
-rw-r--r--server/szurubooru/func/util.py4
-rw-r--r--server/szurubooru/migrations/versions/0061c5c3299f_postmetric_depends_on_posttag.py35
-rw-r--r--server/szurubooru/migrations/versions/3c1f0316fa7f_resize_post_columns.py2
-rw-r--r--server/szurubooru/migrations/versions/51ac43760440_create_metric_tables.py50
-rw-r--r--server/szurubooru/migrations/versions/aae2050fb28c_add_version_to_metric_tables.py32
-rw-r--r--server/szurubooru/model/__init__.py1
-rw-r--r--server/szurubooru/model/metric.py141
-rw-r--r--server/szurubooru/model/post.py6
-rw-r--r--server/szurubooru/model/tag.py5
-rw-r--r--server/szurubooru/rest/context.py29
-rw-r--r--server/szurubooru/search/configs/__init__.py1
-rw-r--r--server/szurubooru/search/configs/post_metric_search_config.py44
-rw-r--r--server/szurubooru/search/configs/post_search_config.py117
-rw-r--r--server/szurubooru/search/configs/util.py4
-rw-r--r--server/szurubooru/search/executor.py124
-rw-r--r--server/szurubooru/tests/api/test_metric_retrieving.py63
-rw-r--r--server/szurubooru/tests/api/test_post_retrieving.py43
-rw-r--r--server/szurubooru/tests/api/test_post_updating.py15
-rw-r--r--server/szurubooru/tests/api/test_tag_updating.py24
-rw-r--r--server/szurubooru/tests/conftest.py53
-rw-r--r--server/szurubooru/tests/func/test_metrics.py459
-rw-r--r--server/szurubooru/tests/func/test_posts.py59
-rw-r--r--server/szurubooru/tests/func/test_similar.py60
-rw-r--r--server/szurubooru/tests/func/test_tags.py30
-rw-r--r--server/szurubooru/tests/model/test_metric.py235
-rw-r--r--server/szurubooru/tests/search/configs/test_post_metric_search_config.py89
-rw-r--r--server/szurubooru/tests/search/configs/test_post_search_config.py159
123 files changed, 5367 insertions, 346 deletions
diff --git a/.gitignore b/.gitignore
index b21e3ad..ec86ed2 100644
--- a/.gitignore
+++ b/.gitignore
@@ -13,3 +13,7 @@ server/**/lib/
server/**/bin/
server/**/pyvenv.cfg
__pycache__/
+
+# IDE files
+.idea
+*.iml \ No newline at end of file
diff --git a/client/Dockerfile b/client/Dockerfile
index ea5151f..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>&nbsp;edit<%
+ %><i class='fa fa-edit'></i>&nbsp;edit<%
%></a><%
%><% } %><%
%><% if (ctx.canDeleteComment) { %><%
%><a href class='delete'><%
- %><i class='fa fa-remove'></i>&nbsp;delete<%
+ %><i class='fa fa-times'></i>&nbsp;delete<%
%></a><%
%><% } %><%
%></span><%
diff --git a/client/html/compact_metric_list_item.tpl b/client/html/compact_metric_list_item.tpl
new file mode 100644
index 0000000..ddddc33
--- /dev/null
+++ b/client/html/compact_metric_list_item.tpl
@@ -0,0 +1,26 @@
+<li><!--
+--><% if (ctx.editMode) { %><!--
+ --><a href="<%- ctx.formatClientLink('tag', ctx.tag.names[0]) %>"
+ class="<%= ctx.makeCssName(ctx.tag.category, 'tag') %>"><!--
+ --><i class='fa fa-sliders-h tag-icon'></i><!--
+ --></a><!--
+ --><a href="<%- ctx.formatClientLink('posts', {
+ query: 'metric-' + ctx.escapeTagName(ctx.tag.names[0]) +
+ ':' + ctx.tag.metric.min + '..' + ctx.tag.metric.max +
+ ' sort:metric-' + ctx.escapeTagName(ctx.tag.names[0])
+ }) %>"
+ class="<%= ctx.makeCssName(ctx.tag.category, 'tag') %>"><!--
+ --><%- ctx.tag.names[0] %>&#32;<!--
+ --></a><!--
+ --><span class='metric-bounds' data-pseudo-content=
+ '<%- ctx.tag.metric.min %> &mdash; <%- ctx.tag.metric.max %>'></span><!--
+ --><span class='metric-controls'>Set<!--
+ --><a href class='create-exact'> exact</a><!--
+ --><a href class='create-range'> range</a><!--
+ --><a href='<%= ctx.getMetricSorterUrl(ctx.post.id, {
+ metrics: ctx.tag.names[0],
+ query: ctx.query}) %>'
+ class='sort'> sort</a><!--
+ --></span><!--
+--><% } %><!--
+--></li>
diff --git a/client/html/compact_post_metric_list_item.tpl b/client/html/compact_post_metric_list_item.tpl
new file mode 100644
index 0000000..aa6da3f
--- /dev/null
+++ b/client/html/compact_post_metric_list_item.tpl
@@ -0,0 +1,32 @@
+<li><!--
+--><% if (ctx.editMode) { %><!--
+ --><a href class='remove-metric' data-pseudo-content='×'/><!--
+ --><a href="<%- ctx.formatClientLink('posts', {
+ query: 'metric-' + ctx.escapeTagName(ctx.tag.names[0]) +
+ ':' + ctx.tag.metric.min + '..' + ctx.tag.metric.max +
+ ' sort:metric-' + ctx.escapeTagName(ctx.tag.names[0])
+ }) %>"
+ class="<%= ctx.makeCssName(ctx.tag.category, 'tag') %>"><!--
+ --><i class='fas fa-angle-right tag-icon'></i><!--
+ --><%- ctx.postMetric.tagName %>:</a><!--
+ --><%= ctx.makeNumericInput({
+ name: 'value',
+ value: ctx.postMetric.value,
+ step: 'any',
+ min: ctx.tag.metric.min,
+ max: ctx.tag.metric.max,
+ }) %><!--
+--><% } else { %><!--
+ --><a href="<%- ctx.formatClientLink('tag', ctx.tag.names[0]) %>"
+ class="<%= ctx.makeCssName(ctx.tag.category, 'tag') %>"><!--
+ --><i class='fas fa-angle-right tag-icon'></i><!--
+ --></a><!--
+ --><a href="<%- ctx.formatClientLink('posts', {
+ query: 'metric-' + ctx.escapeTagName(ctx.tag.names[0]) +
+ ':' + ctx.tag.metric.min + '..' + ctx.tag.metric.max +
+ ' sort:metric-' + ctx.escapeTagName(ctx.tag.names[0])
+ }) %>"
+ class="<%= ctx.makeCssName(ctx.tag.category, 'tag') %>"><!--
+ --><%- ctx.postMetric.tagName %>: <%- ctx.postMetric.value || 0 %></a><!--
+--><% } %><!--
+--></li>
diff --git a/client/html/compact_post_metric_range_list_item.tpl b/client/html/compact_post_metric_range_list_item.tpl
new file mode 100644
index 0000000..fd4c40e
--- /dev/null
+++ b/client/html/compact_post_metric_range_list_item.tpl
@@ -0,0 +1,42 @@
+<li><!--
+--><% if (ctx.editMode) { %><!--
+ --><a class='remove-metric' data-pseudo-content='×'/><!--
+ --><a href="<%- ctx.formatClientLink('posts', {
+ query: 'metric-' + ctx.escapeTagName(ctx.tag.names[0]) +
+ ':' + ctx.tag.metric.min + '..' + ctx.tag.metric.max +
+ ' sort:metric-' + ctx.escapeTagName(ctx.tag.names[0])
+ }) %>"
+ class="<%= ctx.makeCssName(ctx.tag.category, 'tag') %>"><!--
+ --><i class='fas fa-arrows-alt-h tag-icon'></i><!--
+ --><%- ctx.postMetricRange.tagName %>:</a><!--
+ --><%= ctx.makeNumericInput({
+ name: 'low',
+ value: ctx.postMetricRange.low,
+ step: 'any',
+ min: ctx.tag.metric.min,
+ max: ctx.tag.metric.max,
+ }) %><!--
+ --><span class='range-delimiter'>&mdash;</span><!--
+ --><%= ctx.makeNumericInput({
+ name: 'high',
+ value: ctx.postMetricRange.high,
+ step: 'any',
+ min: ctx.tag.metric.min,
+ max: ctx.tag.metric.max,
+ }) %><!--
+--><% } else { %><!--
+ --><a href="<%- ctx.formatClientLink('tag', ctx.tag.names[0]) %>"
+ class="<%= ctx.makeCssName(ctx.tag.category, 'tag') %>"><!--
+ --><i class='fas fa-arrows-alt-h tag-icon'></i><!--
+ --></a><!--
+ --><a href="<%- ctx.formatClientLink('posts', {
+ query: 'metric-' + ctx.escapeTagName(ctx.tag.names[0]) +
+ ':' + ctx.tag.metric.min + '..' + ctx.tag.metric.max +
+ ' sort:metric-' + ctx.escapeTagName(ctx.tag.names[0])
+ }) %>"
+ class="<%= ctx.makeCssName(ctx.tag.category, 'tag') %>"><!--
+ --><%- ctx.postMetricRange.tagName %>:
+ <%- ctx.postMetricRange.low || 0 %> &mdash; <%- ctx.postMetricRange.high || 0 %><!--
+ --></a><!--
+--><% } %><!--
+--></li>
diff --git a/client/html/endless_pager_page.tpl b/client/html/endless_pager_page.tpl
index 9b1cf6c..1212ba8 100644
--- a/client/html/endless_pager_page.tpl
+++ b/client/html/endless_pager_page.tpl
@@ -1,4 +1,5 @@
<div class='page'>
- <p class='page-header'><span>Page <%- ctx.page %> of <%- ctx.totalPages %></span></p>
+<!-- Don't show the header, make page transition seamless: -->
+<!-- <p class='page-header'><span>Page <%- ctx.page %> of <%- ctx.totalPages %></span></p>*} -->
<div class='page-content-holder'></div>
</div>
diff --git a/client/html/fav.tpl b/client/html/fav.tpl
index 38cbd6b..7b72a7c 100644
--- a/client/html/fav.tpl
+++ b/client/html/fav.tpl
@@ -4,11 +4,11 @@
<i class='fa fa-heart'></i>
<% } else { %>
<a href class='add-favorite'>
- <i class='fa fa-heart-o'></i>
+ <i class='far fa-heart'></i>
<% } %>
<% } else { %>
<a class='add-favorite inactive'>
- <i class='fa fa-heart-o'></i>
+ <i class='far fa-heart'></i>
<% } %>
<span class='vim-nav-hint'>add to favorites</span>
</a>
diff --git a/client/html/help_keyboard.tpl b/client/html/help_keyboard.tpl
index f200ce0..5fe53b9 100644
--- a/client/html/help_keyboard.tpl
+++ b/client/html/help_keyboard.tpl
@@ -15,11 +15,21 @@ shortcuts:</p>
</tr>
<tr>
+ <td><kbd>P</kbd></td>
+ <td>Focus first post in post list</td>
+ </tr>
+
+ <tr>
<td><kbd>A</kbd> and <kbd>D</kbd>, <kbd>←</kbd> and <kbd>→</kbd></td>
<td>Go to newer/older page or post</td>
</tr>
<tr>
+ <td><kbd>R</kbd></td>
+ <td>Go to random post</td>
+ </tr>
+
+ <tr>
<td><kbd>F</kbd></td>
<td>Cycle post fit mode</td>
</tr>
@@ -30,13 +40,18 @@ shortcuts:</p>
</tr>
<tr>
- <td><kbd>P</kbd></td>
- <td>Focus first post in post list</td>
+ <td><kbd>T</kbd></td>
+ <td>(In edit mode) Focus tag input</td>
+ </tr>
+
+ <tr>
+ <td><kbd>Command/Ctrl+S</kbd></td>
+ <td>(In edit mode) Save post</td>
</tr>
<tr>
<td><kbd>Delete</kbd></td>
- <td>Delete post (while in edit mode)</td>
+ <td>(In edit mode) delete post</td>
</tr>
</tbody>
</table>
diff --git a/client/html/metric_header.tpl b/client/html/metric_header.tpl
new file mode 100644
index 0000000..a711575
--- /dev/null
+++ b/client/html/metric_header.tpl
@@ -0,0 +1,11 @@
+<form class='horizontal'>
+ <ul class='metric-list'></ul>
+ <wbr>
+ <%= ctx.makeCheckbox({
+ text: 'Show values on posts',
+ name: 'show-values-on-posts',
+ checked: ctx.showValuesOnPost,
+ class: 'append'}) %>
+ <a class='mousetrap button append close sorting'
+ href="<%= ctx.getMetricSorterUrl('random', ctx.parameters) %>">Start sorting</a>
+</form>
diff --git a/client/html/metric_header_item.tpl b/client/html/metric_header_item.tpl
new file mode 100644
index 0000000..880dfed
--- /dev/null
+++ b/client/html/metric_header_item.tpl
@@ -0,0 +1,6 @@
+<li class="<%= ctx.makeCssName(ctx.metric.tag.category, 'tag') %><%
+ if (ctx.selected) { %> selected<% } %>">
+ <a href class="<%= ctx.makeCssName(ctx.metric.tag.category, 'tag') %><%
+ if (ctx.selected) { %> selected<% } %>"><%
+ %><%- ctx.metric.tag.names[0] %></a>
+</li> \ No newline at end of file
diff --git a/client/html/metric_sorter.tpl b/client/html/metric_sorter.tpl
new file mode 100644
index 0000000..3023fbd
--- /dev/null
+++ b/client/html/metric_sorter.tpl
@@ -0,0 +1,39 @@
+<div class='content-wrapper' id='metric-sorter'>
+ <h2>Sorting metric "<%- ctx.primaryMetric %>"</h2>
+ <form>
+ <div class='posts-container'>
+ <div class='left-post-container'></div>
+ <% if (window.innerWidth <= 1000) { %>
+ <div class='messages'></div>
+ <% } %>
+ <div class='sorting-buttons'>
+ <div class='compare-block'>
+ <% if (window.innerWidth <= 1000) { %>
+ <input class='mousetrap save-btn' type='submit' value='Save'>
+ <% } %>
+ <button class='compare left-lt-right'>
+ <i class='fa fa-less-than'></i>
+ </button>
+ <button class='compare left-gt-right'>
+ <i class='fa fa-greater-than'></i>
+ </button>
+ <% if (window.innerWidth <= 1000) { %>
+ <a href class='mousetrap append skip-btn'>Skip</a>
+ <% } %>
+ </div>
+ </div>
+ <div class='right-post-container'></div>
+ </div>
+
+ <% if (window.innerWidth > 1000) { %>
+ <div class='messages'></div>
+ <% } %>
+
+ <div class='buttons'>
+ <% if (window.innerWidth > 1000) { %>
+ <input class='mousetrap save-btn' type='submit' value='Save'>
+ <a href class='mousetrap append skip-btn'>Skip</a>
+ <% } %>
+ </div>
+ </form>
+</div>
diff --git a/client/html/metric_sorter_side.tpl b/client/html/metric_sorter_side.tpl
new file mode 100644
index 0000000..8d0b010
--- /dev/null
+++ b/client/html/metric_sorter_side.tpl
@@ -0,0 +1,5 @@
+<% if (ctx.post) { %>
+ <a href='<%= ctx.getPostUrl(ctx.post.id, ctx.parameters) %>'>
+ <div class='post-container'></div>
+ </a>
+<% } %>
diff --git a/client/html/post_edit_sidebar.tpl b/client/html/post_edit_sidebar.tpl
index 07dcf6f..9408606 100644
--- a/client/html/post_edit_sidebar.tpl
+++ b/client/html/post_edit_sidebar.tpl
@@ -79,6 +79,12 @@
</section>
<% } %>
+ <% if (ctx.canEditPostMetrics) { %>
+ <section class='metrics'>
+ <%= ctx.makeTextInput({}) %>
+ </section>
+ <% } %>
+
<% if (ctx.canEditPostNotes) { %>
<section class='notes'>
<a href class='add'>Add a note</a>
diff --git a/client/html/post_main.tpl b/client/html/post_main.tpl
index 84e48b1..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 &gt;</span>
</a>
</article>
- <% if (ctx.canEditPosts || ctx.canDeletePosts || ctx.canFeaturePosts) { %>
+ <article class='random-post'>
+ <% if (ctx.randomPostId) { %>
+ <% if (ctx.editMode) { %>
+ <a rel='next' href='<%= ctx.getPostEditUrl(ctx.randomPostId, {
+ query: ctx.parameters.query,
+ metrics: ctx.parameters.metrics,
+ cachenumber: Math.round(Math.random() * 1000)}) %>'>
+ <% } else { %>
+ <a rel='next' href='<%= ctx.getPostUrl(ctx.randomPostId, {
+ query: ctx.parameters.query,
+ metrics: ctx.parameters.metrics,
+ cachenumber: Math.round(Math.random() * 1000)}) %>'>
+ <% } %>
+ <% } else { %>
+ <a rel='next' class='inactive'>
+ <% } %>
+ <i class='fa fa-random'></i>
+ <span class='vim-nav-hint'>Random post</span>
+ </a>
+ </article>
<article class='edit-post'>
<% if (ctx.editMode) { %>
<a href='<%= ctx.getPostUrl(ctx.post.id, ctx.parameters) %>'>
@@ -37,30 +56,45 @@
<span class='vim-nav-hint'>Back to view mode</span>
</a>
<% } else { %>
- <a href='<%= ctx.getPostEditUrl(ctx.post.id, ctx.parameters) %>'>
- <i class='fa fa-pencil'></i>
- <span class='vim-nav-hint'>Edit post</span>
+ <% if (ctx.canEditPosts || ctx.canDeletePosts || ctx.canFeaturePosts) { %>
+ <a href='<%= ctx.getPostEditUrl(ctx.post.id, ctx.parameters) %>'>
+ <% } else { %>
+ <a class='inactive'>
+ <% } %>
+ <i class='fa fa-pencil-alt'></i>
+ <span class='vim-nav-hint'>Edit post</span>
</a>
<% } %>
</article>
- <% } %>
</nav>
<div class='sidebar-container'></div>
+
+ <% if (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> &middot;
- <a href='https://danbooru.donmai.us/posts?tags=md5:<%- ctx.post.checksumMD5 %>'>Danbooru</a> &middot;
- <a href='https://lens.google.com/uploadbyurl?url=<%- encodeURIComponent(ctx.post.fullContentUrl) %>'>Google Images</a>
+ <a target="_blank" href='http://iqdb.org/?url=<%- encodeURIComponent(ctx.post.fullContentUrl) %>'>IQDB</a> &middot;
+ <a target="_blank" href='https://danbooru.donmai.us/posts?tags=md5:<%- ctx.post.checksumMD5 %>'>Danbooru</a> &middot;
+ <a target="_blank" href='https://lens.google.com/uploadbyurl?url=<%- encodeURIComponent(ctx.post.fullContentUrl) %>'>Google Images</a>
</section>
<section class='social'>
@@ -83,6 +83,19 @@
</nav>
<% } %>
+ <% if (ctx.canViewSimilar) { %>
+ <nav class='similar'>
+ <h1>Similar</h1>
+ <ul></ul>
+ <a href='<%- ctx.formatClientLink("posts", {query: "similar:" + ctx.post.id}) %>'>See more</a>
+ </nav>
+
+ <nav class='lookalikes'>
+ <h1>Look-alikes</h1>
+ <ul></ul>
+ </nav>
+ <% } %>
+
<nav class='tags'>
<h1>Tags (<%- ctx.post.tags.length %>)</h1>
<% if (ctx.post.tags.length) { %>
@@ -116,4 +129,11 @@
</p>
<% } %>
</nav>
+
+ <% if (ctx.post.metrics.length + ctx.post.metricRanges.length) { %>
+ <nav class='metrics'>
+ <h1>Metrics (<%- ctx.post.metrics.length + ctx.post.metricRanges.length %>)</h1>
+ <ul class='compact-post-metrics'></ul>
+ </nav>
+ <% } %>
</div>
diff --git a/client/html/post_upload.tpl b/client/html/post_upload.tpl
index 3c1b238..c256e55 100644
--- a/client/html/post_upload.tpl
+++ b/client/html/post_upload.tpl
@@ -5,7 +5,7 @@
<div class='control-strip'>
<input type='submit' value='Upload all' class='submit'/>
- <span class='skip-duplicates'>
+ <span class='skip-duplicates control-checkbox'>
<%= ctx.makeCheckbox({
text: 'Skip duplicate',
name: 'skip-duplicates',
@@ -13,6 +13,13 @@
}) %>
</span>
+ <span class='copy-tags-to-originals control-checkbox'>
+ <%= ctx.makeCheckbox({
+ text: 'Copy tags to originals',
+ name: 'copy-tags-to-originals',
+ }) %>
+ </span>
+
<span class='always-upload-similar'>
<%= ctx.makeCheckbox({
text: 'Force upload similar',
@@ -29,6 +36,10 @@
}) %>
</span>
+ <div class='tags'>
+ <%= ctx.makeTextInput({}) %>
+ </div>
+
<input type='button' value='Cancel' class='cancel'/>
</div>
diff --git a/client/html/post_upload_row.tpl b/client/html/post_upload_row.tpl
index 2885e3d..26f174b 100644
--- a/client/html/post_upload_row.tpl
+++ b/client/html/post_upload_row.tpl
@@ -33,7 +33,7 @@
</nav>
<nav>
<ul>
- <li><a href class='remove'><i class='fa fa-remove'></i></a></li>
+ <li><a href class='remove'><i class='fa fa-times'></i></a></li>
</ul>
</nav>
@@ -83,9 +83,13 @@
<%- Math.round((1-lookalike.distance) * 100) %>% match
</div>
<div class='controls'>
- <%= ctx.makeCheckbox({text: 'Copy tags', name: 'copy-tags'}) %>
- <br/>
- <%= ctx.makeCheckbox({text: 'Add relation', name: 'add-relation'}) %>
+ <% if (lookalike.distance > 0) { %>
+ <%= ctx.makeCheckbox({text: 'Copy tags', name: 'copy-tags'}) %>
+ <br/>
+ <%= ctx.makeCheckbox({text: 'Add relation', name: 'add-relation'}) %>
+ <% } else { %>
+ <%= ctx.makeCheckbox({text: 'Copy tags', name: 'copy-tags'}) %>
+ <% } %>
</div>
</li>
<% } %>
diff --git a/client/html/posts_header.tpl b/client/html/posts_header.tpl
index 77d16ed..7d5fa78 100644
--- a/client/html/posts_header.tpl
+++ b/client/html/posts_header.tpl
@@ -3,35 +3,88 @@
%><%= ctx.makeTextInput({text: 'Search query', id: 'search-text', name: 'search-text', value: ctx.parameters.query}) %><%
%><wbr/><%
%><input class='mousetrap' type='submit' value='Search'/><%
- %><wbr/><%
+ %><button id='randomize-button' class='icon-button'><%
+ %><i class="fa fa-random"><%
+ %></button><%
%><% if (ctx.enableSafety) { %><%
%><input data-safety=safe type='button' class='mousetrap safety safety-safe <%- ctx.settings.listPosts.safe ? '' : 'disabled' %>'/><%
%><input data-safety=sketchy type='button' class='mousetrap safety safety-sketchy <%- ctx.settings.listPosts.sketchy ? '' : 'disabled' %>'/><%
%><input data-safety=unsafe type='button' class='mousetrap safety safety-unsafe <%- ctx.settings.listPosts.unsafe ? '' : 'disabled' %>'/><%
%><% } %><%
+ %><% if (ctx.isLoggedIn) { %><%
+ %><a href class='mousetrap icon-button query-shortcut' data-term='special:liked'><%
+ %><i class="fa fa-thumbs-up term-selected"></i><%
+ %><i class="far fa-thumbs-up term-unselected"></i><%
+ %></a><%
+ %><a href class='mousetrap icon-button query-shortcut' data-term='special:fav'><%
+ %><i class="fa fa-heart term-selected"></i><%
+ %><i class="far fa-heart term-unselected"></i><%
+ %></a><%
+ %><% } %><%
+ %><wbr/><%
+ %><a class='mousetrap button append'
+ href='<%- ctx.formatClientLink('help', 'search', 'posts') %>'><%
+ if (window.innerWidth <= 500) { %>Help<%
+ } else { %>Syntax help<% }
+ %></a><%
%><wbr/><%
- %><a class='mousetrap button append' href='<%- ctx.formatClientLink('help', 'search', 'posts') %>'>Syntax help</a><%
+ %><span class="bulk-edit-btn-holder"><%
+ %><a href class='mousetrap button append open bulk-edit-btn'><%
+ if (window.innerWidth <= 500) { %>Mass<%
+ } else { %>Mass edit<% }
+ %><i class='fa fa-chevron-down icon-inline'></i><%
+ %></a><%
+ %><a href class='mousetrap button append close bulk-edit-btn'><%
+ if (window.innerWidth <= 500) { %>Mass<%
+ } else { %>Mass edit<% }
+ %><i class='fa fa-chevron-up icon-inline'></i><%
+ %></a><%
+ %></span><%
+ %><wbr/><%
+ if (ctx.canViewMetrics) {
+ %><span class="metrics-btn-holder"><%
+ %><a href class='mousetrap button append open metrics-btn'><%
+ %>Metrics<%
+ %><i class='fa fa-chevron-down icon-inline'></i><%
+ %></a><%
+ %><a href class='mousetrap button append close metrics-btn'><%
+ %>Metrics<%
+ %><i class='fa fa-chevron-up icon-inline'></i><%
+ %></a><%
+ %></span><%
+ }
%></form><%
- %><% if (ctx.canBulkEditTags) { %><%
- %><form class='horizontal bulk-edit bulk-edit-tags'><%
- %><span class='append hint'>Tagging with:</span><%
- %><a href class='mousetrap button append open'>Mass tag</a><%
- %><%= ctx.makeTextInput({name: 'tag', value: ctx.parameters.tag}) %><%
- %><input class='mousetrap start' type='submit' value='Start tagging'/><%
- %><a href class='mousetrap button append close'>Stop tagging</a><%
- %></form><%
- %><% } %><%
- %><% if (ctx.enableSafety && ctx.canBulkEditSafety) { %><%
- %><form class='horizontal bulk-edit bulk-edit-safety'><%
- %><a href class='mousetrap button append open'>Mass edit safety</a><%
- %><a href class='mousetrap button append close'>Stop editing safety</a><%
- %></form><%
- %><% } %><%
- %><% if (ctx.canBulkDelete) { %><%
- %><form class='horizontal bulk-edit bulk-edit-delete'><%
- %><a href class='mousetrap button append open'>Mass delete</a><%
- %><input class='mousetrap start' type='submit' value='Delete selected posts'/><%
- %><a href class='mousetrap button append close'>Stop deleting</a><%
+ %><div class='bulk-edit-block hidden'><%
+ if (ctx.canBulkEditTags) {
+ %><form class='horizontal bulk-edit bulk-edit-tags'><%
+ %><span class='append hint'>Tagging with:</span><%
+ %><a href class='mousetrap button append open'>Mass tag</a><%
+ %><wbr/><%
+ %><%= ctx.makeTextInput({name: 'tag', value: ctx.parameters.tag}) %><%
+ %><input class='mousetrap start' type='submit' value='Start tagging'/><%
+ %><a href class='mousetrap button append close'>Stop tagging</a><%
+ %></form><%
+ }
+ if (ctx.enableSafety && ctx.canBulkEditSafety) {
+ %><form class='horizontal bulk-edit bulk-edit-safety'><%
+ %><a href class='mousetrap button append open'>Mass edit safety</a><%
+ %><a href class='mousetrap button append close'>Stop editing safety</a><%
+ %></form><%
+ }
+ %><!--TODO: create permission--><%
+ %><form class='horizontal bulk-edit bulk-add-relation'><%
+ %><a href class='mousetrap button append open'>Mass add relation</a><%
+ %><a href class='mousetrap button append close'>Stop adding relation</a><%
%></form><%
- %><% } %><%
+ if (ctx.canBulkDelete) {
+ %><form class='horizontal bulk-edit bulk-edit-delete'><%
+ %><a href class='mousetrap button append open'>Mass delete</a><%
+ %><input class='mousetrap start' type='submit' value='Delete selected posts'/><%
+ %><a href class='mousetrap button append close'>Stop deleting</a><%
+ %></form><%
+ }
+ %></div><%
+ if (ctx.canViewMetrics) {
+ %><div class='metrics-block hidden'></div><%
+ }
%></div>
diff --git a/client/html/posts_page.tpl b/client/html/posts_page.tpl
index 52011ad..bbf6a2d 100644
--- a/client/html/posts_page.tpl
+++ b/client/html/posts_page.tpl
@@ -30,7 +30,7 @@
<% } %>
<% if (post.commentCount) { %>
<span class='icon'>
- <i class='fa fa-commenting'></i>
+ <i class='far fa-comment-dots'></i>
<%- post.commentCount %>
</span>
<% } %>
@@ -42,6 +42,10 @@
<a href class='tag-flipper'>
</a>
<% } %>
+ <% if (ctx.parameters && ctx.parameters.relations) { %>
+ <a href class='relation-flipper'>
+ </a>
+ <% } %>
<% if (ctx.canBulkEditSafety && ctx.parameters && ctx.parameters.safety) { %>
<span class='safety-flipper'>
<% for (let safety of ['safe', 'sketchy', 'unsafe']) { %>
diff --git a/client/html/score.tpl b/client/html/score.tpl
index c190069..2586fdb 100644
--- a/client/html/score.tpl
+++ b/client/html/score.tpl
@@ -3,14 +3,14 @@
<% if (ctx.ownScore == 1) { %>
<i class='fa fa-thumbs-up'></i>
<% } else { %>
- <i class='fa fa-thumbs-o-up'></i>
+ <i class='far fa-thumbs-up'></i>
<% } %>
<span class='vim-nav-hint'>upvote</span>
<span class='vim-nav-hint'>like</span>
</a>
<% } else { %>
<a class='upvote inactive'>
- <i class='fa fa-thumbs-o-up'></i>
+ <i class='far fa-thumbs-up'></i>
</a>
<% } %>
<span class='value'><%- ctx.score %></span>
@@ -19,7 +19,7 @@
<% if (ctx.ownScore == -1) { %>
<i class='fa fa-thumbs-down'></i>
<% } else { %>
- <i class='fa fa-thumbs-o-down'></i>
+ <i class='far fa-thumbs-down'></i>
<% } %>
<span class='vim-nav-hint'>downvote</span>
<span class='vim-nav-hint'>dislike</span>
diff --git a/client/html/settings.tpl b/client/html/settings.tpl
index 0edbe22..063331a 100644
--- a/client/html/settings.tpl
+++ b/client/html/settings.tpl
@@ -11,6 +11,30 @@
}) %>
</li>
+ <li class='uploadSafety'>
+ <label>Safety</label>
+ <div class='radio-wrapper'>
+ <%= ctx.makeRadio({
+ name: 'safety',
+ class: 'safety-safe',
+ value: 'safe',
+ selectedValue: ctx.browsingSettings.uploadSafety,
+ text: 'Safe'}) %>
+ <%= ctx.makeRadio({
+ name: 'safety',
+ class: 'safety-sketchy',
+ value: 'sketchy',
+ selectedValue: ctx.browsingSettings.uploadSafety,
+ text: 'Sketchy'}) %>
+ <%= ctx.makeRadio({
+ name: 'safety',
+ value: 'unsafe',
+ selectedValue: ctx.browsingSettings.uploadSafety,
+ class: 'safety-unsafe',
+ text: 'Unsafe'}) %>
+ </div>
+ </li>
+
<li>
<%= ctx.makeNumericInput({
text: 'Number of posts per page',
@@ -23,6 +47,16 @@
</li>
<li>
+ <%= ctx.makeNumericInput({
+ text: 'Number of similar posts',
+ name: 'similar-posts',
+ value: ctx.browsingSettings.similarPosts,
+ min: 0,
+ max: 100,
+ }) %>
+ </li>
+
+ <li>
<%= ctx.makeCheckbox({
text: 'Use dark theme',
name: 'dark-theme',
diff --git a/client/html/similar_post_item.tpl b/client/html/similar_post_item.tpl
new file mode 100644
index 0000000..7203b1e
--- /dev/null
+++ b/client/html/similar_post_item.tpl
@@ -0,0 +1,5 @@
+<li>
+ <a href='<%= ctx.getPostUrl(ctx.id, ctx.parameters) %>'>
+ <%= ctx.makeThumbnail(ctx.thumbnailUrl) %>
+ </a>
+</li>
diff --git a/client/html/tag.tpl b/client/html/tag.tpl
index d84e299..56255e8 100644
--- a/client/html/tag.tpl
+++ b/client/html/tag.tpl
@@ -6,6 +6,9 @@
--><% if (ctx.canEditAnything) { %><!--
--><li data-name='edit'><a href='<%- ctx.formatClientLink('tag', ctx.tag.names[0], 'edit') %>'>Edit</a></li><!--
--><% } %><!--
+ --><% if (ctx.tag.metric || ctx.canCreateMetric) { %><!--
+ --><li data-name='metric'><a href='<%- ctx.formatClientLink('tag', ctx.tag.names[0], 'metric') %>'>Metric</a></li><!--
+ --><% } %><!--
--><% if (ctx.canMerge) { %><!--
--><li data-name='merge'><a href='<%- ctx.formatClientLink('tag', ctx.tag.names[0], 'merge') %>'>Merge with&hellip;</a></li><!--
--><% } %><!--
diff --git a/client/html/tag_input.tpl b/client/html/tag_input.tpl
index d8ffd7b..60c8969 100644
--- a/client/html/tag_input.tpl
+++ b/client/html/tag_input.tpl
@@ -1,6 +1,6 @@
<div class='tag-input'>
<div class='main-control'>
- <input type='text' placeholder='type to add…'/>
+ <input type='text' placeholder='<%- ctx.tagsPlaceholder %>'/>
<button>Add</button>
</div>
@@ -18,4 +18,6 @@
</div>
<ul class='compact-tags'></ul>
+
+ <a class='tag-from-lookalikes'>From lookalikes...</a>
</div>
diff --git a/client/html/tag_metric.tpl b/client/html/tag_metric.tpl
new file mode 100644
index 0000000..3f06d0c
--- /dev/null
+++ b/client/html/tag_metric.tpl
@@ -0,0 +1,40 @@
+<div class='tag-metric'>
+ <form class='horizontal edit-metric'>
+ <div class='metric-bounds-edit'>
+ <%= ctx.makeNumericInput({
+ text: 'Minimum',
+ name: 'metric-min',
+ value: ctx.metricMin,
+ step: 'any',
+ readonly: !ctx.canEditMetricBounds,
+ }) %>
+ <%= ctx.makeNumericInput({
+ text: 'Maximum',
+ name: 'metric-max',
+ value: ctx.metricMax,
+ step: 'any',
+ readonly: !ctx.canEditMetricBounds,
+ }) %>
+ </div>
+
+ <% if (ctx.tag.metric && ctx.canDeleteMetric) { %>
+ <div class='confirmation'>
+ <%= ctx.makeCheckbox({name: 'confirm-delete',
+ text: 'I confirm that I want to delete this metric.'}) %>
+ </div>
+ <% } %>
+
+ <div class='messages'></div>
+
+ <div class='buttons'><!--
+ --><% if (!ctx.tag.metric && ctx.canCreateMetric) { %><!--
+ --><input type='submit' value='Create metric'/><!--
+ --><% } else if (ctx.tag.metric && ctx.canEditMetricBounds) { %><!--
+ --><input type='submit' value='Update metric'/><!--
+ --><% } %><!--
+ --><% if (ctx.tag.metric && ctx.canDeleteMetric) { %><!--
+ --><input type='button' name='delete' class='delete' value='Delete metric'/><!--
+ --><% } %>
+ </div>
+ </form>
+</div>
diff --git a/client/html/user_registration.tpl b/client/html/user_registration.tpl
index a6d291f..3ac98f4 100644
--- a/client/html/user_registration.tpl
+++ b/client/html/user_registration.tpl
@@ -47,8 +47,8 @@
<ul>
<li><i class='fa fa-upload'></i> upload new posts</li>
<li><i class='fa fa-heart'></i> mark them as favorite</li>
- <li><i class='fa fa-commenting-o'></i> add comments</li>
- <li><i class='fa fa-star-half-o'></i> vote up/down on posts and comments</li>
+ <li><i class='far fa-comment-dots'></i> add comments</li>
+ <li><i class='fa fa-star-half-alt'></i> vote up/down on posts and comments</li>
</ul>
<hr/>
<p>By creating an account, you are agreeing to the <a href='<%- ctx.formatClientLink('help', 'tos') %>'>Terms of Service</a>.</p>
diff --git a/client/js/controllers/metric_sorter_contoller.js b/client/js/controllers/metric_sorter_contoller.js
new file mode 100644
index 0000000..e7a8d76
--- /dev/null
+++ b/client/js/controllers/metric_sorter_contoller.js
@@ -0,0 +1,187 @@
+'use strict';
+
+const api = require('../api.js');
+const router = require('../router.js');
+const views = require('../util/views.js');
+const topNavigation = require('../models/top_navigation.js');
+const Post = require('../models/post.js');
+const PostMetric = require('../models/post_metric.js');
+const PostMetricRange = require('../models/post_metric_range.js');
+const PostList = require('../models/post_list.js');
+const MetricSorterView = require('../views/metric_sorter_view.js');
+const EmptyView = require('../views/empty_view.js');
+
+const LEFT = 'left';
+const RIGHT = 'right';
+
+class MetricSorterController {
+ constructor(ctx) {
+ if (!api.hasPrivilege('posts:view') ||
+ !api.hasPrivilege('metrics:edit:posts')) {
+ this._view = new EmptyView();
+ this._view.showError('You don\'t have privileges to edit post metric values.');
+ return;
+ }
+
+ topNavigation.activate('posts');
+ topNavigation.setTitle('Sorting metrics');
+
+ this._ctx = ctx;
+ this._metricNames = (ctx.parameters.metrics || '')
+ .split(' ')
+ .filter(m => m);
+ if (!this._metricNames.length) {
+ this._view = new EmptyView();
+ this._view.showError('No metrics selected');
+ return;
+ }
+ this._primaryMetricName = this._metricNames[0];
+
+ this._view = new MetricSorterView({
+ primaryMetric: this._primaryMetricName,
+ greaterPost: RIGHT,
+ });
+ this._view.addEventListener('submit', e => this._evtSubmit(e));
+ this._view.addEventListener('skip', e => this._evtSkip(e));
+ this._view.addEventListener('changeMetric', e => this._evtChangeMetric(e));
+
+ if (ctx.parameters.id === 'random') {
+ this.startSortingRandomPost();
+ } else {
+ this.startSortingPost(ctx.parameters.id);
+ }
+ }
+
+ startSortingPost(id) {
+ this._view.clearMessages();
+ this._foundExactValue = false;
+ Post.get(id).then(post => {
+ this._unsortedPost = post;
+ this._view.installLeftPost(post);
+ this.reloadMedianPost();
+ }).catch(error => {
+ this._view.showError(error.message)
+ });
+ }
+
+ startSortingRandomPost() {
+ this._view.clearMessages();
+ this._getRandomUnsortedPostId().then(id => {
+ this._ctx.parameters.id = id;
+ router.replace(views.getMetricSorterUrl(id, this._ctx.parameters));
+ this.startSortingPost(id);
+ }).catch(error => {
+ this._view.showError(error.message)
+ });
+ }
+
+ reloadMedianPost() {
+ const metricName = this._primaryMetricName;
+ let range = this._getOrCreateRange(this._unsortedPost, metricName);
+ this._tryGetMedianPost(metricName, range).then(medianResponse => {
+ if (medianResponse.post) {
+ this._sortedPost = medianResponse.post;
+ this._view.installRightPost(this._sortedPost);
+ } else {
+ // No existing metrics, apply the median value
+ this._foundExactValue = true;
+ let exactValue = (medianResponse.range.low + medianResponse.range.high) / 2;
+ this._view.showSuccess(`Found exact value: ${exactValue}`);
+ this._setExactMetric(this._unsortedPost, metricName, exactValue);
+ //TODO: maybe allow to set exact value?
+ }
+ }).catch(error => {
+ this._view.showError(error.message)
+ });
+ }
+
+ _getRandomUnsortedPostId() {
+ let unsetMetricsQuery = this._metricNames
+ .map(m => `${m} -metric:${m}`)
+ .join(' ');
+ let filterQuery = this._ctx.parameters.query || '';
+ let unsetFullQuery = `${filterQuery} ${unsetMetricsQuery} sort:random`;
+
+ return PostList.search(unsetFullQuery,
+ this._ctx.parameters.skips || 0, 1, ['id']).then(response => {
+ if (!response.results.length) {
+ return Promise.reject(new Error('No posts found'));
+ } else {
+ return Promise.resolve(response.results.at(0).id);
+ }
+ });
+ }
+
+ _tryGetMedianPost(metric, range) {
+ let low = range.low + 0.000000001;
+ let high = range.high - 0.000000001;
+ let median_query = `metric-${metric}:${low}..${high} sort:metric-${metric}`;
+ return PostList.getMedian(median_query, []).then(response => {
+ return Promise.resolve({
+ range: range,
+ post: response.results.at(0)
+ });
+ });
+ }
+
+ _getOrCreateRange(post, metricName) {
+ let range = post.metricRanges.findByTagName(metricName);
+ if (!range) {
+ let tag = post.tags.findByName(metricName);
+ range = PostMetricRange.create(post.id, tag);
+ post.metricRanges.add(range);
+ }
+ return range;
+ }
+
+ _setExactMetric(post, metricName, value) {
+ let range = post.metricRanges.findByTagName(metricName);
+ if (!range) {
+ post.metricRanges.remove(range);
+ }
+ let tag = post.tags.findByName(metricName);
+ let exactMetric = PostMetric.create(post.id, tag);
+ exactMetric.value = value;
+ post.metrics.add(exactMetric);
+ }
+
+ _evtSubmit(e) {
+ let range = this._getOrCreateRange(this._unsortedPost, this._primaryMetricName);
+ if (this._foundExactValue) {
+ this._unsortedPost.metricRanges.remove(range);
+ } else {
+ let medianValue = this._sortedPost.metrics.findByTagName(this._primaryMetricName).value;
+ if (e.detail.greaterPost === LEFT) {
+ range.low = medianValue;
+ } else {
+ range.high = medianValue;
+ }
+ }
+ this._unsortedPost.save().then(() => {
+ if (this._foundExactValue) {
+ this.startSortingRandomPost();
+ } else {
+ this.reloadMedianPost();
+ }
+ }, error => {
+ this._view.showError(error.message)
+ });
+ }
+
+ _evtSkip(e) {
+ this._ctx.parameters.skips = (this._ctx.parameters.skips || 0) + 1;
+ this.startSortingRandomPost();
+ }
+
+ _evtChangeMetric(e) {
+ // this._primaryMetricName = e.detail.metricName;
+ }
+}
+
+module.exports = router => {
+ router.enter(
+ ['post', ':id', 'metric-sorter'],
+ (ctx, next) => {
+ ctx.controller = new MetricSorterController(ctx);
+ });
+};
diff --git a/client/js/controllers/post_list_controller.js b/client/js/controllers/post_list_controller.js
index fdb7b84..0146e9e 100644
--- a/client/js/controllers/post_list_controller.js
+++ b/client/js/controllers/post_list_controller.js
@@ -3,6 +3,7 @@
const router = require("../router.js");
const api = require("../api.js");
const settings = require("../models/settings.js");
+const tags = require("../tags.js");
const uri = require("../util/uri.js");
const PostList = require("../models/post_list.js");
const topNavigation = require("../models/top_navigation.js");
@@ -41,9 +42,11 @@ class PostListController {
this._headerView = new PostsHeaderView({
hostNode: this._pageController.view.pageHeaderHolderNode,
parameters: ctx.parameters,
+ isLoggedIn: api.isLoggedIn(),
enableSafety: api.safetyEnabled(),
canBulkEditTags: api.hasPrivilege("posts:bulk-edit:tags"),
canBulkEditSafety: api.hasPrivilege("posts:bulk-edit:safety"),
+ canViewMetrics: api.hasPrivilege("metrics:list"),
canBulkDelete: api.hasPrivilege("posts:bulk-edit:delete"),
bulkEdit: {
tags: this._bulkEditTags,
@@ -70,21 +73,37 @@ class PostListController {
this._pageController.showSuccess(message);
}
+ showError(message) {
+ this._pageController.showError(message);
+ }
+
get _bulkEditTags() {
return (this._ctx.parameters.tag || "").split(/\s+/).filter((s) => s);
}
+ get _bulkEditRelationsIds() {
+ return (this._ctx.parameters.relations || "").split(/\s+/).filter(s => s)
+ .map(id => parseInt(id));
+ }
+
_evtNavigate(e) {
- router.showNoDispatch(
+ this._ctx = router.showNoDispatch(
uri.formatClientLink("posts", e.detail.parameters)
);
Object.assign(this._ctx.parameters, e.detail.parameters);
+ this._bulkEditTags.map((tagName) =>
+ tags.resolveTagAndCategory(tagName)
+ .catch((error) => window.alert(error.message))
+ );
this._syncPageController();
}
_evtTag(e) {
Promise.all(
- this._bulkEditTags.map((tag) => e.detail.post.tags.addByName(tag))
+ this._bulkEditTags.map((tag) => {
+ let tagData = tags.parseTagAndCategory(tag);
+ return e.detail.post.tags.addByName(tagData.name);
+ })
)
.then(e.detail.post.save())
.catch((error) => window.alert(error.message));
@@ -92,7 +111,8 @@ class PostListController {
_evtUntag(e) {
for (let tag of this._bulkEditTags) {
- e.detail.post.tags.removeByName(tag);
+ let tagData = tags.parseTagAndCategory(tag);
+ e.detail.post.tags.removeByName(tagData.name);
}
e.detail.post.save().catch((error) => window.alert(error.message));
}
@@ -102,6 +122,38 @@ class PostListController {
e.detail.post.save().catch((error) => window.alert(error.message));
}
+ _evtAddRelation(e) {
+ let addedPost = e.detail.post;
+ // If we're just starting to build this instance of relations,
+ // use the first post's list:
+ let relations = this._bulkEditRelationsIds || addedPost.relations;
+ for (let relationId of relations) {
+ addedPost.relations.push(relationId);
+ }
+ // Only save the updated post, the relationship will propagate to
+ // others automatically
+ addedPost.save().catch((error) => window.alert(error.message));
+ relations.push(addedPost.id);
+ this._updateRelationsForBulkEdit(relations);
+
+ }
+
+ _evtRemoveRelation(e) {
+ let removedPost = e.detail.post;
+ let relations = this._bulkEditRelationsIds;
+ removedPost.relations = removedPost.relations
+ .filter((id)=> !relations.some((relationId) => relationId == id));
+ // Only save the updated post, the relationship will propagate to others automatically
+ removedPost.save().catch((error) => window.alert(error.message));
+ relations = relations.filter((id) => id != removedPost.id);
+ this._updateRelationsForBulkEdit(relations);
+ }
+
+ _updateRelationsForBulkEdit(relations) {
+ //Whitespace instead of empty string so that it stays part of the query:
+ this._ctx.parameters.relations = relations.join(" ") || " ";
+ }
+
_evtMarkForDeletion(e) {
const postId = e.detail;
@@ -137,6 +189,7 @@ class PostListController {
_syncPageController() {
this._pageController.run({
parameters: this._ctx.parameters,
+ browserState: this._ctx.state,
defaultLimit: parseInt(settings.get().postsPerPage),
getClientUrlForPage: (offset, limit) => {
const parameters = Object.assign({}, this._ctx.parameters, {
@@ -146,11 +199,13 @@ class PostListController {
return uri.formatClientLink("posts", parameters);
},
requestPage: (offset, limit) => {
+ let query = uri.getPostsQuery(this._ctx.parameters);
return PostList.search(
- this._ctx.parameters.query,
+ query,
offset,
limit,
- fields
+ fields,
+ this._ctx.parameters.cachenumber
);
},
pageRenderer: (pageCtx) => {
@@ -160,6 +215,11 @@ class PostListController {
canBulkEditSafety: api.hasPrivilege(
"posts:bulk-edit:safety"
),
+ canViewMetrics: api.hasPrivilege("metrics:list"),
+ bulkEdit: {
+ tags: this._bulkEditTags,
+ relations: this._ctx.parameters.relations,
+ },
canBulkDelete: api.hasPrivilege("posts:bulk-edit:delete"),
bulkEdit: {
tags: this._bulkEditTags,
@@ -173,11 +233,18 @@ class PostListController {
view.addEventListener("changeSafety", (e) =>
this._evtChangeSafety(e)
);
+ view.addEventListener("addRelation", (e) =>
+ this._evtAddRelation(e)
+ );
+ view.addEventListener("removeRelation", (e) =>
+ this._evtRemoveRelation(e)
+ );
view.addEventListener("markForDeletion", (e) =>
this._evtMarkForDeletion(e)
);
return view;
},
+ readPageFromCache: (rawPage) => PostList.fromResponse(rawPage),
});
}
}
diff --git a/client/js/controllers/post_main_controller.js b/client/js/controllers/post_main_controller.js
index bd33812..7d6dcd3 100644
--- a/client/js/controllers/post_main_controller.js
+++ b/client/js/controllers/post_main_controller.js
@@ -8,6 +8,7 @@ const settings = require("../models/settings.js");
const Comment = require("../models/comment.js");
const Post = require("../models/post.js");
const PostList = require("../models/post_list.js");
+const Tag = require("../models/tag.js");
const PostMainView = require("../views/post_main_view.js");
const BasePostController = require("./base_post_controller.js");
const EmptyView = require("../views/empty_view.js");
@@ -17,30 +18,17 @@ class PostMainController extends BasePostController {
super(ctx);
let parameters = ctx.parameters;
+ let query = uri.getPostsQuery(parameters);
Promise.all([
Post.get(ctx.parameters.id),
PostList.getAround(
ctx.parameters.id,
- parameters ? parameters.query : null
+ query,
+ ctx.parameters.cachenumber
),
]).then(
(responses) => {
const [post, aroundResponse] = responses;
-
- // remove junk from query, but save it into history so that it can
- // be still accessed after history navigation / page refresh
- if (parameters.query) {
- ctx.state.parameters = parameters;
- const url = editMode
- ? uri.formatClientLink(
- "post",
- ctx.parameters.id,
- "edit"
- )
- : uri.formatClientLink("post", ctx.parameters.id);
- router.replace(url, ctx.state, false);
- }
-
this._post = post;
this._view = new PostMainView({
post: post,
@@ -51,6 +39,9 @@ class PostMainController extends BasePostController {
nextPostId: aroundResponse.next
? aroundResponse.next.id
: null,
+ randomPostId: aroundResponse.random
+ ? aroundResponse.random.id
+ : null,
canEditPosts: api.hasPrivilege("posts:edit"),
canDeletePosts: api.hasPrivilege("posts:delete"),
canFeaturePosts: api.hasPrivilege("posts:feature"),
diff --git a/client/js/controllers/post_upload_controller.js b/client/js/controllers/post_upload_controller.js
index 720a116..7ee63ac 100644
--- a/client/js/controllers/post_upload_controller.js
+++ b/client/js/controllers/post_upload_controller.js
@@ -5,6 +5,7 @@ const router = require("../router.js");
const uri = require("../util/uri.js");
const misc = require("../util/misc.js");
const progress = require("../util/progress.js");
+const settings = require("../models/settings.js");
const topNavigation = require("../models/top_navigation.js");
const Post = require("../models/post.js");
const Tag = require("../models/tag.js");
@@ -31,6 +32,7 @@ class PostUploadController {
canUploadAnonymously: api.hasPrivilege("posts:create:anonymous"),
canViewPosts: api.hasPrivilege("posts:view"),
enableSafety: api.safetyEnabled(),
+ defaultSafety: settings.get().uploadSafety
});
this._view.addEventListener("change", (e) => this._evtChange(e));
this._view.addEventListener("submit", (e) => this._evtSubmit(e));
@@ -55,6 +57,7 @@ class PostUploadController {
_evtSubmit(e) {
this._view.disableForm();
this._view.clearMessages();
+ const tagErrors = []; // to be displayed after all uploads
let anyFailures = false;
e.detail.uploadables
@@ -64,6 +67,7 @@ class PostUploadController {
this._uploadSinglePost(
uploadable,
e.detail.skipDuplicates,
+ e.detail.copyTagsToOriginals,
e.detail.alwaysUploadSimilar
).catch((error) => {
anyFailures = true;
@@ -108,6 +112,9 @@ class PostUploadController {
misc.disableExitConfirmation();
const ctx = router.show(uri.formatClientLink("posts"));
ctx.controller.showSuccess("Posts uploaded.");
+ for (let tagError of tagErrors) {
+ ctx.controller.showError(tagError);
+ }
},
(error) => {
this._view.showError(genericErrorMessage);
@@ -116,7 +123,7 @@ class PostUploadController {
);
}
- _uploadSinglePost(uploadable, skipDuplicates, alwaysUploadSimilar) {
+ _uploadSinglePost(uploadable, skipDuplicates, copyTagsToOriginals, alwaysUploadSimilar) {
progress.start();
let reverseSearchPromise = Promise.resolve();
if (!uploadable.lookalikesConfirmed) {
@@ -131,7 +138,11 @@ class PostUploadController {
if (searchResult) {
// notify about exact duplicate
if (searchResult.exactPost) {
- if (skipDuplicates) {
+ if (copyTagsToOriginals) {
+ return this._copyTagsToOriginalAndSave(
+ uploadable, searchResult.exactPost
+ );
+ } else if (skipDuplicates) {
this._view.removeUploadable(uploadable);
return Promise.resolve();
} else {
@@ -140,6 +151,12 @@ class PostUploadController {
`(@${searchResult.exactPost.id})`
);
error.uploadable = uploadable;
+ error.similarPosts = [
+ {
+ distance: 0,
+ post: searchResult.exactPost
+ }
+ ];
return Promise.reject(error);
}
}
@@ -156,6 +173,10 @@ class PostUploadController {
error.uploadable = uploadable;
error.similarPosts = searchResult.similarPosts;
return Promise.reject(error);
+ } else if (uploadable.foundOriginal) {
+ return this._copyTagsToOriginalAndSave(
+ uploadable, uploadable.foundOriginal
+ );
}
}
@@ -199,6 +220,19 @@ class PostUploadController {
}
return post;
}
+
+ _copyTagsToOriginalAndSave(uploadable, original) {
+ uploadable.tags.map(tag => original.tags.addByName(tag));
+ let savePromise = original.save()
+ .then(
+ () => {
+ this._view.removeUploadable(uploadable);
+ return Promise.resolve();
+ }
+ );
+ this._lastCancellablePromise = savePromise;
+ return savePromise;
+ }
}
module.exports = (router) => {
diff --git a/client/js/controllers/tag_controller.js b/client/js/controllers/tag_controller.js
index 80e32c7..b7e6ce3 100644
--- a/client/js/controllers/tag_controller.js
+++ b/client/js/controllers/tag_controller.js
@@ -2,6 +2,7 @@
const router = require("../router.js");
const api = require("../api.js");
+const Metric = require("../models/metric.js");
const misc = require("../util/misc.js");
const uri = require("../util/uri.js");
const Tag = require("../models/tag.js");
@@ -53,6 +54,9 @@ class TagController {
canEditDescription: api.hasPrivilege(
"tags:edit:description"
),
+ canCreateMetric: api.hasPrivilege("metrics:create"),
+ canDeleteMetric: api.hasPrivilege("metrics:delete"),
+ canEditMetricBounds: api.hasPrivilege("metrics:edit:bounds"),
canMerge: api.hasPrivilege("tags:merge"),
canDelete: api.hasPrivilege("tags:delete"),
categories: categories,
@@ -69,6 +73,12 @@ class TagController {
this._view.addEventListener("delete", (e) =>
this._evtDelete(e)
);
+ this._view.addEventListener("metricUpdate", (e) =>
+ this._evtMetricUpdate(e)
+ );
+ this._view.addEventListener("metricDelete", (e) =>
+ this._evtMetricDelete(e)
+ );
},
(error) => {
this._view = new EmptyView();
@@ -116,6 +126,35 @@ class TagController {
);
}
+ _evtMetricUpdate(e) {
+ this._view.clearMessages();
+ this._view.disableForm();
+ const metric = new Metric();
+ metric.min = e.detail.metricMin;
+ metric.max = e.detail.metricMax;
+ e.detail.tag.metric = metric;
+
+ e.detail.tag.save().then(() => {
+ this._view.showSuccess("Metric updated.");
+ this._view.enableForm();
+ }, error => {
+ this._view.showError(error.message);
+ this._view.enableForm();
+ });
+ }
+
+ _evtMetricDelete(e) {
+ this._view.clearMessages();
+ this._view.disableForm();
+ e.detail.tag.deleteMetric().then(() => {
+ this._view.showSuccess("Metric deleted.");
+ this._view.enableForm();
+ }, error => {
+ this._view.showError(error.message);
+ this._view.enableForm();
+ });
+ }
+
_evtMerge(e) {
this._view.clearMessages();
this._view.disableForm();
@@ -160,6 +199,9 @@ module.exports = (router) => {
router.enter(["tag", ":name", "edit"], (ctx, next) => {
ctx.controller = new TagController(ctx, "edit");
});
+ router.enter(["tag", ":name", "metric"], (ctx, next) => {
+ ctx.controller = new TagController(ctx, "metric");
+ });
router.enter(["tag", ":name", "merge"], (ctx, next) => {
ctx.controller = new TagController(ctx, "merge");
});
diff --git a/client/js/controls/expander_control.js b/client/js/controls/expander_control.js
index ffb0e90..f3105ca 100644
--- a/client/js/controls/expander_control.js
+++ b/client/js/controls/expander_control.js
@@ -42,6 +42,10 @@ class ExpanderControl {
this._syncIcon();
}
+ get containerNode() {
+ return this._expanderNode;
+ }
+
// eslint-disable-next-line accessor-pairs
set title(newTitle) {
if (this._expanderNode) {
diff --git a/client/js/controls/metric_header_control.js b/client/js/controls/metric_header_control.js
new file mode 100644
index 0000000..6a70b5b
--- /dev/null
+++ b/client/js/controls/metric_header_control.js
@@ -0,0 +1,85 @@
+'use strict';
+
+const events = require('../events.js');
+const misc = require('../util/misc.js');
+const views = require('../util/views.js');
+const MetricList = require('../models/metric_list.js');
+
+const mainTemplate = views.getTemplate('metric-header');
+const metricItemTemplate = views.getTemplate('metric-header-item');
+
+class MetricHeaderControl extends events.EventTarget {
+ constructor(hostNode, ctx) {
+ super();
+ this._ctx = ctx;
+ this._hostNode = hostNode;
+ this._selectedMetrics = new MetricList();
+
+ this._headerNode = mainTemplate(ctx);
+ this._metricListNode = this._headerNode.querySelector('ul.metric-list');
+
+ this._hostNode.insertBefore(
+ this._headerNode, this._hostNode.nextSibling);
+
+ MetricList.loadAll().then(response => {
+ this._ctx.allMetrics = response.results;
+ this._addSelectedMetrics(ctx.parameters.metrics);
+ this._installMetrics(response.results);
+ this._refreshStartSortingButton();
+ });
+ }
+
+ _addSelectedMetrics(metricsStr) {
+ let selectedNames = (metricsStr || '').split(' ');
+ for (let metric of [...this._ctx.allMetrics]) {
+ if (selectedNames.includes(metric.tag.names[0])) {
+ this._selectedMetrics.add(metric);
+ }
+ }
+ }
+
+ _installMetrics(metrics) {
+ for (let metric of metrics) {
+ const node = metricItemTemplate(Object.assign({},
+ {
+ metric: metric,
+ selected: this._selectedMetrics.includes(metric),
+ },
+ this._ctx));
+ node.addEventListener('click', e =>
+ this._evtMetricClicked(e, node, metric));
+ this._metricListNode.appendChild(node);
+ }
+ }
+
+ _evtMetricClicked(e, node, metric) {
+ e.preventDefault();
+ node.classList.toggle('selected');
+ node.querySelector('a').classList.toggle('selected');
+ if (this._selectedMetrics.includes(metric)) {
+ this._selectedMetrics.remove(metric);
+ } else {
+ this._selectedMetrics.add(metric);
+ }
+ this._ctx.parameters = Object.assign({},
+ this._ctx.parameters, {
+ metrics: this._selectedMetrics
+ .map(m => m.tag.names[0]).join(' '),
+ });
+ this._refreshStartSortingButton();
+ this.dispatchEvent(new CustomEvent('submit'));
+ }
+
+ _refreshStartSortingButton() {
+ let btn = this._hostNode.querySelector('a.sorting');
+ btn.hidden = !this._selectedMetrics.length;
+ btn.setAttribute('href', views.getMetricSorterUrl('random', this._ctx.parameters));
+ }
+
+ refreshQuery(query) {
+ this._ctx.parameters.query = query;
+ this._refreshStartSortingButton();
+ }
+}
+
+module.exports = MetricHeaderControl;
diff --git a/client/js/controls/post_content_control.js b/client/js/controls/post_content_control.js
index 8cbd5d8..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",
[