diff --git a/client/css/colors.styl b/client/css/colors.styl index cf7e7caf..51ae30ed 100644 --- a/client/css/colors.styl +++ b/client/css/colors.styl @@ -18,6 +18,8 @@ $message-error-border-color = #FCC $message-error-background-color = #FFF5F5 $message-success-border-color = #D3E3D3 $message-success-background-color = #F5FFF5 +$pool-navigator-border-color = #AAA +$pool-navigator-background-color = #EEE $input-bad-border-color = #FCC $input-bad-background-color = #FFF5F5 $input-good-border-color = #D3E3D3 diff --git a/client/css/pool-list-view.styl b/client/css/pool-list-view.styl index b7ac15ed..37ec877c 100644 --- a/client/css/pool-list-view.styl +++ b/client/css/pool-list-view.styl @@ -1,47 +1,76 @@ @import colors .pool-list - table - width: 100% - border-spacing: 0 - text-align: left - line-height: 1.3em - tr:hover td - background: $top-navigation-color - th, td - padding: 0.1em 0.5em - th - white-space: nowrap - background: $top-navigation-color - .names - width: 84% - .post-count - text-align: center - width: 8% - .creation-time - text-align: center - width: 8% - white-space: pre - ul - list-style-type: none - margin: 0 - padding: 0 - display: inline - li - padding: 0 - display: inline - &:not(:last-child):after - content: ', ' - @media (max-width: 800px) - .posts - display: none + ul + list-style-type: none + padding: 0 + display: flex + align-content: flex-end + flex-wrap: wrap + margin: 0 -0.25em -.darktheme .pool-list - table - tr:hover td - background: $top-navigation-color-darktheme - th - background: $top-navigation-color-darktheme + li + position: relative + flex-grow: 1 + margin: 2em 1.5em 2em 1.2em; + display: inline-block + text-align: left + min-width: 10em + width: 12vw + &:not(.flexbox-dummy) + min-height: 7.5em + height: 9vw + + .thumbnail-wrapper + display: inline-block + width: 100% + height: 100% + line-height: 80% + font-size: 80% + color: white + outline-offset: -3px + box-shadow: 0 0 0 1px rgba(0,0,0,0.2) + + .thumbnail + width: 100% + height: 100% + outline-offset: -3px + &:not(.empty) + background-position: 50% 30% + position: absolute + display: inline-block + + .thumbnail-1 + right: -0px; + top: -0px; + z-index: 30; + + .thumbnail-2 + right: -10px; + top: -10px; + z-index: 20; + + .thumbnail-3 + right: -20px; + top: -20px; + z-index: 10; + + .pool-name + color: black + font-size: 1em + text-align: center + a + width: 100% + display: inline-block + + &:hover + background: $post-thumbnail-border-color + .thumbnail + opacity: .9 + + &:hover a, a:active, a:focus + .thumbnail + outline: 4px solid $main-color !important .pool-list-header label @@ -61,3 +90,21 @@ .darktheme .pool-list-header .append color: $inactive-link-color-darktheme + +.post-flow + ul + li + min-width: inherit + width: inherit + margin: 0 0.25em 0.5em 0.25em + &:not(.flexbox-dummy) + height: 14vw + .thumbnail + position: static + outline-offset: -1px + .thumbnail-wrapper.no-tags + .thumbnail + outline: 2px solid $post-thumbnail-no-tags-border-color + &:hover a, a:active, a:focus + .thumbnail + outline: 2px solid $main-color !important diff --git a/client/css/pool-navigator-control.styl b/client/css/pool-navigator-control.styl new file mode 100644 index 00000000..ed6cd16a --- /dev/null +++ b/client/css/pool-navigator-control.styl @@ -0,0 +1,38 @@ +@import colors + +.pool-navigator-container + padding: 0 + margin: 0 auto + + .pool-info-wrapper + box-sizing: border-box + width: 100% + margin: 0 0 1em 0 + display: flex + padding: 0.5em 1em + border: 1px solid $pool-navigator-border-color + background: $pool-navigator-background-color + &.active + font-weight: bold + font-size: 1.10em; + padding: 0.58em 1em + + .pool-name + flex: 1 1; + text-align: center; + overflow: hidden; + white-space: nowrap; + -o-text-overflow: ellipsis; + text-overflow: ellipsis; + + .first, .last + flex-basis: 1em; + + .first, .prev, .next, .last + flex: 0 1; + margin: 0 .25em; + white-space: nowrap; + + +.darktheme .pool-navigator-container + background: $pool-navigator-header-background-color-darktheme diff --git a/client/css/pool-navigator-list-control.styl b/client/css/pool-navigator-list-control.styl new file mode 100644 index 00000000..080ad01a --- /dev/null +++ b/client/css/pool-navigator-list-control.styl @@ -0,0 +1,9 @@ +.pool-navigators>ul + list-style-type: none + margin: 0 + padding: 0 + + >li + margin-bottom: 1em + &:last-child + margin-bottom: 0 diff --git a/client/css/post-main-view.styl b/client/css/post-main-view.styl index e643dfbd..fca922d1 100644 --- a/client/css/post-main-view.styl +++ b/client/css/post-main-view.styl @@ -40,11 +40,14 @@ width: 100% .post-container - margin-bottom: 2em + margin-bottom: 1em .post-content margin: 0 + .pool-navigators-container + margin-bottom: 2em + .darktheme .post-view >.sidebar nav.buttons diff --git a/client/html/pool_delete.tpl b/client/html/pool_delete.tpl index 1ef7fb53..f279e0df 100644 --- a/client/html/pool_delete.tpl +++ b/client/html/pool_delete.tpl @@ -1,6 +1,6 @@
-

This pool has '><%- ctx.pool.postCount %> post(s).

+

This pool has '><%- ctx.pool.postCount %> post(s).

diff --git a/client/html/pools_page.tpl b/client/html/pools_page.tpl index 0d811808..c935785f 100644 --- a/client/html/pools_page.tpl +++ b/client/html/pools_page.tpl @@ -1,48 +1,19 @@ -
+<% if (ctx.postFlow) { %>
<% } else { %>
<% } %> <% if (ctx.response.results.length) { %> - - - - - - - - <% for (let pool of ctx.response.results) { %> - - - - - - <% } %> - -
- <% if (ctx.parameters.query == 'sort:name' || !ctx.parameters.query) { %> - '>Pool name(s) - <% } else { %> - '>Pool name(s) + - <% if (ctx.parameters.query == 'sort:post-count') { %> - '>Post count - <% } else { %> - '>Post count - <% } %> - - <% if (ctx.parameters.query == 'sort:creation-time') { %> - '>Created on - <% } else { %> - '>Created on - <% } %> -
-
    - <% for (let name of pool.names) { %> -
  • <%= ctx.makePoolLink(pool.id, false, false, pool, name) %>
  • - <% } %> -
-
- '><%- pool.postCount %> - - <%= ctx.makeRelativeTime(pool.creationTime) %> -
+ +
+ <%= ctx.makePoolLink(pool.id, false, false, pool, name) %> +
+ + <% } %> + <%= ctx.makeFlexboxAlign() %> + <% } %>
diff --git a/client/html/post_main.tpl b/client/html/post_main.tpl index 54c57333..b2e1e6f5 100644 --- a/client/html/post_main.tpl +++ b/client/html/post_main.tpl @@ -54,6 +54,10 @@
+ <% if (ctx.canListPools && ctx.canViewPools) { %> +
+ <% } %> + <% if (ctx.canListComments) { %>
<% } %> diff --git a/client/js/controllers/pool_list_controller.js b/client/js/controllers/pool_list_controller.js index a66f8163..37a45cfc 100644 --- a/client/js/controllers/pool_list_controller.js +++ b/client/js/controllers/pool_list_controller.js @@ -2,6 +2,7 @@ const router = require("../router.js"); const api = require("../api.js"); +const settings = require("../models/settings.js"); const uri = require("../util/uri.js"); const PoolList = require("../models/pool_list.js"); const topNavigation = require("../models/top_navigation.js"); @@ -108,6 +109,11 @@ class PoolListController { ); }, pageRenderer: (pageCtx) => { + Object.assign(pageCtx, { + canViewPosts: api.hasPrivilege("posts:view"), + canViewPools: api.hasPrivilege("pools:view"), + postFlow: settings.get().postFlow, + }); return new PoolsPageView(pageCtx); }, }); diff --git a/client/js/controllers/post_main_controller.js b/client/js/controllers/post_main_controller.js index 95cfdb52..a2717e85 100644 --- a/client/js/controllers/post_main_controller.js +++ b/client/js/controllers/post_main_controller.js @@ -16,6 +16,14 @@ class PostMainController extends BasePostController { constructor(ctx, editMode) { super(ctx); + let poolPostsAround = Promise.resolve({results: [], activePool: null}); + if (api.hasPrivilege("pools:list") && api.hasPrivilege("pools:view")) { + poolPostsAround = PostList.getPoolPostsAround( + ctx.parameters.id, + parameters ? parameters.query : null + ); + } + let parameters = ctx.parameters; Promise.all([ Post.get(ctx.parameters.id), @@ -23,9 +31,11 @@ class PostMainController extends BasePostController { ctx.parameters.id, parameters ? parameters.query : null ), + poolPostsAround ]).then( (responses) => { - const [post, aroundResponse] = responses; + const [post, aroundResponse, poolPostsAroundResponse] = responses; + let activePool = null; // remove junk from query, but save it into history so that it can // be still accessed after history navigation / page refresh @@ -39,11 +49,20 @@ class PostMainController extends BasePostController { ) : uri.formatClientLink("post", ctx.parameters.id); router.replace(url, ctx.state, false); + console.log(parameters.query); + parameters.query.split(" ").forEach((item) => { + const found = item.match(/^pool:([0-9]+)/i); + if (found) { + activePool = parseInt(found[1]); + } + }); } this._post = post; this._view = new PostMainView({ post: post, + poolPostsAround: poolPostsAroundResponse, + activePool: activePool, editMode: editMode, prevPostId: aroundResponse.prev ? aroundResponse.prev.id @@ -56,6 +75,8 @@ class PostMainController extends BasePostController { canFeaturePosts: api.hasPrivilege("posts:feature"), canListComments: api.hasPrivilege("comments:list"), canCreateComments: api.hasPrivilege("comments:create"), + canListPools: api.hasPrivilege("pools:list"), + canViewPools: api.hasPrivilege("pools:view"), parameters: parameters, }); diff --git a/client/js/controls/pool_navigator_control.js b/client/js/controls/pool_navigator_control.js new file mode 100644 index 00000000..5961ac70 --- /dev/null +++ b/client/js/controls/pool_navigator_control.js @@ -0,0 +1,35 @@ +"use strict"; + +const api = require("../api.js"); +const misc = require("../util/misc.js"); +const events = require("../events.js"); +const views = require("../util/views.js"); + +const template = views.getTemplate("pool-navigator"); + +class PoolNavigatorControl extends events.EventTarget { + constructor(hostNode, poolPostAround, isActivePool) { + super(); + this._hostNode = hostNode; + this._poolPostAround = poolPostAround; + this._isActivePool = isActivePool; + + views.replaceContent( + this._hostNode, + template({ + pool: poolPostAround.pool, + parameters: { query: `pool:${poolPostAround.pool.id}` }, + linkClass: misc.makeCssName(poolPostAround.pool.category, "pool"), + canViewPosts: api.hasPrivilege("posts:view"), + canViewPools: api.hasPrivilege("pools:view"), + firstPost: poolPostAround.firstPost, + prevPost: poolPostAround.prevPost, + nextPost: poolPostAround.nextPost, + lastPost: poolPostAround.lastPost, + isActivePool: isActivePool + }) + ); + } +} + +module.exports = PoolNavigatorControl; diff --git a/client/js/controls/pool_navigator_list_control.js b/client/js/controls/pool_navigator_list_control.js new file mode 100644 index 00000000..bc2ff221 --- /dev/null +++ b/client/js/controls/pool_navigator_list_control.js @@ -0,0 +1,59 @@ +"use strict"; + +const events = require("../events.js"); +const views = require("../util/views.js"); +const PoolNavigatorControl = require("../controls/pool_navigator_control.js"); + +const template = views.getTemplate("pool-navigator-list"); + +class PoolNavigatorListControl extends events.EventTarget { + constructor(hostNode, poolPostsAround, activePool) { + super(); + this._hostNode = hostNode; + this._poolPostsAround = poolPostsAround; + this._activePool = activePool; + this._indexToNode = {}; + + for (let [i, entry] of this._poolPostsAround.entries()) { + this._installPoolNavigatorNode(entry, i); + } + } + + get _poolNavigatorListNode() { + return this._hostNode; + } + + _installPoolNavigatorNode(poolPostAround, i) { + const isActivePool = poolPostAround.pool.id == this._activePool + const poolListItemNode = document.createElement("div"); + const poolControl = new PoolNavigatorControl( + poolListItemNode, + poolPostAround, + isActivePool + ); + // events.proxyEvent(commentControl, this, "submit"); + // events.proxyEvent(commentControl, this, "score"); + // events.proxyEvent(commentControl, this, "delete"); + this._indexToNode[poolPostAround.id] = poolListItemNode; + if (isActivePool) { + this._poolNavigatorListNode.insertBefore(poolListItemNode, this._poolNavigatorListNode.firstChild); + } else { + this._poolNavigatorListNode.appendChild(poolListItemNode); + } + } + + _uninstallPoolNavigatorNode(index) { + const poolListItemNode = this._indexToNode[index]; + poolListItemNode.parentNode.removeChild(poolListItemNode); + } + + _evtAdd(e) { + this._installPoolNavigatorNode(e.detail.index); + } + + _evtRemove(e) { + this._uninstallPoolNavigatorNode(e.detail.index); + } +} + +module.exports = PoolNavigatorListControl; diff --git a/client/js/models/post_list.js b/client/js/models/post_list.js index 8c2c9d4e..884dc5f0 100644 --- a/client/js/models/post_list.js +++ b/client/js/models/post_list.js @@ -16,6 +16,15 @@ class PostList extends AbstractList { ); } + static getPoolPostsAround(id, searchQuery) { + return api.get( + uri.formatApiLink("post", id, "pool-posts-around", { + query: PostList._decorateSearchQuery(searchQuery || ""), + fields: "id", + }) + ); + } + static search(text, offset, limit, fields) { return api .get( diff --git a/client/js/util/views.js b/client/js/util/views.js index 38c98a13..f9470375 100644 --- a/client/js/util/views.js +++ b/client/js/util/views.js @@ -40,12 +40,12 @@ function makeRelativeTime(time) { ); } -function makeThumbnail(url) { +function makeThumbnail(url, klass) { return makeElement( "span", url ? { - class: "thumbnail", + class: klass || "thumbnail", style: `background-image: url(\'${url}\')`, } : { class: "thumbnail empty" }, @@ -53,6 +53,23 @@ function makeThumbnail(url) { ); } +function makePoolThumbnails(posts, postFlow) { + if (posts.length == 0) { + return makeThumbnail(null); + } + if (postFlow) { + return makeThumbnail(posts.at(0).thumbnailUrl); + } + + let s = ""; + + for (let i = 0; i < Math.min(3, posts.length); i++) { + s += makeThumbnail(posts.at(i).thumbnailUrl, "thumbnail thumbnail-" + (i+1)); + } + + return s; +} + function makeRadio(options) { _imbueId(options); return makeElement( @@ -254,7 +271,7 @@ function makePoolLink(id, includeHash, includeCount, pool, name) { misc.escapeHtml(text) ) : makeElement( - "span", + "div", { class: misc.makeCssName(category, "pool") }, misc.escapeHtml(text) ); @@ -436,6 +453,7 @@ function getTemplate(templatePath) { makeFileSize: makeFileSize, makeMarkdown: makeMarkdown, makeThumbnail: makeThumbnail, + makePoolThumbnails: makePoolThumbnails, makeRadio: makeRadio, makeCheckbox: makeCheckbox, makeSelect: makeSelect, diff --git a/client/js/views/post_main_view.js b/client/js/views/post_main_view.js index 5ef7f61e..7261b58f 100644 --- a/client/js/views/post_main_view.js +++ b/client/js/views/post_main_view.js @@ -12,6 +12,7 @@ const PostReadonlySidebarControl = require("../controls/post_readonly_sidebar_co const PostEditSidebarControl = require("../controls/post_edit_sidebar_control.js"); const CommentControl = require("../controls/comment_control.js"); const CommentListControl = require("../controls/comment_list_control.js"); +const PoolNavigatorListControl = require("../controls/pool_navigator_list_control.js"); const template = views.getTemplate("post-main"); @@ -57,6 +58,7 @@ class PostMainView { this._installSidebar(ctx); this._installCommentForm(); this._installComments(ctx.post.comments); + this._installPoolNavigators(ctx.poolPostsAround, ctx.activePool); const showPreviousImage = () => { if (ctx.prevPostId) { @@ -137,6 +139,21 @@ class PostMainView { } } + _installPoolNavigators(poolPostsAround, activePool) { + const poolNavigatorsContainerNode = document.querySelector( + "#content-holder .pool-navigators-container" + ); + if (!poolNavigatorsContainerNode) { + return; + } + + this.poolNavigatorsControl = new PoolNavigatorListControl( + poolNavigatorsContainerNode, + poolPostsAround, + activePool + ); + } + _installCommentForm() { const commentFormContainer = document.querySelector( "#content-holder .comment-form-container" diff --git a/doc/API.md b/doc/API.md index 00ee75a9..c007aed1 100644 --- a/doc/API.md +++ b/doc/API.md @@ -794,38 +794,39 @@ data. **Sort style tokens** - | `` | Description | - | ---------------- | ------------------------------------------------ | - | `random` | as random as it can get | - | `id` | highest to lowest post number | - | `score` | highest scored | - | `tag-count` | with most tags | - | `comment-count` | most commented first | - | `fav-count` | loved by most | - | `note-count` | with most annotations | - | `relation-count` | with most relations | - | `feature-count` | most often featured | - | `file-size` | largest files first | - | `image-width` | widest images first | - | `image-height` | tallest images first | - | `image-area` | largest images first | - | `width` | alias of `image-width` | - | `height` | alias of `image-height` | - | `area` | alias of `image-area` | - | `creation-date` | newest to oldest (pretty much same as id) | - | `creation-time` | alias of `creation-date` | - | `date` | alias of `creation-date` | - | `time` | alias of `creation-date` | - | `last-edit-date` | like creation-date, only looks at last edit time | - | `last-edit-time` | alias of `last-edit-date` | - | `edit-date` | alias of `last-edit-date` | - | `edit-time` | alias of `last-edit-date` | - | `comment-date` | recently commented by anyone | - | `comment-time` | alias of `comment-date` | - | `fav-date` | recently added to favorites by anyone | - | `fav-time` | alias of `fav-date` | - | `feature-date` | recently featured | - | `feature-time` | alias of `feature-time` | + | `` | Description | + | ---------------- | ------------------------------------------------ | + | `random` | as random as it can get | + | `id` | highest to lowest post number | + | `score` | highest scored | + | `tag-count` | with most tags | + | `comment-count` | most commented first | + | `fav-count` | loved by most | + | `note-count` | with most annotations | + | `relation-count` | with most relations | + | `feature-count` | most often featured | + | `file-size` | largest files first | + | `image-width` | widest images first | + | `image-height` | tallest images first | + | `image-area` | largest images first | + | `width` | alias of `image-width` | + | `height` | alias of `image-height` | + | `area` | alias of `image-area` | + | `creation-date` | newest to oldest (pretty much same as id) | + | `creation-time` | alias of `creation-date` | + | `date` | alias of `creation-date` | + | `time` | alias of `creation-date` | + | `last-edit-date` | like creation-date, only looks at last edit time | + | `last-edit-time` | alias of `last-edit-date` | + | `edit-date` | alias of `last-edit-date` | + | `edit-time` | alias of `last-edit-date` | + | `comment-date` | recently commented by anyone | + | `comment-time` | alias of `comment-date` | + | `fav-date` | recently added to favorites by anyone | + | `fav-time` | alias of `fav-date` | + | `feature-date` | recently featured | + | `feature-time` | alias of `feature-time` | + | `pool` | post order of the pool referenced by the `pool:` named token in the same search query | **Special tokens** @@ -1357,6 +1358,7 @@ data. | `` | Description | | ------------------- | ----------------------------------------- | + | `id` | having given pool number | | `name` | having given name (accepts wildcards) | | `category` | having given category (accepts wildcards) | | `creation-date` | created at given date | @@ -1369,18 +1371,19 @@ data. **Sort style tokens** - | `` | Description | - | ------------------- | ---------------------------- | - | `random` | as random as it can get | - | `name` | A to Z | - | `category` | category (A to Z) | - | `creation-date` | recently created first | - | `creation-time` | alias of `creation-date` | - | `last-edit-date` | recently edited first | - | `last-edit-time` | alias of `creation-time` | - | `edit-date` | alias of `creation-time` | - | `edit-time` | alias of `creation-time` | - | `post-count` | used in most posts first | + | `` | Description | + | ------------------- | ---------------------------- | + | `random` | as random as it can get | + | `id` | highest to lowest pool number | + | `name` | A to Z | + | `category` | category (A to Z) | + | `creation-date` | recently created first | + | `creation-time` | alias of `creation-date` | + | `last-edit-date` | recently edited first | + | `last-edit-time` | alias of `creation-time` | + | `edit-date` | alias of `creation-time` | + | `edit-time` | alias of `creation-time` | + | `post-count` | used in most posts first | **Special tokens** diff --git a/server/requirements.txt b/server/requirements.txt index ffe18f0c..1eabef8a 100644 --- a/server/requirements.txt +++ b/server/requirements.txt @@ -13,3 +13,4 @@ pytz>=2018.3 pyyaml>=3.11 SQLAlchemy>=1.0.12, <1.4 yt-dlp +alembic_utils>=0.5.6 diff --git a/server/szurubooru/api/post_api.py b/server/szurubooru/api/post_api.py index daba7f7e..fe5d1a3a 100644 --- a/server/szurubooru/api/post_api.py +++ b/server/szurubooru/api/post_api.py @@ -284,6 +284,19 @@ def get_posts_around( ) +@rest.routes.get("/post/(?P[^/]+)/pool-posts-around/?") +def get_pool_posts_around( + ctx: rest.Context, params: Dict[str, str] +) -> rest.Response: + auth.verify_privilege(ctx.user, "posts:list") + auth.verify_privilege(ctx.user, "pools:list") + auth.verify_privilege(ctx.user, "pools:view") + _search_executor_config.user = ctx.user + post = _get_post(params) + results = posts.get_pool_posts_around(post) + return posts.serialize_pool_posts_around(results) + + @rest.routes.post("/posts/reverse-search/?") def get_posts_by_image( ctx: rest.Context, _params: Dict[str, str] = {} diff --git a/server/szurubooru/func/posts.py b/server/szurubooru/func/posts.py index be2259cf..629cff9d 100644 --- a/server/szurubooru/func/posts.py +++ b/server/szurubooru/func/posts.py @@ -2,6 +2,7 @@ import hmac import logging from datetime import datetime from typing import Any, Callable, Dict, List, Optional, Tuple +from collections import namedtuple import sqlalchemy as sa @@ -134,12 +135,16 @@ def get_post_content_path(post: model.Post) -> str: ) +def get_post_thumbnail_path_from_id(post_id: int) -> str: + return "generated-thumbnails/%d_%s.jpg" % ( + post_id, + get_post_security_hash(post_id), + ) + + def get_post_thumbnail_path(post: model.Post) -> str: assert post - return "generated-thumbnails/%d_%s.jpg" % ( - post.post_id, - get_post_security_hash(post.post_id), - ) + return get_post_thumbnail_path_from_id(post.post_id) def get_post_thumbnail_backup_path(post: model.Post) -> str: @@ -968,3 +973,81 @@ def search_by_image(image_content: bytes) -> List[Tuple[float, model.Post]]: ] else: return [] + + +PoolPostsAround = namedtuple('PoolPostsAround', 'pool first_post prev_post next_post last_post') + +def get_pool_posts_around(post: model.Post) -> List[PoolPostsAround]: + around = dict() + pool_ids = set() + post_ids = set() + + dbquery = """ + SELECT around.ord, around.pool_id, around.post_id, around.delta + FROM pool_post pp, + LATERAL get_pool_posts_around(pp.pool_id, pp.post_id) around + WHERE pp.post_id = :post_id; + """ + + for order, pool_id, post_id, delta in db.session.execute(dbquery, {"post_id": post.post_id}): + if pool_id not in around: + around[pool_id] = [None, None, None, None] + if delta == -2: + around[pool_id][0] = post_id + elif delta == -1: + around[pool_id][1] = post_id + elif delta == 1: + around[pool_id][2] = post_id + elif delta == 2: + around[pool_id][3] = post_id + pool_ids.add(pool_id) + post_ids.add(post_id) + + pools = dict() + posts = dict() + + for pool in db.session.query(model.Pool).filter(model.Pool.pool_id.in_(pool_ids)).all(): + pools[pool.pool_id] = pool + + for result in db.session.query(model.Post.post_id).filter(model.Post.post_id.in_(post_ids)).all(): + post_id = result[0] + posts[post_id] = { "id": post_id, "thumbnailUrl": get_post_thumbnail_path_from_id(post_id) } + + results = [] + + for pool_id, entry in around.items(): + first_post = None + prev_post = None + next_post = None + last_post = None + if entry[1] is not None: + prev_post = posts[entry[1]] + if entry[0] is not None: + first_post = posts[entry[0]] + if entry[2] is not None: + next_post = posts[entry[2]] + if entry[3] is not None: + last_post = posts[entry[3]] + results.append(PoolPostsAround(pools[pool_id], first_post, prev_post, next_post, last_post)) + + return results + + +def sort_pool_posts_around(around: List[PoolPostsAround]) -> List[PoolPostsAround]: + return sorted( + around, + key=lambda entry: entry.pool.pool_id, + ) + + +def serialize_pool_posts_around(around: List[PoolPostsAround]) -> Optional[rest.Response]: + return [ + { + "pool": pools.serialize_micro_pool(entry.pool), + "firstPost": entry.first_post, + "prevPost": entry.prev_post, + "nextPost": entry.next_post, + "lastPost": entry.last_post + } + for entry in sort_pool_posts_around(around) + ] diff --git a/server/szurubooru/migrations/env.py b/server/szurubooru/migrations/env.py index cd4f6ad1..6b155888 100644 --- a/server/szurubooru/migrations/env.py +++ b/server/szurubooru/migrations/env.py @@ -11,6 +11,7 @@ import sys from time import sleep import alembic +from alembic_utils.replaceable_entity import register_entities import sqlalchemy as sa @@ -21,6 +22,9 @@ sys.path.append(os.path.join(dir_to_self, *[os.pardir] * 2)) import szurubooru.config # noqa: E402 import szurubooru.model.base # noqa: E402 + +from szurubooru.migrations.functions import get_pool_posts_around # noqa: E402 +register_entities([get_pool_posts_around]) # fmt: on diff --git a/server/szurubooru/migrations/functions.py b/server/szurubooru/migrations/functions.py new file mode 100644 index 00000000..ae39e62b --- /dev/null +++ b/server/szurubooru/migrations/functions.py @@ -0,0 +1,68 @@ +from alembic_utils.pg_function import PGFunction + +get_pool_posts_around = PGFunction.from_sql(""" +CREATE OR REPLACE FUNCTION public.get_pool_posts_around( + P_POOL_ID int, + P_POST_ID int +) + RETURNS TABLE ( + ORD int, + POOL_ID int, + POST_ID int, + DELTA int + ) + LANGUAGE PLPGSQL +AS $$ +BEGIN + RETURN QUERY WITH main AS ( + SELECT * FROM pool_post WHERE pool_post.pool_id = P_POOL_ID AND pool_post.post_id = P_POST_ID + ), + around AS ( + (SELECT pool_post.ord, + pool_post.pool_id, + pool_post.post_id, + 1 as delta, + main.ord AS target_ord, + main.pool_id AS target_pool_id + FROM pool_post, main + WHERE pool_post.ord > main.ord + AND pool_post.pool_id = main.pool_id + ORDER BY pool_post.ord ASC LIMIT 1) + UNION + (SELECT pool_post.ord, + pool_post.pool_id, + pool_post.post_id, + -1 as delta, + main.ord AS target_ord, + main.pool_id AS target_pool_id + FROM pool_post, main + WHERE pool_post.ord < main.ord + AND pool_post.pool_id = main.pool_id + ORDER BY pool_post.ord DESC LIMIT 1) + UNION + (SELECT pool_post.ord, + pool_post.pool_id, + pool_post.post_id, + 2 as delta, + main.ord AS target_ord, + main.pool_id AS target_pool_id + FROM pool_post, main + WHERE pool_post.ord = (SELECT MAX(pool_post.ord) FROM pool_post) + AND pool_post.pool_id = main.pool_id + ORDER BY pool_post.ord DESC LIMIT 1) + UNION + (SELECT pool_post.ord, + pool_post.pool_id, + pool_post.post_id, + -2 as delta, + main.ord AS target_ord, + main.pool_id AS target_pool_id + FROM pool_post, main + WHERE pool_post.ord = (SELECT MIN(pool_post.ord) FROM pool_post) + AND pool_post.pool_id = main.pool_id + ORDER BY pool_post.ord DESC LIMIT 1) + ) + SELECT around.ord, around.pool_id, around.post_id, around.delta FROM around; +END +$$ +""") diff --git a/server/szurubooru/migrations/versions/f0b8a4298dc7_add_get_pool_posts_around_function.py b/server/szurubooru/migrations/versions/f0b8a4298dc7_add_get_pool_posts_around_function.py new file mode 100644 index 00000000..3882a3af --- /dev/null +++ b/server/szurubooru/migrations/versions/f0b8a4298dc7_add_get_pool_posts_around_function.py @@ -0,0 +1,33 @@ +''' +add get pool posts around function + +Revision ID: f0b8a4298dc7 +Created at: 2021-05-08 21:23:48.782025 +''' + +import sqlalchemy as sa +from alembic import op + +from alembic_utils.pg_function import PGFunction +from sqlalchemy import text as sql_text + +revision = 'f0b8a4298dc7' +down_revision = 'adcd63ff76a2' +branch_labels = None +depends_on = None + +def upgrade(): + public_get_pool_posts_around = PGFunction( + schema="public", + signature="get_pool_posts_around( P_POOL_ID int, P_POST_ID int )", + definition='returns TABLE (\n ORD int,\n POOL_ID int,\n POST_ID int,\n DELTA int\n )\n LANGUAGE PLPGSQL\nAS $$\nBEGIN\n RETURN QUERY WITH main AS (\n SELECT * FROM pool_post WHERE pool_post.pool_id = P_POOL_ID AND pool_post.post_id = P_POST_ID\n ),\n around AS (\n (SELECT pool_post.ord,\n pool_post.pool_id,\n pool_post.post_id,\n 1 as delta,\n main.ord AS target_ord,\n main.pool_id AS target_pool_id\n FROM pool_post, main\n WHERE pool_post.ord > main.ord\n AND pool_post.pool_id = main.pool_id\n ORDER BY pool_post.ord ASC LIMIT 1)\n UNION\n (SELECT pool_post.ord,\n pool_post.pool_id,\n pool_post.post_id,\n -1 as delta,\n main.ord AS target_ord,\n main.pool_id AS target_pool_id\n FROM pool_post, main\n WHERE pool_post.ord < main.ord\n AND pool_post.pool_id = main.pool_id\n ORDER BY pool_post.ord DESC LIMIT 1)\n )\n SELECT around.ord, around.pool_id, around.post_id, around.delta FROM around;\nEND\n$$' + ) + op.create_entity(public_get_pool_posts_around) + +def downgrade(): + public_get_pool_posts_around = PGFunction( + schema="public", + signature="get_pool_posts_around( P_POOL_ID int, P_POST_ID int )", + definition='returns TABLE (\n ORD int,\n POOL_ID int,\n POST_ID int,\n DELTA int\n )\n LANGUAGE PLPGSQL\nAS $$\nBEGIN\n RETURN QUERY WITH main AS (\n SELECT * FROM pool_post WHERE pool_post.pool_id = P_POOL_ID AND pool_post.post_id = P_POST_ID\n ),\n around AS (\n (SELECT pool_post.ord,\n pool_post.pool_id,\n pool_post.post_id,\n 1 as delta,\n main.ord AS target_ord,\n main.pool_id AS target_pool_id\n FROM pool_post, main\n WHERE pool_post.ord > main.ord\n AND pool_post.pool_id = main.pool_id\n ORDER BY pool_post.ord ASC LIMIT 1)\n UNION\n (SELECT pool_post.ord,\n pool_post.pool_id,\n pool_post.post_id,\n -1 as delta,\n main.ord AS target_ord,\n main.pool_id AS target_pool_id\n FROM pool_post, main\n WHERE pool_post.ord < main.ord\n AND pool_post.pool_id = main.pool_id\n ORDER BY pool_post.ord DESC LIMIT 1)\n )\n SELECT around.ord, around.pool_id, around.post_id, around.delta FROM around;\nEND\n$$' + ) + op.drop_entity(public_get_pool_posts_around) diff --git a/server/szurubooru/search/configs/base_search_config.py b/server/szurubooru/search/configs/base_search_config.py index d60f3617..34b93811 100644 --- a/server/szurubooru/search/configs/base_search_config.py +++ b/server/szurubooru/search/configs/base_search_config.py @@ -1,5 +1,7 @@ from typing import Callable, Dict, Optional, Tuple +import sqlalchemy as sa + from szurubooru.search import criteria, tokens from szurubooru.search.query import SearchQuery from szurubooru.search.typing import SaColumn, SaQuery @@ -24,6 +26,21 @@ class BaseSearchConfig: def create_around_query(self) -> SaQuery: raise NotImplementedError() + def create_around_filter_queries(self, filter_query: SaQuery, entity_id: int) -> Tuple[SaQuery, SaQuery]: + prev_filter_query = ( + filter_query.filter(self.id_column > entity_id) + .order_by(None) + .order_by(sa.func.abs(self.id_column - entity_id).asc()) + .limit(1) + ) + next_filter_query = ( + filter_query.filter(self.id_column < entity_id) + .order_by(None) + .order_by(sa.func.abs(self.id_column - entity_id).asc()) + .limit(1) + ) + return (prev_filter_query, next_filter_query) + def finalize_query(self, query: SaQuery) -> SaQuery: return query diff --git a/server/szurubooru/search/configs/pool_search_config.py b/server/szurubooru/search/configs/pool_search_config.py index 88b30a6e..30b4d4ea 100644 --- a/server/szurubooru/search/configs/pool_search_config.py +++ b/server/szurubooru/search/configs/pool_search_config.py @@ -30,7 +30,7 @@ class PoolSearchConfig(BaseSearchConfig): raise NotImplementedError() def finalize_query(self, query: SaQuery) -> SaQuery: - return query.order_by(model.Pool.first_name.asc()) + return query.order_by(model.Pool.pool_id.desc()) @property def anonymous_filter(self) -> Filter: @@ -45,6 +45,7 @@ class PoolSearchConfig(BaseSearchConfig): def named_filters(self) -> Dict[str, Filter]: return util.unalias_dict( [ + (["id"], search_util.create_num_filter(model.Pool.pool_id)), ( ["name"], search_util.create_subquery_filter( @@ -91,6 +92,7 @@ class PoolSearchConfig(BaseSearchConfig): ["random"], (sa.sql.expression.func.random(), self.SORT_NONE), ), + (["id"], (model.Pool.pool_id, self.SORT_DESC)), (["name"], (model.Pool.first_name, self.SORT_ASC)), (["category"], (model.PoolCategory.name, self.SORT_ASC)), ( diff --git a/server/szurubooru/search/configs/post_search_config.py b/server/szurubooru/search/configs/post_search_config.py index 8d4672d4..645f4b2d 100644 --- a/server/szurubooru/search/configs/post_search_config.py +++ b/server/szurubooru/search/configs/post_search_config.py @@ -1,4 +1,4 @@ -from typing import Any, Dict, Optional, Tuple +from typing import Any, Dict, Optional, Tuple, Callable, Union import sqlalchemy as sa @@ -114,12 +114,49 @@ def _pool_filter( query: SaQuery, criterion: Optional[criteria.BaseCriterion], negated: bool ) -> SaQuery: assert criterion - return search_util.create_subquery_filter( - model.Post.post_id, - model.PoolPost.post_id, - model.PoolPost.pool_id, - search_util.create_num_filter, - )(query, criterion, negated) + from szurubooru.search.configs import util as search_util + subquery = db.session.query(model.PoolPost.post_id.label("foreign_id")) + subquery = subquery.options(sa.orm.lazyload("*")) + subquery = search_util.create_num_filter(model.PoolPost.pool_id)(subquery, criterion, False) + subquery = subquery.subquery("t") + expression = model.Post.post_id.in_(subquery) + if negated: + expression = ~expression + return query.filter(expression) + + +def _pool_sort( + query: SaQuery, pool_id: Optional[int] +) -> SaQuery: + if pool_id is None: + return query + return query.join(model.PoolPost, sa.and_(model.PoolPost.post_id == model.Post.post_id, model.PoolPost.pool_id == pool_id)) \ + .order_by(model.PoolPost.order.desc()) + + +def _posts_around_pool(filter_query: SaQuery, post_id: int, pool_id: int) -> Tuple[SaQuery, SaQuery]: + this_order = db.session.query(model.PoolPost) \ + .filter(model.PoolPost.post_id == post_id) \ + .filter(model.PoolPost.pool_id == pool_id) \ + .one().order + + filter_query = db.session.query(model.Post) \ + .join(model.PoolPost, model.PoolPost.pool_id == pool_id) \ + .filter(model.PoolPost.post_id == model.Post.post_id) + + prev_filter_query = ( + filter_query.filter(model.PoolPost.order > this_order) + .order_by(None) + .order_by(sa.func.abs(model.PoolPost.order - this_order).asc()) + .limit(1) + ) + next_filter_query = ( + filter_query.filter(model.PoolPost.order < this_order) + .order_by(None) + .order_by(sa.func.abs(model.PoolPost.order - this_order).asc()) + .limit(1) + ) + return (prev_filter_query, next_filter_query) def _category_filter( @@ -153,6 +190,7 @@ def _category_filter( class PostSearchConfig(BaseSearchConfig): def __init__(self) -> None: self.user = None # type: Optional[model.User] + self.pool_id = None # type: Optional[int] def on_search_query_parsed(self, search_query: SearchQuery) -> SaQuery: new_special_tokens = [] @@ -177,10 +215,19 @@ class PostSearchConfig(BaseSearchConfig): else: new_special_tokens.append(token) search_query.special_tokens = new_special_tokens + self.pool_id = None + for token in search_query.named_tokens: + if token.name == "pool" and isinstance(token.criterion, criteria.PlainCriterion): + self.pool_id = token.criterion.value def create_around_query(self) -> SaQuery: return db.session.query(model.Post).options(sa.orm.lazyload("*")) + def create_around_filter_queries(self, filter_query: SaQuery, entity_id: int) -> Tuple[SaQuery, SaQuery]: + if self.pool_id is not None: + return _posts_around_pool(filter_query, entity_id, self.pool_id) + return super(PostSearchConfig, self).create_around_filter_queries(filter_query, entity_id) + def create_filter_query(self, disable_eager_loads: bool) -> SaQuery: strategy = ( sa.orm.lazyload if disable_eager_loads else sa.orm.subqueryload @@ -382,7 +429,7 @@ class PostSearchConfig(BaseSearchConfig): ) @property - def sort_columns(self) -> Dict[str, Tuple[SaColumn, str]]: + def sort_columns(self) -> Dict[str, Union[Tuple[SaColumn, str], Callable[[SaQuery], None]]]: return util.unalias_dict( [ ( @@ -444,6 +491,10 @@ class PostSearchConfig(BaseSearchConfig): ["feature-date", "feature-time"], (model.Post.last_feature_time, self.SORT_DESC), ), + ( + ["pool"], + lambda subquery: _pool_sort(subquery, self.pool_id) + ) ] ) diff --git a/server/szurubooru/search/configs/util.py b/server/szurubooru/search/configs/util.py index 58e6ebe5..fd40b43d 100644 --- a/server/szurubooru/search/configs/util.py +++ b/server/szurubooru/search/configs/util.py @@ -205,6 +205,7 @@ def create_subquery_filter( filter_column: SaColumn, filter_factory: SaColumn, subquery_decorator: Callable[[SaQuery], None] = None, + order: SaQuery = None, ) -> Filter: filter_func = filter_factory(filter_column) diff --git a/server/szurubooru/search/executor.py b/server/szurubooru/search/executor.py index a5ef9625..6b39ff4a 100644 --- a/server/szurubooru/search/executor.py +++ b/server/szurubooru/search/executor.py @@ -47,18 +47,7 @@ class Executor: filter_query = self._prepare_db_query( filter_query, search_query, False ) - 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) - ) - 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) - ) + prev_filter_query, next_filter_query = self.config.create_around_filter_queries(filter_query, entity_id) return ( prev_filter_query.one_or_none(), next_filter_query.one_or_none(), @@ -181,14 +170,18 @@ class Executor: _format_dict_keys(self.config.sort_columns), ) ) - column, default_order = self.config.sort_columns[ + entry = self.config.sort_columns[ sort_token.name ] - order = _get_order(sort_token.order, default_order) - if order == sort_token.SORT_ASC: - db_query = db_query.order_by(column.asc()) - elif order == sort_token.SORT_DESC: - db_query = db_query.order_by(column.desc()) + if callable(entry): + db_query = entry(db_query) + else: + column, default_order = entry + order = _get_order(sort_token.order, default_order) + if order == sort_token.SORT_ASC: + db_query = db_query.order_by(column.asc()) + elif order == sort_token.SORT_DESC: + db_query = db_query.order_by(column.desc()) db_query = self.config.finalize_query(db_query) return db_query diff --git a/server/szurubooru/search/typing.py b/server/szurubooru/search/typing.py index 686c2cb6..011f7eae 100644 --- a/server/szurubooru/search/typing.py +++ b/server/szurubooru/search/typing.py @@ -1,4 +1,4 @@ -from typing import Any, Callable +from typing import Any, Callable, Union SaColumn = Any SaQuery = Any diff --git a/server/szurubooru/tests/func/test_posts.py b/server/szurubooru/tests/func/test_posts.py index fa1b3bb6..019f40ac 100644 --- a/server/szurubooru/tests/func/test_posts.py +++ b/server/szurubooru/tests/func/test_posts.py @@ -1221,3 +1221,30 @@ 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_pool_posts_around(post_factory, pool_factory, config_injector): + from szurubooru.migrations.functions import get_pool_posts_around + db.session.execute(get_pool_posts_around.to_sql_statement_create()) + db.session.flush() + + config_injector({"allow_broken_uploads": False, "secret": "test"}) + post1 = post_factory(id=1) + post2 = post_factory(id=2) + post3 = post_factory(id=3) + post4 = post_factory(id=4) + pool1 = pool_factory(id=1) + pool2 = pool_factory(id=2) + pool1.posts = [post1, post2, post3, post4] + pool2.posts = [post3, post4, post2] + db.session.add_all([post1, post2, post3, post4, pool1, pool2]) + db.session.flush() + around = posts.get_pool_posts_around(post2) + assert around[0].first_post["id"] == post1.post_id + assert around[0].prev_post["id"] == post1.post_id + assert around[0].next_post["id"] == post3.post_id + assert around[0].last_post["id"] == post4.post_id + assert around[1].first_post["id"] == post3.post_id + assert around[1].prev_post["id"] == post4.post_id + assert around[1].next_post == None + assert around[1].last_post == None 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 b86fa273..ef115d71 100644 --- a/server/szurubooru/tests/search/configs/test_post_search_config.py +++ b/server/szurubooru/tests/search/configs/test_post_search_config.py @@ -725,6 +725,7 @@ def test_filter_by_feature_date( "sort:fav-time", "sort:feature-date", "sort:feature-time", + "sort:pool", ], ) def test_sort_tokens(verify_unpaged, post_factory, input): @@ -915,3 +916,42 @@ def test_search_by_tag_category( ) db.session.flush() verify_unpaged(input, expected_post_ids) + + +def test_sort_pool( + post_factory, pool_factory, pool_category_factory, verify_unpaged +): + post1 = post_factory(id=1) + post2 = post_factory(id=2) + post3 = post_factory(id=3) + post4 = post_factory(id=4) + pool1 = pool_factory( + id=1, + names=["pool1"], + description="desc", + category=pool_category_factory("test-cat1"), + ) + pool1.posts = [post1, post4, post3] + pool2 = pool_factory( + id=2, + names=["pool2"], + description="desc", + category=pool_category_factory("test-cat2"), + ) + pool2.posts = [post3, post4, post2] + db.session.add_all( + [ + post1, + post2, + post3, + post4, + pool1, + pool2 + ] + ) + db.session.flush() + verify_unpaged("pool:1 sort:pool", [1, 4, 3]) + verify_unpaged("pool:2 sort:pool", [3, 4, 2]) + verify_unpaged("pool:1 pool:2 sort:pool", [4, 3]) + verify_unpaged("pool:2 pool:1 sort:pool", [3, 4]) + verify_unpaged("sort:pool", [1, 2, 3, 4])