Merge branch 'old-pool-divergence' into update/Ruin0x11/improve-pools

This commit is contained in:
Rebecca Nelson 2024-04-13 22:55:03 -05:00
commit 2ce243d7e7
33 changed files with 795 additions and 167 deletions

View file

@ -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

View file

@ -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
ul
list-style-type: none
padding: 0
display: flex
align-content: flex-end
flex-wrap: wrap
margin: 0 -0.25em
.darktheme .pool-list
table
tr:hover td
background: $top-navigation-color-darktheme
th
background: $top-navigation-color-darktheme
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

View file

@ -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

View file

@ -0,0 +1,9 @@
.pool-navigators>ul
list-style-type: none
margin: 0
padding: 0
>li
margin-bottom: 1em
&:last-child
margin-bottom: 0

View file

@ -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

View file

@ -1,6 +1,6 @@
<div class='pool-delete'>
<form>
<p>This pool has <a href='<%- ctx.formatClientLink('posts', {query: 'pool:' + ctx.pool.id}) %>'><%- ctx.pool.postCount %> post(s)</a>.</p>
<p>This pool has <a href='<%- ctx.formatClientLink('posts', {query: 'pool:' + ctx.pool.id + ' sort:pool'}) %>'><%- ctx.pool.postCount %> post(s)</a>.</p>
<ul class='input'>
<li>

View file

@ -0,0 +1,49 @@
<div class='pool-navigator-container'>
<div class='pool-info-wrapper <%= ctx.isActivePool ? "active" : "" %>'>
<span class='first'>
<% if (ctx.canViewPosts && ctx.firstPost) { %>
<a class='<%- ctx.linkClass %>' href='<%= ctx.getPostUrl(ctx.firstPost.id, ctx.parameters) %>'>
<% } %>
«
<% if (ctx.canViewPosts && ctx.firstPost) { %>
</a>
<% } %>
</span>
<span class='prev'>
<% if (ctx.canViewPosts && ctx.prevPost) { %>
<a class='<%- ctx.linkClass %>' href='<%= ctx.getPostUrl(ctx.prevPost.id, ctx.parameters) %>'>
<% } %>
prev
<% if (ctx.canViewPosts && ctx.prevPost) { %>
</a>
<% } %>
</span>
<span class='pool-name'>
<% if (ctx.canViewPools) { %>
<a class='<%- ctx.linkClass %>' href='<%= ctx.formatClientLink("pool", ctx.pool.id) %>'>
<% } %>
Pool: <%- ctx.pool.names[0] %>
<% if (ctx.canViewPools) { %>
</a>
<% } %>
</span>
<span class='next'>
<% if (ctx.canViewPosts && ctx.nextPost) { %>
<a class='<%- ctx.linkClass %>' href='<%= ctx.getPostUrl(ctx.nextPost.id, ctx.parameters) %>'>
<% } %>
next
<% if (ctx.canViewPosts && ctx.nextPost) { %>
</a>
<% } %>
</span>
<span class='last'>
<% if (ctx.canViewPosts && ctx.lastPost) { %>
<a class='<%- ctx.linkClass %>' href='<%= ctx.getPostUrl(ctx.lastPost.id, ctx.parameters) %>'>
<% } %>
»
<% if (ctx.canViewPosts && ctx.lastPost) { %>
</a>
<% } %>
</span>
</div>
</div>

View file

@ -0,0 +1,4 @@
<div class='pool-navigators'>
<ul>
</ul>
</div>

View file

@ -18,6 +18,6 @@
<section class='description'>
<hr/>
<%= ctx.makeMarkdown(ctx.pool.description || 'This pool has no description yet.') %>
<p>This pool has <a href='<%- ctx.formatClientLink('posts', {query: 'pool:' + ctx.pool.id}) %>'><%- ctx.pool.postCount %> post(s)</a>.</p>
<p>This pool has <a href='<%- ctx.formatClientLink('posts', {query: 'pool:' + ctx.pool.id + ' sort:pool'}) %>'><%- ctx.pool.postCount %> post(s)</a>.</p>
</section>
</div>

View file

@ -1,48 +1,19 @@
<div class='pool-list table-wrap'>
<% if (ctx.postFlow) { %><div class='pool-list post-flow'><% } else { %><div class='pool-list'><% } %>
<% if (ctx.response.results.length) { %>
<table>
<thead>
<th class='names'>
<% if (ctx.parameters.query == 'sort:name' || !ctx.parameters.query) { %>
<a href='<%- ctx.formatClientLink('pools', {query: '-sort:name'}) %>'>Pool name(s)</a>
<% } else { %>
<a href='<%- ctx.formatClientLink('pools', {query: 'sort:name'}) %>'>Pool name(s)</a>
<ul>
<% for (let pool of ctx.response.results) { %>
<li data-pool-id='<%= pool.id %>'>
<a class='thumbnail-wrapper' href='<%= ctx.canViewPools ? ctx.formatClientLink("pool", pool.id) : "" %>'>
<% if (ctx.canViewPosts) { %>
<%= ctx.makePoolThumbnails(pool.posts, ctx.postFlow) %>
<% } %>
</th>
<th class='post-count'>
<% if (ctx.parameters.query == 'sort:post-count') { %>
<a href='<%- ctx.formatClientLink('pools', {query: '-sort:post-count'}) %>'>Post count</a>
<% } else { %>
<a href='<%- ctx.formatClientLink('pools', {query: 'sort:post-count'}) %>'>Post count</a>
<% } %>
</th>
<th class='creation-time'>
<% if (ctx.parameters.query == 'sort:creation-time') { %>
<a href='<%- ctx.formatClientLink('pools', {query: '-sort:creation-time'}) %>'>Created on</a>
<% } else { %>
<a href='<%- ctx.formatClientLink('pools', {query: 'sort:creation-time'}) %>'>Created on</a>
<% } %>
</th>
</thead>
<tbody>
<% for (let pool of ctx.response.results) { %>
<tr>
<td class='names'>
<ul>
<% for (let name of pool.names) { %>
<li><%= ctx.makePoolLink(pool.id, false, false, pool, name) %></li>
<% } %>
</ul>
</td>
<td class='post-count'>
<a href='<%- ctx.formatClientLink('posts', {query: 'pool:' + pool.id}) %>'><%- pool.postCount %></a>
</td>
<td class='creation-time'>
<%= ctx.makeRelativeTime(pool.creationTime) %>
</td>
</tr>
<% } %>
</tbody>
</table>
</a>
<div class='pool-name'>
<%= ctx.makePoolLink(pool.id, false, false, pool, name) %>
</div>
</li>
<% } %>
<%= ctx.makeFlexboxAlign() %>
</ul>
<% } %>
</div>

View file

@ -54,6 +54,10 @@
<div class='content'>
<div class='post-container'></div>
<% if (ctx.canListPools && ctx.canViewPools) { %>
<div class='pool-navigators-container'></div>
<% } %>
<% if (ctx.canListComments) { %>
<div class='comments-container'></div>
<% } %>

View file

@ -2,6 +2,7 @@
const router = require("../router.js");
const api = require("../api.js");
const settings = require("../models/settings.js");
const uri = require("../util/uri.js");
const PoolList = require("../models/pool_list.js");
const topNavigation = require("../models/top_navigation.js");
@ -108,6 +109,11 @@ class PoolListController {
);
},
pageRenderer: (pageCtx) => {
Object.assign(pageCtx, {
canViewPosts: api.hasPrivilege("posts:view"),
canViewPools: api.hasPrivilege("pools:view"),
postFlow: settings.get().postFlow,
});
return new PoolsPageView(pageCtx);
},
});

View file

@ -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,
});

View file

@ -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;

View file

@ -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;

View file

@ -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(

View file

@ -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,

View file

@ -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"

View file

@ -794,38 +794,39 @@ data.
**Sort style tokens**
| `<value>` | 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` |
| `<value>` | 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.
| `<key>` | 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**
| `<value>` | 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 |
| `<value>` | 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**

View file

@ -13,3 +13,4 @@ pytz>=2018.3
pyyaml>=3.11
SQLAlchemy>=1.0.12, <1.4
yt-dlp
alembic_utils>=0.5.6

View file

@ -284,6 +284,19 @@ def get_posts_around(
)
@rest.routes.get("/post/(?P<post_id>[^/]+)/pool-posts-around/?")
def get_pool_posts_around(
ctx: rest.Context, params: Dict[str, str]
) -> rest.Response:
auth.verify_privilege(ctx.user, "posts:list")
auth.verify_privilege(ctx.user, "pools:list")
auth.verify_privilege(ctx.user, "pools:view")
_search_executor_config.user = ctx.user
post = _get_post(params)
results = posts.get_pool_posts_around(post)
return posts.serialize_pool_posts_around(results)
@rest.routes.post("/posts/reverse-search/?")
def get_posts_by_image(
ctx: rest.Context, _params: Dict[str, str] = {}

View file

@ -2,6 +2,7 @@ import hmac
import logging
from datetime import datetime
from typing import Any, Callable, Dict, List, Optional, Tuple
from collections import namedtuple
import sqlalchemy as sa
@ -134,12 +135,16 @@ def get_post_content_path(post: model.Post) -> str:
)
def get_post_thumbnail_path_from_id(post_id: int) -> str:
return "generated-thumbnails/%d_%s.jpg" % (
post_id,
get_post_security_hash(post_id),
)
def get_post_thumbnail_path(post: model.Post) -> str:
assert post
return "generated-thumbnails/%d_%s.jpg" % (
post.post_id,
get_post_security_hash(post.post_id),
)
return get_post_thumbnail_path_from_id(post.post_id)
def get_post_thumbnail_backup_path(post: model.Post) -> str:
@ -968,3 +973,81 @@ def search_by_image(image_content: bytes) -> List[Tuple[float, model.Post]]:
]
else:
return []
PoolPostsAround = namedtuple('PoolPostsAround', 'pool first_post prev_post next_post last_post')
def get_pool_posts_around(post: model.Post) -> List[PoolPostsAround]:
around = dict()
pool_ids = set()
post_ids = set()
dbquery = """
SELECT around.ord, around.pool_id, around.post_id, around.delta
FROM pool_post pp,
LATERAL get_pool_posts_around(pp.pool_id, pp.post_id) around
WHERE pp.post_id = :post_id;
"""
for order, pool_id, post_id, delta in db.session.execute(dbquery, {"post_id": post.post_id}):
if pool_id not in around:
around[pool_id] = [None, None, None, None]
if delta == -2:
around[pool_id][0] = post_id
elif delta == -1:
around[pool_id][1] = post_id
elif delta == 1:
around[pool_id][2] = post_id
elif delta == 2:
around[pool_id][3] = post_id
pool_ids.add(pool_id)
post_ids.add(post_id)
pools = dict()
posts = dict()
for pool in db.session.query(model.Pool).filter(model.Pool.pool_id.in_(pool_ids)).all():
pools[pool.pool_id] = pool
for result in db.session.query(model.Post.post_id).filter(model.Post.post_id.in_(post_ids)).all():
post_id = result[0]
posts[post_id] = { "id": post_id, "thumbnailUrl": get_post_thumbnail_path_from_id(post_id) }
results = []
for pool_id, entry in around.items():
first_post = None
prev_post = None
next_post = None
last_post = None
if entry[1] is not None:
prev_post = posts[entry[1]]
if entry[0] is not None:
first_post = posts[entry[0]]
if entry[2] is not None:
next_post = posts[entry[2]]
if entry[3] is not None:
last_post = posts[entry[3]]
results.append(PoolPostsAround(pools[pool_id], first_post, prev_post, next_post, last_post))
return results
def sort_pool_posts_around(around: List[PoolPostsAround]) -> List[PoolPostsAround]:
return sorted(
around,
key=lambda entry: entry.pool.pool_id,
)
def serialize_pool_posts_around(around: List[PoolPostsAround]) -> Optional[rest.Response]:
return [
{
"pool": pools.serialize_micro_pool(entry.pool),
"firstPost": entry.first_post,
"prevPost": entry.prev_post,
"nextPost": entry.next_post,
"lastPost": entry.last_post
}
for entry in sort_pool_posts_around(around)
]

View file

@ -11,6 +11,7 @@ import sys
from time import sleep
import alembic
from alembic_utils.replaceable_entity import register_entities
import sqlalchemy as sa
@ -21,6 +22,9 @@ sys.path.append(os.path.join(dir_to_self, *[os.pardir] * 2))
import szurubooru.config # noqa: E402
import szurubooru.model.base # noqa: E402
from szurubooru.migrations.functions import get_pool_posts_around # noqa: E402
register_entities([get_pool_posts_around])
# fmt: on

View file

@ -0,0 +1,68 @@
from alembic_utils.pg_function import PGFunction
get_pool_posts_around = PGFunction.from_sql("""
CREATE OR REPLACE FUNCTION public.get_pool_posts_around(
P_POOL_ID int,
P_POST_ID int
)
RETURNS TABLE (
ORD int,
POOL_ID int,
POST_ID int,
DELTA int
)
LANGUAGE PLPGSQL
AS $$
BEGIN
RETURN QUERY WITH main AS (
SELECT * FROM pool_post WHERE pool_post.pool_id = P_POOL_ID AND pool_post.post_id = P_POST_ID
),
around AS (
(SELECT pool_post.ord,
pool_post.pool_id,
pool_post.post_id,
1 as delta,
main.ord AS target_ord,
main.pool_id AS target_pool_id
FROM pool_post, main
WHERE pool_post.ord > main.ord
AND pool_post.pool_id = main.pool_id
ORDER BY pool_post.ord ASC LIMIT 1)
UNION
(SELECT pool_post.ord,
pool_post.pool_id,
pool_post.post_id,
-1 as delta,
main.ord AS target_ord,
main.pool_id AS target_pool_id
FROM pool_post, main
WHERE pool_post.ord < main.ord
AND pool_post.pool_id = main.pool_id
ORDER BY pool_post.ord DESC LIMIT 1)
UNION
(SELECT pool_post.ord,
pool_post.pool_id,
pool_post.post_id,
2 as delta,
main.ord AS target_ord,
main.pool_id AS target_pool_id
FROM pool_post, main
WHERE pool_post.ord = (SELECT MAX(pool_post.ord) FROM pool_post)
AND pool_post.pool_id = main.pool_id
ORDER BY pool_post.ord DESC LIMIT 1)
UNION
(SELECT pool_post.ord,
pool_post.pool_id,
pool_post.post_id,
-2 as delta,
main.ord AS target_ord,
main.pool_id AS target_pool_id
FROM pool_post, main
WHERE pool_post.ord = (SELECT MIN(pool_post.ord) FROM pool_post)
AND pool_post.pool_id = main.pool_id
ORDER BY pool_post.ord DESC LIMIT 1)
)
SELECT around.ord, around.pool_id, around.post_id, around.delta FROM around;
END
$$
""")

View file

@ -0,0 +1,33 @@
'''
add get pool posts around function
Revision ID: f0b8a4298dc7
Created at: 2021-05-08 21:23:48.782025
'''
import sqlalchemy as sa
from alembic import op
from alembic_utils.pg_function import PGFunction
from sqlalchemy import text as sql_text
revision = 'f0b8a4298dc7'
down_revision = 'adcd63ff76a2'
branch_labels = None
depends_on = None
def upgrade():
public_get_pool_posts_around = PGFunction(
schema="public",
signature="get_pool_posts_around( P_POOL_ID int, P_POST_ID int )",
definition='returns TABLE (\n ORD int,\n POOL_ID int,\n POST_ID int,\n DELTA int\n )\n LANGUAGE PLPGSQL\nAS $$\nBEGIN\n RETURN QUERY WITH main AS (\n SELECT * FROM pool_post WHERE pool_post.pool_id = P_POOL_ID AND pool_post.post_id = P_POST_ID\n ),\n around AS (\n (SELECT pool_post.ord,\n pool_post.pool_id,\n pool_post.post_id,\n 1 as delta,\n main.ord AS target_ord,\n main.pool_id AS target_pool_id\n FROM pool_post, main\n WHERE pool_post.ord > main.ord\n AND pool_post.pool_id = main.pool_id\n ORDER BY pool_post.ord ASC LIMIT 1)\n UNION\n (SELECT pool_post.ord,\n pool_post.pool_id,\n pool_post.post_id,\n -1 as delta,\n main.ord AS target_ord,\n main.pool_id AS target_pool_id\n FROM pool_post, main\n WHERE pool_post.ord < main.ord\n AND pool_post.pool_id = main.pool_id\n ORDER BY pool_post.ord DESC LIMIT 1)\n )\n SELECT around.ord, around.pool_id, around.post_id, around.delta FROM around;\nEND\n$$'
)
op.create_entity(public_get_pool_posts_around)
def downgrade():
public_get_pool_posts_around = PGFunction(
schema="public",
signature="get_pool_posts_around( P_POOL_ID int, P_POST_ID int )",
definition='returns TABLE (\n ORD int,\n POOL_ID int,\n POST_ID int,\n DELTA int\n )\n LANGUAGE PLPGSQL\nAS $$\nBEGIN\n RETURN QUERY WITH main AS (\n SELECT * FROM pool_post WHERE pool_post.pool_id = P_POOL_ID AND pool_post.post_id = P_POST_ID\n ),\n around AS (\n (SELECT pool_post.ord,\n pool_post.pool_id,\n pool_post.post_id,\n 1 as delta,\n main.ord AS target_ord,\n main.pool_id AS target_pool_id\n FROM pool_post, main\n WHERE pool_post.ord > main.ord\n AND pool_post.pool_id = main.pool_id\n ORDER BY pool_post.ord ASC LIMIT 1)\n UNION\n (SELECT pool_post.ord,\n pool_post.pool_id,\n pool_post.post_id,\n -1 as delta,\n main.ord AS target_ord,\n main.pool_id AS target_pool_id\n FROM pool_post, main\n WHERE pool_post.ord < main.ord\n AND pool_post.pool_id = main.pool_id\n ORDER BY pool_post.ord DESC LIMIT 1)\n )\n SELECT around.ord, around.pool_id, around.post_id, around.delta FROM around;\nEND\n$$'
)
op.drop_entity(public_get_pool_posts_around)

View file

@ -1,5 +1,7 @@
from typing import Callable, Dict, Optional, Tuple
import sqlalchemy as sa
from szurubooru.search import criteria, tokens
from szurubooru.search.query import SearchQuery
from szurubooru.search.typing import SaColumn, SaQuery
@ -24,6 +26,21 @@ class BaseSearchConfig:
def create_around_query(self) -> SaQuery:
raise NotImplementedError()
def create_around_filter_queries(self, filter_query: SaQuery, entity_id: int) -> Tuple[SaQuery, SaQuery]:
prev_filter_query = (
filter_query.filter(self.id_column > entity_id)
.order_by(None)
.order_by(sa.func.abs(self.id_column - entity_id).asc())
.limit(1)
)
next_filter_query = (
filter_query.filter(self.id_column < entity_id)
.order_by(None)
.order_by(sa.func.abs(self.id_column - entity_id).asc())
.limit(1)
)
return (prev_filter_query, next_filter_query)
def finalize_query(self, query: SaQuery) -> SaQuery:
return query

View file

@ -30,7 +30,7 @@ class PoolSearchConfig(BaseSearchConfig):
raise NotImplementedError()
def finalize_query(self, query: SaQuery) -> SaQuery:
return query.order_by(model.Pool.first_name.asc())
return query.order_by(model.Pool.pool_id.desc())
@property
def anonymous_filter(self) -> Filter:
@ -45,6 +45,7 @@ class PoolSearchConfig(BaseSearchConfig):
def named_filters(self) -> Dict[str, Filter]:
return util.unalias_dict(
[
(["id"], search_util.create_num_filter(model.Pool.pool_id)),
(
["name"],
search_util.create_subquery_filter(
@ -91,6 +92,7 @@ class PoolSearchConfig(BaseSearchConfig):
["random"],
(sa.sql.expression.func.random(), self.SORT_NONE),
),
(["id"], (model.Pool.pool_id, self.SORT_DESC)),
(["name"], (model.Pool.first_name, self.SORT_ASC)),
(["category"], (model.PoolCategory.name, self.SORT_ASC)),
(

View file

@ -1,4 +1,4 @@
from typing import Any, Dict, Optional, Tuple
from typing import Any, Dict, Optional, Tuple, Callable, Union
import sqlalchemy as sa
@ -114,12 +114,49 @@ def _pool_filter(
query: SaQuery, criterion: Optional[criteria.BaseCriterion], negated: bool
) -> SaQuery:
assert criterion
return search_util.create_subquery_filter(
model.Post.post_id,
model.PoolPost.post_id,
model.PoolPost.pool_id,
search_util.create_num_filter,
)(query, criterion, negated)
from szurubooru.search.configs import util as search_util
subquery = db.session.query(model.PoolPost.post_id.label("foreign_id"))
subquery = subquery.options(sa.orm.lazyload("*"))
subquery = search_util.create_num_filter(model.PoolPost.pool_id)(subquery, criterion, False)
subquery = subquery.subquery("t")
expression = model.Post.post_id.in_(subquery)
if negated:
expression = ~expression
return query.filter(expression)
def _pool_sort(
query: SaQuery, pool_id: Optional[int]
) -> SaQuery:
if pool_id is None:
return query
return query.join(model.PoolPost, sa.and_(model.PoolPost.post_id == model.Post.post_id, model.PoolPost.pool_id == pool_id)) \
.order_by(model.PoolPost.order.desc())
def _posts_around_pool(filter_query: SaQuery, post_id: int, pool_id: int) -> Tuple[SaQuery, SaQuery]:
this_order = db.session.query(model.PoolPost) \
.filter(model.PoolPost.post_id == post_id) \
.filter(model.PoolPost.pool_id == pool_id) \
.one().order
filter_query = db.session.query(model.Post) \
.join(model.PoolPost, model.PoolPost.pool_id == pool_id) \
.filter(model.PoolPost.post_id == model.Post.post_id)
prev_filter_query = (
filter_query.filter(model.PoolPost.order > this_order)
.order_by(None)
.order_by(sa.func.abs(model.PoolPost.order - this_order).asc())
.limit(1)
)
next_filter_query = (
filter_query.filter(model.PoolPost.order < this_order)
.order_by(None)
.order_by(sa.func.abs(model.PoolPost.order - this_order).asc())
.limit(1)
)
return (prev_filter_query, next_filter_query)
def _category_filter(
@ -153,6 +190,7 @@ def _category_filter(
class PostSearchConfig(BaseSearchConfig):
def __init__(self) -> None:
self.user = None # type: Optional[model.User]
self.pool_id = None # type: Optional[int]
def on_search_query_parsed(self, search_query: SearchQuery) -> SaQuery:
new_special_tokens = []
@ -177,10 +215,19 @@ class PostSearchConfig(BaseSearchConfig):
else:
new_special_tokens.append(token)
search_query.special_tokens = new_special_tokens
self.pool_id = None
for token in search_query.named_tokens:
if token.name == "pool" and isinstance(token.criterion, criteria.PlainCriterion):
self.pool_id = token.criterion.value
def create_around_query(self) -> SaQuery:
return db.session.query(model.Post).options(sa.orm.lazyload("*"))
def create_around_filter_queries(self, filter_query: SaQuery, entity_id: int) -> Tuple[SaQuery, SaQuery]:
if self.pool_id is not None:
return _posts_around_pool(filter_query, entity_id, self.pool_id)
return super(PostSearchConfig, self).create_around_filter_queries(filter_query, entity_id)
def create_filter_query(self, disable_eager_loads: bool) -> SaQuery:
strategy = (
sa.orm.lazyload if disable_eager_loads else sa.orm.subqueryload
@ -382,7 +429,7 @@ class PostSearchConfig(BaseSearchConfig):
)
@property
def sort_columns(self) -> Dict[str, Tuple[SaColumn, str]]:
def sort_columns(self) -> Dict[str, Union[Tuple[SaColumn, str], Callable[[SaQuery], None]]]:
return util.unalias_dict(
[
(
@ -444,6 +491,10 @@ class PostSearchConfig(BaseSearchConfig):
["feature-date", "feature-time"],
(model.Post.last_feature_time, self.SORT_DESC),
),
(
["pool"],
lambda subquery: _pool_sort(subquery, self.pool_id)
)
]
)

View file

@ -205,6 +205,7 @@ def create_subquery_filter(
filter_column: SaColumn,
filter_factory: SaColumn,
subquery_decorator: Callable[[SaQuery], None] = None,
order: SaQuery = None,
) -> Filter:
filter_func = filter_factory(filter_column)

View file

@ -47,18 +47,7 @@ class Executor:
filter_query = self._prepare_db_query(
filter_query, search_query, False
)
prev_filter_query = (
filter_query.filter(self.config.id_column > entity_id)
.order_by(None)
.order_by(sa.func.abs(self.config.id_column - entity_id).asc())
.limit(1)
)
next_filter_query = (
filter_query.filter(self.config.id_column < entity_id)
.order_by(None)
.order_by(sa.func.abs(self.config.id_column - entity_id).asc())
.limit(1)
)
prev_filter_query, next_filter_query = self.config.create_around_filter_queries(filter_query, entity_id)
return (
prev_filter_query.one_or_none(),
next_filter_query.one_or_none(),
@ -181,14 +170,18 @@ class Executor:
_format_dict_keys(self.config.sort_columns),
)
)
column, default_order = self.config.sort_columns[
entry = self.config.sort_columns[
sort_token.name
]
order = _get_order(sort_token.order, default_order)
if order == sort_token.SORT_ASC:
db_query = db_query.order_by(column.asc())
elif order == sort_token.SORT_DESC:
db_query = db_query.order_by(column.desc())
if callable(entry):
db_query = entry(db_query)
else:
column, default_order = entry
order = _get_order(sort_token.order, default_order)
if order == sort_token.SORT_ASC:
db_query = db_query.order_by(column.asc())
elif order == sort_token.SORT_DESC:
db_query = db_query.order_by(column.desc())
db_query = self.config.finalize_query(db_query)
return db_query

View file

@ -1,4 +1,4 @@
from typing import Any, Callable
from typing import Any, Callable, Union
SaColumn = Any
SaQuery = Any

View file

@ -1221,3 +1221,30 @@ def test_search_by_image(post_factory, config_injector, read_asset):
result2 = posts.search_by_image(read_asset("png.png"))
assert not result2
def test_get_pool_posts_around(post_factory, pool_factory, config_injector):
from szurubooru.migrations.functions import get_pool_posts_around
db.session.execute(get_pool_posts_around.to_sql_statement_create())
db.session.flush()
config_injector({"allow_broken_uploads": False, "secret": "test"})
post1 = post_factory(id=1)
post2 = post_factory(id=2)
post3 = post_factory(id=3)
post4 = post_factory(id=4)
pool1 = pool_factory(id=1)
pool2 = pool_factory(id=2)
pool1.posts = [post1, post2, post3, post4]
pool2.posts = [post3, post4, post2]
db.session.add_all([post1, post2, post3, post4, pool1, pool2])
db.session.flush()
around = posts.get_pool_posts_around(post2)
assert around[0].first_post["id"] == post1.post_id
assert around[0].prev_post["id"] == post1.post_id
assert around[0].next_post["id"] == post3.post_id
assert around[0].last_post["id"] == post4.post_id
assert around[1].first_post["id"] == post3.post_id
assert around[1].prev_post["id"] == post4.post_id
assert around[1].next_post == None
assert around[1].last_post == None

View file

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