diff --git a/.gitignore b/.gitignore index b21e3adf7..625b3ed80 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,8 @@ # User-specific configuration config.yaml .env +sql/ +data/ # Client Development Artifacts */*_modules/ @@ -13,3 +15,6 @@ server/**/lib/ server/**/bin/ server/**/pyvenv.cfg __pycache__/ + +.vscode +docker-compose.dev.yml diff --git a/client/Dockerfile b/client/Dockerfile index ea5151fa4..7dbad2509 100644 --- a/client/Dockerfile +++ b/client/Dockerfile @@ -1,3 +1,4 @@ +ARG BUILDPLATFORM=linux/amd64 FROM --platform=$BUILDPLATFORM node:lts as builder WORKDIR /opt/app diff --git a/client/css/colors.styl b/client/css/colors.styl index cf7e7caf7..51ae30ed4 100644 --- a/client/css/colors.styl +++ b/client/css/colors.styl @@ -18,6 +18,8 @@ $message-error-border-color = #FCC $message-error-background-color = #FFF5F5 $message-success-border-color = #D3E3D3 $message-success-background-color = #F5FFF5 +$pool-navigator-border-color = #AAA +$pool-navigator-background-color = #EEE $input-bad-border-color = #FCC $input-bad-background-color = #FFF5F5 $input-good-border-color = #D3E3D3 diff --git a/client/css/pool-list-view.styl b/client/css/pool-list-view.styl index b7ac15eda..37ec877c0 100644 --- a/client/css/pool-list-view.styl +++ b/client/css/pool-list-view.styl @@ -1,47 +1,76 @@ @import colors .pool-list - table - width: 100% - border-spacing: 0 - text-align: left - line-height: 1.3em - tr:hover td - background: $top-navigation-color - th, td - padding: 0.1em 0.5em - th - white-space: nowrap - background: $top-navigation-color - .names - width: 84% - .post-count - text-align: center - width: 8% - .creation-time - text-align: center - width: 8% - white-space: pre - ul - list-style-type: none - margin: 0 - padding: 0 - display: inline - li - padding: 0 - display: inline - &:not(:last-child):after - content: ', ' - @media (max-width: 800px) - .posts - display: none - -.darktheme .pool-list - table - tr:hover td - background: $top-navigation-color-darktheme - th - background: $top-navigation-color-darktheme + ul + list-style-type: none + padding: 0 + display: flex + align-content: flex-end + flex-wrap: wrap + margin: 0 -0.25em + + li + position: relative + flex-grow: 1 + margin: 2em 1.5em 2em 1.2em; + display: inline-block + text-align: left + min-width: 10em + width: 12vw + &:not(.flexbox-dummy) + min-height: 7.5em + height: 9vw + + .thumbnail-wrapper + display: inline-block + width: 100% + height: 100% + line-height: 80% + font-size: 80% + color: white + outline-offset: -3px + box-shadow: 0 0 0 1px rgba(0,0,0,0.2) + + .thumbnail + width: 100% + height: 100% + outline-offset: -3px + &:not(.empty) + background-position: 50% 30% + position: absolute + display: inline-block + + .thumbnail-1 + right: -0px; + top: -0px; + z-index: 30; + + .thumbnail-2 + right: -10px; + top: -10px; + z-index: 20; + + .thumbnail-3 + right: -20px; + top: -20px; + z-index: 10; + + .pool-name + color: black + font-size: 1em + text-align: center + a + width: 100% + display: inline-block + + &:hover + background: $post-thumbnail-border-color + .thumbnail + opacity: .9 + + &:hover a, a:active, a:focus + .thumbnail + outline: 4px solid $main-color !important .pool-list-header label @@ -61,3 +90,21 @@ .darktheme .pool-list-header .append color: $inactive-link-color-darktheme + +.post-flow + ul + li + min-width: inherit + width: inherit + margin: 0 0.25em 0.5em 0.25em + &:not(.flexbox-dummy) + height: 14vw + .thumbnail + position: static + outline-offset: -1px + .thumbnail-wrapper.no-tags + .thumbnail + outline: 2px solid $post-thumbnail-no-tags-border-color + &:hover a, a:active, a:focus + .thumbnail + outline: 2px solid $main-color !important diff --git a/client/css/pool-navigator-control.styl b/client/css/pool-navigator-control.styl new file mode 100644 index 000000000..ed6cd16a6 --- /dev/null +++ b/client/css/pool-navigator-control.styl @@ -0,0 +1,38 @@ +@import colors + +.pool-navigator-container + padding: 0 + margin: 0 auto + + .pool-info-wrapper + box-sizing: border-box + width: 100% + margin: 0 0 1em 0 + display: flex + padding: 0.5em 1em + border: 1px solid $pool-navigator-border-color + background: $pool-navigator-background-color + &.active + font-weight: bold + font-size: 1.10em; + padding: 0.58em 1em + + .pool-name + flex: 1 1; + text-align: center; + overflow: hidden; + white-space: nowrap; + -o-text-overflow: ellipsis; + text-overflow: ellipsis; + + .first, .last + flex-basis: 1em; + + .first, .prev, .next, .last + flex: 0 1; + margin: 0 .25em; + white-space: nowrap; + + +.darktheme .pool-navigator-container + background: $pool-navigator-header-background-color-darktheme diff --git a/client/css/pool-navigator-list-control.styl b/client/css/pool-navigator-list-control.styl new file mode 100644 index 000000000..080ad01ab --- /dev/null +++ b/client/css/pool-navigator-list-control.styl @@ -0,0 +1,9 @@ +.pool-navigators>ul + list-style-type: none + margin: 0 + padding: 0 + + >li + margin-bottom: 1em + &:last-child + margin-bottom: 0 diff --git a/client/css/post-main-view.styl b/client/css/post-main-view.styl index e643dfbde..fca922d1c 100644 --- a/client/css/post-main-view.styl +++ b/client/css/post-main-view.styl @@ -40,11 +40,14 @@ width: 100% .post-container - margin-bottom: 2em + margin-bottom: 1em .post-content margin: 0 + .pool-navigators-container + margin-bottom: 2em + .darktheme .post-view >.sidebar nav.buttons diff --git a/client/css/post-upload.styl b/client/css/post-upload.styl index cb6b00670..2b38d9287 100644 --- a/client/css/post-upload.styl +++ b/client/css/post-upload.styl @@ -65,7 +65,7 @@ $cancel-button-color = tomato img width: 100% height: 100% - + video width: 100% height: 100% diff --git a/client/html/pool_delete.tpl b/client/html/pool_delete.tpl index 1ef7fb534..f279e0dfc 100644 --- a/client/html/pool_delete.tpl +++ b/client/html/pool_delete.tpl @@ -1,6 +1,6 @@
-

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

+

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

diff --git a/client/html/pools_page.tpl b/client/html/pools_page.tpl index 0d8118085..c935785f9 100644 --- a/client/html/pools_page.tpl +++ b/client/html/pools_page.tpl @@ -1,48 +1,19 @@ -
+<% if (ctx.postFlow) { %>
<% } else { %>
<% } %> <% if (ctx.response.results.length) { %> - - - - - - - - <% for (let pool of ctx.response.results) { %> - - - - - - <% } %> - -
- <% if (ctx.parameters.query == 'sort:name' || !ctx.parameters.query) { %> - '>Pool name(s) - <% } else { %> - '>Pool name(s) + - <% if (ctx.parameters.query == 'sort:post-count') { %> - '>Post count - <% } else { %> - '>Post count - <% } %> - - <% if (ctx.parameters.query == 'sort:creation-time') { %> - '>Created on - <% } else { %> - '>Created on - <% } %> -
-
    - <% for (let name of pool.names) { %> -
  • <%= ctx.makePoolLink(pool.id, false, false, pool, name) %>
  • - <% } %> -
-
- '><%- pool.postCount %> - - <%= ctx.makeRelativeTime(pool.creationTime) %> -
+ +
+ <%= ctx.makePoolLink(pool.id, false, false, pool, name) %> +
+ + <% } %> + <%= ctx.makeFlexboxAlign() %> + <% } %>
diff --git a/client/html/post_main.tpl b/client/html/post_main.tpl index 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])