diff --git a/client/html/post_main.tpl b/client/html/post_main.tpl
index 54c573330..b2e1e6f57 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 a66f81630..67608684c 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");
@@ -42,7 +43,7 @@ class PoolListController {
});
this._headerView.addEventListener(
"submit",
- (e) => this._evtSubmit(e),
+ (e) => this._evtSubmit(e)
);
this._headerView.addEventListener(
"navigate",
@@ -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 95cfdb52f..a2717e855 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 000000000..5961ac70f
--- /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 000000000..bc2ff2212
--- /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 8c2c9d4ed..884dc5f0f 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 38c98a137..f94703752 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 5ef7f61ee..7261b58f4 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 00ee75a9c..c007aed10 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/Dockerfile b/server/Dockerfile
index 3e4dadfba..ad01406c7 100644
--- a/server/Dockerfile
+++ b/server/Dockerfile
@@ -1,4 +1,5 @@
ARG ALPINE_VERSION=3.13
+ARG BUILDPLATFORM=linux/amd64
FROM alpine:$ALPINE_VERSION as prereqs
@@ -33,9 +34,6 @@ RUN pip3 install --no-cache-dir --disable-pip-version-check \
"pillow-avif-plugin~=1.1.0"
RUN apk --no-cache del py3-pip
-COPY ./ /opt/app/
-RUN rm -rf /opt/app/szurubooru/tests
-
FROM --platform=$BUILDPLATFORM prereqs as testing
WORKDIR /opt/app
@@ -48,12 +46,14 @@ RUN apk --no-cache add \
&& pip3 install --no-cache-dir --disable-pip-version-check \
pytest-pgsql \
freezegun \
- && apk --no-cache del py3-pip \
- && addgroup app \
+ && apk --no-cache del py3-pip
+
+COPY ./ /opt/app/
+
+RUN addgroup app \
&& adduser -SDH -h /opt/app -g '' -G app app \
&& chown app:app /opt/app
-COPY --chown=app:app ./szurubooru/tests /opt/app/szurubooru/tests/
ENV TEST_ENVIRONMENT="true"
USER app
@@ -70,8 +70,11 @@ ARG PGID=1000
RUN apk --no-cache add \
dumb-init \
py3-setuptools \
- py3-waitress \
- && mkdir -p /opt/app /data \
+ py3-waitress
+
+COPY ./ /opt/app/
+
+RUN mkdir -p /opt/app /data \
&& addgroup -g ${PGID} app \
&& adduser -SDH -h /opt/app -g '' -G app -u ${PUID} app \
&& chown -R app:app /opt/app /data
diff --git a/server/szurubooru/api/post_api.py b/server/szurubooru/api/post_api.py
index daba7f7ea..ea034a8fe 100644
--- a/server/szurubooru/api/post_api.py
+++ b/server/szurubooru/api/post_api.py
@@ -284,6 +284,18 @@ 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")
+ post = _get_post(params)
+ results = posts.get_pool_posts_around(post)
+ return posts.serialize_pool_posts_around(ctx, 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 be2259cf4..b8c608ab8 100644
--- a/server/szurubooru/func/posts.py
+++ b/server/szurubooru/func/posts.py
@@ -2,6 +2,7 @@
import logging
from datetime import datetime
from typing import Any, Callable, Dict, List, Optional, Tuple
+from collections import namedtuple
import sqlalchemy as sa
@@ -968,3 +969,48 @@ 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]:
+ results = []
+ for pool in post.pools:
+ first_post, prev_post, next_post, last_post = None, None, None, None
+
+ # find index of current post:
+ index_in_pool = list(map(lambda p: p.post_id, pool.posts)).index(post.post_id)
+
+ # collect first, prev, next, last post:
+ if index_in_pool > 0:
+ first_post = pool.posts[0]
+ prev_post = pool.posts[index_in_pool - 1]
+ if index_in_pool < len(pool.posts) - 1:
+ next_post = pool.posts[index_in_pool + 1]
+ last_post = pool.posts[-1]
+
+ around = PoolPostsAround(pool, first_post, prev_post, next_post, last_post)
+ results.append(around)
+
+ 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(ctx: rest.Context, around: List[PoolPostsAround]) -> Optional[rest.Response]:
+ return [
+ {
+ "pool": pools.serialize_micro_pool(entry.pool),
+ "firstPost": serialize_micro_post(entry.first_post, ctx.user),
+ "prevPost": serialize_micro_post(entry.prev_post, ctx.user),
+ "nextPost": serialize_micro_post(entry.next_post, ctx.user),
+ "lastPost": serialize_micro_post(entry.last_post, ctx.user)
+ }
+ for entry in sort_pool_posts_around(around)
+ ]
diff --git a/server/szurubooru/search/configs/base_search_config.py b/server/szurubooru/search/configs/base_search_config.py
index d60f3617c..34b938111 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 @@ def create_count_query(self, disable_eager_loads: bool) -> SaQuery:
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 88b30a6ef..30b4d4ea8 100644
--- a/server/szurubooru/search/configs/pool_search_config.py
+++ b/server/szurubooru/search/configs/pool_search_config.py
@@ -30,7 +30,7 @@ def create_around_query(self) -> SaQuery:
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 @@ def anonymous_filter(self) -> Filter:
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 @@ def sort_columns(self) -> Dict[str, Tuple[SaColumn, str]]:
["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 8d4672d46..6dea12625 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,48 @@ 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)
+ 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 +189,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 +214,19 @@ def on_search_query_parsed(self, search_query: SearchQuery) -> SaQuery:
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 +428,7 @@ def named_filters(self) -> Dict[str, Filter]:
)
@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 +490,10 @@ def sort_columns(self) -> Dict[str, Tuple[SaColumn, str]]:
["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/executor.py b/server/szurubooru/search/executor.py
index a5ef9625d..6b39ff4ac 100644
--- a/server/szurubooru/search/executor.py
+++ b/server/szurubooru/search/executor.py
@@ -47,18 +47,7 @@ def get_around(
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 @@ def _prepare_db_query(
_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/tests/func/test_posts.py b/server/szurubooru/tests/func/test_posts.py
index fa1b3bb62..1c187fe1b 100644
--- a/server/szurubooru/tests/func/test_posts.py
+++ b/server/szurubooru/tests/func/test_posts.py
@@ -1221,3 +1221,26 @@ 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):
+ 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.post_id == post1.post_id
+ assert around[0].prev_post.post_id == post1.post_id
+ assert around[0].next_post.post_id == post3.post_id
+ assert around[0].last_post.post_id == post4.post_id
+ assert around[1].first_post.post_id == post3.post_id
+ assert around[1].prev_post.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_pool_search_config.py b/server/szurubooru/tests/search/configs/test_pool_search_config.py
index 1103ec402..5df9b2e5d 100644
--- a/server/szurubooru/tests/search/configs/test_pool_search_config.py
+++ b/server/szurubooru/tests/search/configs/test_pool_search_config.py
@@ -12,13 +12,16 @@ def executor():
@pytest.fixture
def verify_unpaged(executor):
- def verify(input, expected_pool_names):
+ def verify(input, expected_pool_names, test_order=False):
actual_count, actual_pools = executor.execute(
input, offset=0, limit=100
)
actual_pool_names = [u.names[0].name for u in actual_pools]
- assert actual_count == len(expected_pool_names)
+ if not test_order:
+ actual_pool_names = sorted(actual_pool_names)
+ expected_pool_names = sorted(expected_pool_names)
assert actual_pool_names == expected_pool_names
+ assert actual_count == len(expected_pool_names)
return verify
@@ -323,7 +326,6 @@ def test_filter_by_invalid_input(executor, input):
@pytest.mark.parametrize(
"input,expected_pool_names",
[
- ("", ["t1", "t2"]),
("sort:name", ["t1", "t2"]),
("-sort:name", ["t2", "t1"]),
("sort:name,asc", ["t1", "t2"]),
@@ -338,13 +340,12 @@ def test_sort_by_name(
db.session.add(pool_factory(id=2, names=["t2"]))
db.session.add(pool_factory(id=1, names=["t1"]))
db.session.flush()
- verify_unpaged(input, expected_pool_names)
+ verify_unpaged(input, expected_pool_names, test_order=True)
@pytest.mark.parametrize(
"input,expected_pool_names",
[
- ("", ["t1", "t2", "t3"]),
("sort:creation-date", ["t3", "t2", "t1"]),
("sort:creation-time", ["t3", "t2", "t1"]),
],
@@ -360,13 +361,12 @@ def test_sort_by_creation_time(
pool3.creation_time = datetime(1991, 1, 3)
db.session.add_all([pool3, pool1, pool2])
db.session.flush()
- verify_unpaged(input, expected_pool_names)
+ verify_unpaged(input, expected_pool_names, test_order=True)
@pytest.mark.parametrize(
"input,expected_pool_names",
[
- ("", ["t1", "t2", "t3"]),
("sort:last-edit-date", ["t3", "t2", "t1"]),
("sort:last-edit-time", ["t3", "t2", "t1"]),
("sort:edit-date", ["t3", "t2", "t1"]),
@@ -384,7 +384,7 @@ def test_sort_by_last_edit_time(
pool3.last_edit_time = datetime(1991, 1, 3)
db.session.add_all([pool3, pool1, pool2])
db.session.flush()
- verify_unpaged(input, expected_pool_names)
+ verify_unpaged(input, expected_pool_names, test_order=True)
@pytest.mark.parametrize(
@@ -405,7 +405,7 @@ def test_sort_by_post_count(
pool2.posts.append(post1)
pool2.posts.append(post2)
db.session.flush()
- verify_unpaged(input, expected_pool_names)
+ verify_unpaged(input, expected_pool_names, test_order=True)
@pytest.mark.parametrize(
@@ -423,9 +423,9 @@ def test_sort_by_category(
):
cat1 = pool_category_factory(name="cat1")
cat2 = pool_category_factory(name="cat2")
- pool1 = pool_factory(id=1, names=["t1"], category=cat2)
- pool2 = pool_factory(id=2, names=["t2"], category=cat2)
+ pool2 = pool_factory(id=1, names=["t2"], category=cat2)
+ pool1 = pool_factory(id=2, names=["t1"], category=cat2)
pool3 = pool_factory(id=3, names=["t3"], category=cat1)
db.session.add_all([pool1, pool2, pool3])
db.session.flush()
- verify_unpaged(input, expected_pool_names)
+ verify_unpaged(input, expected_pool_names, test_order=True)
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 b86fa273e..ef115d71e 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])