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.canViewPosts && ctx.firstPost) { %> + + <% } %> + « + <% if (ctx.canViewPosts && ctx.firstPost) { %> + + <% } %> + + + <% if (ctx.canViewPosts && ctx.previousPost) { %> + + <% } %> + ‹ prev + <% if (ctx.canViewPosts && ctx.previousPost) { %> + + <% } %> + + + <% if (ctx.canViewPools) { %> + + <% } %> + Pool: <%- ctx.pool.names[0] %> + <% if (ctx.canViewPools) { %> + + <% } %> + + + <% if (ctx.canViewPosts && ctx.nextPost) { %> + + <% } %> + next › + <% if (ctx.canViewPosts && ctx.nextPost) { %> + + <% } %> + + + <% if (ctx.canViewPosts && ctx.lastPost) { %> + + <% } %> + » + <% if (ctx.canViewPosts && ctx.lastPost) { %> + + <% } %> + +
+
diff --git a/client/html/pool_navigator_list.tpl b/client/html/pool_navigator_list.tpl new file mode 100644 index 00000000..6c60e4e2 --- /dev/null +++ b/client/html/pool_navigator_list.tpl @@ -0,0 +1,4 @@ +
+ +
\ No newline at end of file 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/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 == []