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])