This commit is contained in:
Rebecca Nelson 2024-04-19 16:39:21 +00:00 committed by GitHub
commit 874f46ef7e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
32 changed files with 671 additions and 184 deletions

5
.gitignore vendored
View file

@ -1,6 +1,8 @@
# User-specific configuration # User-specific configuration
config.yaml config.yaml
.env .env
sql/
data/
# Client Development Artifacts # Client Development Artifacts
*/*_modules/ */*_modules/
@ -13,3 +15,6 @@ server/**/lib/
server/**/bin/ server/**/bin/
server/**/pyvenv.cfg server/**/pyvenv.cfg
__pycache__/ __pycache__/
.vscode
docker-compose.dev.yml

View file

@ -1,3 +1,4 @@
ARG BUILDPLATFORM=linux/amd64
FROM --platform=$BUILDPLATFORM node:lts as builder FROM --platform=$BUILDPLATFORM node:lts as builder
WORKDIR /opt/app WORKDIR /opt/app

View file

@ -18,6 +18,8 @@ $message-error-border-color = #FCC
$message-error-background-color = #FFF5F5 $message-error-background-color = #FFF5F5
$message-success-border-color = #D3E3D3 $message-success-border-color = #D3E3D3
$message-success-background-color = #F5FFF5 $message-success-background-color = #F5FFF5
$pool-navigator-border-color = #AAA
$pool-navigator-background-color = #EEE
$input-bad-border-color = #FCC $input-bad-border-color = #FCC
$input-bad-background-color = #FFF5F5 $input-bad-background-color = #FFF5F5
$input-good-border-color = #D3E3D3 $input-good-border-color = #D3E3D3

View file

@ -1,47 +1,76 @@
@import colors @import colors
.pool-list .pool-list
table ul
width: 100% list-style-type: none
border-spacing: 0 padding: 0
text-align: left display: flex
line-height: 1.3em align-content: flex-end
tr:hover td flex-wrap: wrap
background: $top-navigation-color margin: 0 -0.25em
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 li
table position: relative
tr:hover td flex-grow: 1
background: $top-navigation-color-darktheme margin: 2em 1.5em 2em 1.2em;
th display: inline-block
background: $top-navigation-color-darktheme 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 .pool-list-header
label label
@ -61,3 +90,21 @@
.darktheme .pool-list-header .darktheme .pool-list-header
.append .append
color: $inactive-link-color-darktheme 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% width: 100%
.post-container .post-container
margin-bottom: 2em margin-bottom: 1em
.post-content .post-content
margin: 0 margin: 0
.pool-navigators-container
margin-bottom: 2em
.darktheme .post-view .darktheme .post-view
>.sidebar >.sidebar
nav.buttons nav.buttons

View file

@ -65,7 +65,7 @@ $cancel-button-color = tomato
img img
width: 100% width: 100%
height: 100% height: 100%
video video
width: 100% width: 100%
height: 100% height: 100%

View file

@ -1,6 +1,6 @@
<div class='pool-delete'> <div class='pool-delete'>
<form> <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'> <ul class='input'>
<li> <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'> <section class='description'>
<hr/> <hr/>
<%= ctx.makeMarkdown(ctx.pool.description || 'This pool has no description yet.') %> <%= 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> </section>
</div> </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) { %> <% if (ctx.response.results.length) { %>
<table> <ul>
<thead> <% for (let pool of ctx.response.results) { %>
<th class='names'> <li data-pool-id='<%= pool.id %>'>
<% if (ctx.parameters.query == 'sort:name' || !ctx.parameters.query) { %> <a class='thumbnail-wrapper' href='<%= ctx.canViewPools ? ctx.formatClientLink("pool", pool.id) : "" %>'>
<a href='<%- ctx.formatClientLink('pools', {query: '-sort:name'}) %>'>Pool name(s)</a> <% if (ctx.canViewPosts) { %>
<% } else { %> <%= ctx.makePoolThumbnails(pool.posts, ctx.postFlow) %>
<a href='<%- ctx.formatClientLink('pools', {query: 'sort:name'}) %>'>Pool name(s)</a>
<% } %> <% } %>
</th> </a>
<th class='post-count'> <div class='pool-name'>
<% if (ctx.parameters.query == 'sort:post-count') { %> <%= ctx.makePoolLink(pool.id, false, false, pool, name) %>
<a href='<%- ctx.formatClientLink('pools', {query: '-sort:post-count'}) %>'>Post count</a> </div>
<% } else { %> </li>
<a href='<%- ctx.formatClientLink('pools', {query: 'sort:post-count'}) %>'>Post count</a> <% } %>
<% } %> <%= ctx.makeFlexboxAlign() %>
</th> </ul>
<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>
<% } %> <% } %>
</div> </div>

View file

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

View file

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

View file

@ -16,6 +16,14 @@ class PostMainController extends BasePostController {
constructor(ctx, editMode) { constructor(ctx, editMode) {
super(ctx); 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; let parameters = ctx.parameters;
Promise.all([ Promise.all([
Post.get(ctx.parameters.id), Post.get(ctx.parameters.id),
@ -23,9 +31,11 @@ class PostMainController extends BasePostController {
ctx.parameters.id, ctx.parameters.id,
parameters ? parameters.query : null parameters ? parameters.query : null
), ),
poolPostsAround
]).then( ]).then(
(responses) => { (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 // remove junk from query, but save it into history so that it can
// be still accessed after history navigation / page refresh // be still accessed after history navigation / page refresh
@ -39,11 +49,20 @@ class PostMainController extends BasePostController {
) )
: uri.formatClientLink("post", ctx.parameters.id); : uri.formatClientLink("post", ctx.parameters.id);
router.replace(url, ctx.state, false); 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._post = post;
this._view = new PostMainView({ this._view = new PostMainView({
post: post, post: post,
poolPostsAround: poolPostsAroundResponse,
activePool: activePool,
editMode: editMode, editMode: editMode,
prevPostId: aroundResponse.prev prevPostId: aroundResponse.prev
? aroundResponse.prev.id ? aroundResponse.prev.id
@ -56,6 +75,8 @@ class PostMainController extends BasePostController {
canFeaturePosts: api.hasPrivilege("posts:feature"), canFeaturePosts: api.hasPrivilege("posts:feature"),
canListComments: api.hasPrivilege("comments:list"), canListComments: api.hasPrivilege("comments:list"),
canCreateComments: api.hasPrivilege("comments:create"), canCreateComments: api.hasPrivilege("comments:create"),
canListPools: api.hasPrivilege("pools:list"),
canViewPools: api.hasPrivilege("pools:view"),
parameters: parameters, 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) { static search(text, offset, limit, fields) {
return api return api
.get( .get(

View file

@ -40,12 +40,12 @@ function makeRelativeTime(time) {
); );
} }
function makeThumbnail(url) { function makeThumbnail(url, klass) {
return makeElement( return makeElement(
"span", "span",
url url
? { ? {
class: "thumbnail", class: klass || "thumbnail",
style: `background-image: url(\'${url}\')`, style: `background-image: url(\'${url}\')`,
} }
: { class: "thumbnail empty" }, : { 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) { function makeRadio(options) {
_imbueId(options); _imbueId(options);
return makeElement( return makeElement(
@ -254,7 +271,7 @@ function makePoolLink(id, includeHash, includeCount, pool, name) {
misc.escapeHtml(text) misc.escapeHtml(text)
) )
: makeElement( : makeElement(
"span", "div",
{ class: misc.makeCssName(category, "pool") }, { class: misc.makeCssName(category, "pool") },
misc.escapeHtml(text) misc.escapeHtml(text)
); );
@ -436,6 +453,7 @@ function getTemplate(templatePath) {
makeFileSize: makeFileSize, makeFileSize: makeFileSize,
makeMarkdown: makeMarkdown, makeMarkdown: makeMarkdown,
makeThumbnail: makeThumbnail, makeThumbnail: makeThumbnail,
makePoolThumbnails: makePoolThumbnails,
makeRadio: makeRadio, makeRadio: makeRadio,
makeCheckbox: makeCheckbox, makeCheckbox: makeCheckbox,
makeSelect: makeSelect, 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 PostEditSidebarControl = require("../controls/post_edit_sidebar_control.js");
const CommentControl = require("../controls/comment_control.js"); const CommentControl = require("../controls/comment_control.js");
const CommentListControl = require("../controls/comment_list_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"); const template = views.getTemplate("post-main");
@ -57,6 +58,7 @@ class PostMainView {
this._installSidebar(ctx); this._installSidebar(ctx);
this._installCommentForm(); this._installCommentForm();
this._installComments(ctx.post.comments); this._installComments(ctx.post.comments);
this._installPoolNavigators(ctx.poolPostsAround, ctx.activePool);
const showPreviousImage = () => { const showPreviousImage = () => {
if (ctx.prevPostId) { 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() { _installCommentForm() {
const commentFormContainer = document.querySelector( const commentFormContainer = document.querySelector(
"#content-holder .comment-form-container" "#content-holder .comment-form-container"

View file

@ -794,38 +794,39 @@ data.
**Sort style tokens** **Sort style tokens**
| `<value>` | Description | | `<value>` | Description |
| ---------------- | ------------------------------------------------ | | ---------------- | ------------------------------------------------ |
| `random` | as random as it can get | | `random` | as random as it can get |
| `id` | highest to lowest post number | | `id` | highest to lowest post number |
| `score` | highest scored | | `score` | highest scored |
| `tag-count` | with most tags | | `tag-count` | with most tags |
| `comment-count` | most commented first | | `comment-count` | most commented first |
| `fav-count` | loved by most | | `fav-count` | loved by most |
| `note-count` | with most annotations | | `note-count` | with most annotations |
| `relation-count` | with most relations | | `relation-count` | with most relations |
| `feature-count` | most often featured | | `feature-count` | most often featured |
| `file-size` | largest files first | | `file-size` | largest files first |
| `image-width` | widest images first | | `image-width` | widest images first |
| `image-height` | tallest images first | | `image-height` | tallest images first |
| `image-area` | largest images first | | `image-area` | largest images first |
| `width` | alias of `image-width` | | `width` | alias of `image-width` |
| `height` | alias of `image-height` | | `height` | alias of `image-height` |
| `area` | alias of `image-area` | | `area` | alias of `image-area` |
| `creation-date` | newest to oldest (pretty much same as id) | | `creation-date` | newest to oldest (pretty much same as id) |
| `creation-time` | alias of `creation-date` | | `creation-time` | alias of `creation-date` |
| `date` | alias of `creation-date` | | `date` | alias of `creation-date` |
| `time` | alias of `creation-date` | | `time` | alias of `creation-date` |
| `last-edit-date` | like creation-date, only looks at last edit time | | `last-edit-date` | like creation-date, only looks at last edit time |
| `last-edit-time` | alias of `last-edit-date` | | `last-edit-time` | alias of `last-edit-date` |
| `edit-date` | alias of `last-edit-date` | | `edit-date` | alias of `last-edit-date` |
| `edit-time` | alias of `last-edit-date` | | `edit-time` | alias of `last-edit-date` |
| `comment-date` | recently commented by anyone | | `comment-date` | recently commented by anyone |
| `comment-time` | alias of `comment-date` | | `comment-time` | alias of `comment-date` |
| `fav-date` | recently added to favorites by anyone | | `fav-date` | recently added to favorites by anyone |
| `fav-time` | alias of `fav-date` | | `fav-time` | alias of `fav-date` |
| `feature-date` | recently featured | | `feature-date` | recently featured |
| `feature-time` | alias of `feature-time` | | `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** **Special tokens**
@ -1357,6 +1358,7 @@ data.
| `<key>` | Description | | `<key>` | Description |
| ------------------- | ----------------------------------------- | | ------------------- | ----------------------------------------- |
| `id` | having given pool number |
| `name` | having given name (accepts wildcards) | | `name` | having given name (accepts wildcards) |
| `category` | having given category (accepts wildcards) | | `category` | having given category (accepts wildcards) |
| `creation-date` | created at given date | | `creation-date` | created at given date |
@ -1369,18 +1371,19 @@ data.
**Sort style tokens** **Sort style tokens**
| `<value>` | Description | | `<value>` | Description |
| ------------------- | ---------------------------- | | ------------------- | ---------------------------- |
| `random` | as random as it can get | | `random` | as random as it can get |
| `name` | A to Z | | `id` | highest to lowest pool number |
| `category` | category (A to Z) | | `name` | A to Z |
| `creation-date` | recently created first | | `category` | category (A to Z) |
| `creation-time` | alias of `creation-date` | | `creation-date` | recently created first |
| `last-edit-date` | recently edited first | | `creation-time` | alias of `creation-date` |
| `last-edit-time` | alias of `creation-time` | | `last-edit-date` | recently edited first |
| `edit-date` | alias of `creation-time` | | `last-edit-time` | alias of `creation-time` |
| `edit-time` | alias of `creation-time` | | `edit-date` | alias of `creation-time` |
| `post-count` | used in most posts first | | `edit-time` | alias of `creation-time` |
| `post-count` | used in most posts first |
**Special tokens** **Special tokens**

View file

@ -1,4 +1,5 @@
ARG ALPINE_VERSION=3.13 ARG ALPINE_VERSION=3.13
ARG BUILDPLATFORM=linux/amd64
FROM alpine:$ALPINE_VERSION as prereqs 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" "pillow-avif-plugin~=1.1.0"
RUN apk --no-cache del py3-pip RUN apk --no-cache del py3-pip
COPY ./ /opt/app/
RUN rm -rf /opt/app/szurubooru/tests
FROM --platform=$BUILDPLATFORM prereqs as testing FROM --platform=$BUILDPLATFORM prereqs as testing
WORKDIR /opt/app WORKDIR /opt/app
@ -48,12 +46,14 @@ RUN apk --no-cache add \
&& pip3 install --no-cache-dir --disable-pip-version-check \ && pip3 install --no-cache-dir --disable-pip-version-check \
pytest-pgsql \ pytest-pgsql \
freezegun \ freezegun \
&& apk --no-cache del py3-pip \ && apk --no-cache del py3-pip
&& addgroup app \
COPY ./ /opt/app/
RUN addgroup app \
&& adduser -SDH -h /opt/app -g '' -G app app \ && adduser -SDH -h /opt/app -g '' -G app app \
&& chown app:app /opt/app && chown app:app /opt/app
COPY --chown=app:app ./szurubooru/tests /opt/app/szurubooru/tests/
ENV TEST_ENVIRONMENT="true" ENV TEST_ENVIRONMENT="true"
USER app USER app
@ -70,8 +70,11 @@ ARG PGID=1000
RUN apk --no-cache add \ RUN apk --no-cache add \
dumb-init \ dumb-init \
py3-setuptools \ py3-setuptools \
py3-waitress \ py3-waitress
&& mkdir -p /opt/app /data \
COPY ./ /opt/app/
RUN mkdir -p /opt/app /data \
&& addgroup -g ${PGID} app \ && addgroup -g ${PGID} app \
&& adduser -SDH -h /opt/app -g '' -G app -u ${PUID} app \ && adduser -SDH -h /opt/app -g '' -G app -u ${PUID} app \
&& chown -R app:app /opt/app /data && chown -R app:app /opt/app /data

View file

@ -284,6 +284,18 @@ 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")
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/?") @rest.routes.post("/posts/reverse-search/?")
def get_posts_by_image( def get_posts_by_image(
ctx: rest.Context, _params: Dict[str, str] = {} ctx: rest.Context, _params: Dict[str, str] = {}

View file

@ -2,6 +2,7 @@ import hmac
import logging import logging
from datetime import datetime from datetime import datetime
from typing import Any, Callable, Dict, List, Optional, Tuple from typing import Any, Callable, Dict, List, Optional, Tuple
from collections import namedtuple
import sqlalchemy as sa import sqlalchemy as sa
@ -968,3 +969,48 @@ def search_by_image(image_content: bytes) -> List[Tuple[float, model.Post]]:
] ]
else: else:
return [] 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)
]

View file

@ -1,5 +1,7 @@
from typing import Callable, Dict, Optional, Tuple from typing import Callable, Dict, Optional, Tuple
import sqlalchemy as sa
from szurubooru.search import criteria, tokens from szurubooru.search import criteria, tokens
from szurubooru.search.query import SearchQuery from szurubooru.search.query import SearchQuery
from szurubooru.search.typing import SaColumn, SaQuery from szurubooru.search.typing import SaColumn, SaQuery
@ -24,6 +26,21 @@ class BaseSearchConfig:
def create_around_query(self) -> SaQuery: def create_around_query(self) -> SaQuery:
raise NotImplementedError() 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: def finalize_query(self, query: SaQuery) -> SaQuery:
return query return query

View file

@ -30,7 +30,7 @@ class PoolSearchConfig(BaseSearchConfig):
raise NotImplementedError() raise NotImplementedError()
def finalize_query(self, query: SaQuery) -> SaQuery: 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 @property
def anonymous_filter(self) -> Filter: def anonymous_filter(self) -> Filter:
@ -45,6 +45,7 @@ class PoolSearchConfig(BaseSearchConfig):
def named_filters(self) -> Dict[str, Filter]: def named_filters(self) -> Dict[str, Filter]:
return util.unalias_dict( return util.unalias_dict(
[ [
(["id"], search_util.create_num_filter(model.Pool.pool_id)),
( (
["name"], ["name"],
search_util.create_subquery_filter( search_util.create_subquery_filter(
@ -91,6 +92,7 @@ class PoolSearchConfig(BaseSearchConfig):
["random"], ["random"],
(sa.sql.expression.func.random(), self.SORT_NONE), (sa.sql.expression.func.random(), self.SORT_NONE),
), ),
(["id"], (model.Pool.pool_id, self.SORT_DESC)),
(["name"], (model.Pool.first_name, self.SORT_ASC)), (["name"], (model.Pool.first_name, self.SORT_ASC)),
(["category"], (model.PoolCategory.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 import sqlalchemy as sa
@ -114,12 +114,48 @@ def _pool_filter(
query: SaQuery, criterion: Optional[criteria.BaseCriterion], negated: bool query: SaQuery, criterion: Optional[criteria.BaseCriterion], negated: bool
) -> SaQuery: ) -> SaQuery:
assert criterion assert criterion
return search_util.create_subquery_filter( subquery = db.session.query(model.PoolPost.post_id.label("foreign_id"))
model.Post.post_id, subquery = subquery.options(sa.orm.lazyload("*"))
model.PoolPost.post_id, subquery = search_util.create_num_filter(model.PoolPost.pool_id)(subquery, criterion, False)
model.PoolPost.pool_id, subquery = subquery.subquery("t")
search_util.create_num_filter, expression = model.Post.post_id.in_(subquery)
)(query, criterion, negated) 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( def _category_filter(
@ -153,6 +189,7 @@ def _category_filter(
class PostSearchConfig(BaseSearchConfig): class PostSearchConfig(BaseSearchConfig):
def __init__(self) -> None: def __init__(self) -> None:
self.user = None # type: Optional[model.User] self.user = None # type: Optional[model.User]
self.pool_id = None # type: Optional[int]
def on_search_query_parsed(self, search_query: SearchQuery) -> SaQuery: def on_search_query_parsed(self, search_query: SearchQuery) -> SaQuery:
new_special_tokens = [] new_special_tokens = []
@ -177,10 +214,19 @@ class PostSearchConfig(BaseSearchConfig):
else: else:
new_special_tokens.append(token) new_special_tokens.append(token)
search_query.special_tokens = new_special_tokens 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: def create_around_query(self) -> SaQuery:
return db.session.query(model.Post).options(sa.orm.lazyload("*")) 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: def create_filter_query(self, disable_eager_loads: bool) -> SaQuery:
strategy = ( strategy = (
sa.orm.lazyload if disable_eager_loads else sa.orm.subqueryload sa.orm.lazyload if disable_eager_loads else sa.orm.subqueryload
@ -382,7 +428,7 @@ class PostSearchConfig(BaseSearchConfig):
) )
@property @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( return util.unalias_dict(
[ [
( (
@ -444,6 +490,10 @@ class PostSearchConfig(BaseSearchConfig):
["feature-date", "feature-time"], ["feature-date", "feature-time"],
(model.Post.last_feature_time, self.SORT_DESC), (model.Post.last_feature_time, self.SORT_DESC),
), ),
(
["pool"],
lambda subquery: _pool_sort(subquery, self.pool_id)
)
] ]
) )

View file

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

View file

@ -1221,3 +1221,26 @@ def test_search_by_image(post_factory, config_injector, read_asset):
result2 = posts.search_by_image(read_asset("png.png")) result2 = posts.search_by_image(read_asset("png.png"))
assert not result2 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

View file

@ -12,13 +12,16 @@ def executor():
@pytest.fixture @pytest.fixture
def verify_unpaged(executor): 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( actual_count, actual_pools = executor.execute(
input, offset=0, limit=100 input, offset=0, limit=100
) )
actual_pool_names = [u.names[0].name for u in actual_pools] 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_pool_names == expected_pool_names
assert actual_count == len(expected_pool_names)
return verify return verify
@ -323,7 +326,6 @@ def test_filter_by_invalid_input(executor, input):
@pytest.mark.parametrize( @pytest.mark.parametrize(
"input,expected_pool_names", "input,expected_pool_names",
[ [
("", ["t1", "t2"]),
("sort:name", ["t1", "t2"]), ("sort:name", ["t1", "t2"]),
("-sort:name", ["t2", "t1"]), ("-sort:name", ["t2", "t1"]),
("sort:name,asc", ["t1", "t2"]), ("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=2, names=["t2"]))
db.session.add(pool_factory(id=1, names=["t1"])) db.session.add(pool_factory(id=1, names=["t1"]))
db.session.flush() db.session.flush()
verify_unpaged(input, expected_pool_names) verify_unpaged(input, expected_pool_names, test_order=True)
@pytest.mark.parametrize( @pytest.mark.parametrize(
"input,expected_pool_names", "input,expected_pool_names",
[ [
("", ["t1", "t2", "t3"]),
("sort:creation-date", ["t3", "t2", "t1"]), ("sort:creation-date", ["t3", "t2", "t1"]),
("sort:creation-time", ["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) pool3.creation_time = datetime(1991, 1, 3)
db.session.add_all([pool3, pool1, pool2]) db.session.add_all([pool3, pool1, pool2])
db.session.flush() db.session.flush()
verify_unpaged(input, expected_pool_names) verify_unpaged(input, expected_pool_names, test_order=True)
@pytest.mark.parametrize( @pytest.mark.parametrize(
"input,expected_pool_names", "input,expected_pool_names",
[ [
("", ["t1", "t2", "t3"]),
("sort:last-edit-date", ["t3", "t2", "t1"]), ("sort:last-edit-date", ["t3", "t2", "t1"]),
("sort:last-edit-time", ["t3", "t2", "t1"]), ("sort:last-edit-time", ["t3", "t2", "t1"]),
("sort:edit-date", ["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) pool3.last_edit_time = datetime(1991, 1, 3)
db.session.add_all([pool3, pool1, pool2]) db.session.add_all([pool3, pool1, pool2])
db.session.flush() db.session.flush()
verify_unpaged(input, expected_pool_names) verify_unpaged(input, expected_pool_names, test_order=True)
@pytest.mark.parametrize( @pytest.mark.parametrize(
@ -405,7 +405,7 @@ def test_sort_by_post_count(
pool2.posts.append(post1) pool2.posts.append(post1)
pool2.posts.append(post2) pool2.posts.append(post2)
db.session.flush() db.session.flush()
verify_unpaged(input, expected_pool_names) verify_unpaged(input, expected_pool_names, test_order=True)
@pytest.mark.parametrize( @pytest.mark.parametrize(
@ -423,9 +423,9 @@ def test_sort_by_category(
): ):
cat1 = pool_category_factory(name="cat1") cat1 = pool_category_factory(name="cat1")
cat2 = pool_category_factory(name="cat2") cat2 = pool_category_factory(name="cat2")
pool1 = pool_factory(id=1, names=["t1"], category=cat2) pool2 = pool_factory(id=1, names=["t2"], category=cat2)
pool2 = pool_factory(id=2, names=["t2"], category=cat2) pool1 = pool_factory(id=2, names=["t1"], category=cat2)
pool3 = pool_factory(id=3, names=["t3"], category=cat1) pool3 = pool_factory(id=3, names=["t3"], category=cat1)
db.session.add_all([pool1, pool2, pool3]) db.session.add_all([pool1, pool2, pool3])
db.session.flush() db.session.flush()
verify_unpaged(input, expected_pool_names) verify_unpaged(input, expected_pool_names, test_order=True)

View file

@ -725,6 +725,7 @@ def test_filter_by_feature_date(
"sort:fav-time", "sort:fav-time",
"sort:feature-date", "sort:feature-date",
"sort:feature-time", "sort:feature-time",
"sort:pool",
], ],
) )
def test_sort_tokens(verify_unpaged, post_factory, input): def test_sort_tokens(verify_unpaged, post_factory, input):
@ -915,3 +916,42 @@ def test_search_by_tag_category(
) )
db.session.flush() db.session.flush()
verify_unpaged(input, expected_post_ids) 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])