diff --git a/client/css/pool-navigator-control.styl b/client/css/pool-navigator-control.styl
new file mode 100644
index 00000000..56c22d28
--- /dev/null
+++ b/client/css/pool-navigator-control.styl
@@ -0,0 +1,34 @@
+@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
+
+ .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
\ No newline at end of file
diff --git a/client/css/pool-navigator-list.styl b/client/css/pool-navigator-list.styl
new file mode 100644
index 00000000..080ad01a
--- /dev/null
+++ b/client/css/pool-navigator-list.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/html/pool_navigator.tpl b/client/html/pool_navigator.tpl
new file mode 100644
index 00000000..647684d8
--- /dev/null
+++ b/client/html/pool_navigator.tpl
@@ -0,0 +1,49 @@
+
+ <% if (ctx.canListPools && ctx.canViewPools) { %>
+
+ <% } %>
+
<% if (ctx.canListComments) { %>
<% } %>
diff --git a/client/js/controllers/post_main_controller.js b/client/js/controllers/post_main_controller.js
index 95cfdb52..5576b0e2 100644
--- a/client/js/controllers/post_main_controller.js
+++ b/client/js/controllers/post_main_controller.js
@@ -11,11 +11,17 @@ const PostList = require("../models/post_list.js");
const PostMainView = require("../views/post_main_view.js");
const BasePostController = require("./base_post_controller.js");
const EmptyView = require("../views/empty_view.js");
+const PoolNavigatorListControl = require("../controls/pool_navigator_list_control.js");
class PostMainController extends BasePostController {
constructor(ctx, editMode) {
super(ctx);
+ let poolPostsNearby = Promise.resolve({results: []});
+ if (api.hasPrivilege("pools:list") && api.hasPrivilege("pools:view")) {
+ poolPostsNearby = PostList.getNearbyPoolPosts(ctx.parameters.id);
+ }
+
let parameters = ctx.parameters;
Promise.all([
Post.get(ctx.parameters.id),
@@ -23,9 +29,10 @@ class PostMainController extends BasePostController {
ctx.parameters.id,
parameters ? parameters.query : null
),
+ poolPostsNearby
]).then(
(responses) => {
- const [post, aroundResponse] = responses;
+ const [post, aroundResponse, poolPostsNearby] = responses;
// remove junk from query, but save it into history so that it can
// be still accessed after history navigation / page refresh
@@ -44,6 +51,7 @@ class PostMainController extends BasePostController {
this._post = post;
this._view = new PostMainView({
post: post,
+ poolPostsNearby: poolPostsNearby,
editMode: editMode,
prevPostId: aroundResponse.prev
? aroundResponse.prev.id
@@ -56,6 +64,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..fa4394d1
--- /dev/null
+++ b/client/js/controls/pool_navigator_control.js
@@ -0,0 +1,33 @@
+"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, poolPostNearby) {
+ super();
+ this._hostNode = hostNode;
+ this._poolPostNearby = poolPostNearby;
+
+ views.replaceContent(
+ this._hostNode,
+ template({
+ pool: poolPostNearby.pool,
+ parameters: { query: `pool:${poolPostNearby.pool.id}` },
+ linkClass: misc.makeCssName(poolPostNearby.pool.category, "pool"),
+ canViewPosts: api.hasPrivilege("posts:view"),
+ canViewPools: api.hasPrivilege("pools:view"),
+ firstPost: poolPostNearby.firstPost,
+ previousPost: poolPostNearby.previousPost,
+ nextPost: poolPostNearby.nextPost,
+ lastPost: poolPostNearby.lastPost,
+ })
+ );
+ }
+}
+
+module.exports = PoolNavigatorControl;
\ No newline at end of file
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..6aa5302f
--- /dev/null
+++ b/client/js/controls/pool_navigator_list_control.js
@@ -0,0 +1,49 @@
+"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, poolPostNearby) {
+ super();
+ this._hostNode = hostNode;
+ this._poolPostNearby = poolPostNearby;
+ this._indexToNode = {};
+
+ for (let [i, entry] of this._poolPostNearby.entries()) {
+ this._installPoolNavigatorNode(entry, i);
+ }
+ }
+
+ get _poolNavigatorListNode() {
+ return this._hostNode;
+ }
+
+ _installPoolNavigatorNode(poolPostNearby, i) {
+ const poolListItemNode = document.createElement("div");
+ const poolControl = new PoolNavigatorControl(
+ poolListItemNode,
+ poolPostNearby,
+ );
+ this._indexToNode[poolPostNearby.id] = poolListItemNode;
+ 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..16df6eb8 100644
--- a/client/js/models/post_list.js
+++ b/client/js/models/post_list.js
@@ -16,6 +16,14 @@ class PostList extends AbstractList {
);
}
+ static getNearbyPoolPosts(id) {
+ return api.get(
+ uri.formatApiLink("post", id, "pools-nearby", {
+ fields: "id",
+ })
+ );
+ }
+
static search(text, offset, limit, fields) {
return api
.get(
diff --git a/client/js/views/post_main_view.js b/client/js/views/post_main_view.js
index 5ef7f61e..805a9e73 100644
--- a/client/js/views/post_main_view.js
+++ b/client/js/views/post_main_view.js
@@ -10,6 +10,7 @@ const PostContentControl = require("../controls/post_content_control.js");
const PostNotesOverlayControl = require("../controls/post_notes_overlay_control.js");
const PostReadonlySidebarControl = require("../controls/post_readonly_sidebar_control.js");
const PostEditSidebarControl = require("../controls/post_edit_sidebar_control.js");
+const PoolNavigatorListControl = require("../controls/pool_navigator_list_control.js");
const CommentControl = require("../controls/comment_control.js");
const CommentListControl = require("../controls/comment_list_control.js");
@@ -57,6 +58,7 @@ class PostMainView {
this._installSidebar(ctx);
this._installCommentForm();
this._installComments(ctx.post.comments);
+ this._installPoolNavigators(ctx.poolPostsNearby);
const showPreviousImage = () => {
if (ctx.prevPostId) {
@@ -137,6 +139,20 @@ class PostMainView {
}
}
+ _installPoolNavigators(poolPostsNearby) {
+ const poolNavigatorsContainerNode = document.querySelector(
+ "#content-holder .pool-navigators-container"
+ );
+ if (!poolNavigatorsContainerNode) {
+ return;
+ }
+
+ this.poolNavigatorsControl = new PoolNavigatorListControl(
+ poolNavigatorsContainerNode,
+ poolPostsNearby,
+ );
+ }
+
_installCommentForm() {
const commentFormContainer = document.querySelector(
"#content-holder .comment-form-container"
diff --git a/doc/API.md b/doc/API.md
index 00ee75a9..9413ae5e 100644
--- a/doc/API.md
+++ b/doc/API.md
@@ -975,6 +975,43 @@ data.
Retrieves information about posts that are before or after an existing post.
+## Getting pools around post
+- **Request**
+
+ `GET /post/
/pools-nearby`
+
+- **Output**
+
+ ```json5
+ [
+ {
+ "pool": ,
+ "firstPost": ,
+ "lastPost": ,
+ "nextPost": ,
+ "previousPost":
+ },
+ ...
+ ]
+ ```
+
+- **Field meaning**
+
+- ``: The associated [micro pool resource](#micro-pool).
+- ``: A [micro post resource](#micro-post) that displays the first post in the pool.
+- ``: A [micro post resource](#micro-post) that displays the last post in the pool.
+- ``: A [micro post resource](#micro-post) that displays the next post in the pool.
+- ``: A [micro post resource](#micro-post) that displays the previous post in the pool.
+
+- **Errors**
+
+ - the post does not exist
+ - privileges are too low
+
+- **Description**
+
+ Retrieves extra information about any pools that the post is in.
+
## Deleting post
- **Request**
diff --git a/server/szurubooru/api/post_api.py b/server/szurubooru/api/post_api.py
index daba7f7e..34a2136c 100644
--- a/server/szurubooru/api/post_api.py
+++ b/server/szurubooru/api/post_api.py
@@ -5,12 +5,10 @@ from szurubooru import db, errors, model, rest, search
from szurubooru.func import (
auth,
favorites,
- mime,
posts,
scores,
serialization,
snapshots,
- tags,
versions,
)
@@ -283,6 +281,19 @@ def get_posts_around(
ctx, post_id, lambda post: _serialize_post(ctx, post)
)
+@rest.routes.get("/post/(?P[^/]+)/pools-nearby/?")
+def get_pools_around(
+ ctx: rest.Context, params: Dict[str, str]
+) -> rest.Response:
+ auth.verify_privilege(ctx.user, "posts:list")
+ auth.verify_privilege(ctx.user, "posts:view")
+ auth.verify_privilege(ctx.user, "pools:list")
+ _search_executor_config.user = ctx.user
+ post = _get_post(params)
+ results = posts.get_pools_nearby(post)
+ return posts.serialize_pool_posts_nearby(results)
+
+
@rest.routes.post("/posts/reverse-search/?")
def get_posts_by_image(
diff --git a/server/szurubooru/func/posts.py b/server/szurubooru/func/posts.py
index be2259cf..c2d88c8d 100644
--- a/server/szurubooru/func/posts.py
+++ b/server/szurubooru/func/posts.py
@@ -1,6 +1,8 @@
import hmac
import logging
+from collections import namedtuple
from datetime import datetime
+from itertools import tee, chain, islice
from typing import Any, Callable, Dict, List, Optional, Tuple
import sqlalchemy as sa
@@ -15,7 +17,6 @@ from szurubooru.func import (
pools,
scores,
serialization,
- snapshots,
tags,
users,
util,
@@ -96,6 +97,13 @@ FLAG_MAP = {
model.Post.FLAG_SOUND: "sound",
}
+# https://stackoverflow.com/a/1012089
+def _get_nearby_iter(post_list):
+ previous_item, current_item, next_item = tee(post_list, 3)
+ previous_item = chain([None], previous_item)
+ next_item = chain(islice(next_item, 1, None), [None])
+ return zip(previous_item, current_item, next_item)
+
def get_post_security_hash(id: int) -> str:
return hmac.new(
@@ -968,3 +976,47 @@ def search_by_image(image_content: bytes) -> List[Tuple[float, model.Post]]:
]
else:
return []
+
+PoolPostsNearby = namedtuple('PoolPostsNearby', 'pool first_post prev_post next_post last_post')
+def get_pools_nearby(
+ post: model.Post
+) -> List[PoolPostsNearby]:
+ response = []
+ pools = post.pools
+
+ for pool in pools:
+ prev_post_id = None
+ next_post_id = None
+ first_post_id = pool.posts[0].post_id,
+ last_post_id = pool.posts[-1].post_id,
+
+ for previous_item, current_item, next_item in _get_nearby_iter(pool.posts):
+ if post.post_id == current_item.post_id:
+ if previous_item != None:
+ prev_post_id = previous_item.post_id
+ if next_item != None:
+ next_post_id = next_item.post_id
+ break
+
+ resp_entry = PoolPostsNearby(
+ pool=pool,
+ first_post=first_post_id,
+ last_post=last_post_id,
+ prev_post=prev_post_id,
+ next_post=next_post_id,
+ )
+ response.append(resp_entry)
+ return response
+
+def serialize_pool_posts_nearby(
+ nearby: List[PoolPostsNearby]
+) -> Optional[rest.Response]:
+ return [
+ {
+ "pool": pools.serialize_micro_pool(entry.pool),
+ "firstPost": serialize_micro_post(try_get_post_by_id(entry.first_post), None),
+ "lastPost": serialize_micro_post(try_get_post_by_id(entry.last_post), None),
+ "previousPost": serialize_micro_post(try_get_post_by_id(entry.prev_post), None),
+ "nextPost": serialize_micro_post(try_get_post_by_id(entry.next_post), None),
+ } for entry in nearby
+ ]
diff --git a/server/szurubooru/tests/api/test_post_retrieving.py b/server/szurubooru/tests/api/test_post_retrieving.py
index ac984c24..aa70f77a 100644
--- a/server/szurubooru/tests/api/test_post_retrieving.py
+++ b/server/szurubooru/tests/api/test_post_retrieving.py
@@ -14,6 +14,7 @@ def inject_config(config_injector):
"privileges": {
"posts:list": model.User.RANK_REGULAR,
"posts:view": model.User.RANK_REGULAR,
+ "pools:list": model.User.RANK_REGULAR,
},
}
)
@@ -125,3 +126,55 @@ def test_trying_to_retrieve_single_without_privileges(
context_factory(user=user_factory(rank=model.User.RANK_ANONYMOUS)),
{"post_id": 999},
)
+
+def test_get_pool_post_around(user_factory, post_factory, pool_factory, pool_post_factory, context_factory):
+ p1 = post_factory(id=1)
+ p2 = post_factory(id=2)
+ p3 = post_factory(id=3)
+ db.session.add_all([p1, p2, p3])
+
+ pool = pool_factory(id=1)
+ db.session.add(pool)
+
+ pool_posts = [pool_post_factory(pool=pool, post=p1), pool_post_factory(pool=pool, post=p2), pool_post_factory(pool=pool, post=p3)]
+ db.session.add_all(pool_posts)
+
+ result = api.post_api.get_pools_around(context_factory(user=user_factory(rank=model.User.RANK_REGULAR)), {"post_id": 2})
+ assert result[0]["previousPost"]["id"] == 1 and result[0]["nextPost"]["id"] == 3
+
+def test_get_pool_post_around_start(user_factory, post_factory, pool_factory, pool_post_factory, context_factory):
+ p1 = post_factory(id=1)
+ p2 = post_factory(id=2)
+ p3 = post_factory(id=3)
+ db.session.add_all([p1, p2, p3])
+
+ pool = pool_factory(id=1)
+ db.session.add(pool)
+
+ pool_posts = [pool_post_factory(pool=pool, post=p1), pool_post_factory(pool=pool, post=p2), pool_post_factory(pool=pool, post=p3)]
+ db.session.add_all(pool_posts)
+
+ result = api.post_api.get_pools_around(context_factory(user=user_factory(rank=model.User.RANK_REGULAR)), {"post_id": 1})
+ assert result[0]["previousPost"] == None and result[0]["nextPost"]["id"] == 2
+
+def test_get_pool_post_around_end(user_factory, post_factory, pool_factory, pool_post_factory, context_factory):
+ p1 = post_factory(id=1)
+ p2 = post_factory(id=2)
+ p3 = post_factory(id=3)
+ db.session.add_all([p1, p2, p3])
+
+ pool = pool_factory(id=1)
+ db.session.add(pool)
+
+ pool_posts = [pool_post_factory(pool=pool, post=p1), pool_post_factory(pool=pool, post=p2), pool_post_factory(pool=pool, post=p3)]
+ db.session.add_all(pool_posts)
+
+ result = api.post_api.get_pools_around(context_factory(user=user_factory(rank=model.User.RANK_REGULAR)), {"post_id": 3})
+ assert result[0]["previousPost"]["id"] == 2 and result[0]["nextPost"] == None
+
+def test_get_pool_post_around_no_pool(user_factory, post_factory, pool_factory, pool_post_factory, context_factory):
+ p1 = post_factory(id=1)
+ db.session.add(p1)
+
+ result = api.post_api.get_pools_around(context_factory(user=user_factory(rank=model.User.RANK_REGULAR)), {"post_id": 1})
+ assert result == []