mirror of
https://github.com/rr-/szurubooru.git
synced 2025-07-17 08:26:24 +00:00
Compare commits
59 Commits
2fb2ffb2e1
...
bfe3767b68
Author | SHA1 | Date | |
---|---|---|---|
bfe3767b68 | |||
952e683b72 | |||
1c08492a94 | |||
1fd36b1f32 | |||
418640d967 | |||
9362444871 | |||
5d262b0059 | |||
0972139831 | |||
535d2d2a8b | |||
795ba068e6 | |||
061c604f14 | |||
2b39414715 | |||
289e004f9c | |||
10bff18b0c | |||
7724b282b4 | |||
2d855a1773 | |||
ac64af6179 | |||
547ac4c398 | |||
743c512857 | |||
10c15752d3 | |||
8e4981dfb2 | |||
b971c966c8 | |||
abccf92979 | |||
c294c80027 | |||
c599465015 | |||
7b5d367430 | |||
a343447704 | |||
376f687c38 | |||
4fd848abf2 | |||
61b9f81e39 | |||
b721865931 | |||
46e3295003 | |||
031131506e | |||
d102578b54 | |||
6edb25d87b | |||
93fc15f2a4 | |||
4f9d46e1c2 | |||
b72e81850d | |||
c1c695f082 | |||
4b6b231fc8 | |||
6b0c3cfc7f | |||
4ec8cb3ba2 | |||
8d971234a2 | |||
a16bb198ab | |||
3f182a66ad | |||
b52363e82d | |||
3bf45e4c0a | |||
5596f53744 | |||
da425afc49 | |||
d7394d672f | |||
190d795426 | |||
7c92ceaf6a | |||
9e00f37464 | |||
59c497e168 | |||
c292b96f06 | |||
7a82e9d581 | |||
4806bbe0ed | |||
c2fdc2d070 | |||
ffdf115714 |
@ -8,7 +8,7 @@ scrubbing](https://sjp.pwn.pl/sjp/;2527372). It is pronounced as *shoorubooru*.
|
||||
## Features
|
||||
|
||||
- Post content: images (JPG, PNG, GIF, animated GIF), videos (MP4, WEBM), Flash animations
|
||||
- Ability to retrieve web video content using [youtube-dl](https://github.com/ytdl-org/youtube-dl)
|
||||
- Ability to retrieve web video content using [yt-dlp](https://github.com/yt-dlp/yt-dlp)
|
||||
- Post comments
|
||||
- Post notes / annotations, including arbitrary polygons
|
||||
- Rich JSON REST API ([see documentation](doc/API.md))
|
||||
|
@ -127,6 +127,10 @@ $comment-border-color = #DDD
|
||||
color: mix($main-color, $inactive-link-color-darktheme)
|
||||
|
||||
.comment-content
|
||||
p
|
||||
word-wrap: normal
|
||||
word-break: break-word
|
||||
|
||||
ul, ol
|
||||
list-style-position: inside
|
||||
margin: 1em 0
|
||||
|
@ -106,6 +106,11 @@ form .fa-question-circle-o
|
||||
background-color: $scrollbar-bg-color
|
||||
&::-webkit-scrollbar-thumb
|
||||
background-color: $scrollbar-thumb-color
|
||||
li[data-name=view]
|
||||
background: $button-enabled-background-color
|
||||
margin-right: 1em
|
||||
a
|
||||
color: $button-enabled-text-color
|
||||
>.content-wrapper:not(.transparent)
|
||||
background: $top-navigation-color
|
||||
padding: 1.8em
|
||||
@ -214,8 +219,6 @@ nav
|
||||
ul li[data-name=settings],
|
||||
ul li[data-name=help]
|
||||
float: none
|
||||
.access-key
|
||||
text-decoration: underline
|
||||
.thumbnail
|
||||
width: 1.5em
|
||||
height: 1.5em
|
||||
@ -244,9 +247,6 @@ nav
|
||||
#mobile-navigation-toggle
|
||||
color: $text-color-darktheme
|
||||
|
||||
a .access-key
|
||||
text-decoration: underline
|
||||
|
||||
.messages
|
||||
margin: 0 auto
|
||||
text-align: left
|
||||
@ -287,6 +287,7 @@ a .access-key
|
||||
background-size: cover
|
||||
background-position: center
|
||||
display: inline-block
|
||||
overflow: hidden
|
||||
width: 20px
|
||||
height: 20px
|
||||
&.empty
|
||||
@ -298,13 +299,12 @@ a .access-key
|
||||
background-position: 0 0, 0 10px, 10px -10px, -10px 0px
|
||||
background-repeat: repeat
|
||||
background-size: 20px 20px
|
||||
img
|
||||
img, video
|
||||
opacity: 0
|
||||
width: auto
|
||||
height: 100%
|
||||
video
|
||||
width: auto
|
||||
object-fit: cover
|
||||
width: 100%
|
||||
height: 100%
|
||||
display: block
|
||||
|
||||
.flexbox-dummy
|
||||
height: 0 !important
|
||||
|
@ -1,14 +1,16 @@
|
||||
@import colors
|
||||
|
||||
.post-container
|
||||
.post-content.transparency-grid img
|
||||
background-image:
|
||||
linear-gradient(45deg, $transparency-grid-square-color 25%, transparent 25%),
|
||||
linear-gradient(-45deg, $transparency-grid-square-color 25%, transparent 25%),
|
||||
linear-gradient(45deg, transparent 75%, $transparency-grid-square-color 75%),
|
||||
linear-gradient(-45deg, transparent 75%, $transparency-grid-square-color 75%)
|
||||
background-size: 20px 20px
|
||||
background-position: 0 0, 0 10px, 10px -10px, -10px 0px
|
||||
.post-content
|
||||
&.transparency-grid, &.post-error
|
||||
background-image:
|
||||
linear-gradient(45deg, $transparency-grid-square-color 25%, transparent 25%),
|
||||
linear-gradient(-45deg, $transparency-grid-square-color 25%, transparent 25%),
|
||||
linear-gradient(45deg, transparent 75%, $transparency-grid-square-color 75%),
|
||||
linear-gradient(-45deg, transparent 75%, $transparency-grid-square-color 75%)
|
||||
background-position: 0 0, 0 10px, 10px -10px, -10px 0px
|
||||
background-repeat: repeat
|
||||
background-size: 20px 20px
|
||||
|
||||
text-align: center
|
||||
.post-content
|
||||
@ -17,6 +19,8 @@
|
||||
position: relative
|
||||
|
||||
.resize-listener
|
||||
background-repeat: no-repeat
|
||||
background-size: cover
|
||||
position: absolute
|
||||
left: 0
|
||||
right: 0
|
||||
@ -27,3 +31,14 @@
|
||||
|
||||
img
|
||||
image-orientation: from-image
|
||||
|
||||
.darktheme .post-container .post-content
|
||||
&.transparency-grid, &.post-error
|
||||
background-image:
|
||||
linear-gradient(45deg, $transparency-grid-square-color 25%, transparent 25%),
|
||||
linear-gradient(-45deg, $transparency-grid-square-color 25%, transparent 25%),
|
||||
linear-gradient(45deg, transparent 75%, $transparency-grid-square-color 75%),
|
||||
linear-gradient(-45deg, transparent 75%, $transparency-grid-square-color 75%)
|
||||
background-position: 0 0, 0 10px, 10px -10px, -10px 0px
|
||||
background-repeat: repeat
|
||||
background-size: 20px 20px
|
||||
|
@ -187,6 +187,9 @@
|
||||
vertical-align: top
|
||||
@media (max-width: 1000px)
|
||||
display: block
|
||||
&.bulk-edit-tags:not(.opened), &.bulk-edit-safety:not(.opened)
|
||||
float: left
|
||||
margin-right: 1em
|
||||
input
|
||||
margin-bottom: 0.25em
|
||||
margin-right: 0.25em
|
||||
|
@ -15,38 +15,42 @@
|
||||
border: 0
|
||||
outline: 0
|
||||
|
||||
nav.buttons
|
||||
margin-top: 0
|
||||
display: flex
|
||||
flex-wrap: wrap
|
||||
article
|
||||
flex: 1 0 33%
|
||||
a
|
||||
display: inline-block
|
||||
width: 100%
|
||||
padding: 0.3em 0
|
||||
text-align: center
|
||||
vertical-align: middle
|
||||
transition: background 0.2s linear, box-shadow 0.2s linear
|
||||
&:not(.inactive):hover
|
||||
background: lighten($main-color, 90%)
|
||||
i
|
||||
font-size: 140%
|
||||
>.sidebar>nav.buttons, >.content nav.buttons
|
||||
margin-top: 0
|
||||
display: flex
|
||||
flex-wrap: wrap
|
||||
article
|
||||
flex: 1 0 33%
|
||||
a
|
||||
display: inline-block
|
||||
width: 100%
|
||||
padding: 0.3em 0
|
||||
text-align: center
|
||||
@media (max-width: 800px)
|
||||
margin-top: 2em
|
||||
vertical-align: middle
|
||||
transition: background 0.2s linear, box-shadow 0.2s linear
|
||||
&:not(.inactive):hover
|
||||
background: lighten($main-color, 90%)
|
||||
i
|
||||
font-size: 140%
|
||||
text-align: center
|
||||
@media (max-width: 800px)
|
||||
margin-top: 0.6em
|
||||
margin-bottom: 0.6em
|
||||
|
||||
>.content
|
||||
width: 100%
|
||||
|
||||
.post-container
|
||||
margin-bottom: 2em
|
||||
margin-bottom: 0.6em
|
||||
|
||||
.post-content
|
||||
margin: 0
|
||||
|
||||
.after-mobile-controls
|
||||
width: 100%
|
||||
|
||||
.darktheme .post-view
|
||||
>.sidebar
|
||||
>.sidebar, >.content
|
||||
nav.buttons
|
||||
article
|
||||
a:not(.inactive):hover
|
||||
@ -56,6 +60,8 @@
|
||||
@media (max-width: 800px)
|
||||
.post-view
|
||||
flex-wrap: wrap
|
||||
>.after-mobile-controls
|
||||
order: 3
|
||||
>.sidebar
|
||||
order: 2
|
||||
min-width: 100%
|
||||
@ -113,7 +119,6 @@
|
||||
h1
|
||||
margin-bottom: 0.5em
|
||||
.thumbnail
|
||||
background-position: 50% 30%
|
||||
width: 4em
|
||||
height: 3em
|
||||
li
|
||||
|
@ -62,22 +62,22 @@ $cancel-button-color = tomato
|
||||
margin: 0 0 1.2em 0
|
||||
padding-left: 13em
|
||||
|
||||
img
|
||||
width: 100%
|
||||
height: 100%
|
||||
|
||||
video
|
||||
width: 100%
|
||||
height: 100%
|
||||
|
||||
&>.thumbnail-wrapper
|
||||
float: left
|
||||
width: 12em
|
||||
height: 8em
|
||||
margin: 0 0 0 -13em
|
||||
a
|
||||
display: block
|
||||
height: 100%
|
||||
width: 100%
|
||||
.thumbnail
|
||||
width: 100%
|
||||
height: 100%
|
||||
video
|
||||
opacity: 1
|
||||
img, video
|
||||
object-fit: contain
|
||||
|
||||
.uploadable
|
||||
border: 1px solid $upload-border-color
|
||||
|
@ -21,10 +21,11 @@
|
||||
.details
|
||||
font-size: 90%
|
||||
line-height: 130%
|
||||
.image
|
||||
margin: 0.25em 0.6em 0.25em 0
|
||||
.thumbnail
|
||||
width: 3em
|
||||
height: 3em
|
||||
margin: 0.25em 0.6em 0 0
|
||||
|
||||
.darktheme .user-list
|
||||
ul li
|
||||
|
@ -2,7 +2,7 @@
|
||||
<html>
|
||||
<head>
|
||||
<meta charset='utf-8'/>
|
||||
<meta name='viewport' content='width=device-width, initial-scale=1, maximum-scale=1'/>
|
||||
<meta name='viewport' content='width=device-width, initial-scale=1.0'>
|
||||
<meta name='theme-color' content='#24aadd'/>
|
||||
<meta name='apple-mobile-web-app-capable' content='yes'/>
|
||||
<meta name='apple-mobile-web-app-status-bar-style' content='black'/>
|
||||
|
@ -1,13 +1,14 @@
|
||||
<div class='post-content post-type-<%- ctx.post.type %>'>
|
||||
<% if (['image', 'animation'].includes(ctx.post.type)) { %>
|
||||
|
||||
<img class='resize-listener' alt='' src='<%- ctx.post.contentUrl %>'/>
|
||||
<img class='resize-listener' alt='' src='<%- ctx.post.contentUrl %>' draggable='false' fetchPriority='high'/>
|
||||
|
||||
<% } else if (ctx.post.type === 'flash') { %>
|
||||
|
||||
<object class='resize-listener' width='<%- ctx.post.canvasWidth %>' height='<%- ctx.post.canvasHeight %>' data='<%- ctx.post.contentUrl %>'>
|
||||
<param name='wmode' value='opaque'/>
|
||||
<param name='wmode' value='transparent'/>
|
||||
<param name='movie' value='<%- ctx.post.contentUrl %>'/>
|
||||
<div class='messages'><div class='message-wrapper'><div class='message error'>Your browser does not support Flash.</div></div></div>
|
||||
</object>
|
||||
|
||||
<% } else if (ctx.post.type === 'video') { %>
|
||||
@ -19,6 +20,8 @@
|
||||
loop: (ctx.post.flags || []).includes('loop'),
|
||||
playsinline: true,
|
||||
autoplay: ctx.autoplay,
|
||||
preload: 'auto',
|
||||
poster: ctx.post.originalThumbnailUrl,
|
||||
},
|
||||
ctx.makeElement('source', {
|
||||
type: ctx.post.mimeType,
|
||||
|
@ -29,6 +29,7 @@
|
||||
<span class='vim-nav-hint'>Next post ></span>
|
||||
</a>
|
||||
</article>
|
||||
<% if (ctx.canEditPosts || ctx.canDeletePosts || ctx.canFeaturePosts) { %>
|
||||
<article class='edit-post'>
|
||||
<% if (ctx.editMode) { %>
|
||||
<a href='<%= ctx.getPostUrl(ctx.post.id, ctx.parameters) %>'>
|
||||
@ -36,16 +37,13 @@
|
||||
<span class='vim-nav-hint'>Back to view mode</span>
|
||||
</a>
|
||||
<% } else { %>
|
||||
<% if (ctx.canEditPosts || ctx.canDeletePosts || ctx.canFeaturePosts) { %>
|
||||
<a href='<%= ctx.getPostEditUrl(ctx.post.id, ctx.parameters) %>'>
|
||||
<% } else { %>
|
||||
<a class='inactive'>
|
||||
<% } %>
|
||||
<i class='fa fa-pencil'></i>
|
||||
<span class='vim-nav-hint'>Edit post</span>
|
||||
<a href='<%= ctx.getPostEditUrl(ctx.post.id, ctx.parameters) %>'>
|
||||
<i class='fa fa-pencil'></i>
|
||||
<span class='vim-nav-hint'>Edit post</span>
|
||||
</a>
|
||||
<% } %>
|
||||
</article>
|
||||
<% } %>
|
||||
</nav>
|
||||
|
||||
<div class='sidebar-container'></div>
|
||||
@ -54,13 +52,15 @@
|
||||
<div class='content'>
|
||||
<div class='post-container'></div>
|
||||
|
||||
<% if (ctx.canListComments) { %>
|
||||
<div class='comments-container'></div>
|
||||
<% } %>
|
||||
<div class='after-mobile-controls'>
|
||||
<% if (ctx.canCreateComments) { %>
|
||||
<h2>Add comment</h2>
|
||||
<div class='comment-form-container'></div>
|
||||
<% } %>
|
||||
|
||||
<% if (ctx.canCreateComments) { %>
|
||||
<h2>Add comment</h2>
|
||||
<div class='comment-form-container'></div>
|
||||
<% } %>
|
||||
<% if (ctx.canListComments) { %>
|
||||
<div class='comments-container'></div>
|
||||
<% } %>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -17,8 +17,8 @@
|
||||
'video/mp4': 'MPEG-4',
|
||||
'video/quicktime': 'MOV',
|
||||
'application/x-shockwave-flash': 'SWF',
|
||||
}[ctx.post.mimeType] %>
|
||||
</a>
|
||||
}[ctx.post.mimeType] %><!--
|
||||
--></a>
|
||||
(<%- ctx.post.canvasWidth %>x<%- ctx.post.canvasHeight %>)
|
||||
<% if (ctx.post.flags.length) { %><!--
|
||||
--><% if (ctx.post.flags.includes('loop')) { %><i class='fa fa-repeat'></i><% } %><!--
|
||||
@ -58,7 +58,7 @@
|
||||
Search on
|
||||
<a href='http://iqdb.org/?url=<%- encodeURIComponent(ctx.post.fullContentUrl) %>'>IQDB</a> ·
|
||||
<a href='https://danbooru.donmai.us/posts?tags=md5:<%- ctx.post.checksumMD5 %>'>Danbooru</a> ·
|
||||
<a href='https://www.google.com/searchbyimage?&image_url=<%- encodeURIComponent(ctx.post.fullContentUrl) %>'>Google Images</a>
|
||||
<a href='https://lens.google.com/uploadbyurl?url=<%- encodeURIComponent(ctx.post.fullContentUrl) %>'>Google Images</a>
|
||||
</section>
|
||||
|
||||
<section class='social'>
|
||||
@ -99,10 +99,10 @@
|
||||
--><% if (ctx.canListPosts) { %><!--
|
||||
--><a href='<%- ctx.formatClientLink('posts', {query: ctx.escapeTagName(tag.names[0])}) %>' class='<%= ctx.makeCssName(tag.category, 'tag') %>'><!--
|
||||
--><% } %><!--
|
||||
--><%- ctx.getPrettyName(tag.names[0]) %> <!--
|
||||
--><%- ctx.getPrettyName(tag.names[0]) %><!--
|
||||
--><% if (ctx.canListPosts) { %><!--
|
||||
--></a><!--
|
||||
--><% } %><!--
|
||||
--><% } %> <!--
|
||||
--><span class='tag-usages' data-pseudo-content='<%- tag.postCount %>'></span><!--
|
||||
--></li><!--
|
||||
--><% } %><!--
|
||||
|
@ -10,7 +10,7 @@
|
||||
|
||||
<div class='thumbnail'>
|
||||
<a href='<%= ctx.uploadable.previewUrl %>'>
|
||||
<video id='video' nocontrols muted>
|
||||
<video nocontrols muted>
|
||||
<source type='<%- ctx.uploadable.mimeType %>' src='<%- ctx.uploadable.previewUrl %>'/>
|
||||
</video>
|
||||
</a>
|
||||
|
@ -16,7 +16,6 @@
|
||||
%><form class='horizontal bulk-edit bulk-edit-tags'><%
|
||||
%><span class='append hint'>Tagging with:</span><%
|
||||
%><a href class='mousetrap button append open'>Mass tag</a><%
|
||||
%><wbr/><%
|
||||
%><%= ctx.makeTextInput({name: 'tag', value: ctx.parameters.tag}) %><%
|
||||
%><input class='mousetrap start' type='submit' value='Start tagging'/><%
|
||||
%><a href class='mousetrap button append close'>Stop tagging</a><%
|
||||
|
@ -294,11 +294,16 @@ class Api extends events.EventTarget {
|
||||
// transform the request: upload each file, then make the request use
|
||||
// its tokens.
|
||||
data = Object.assign({}, data);
|
||||
let fileData = {};
|
||||
let abortFunction = () => {};
|
||||
let promise = Promise.resolve();
|
||||
if (files) {
|
||||
for (let key of Object.keys(files)) {
|
||||
const file = files[key];
|
||||
if (file === null) {
|
||||
fileData[key] = null;
|
||||
continue;
|
||||
}
|
||||
const fileId = this._getFileId(file);
|
||||
if (fileTokens[fileId]) {
|
||||
data[key + "Token"] = fileTokens[fileId];
|
||||
@ -324,7 +329,7 @@ class Api extends events.EventTarget {
|
||||
url,
|
||||
requestFactory,
|
||||
data,
|
||||
{},
|
||||
fileData,
|
||||
options
|
||||
);
|
||||
abortFunction = () => requestPromise.abort();
|
||||
@ -388,7 +393,7 @@ class Api extends events.EventTarget {
|
||||
if (files) {
|
||||
for (let key of Object.keys(files)) {
|
||||
const value = files[key];
|
||||
if (value.constructor === String) {
|
||||
if (value !== null && value.constructor === String) {
|
||||
data[key + "Url"] = value;
|
||||
} else {
|
||||
req.attach(key, value || new Blob());
|
||||
|
@ -8,7 +8,7 @@ const PageController = require("../controllers/page_controller.js");
|
||||
const CommentsPageView = require("../views/comments_page_view.js");
|
||||
const EmptyView = require("../views/empty_view.js");
|
||||
|
||||
const fields = ["id", "comments", "commentCount", "thumbnailUrl"];
|
||||
const fields = ["id", "comments", "commentCount", "thumbnailUrl", "customThumbnailUrl"];
|
||||
|
||||
class CommentsController {
|
||||
constructor(ctx) {
|
||||
|
@ -91,16 +91,16 @@ class PoolController {
|
||||
_evtUpdate(e) {
|
||||
this._view.clearMessages();
|
||||
this._view.disableForm();
|
||||
if (e.detail.names !== undefined) {
|
||||
if (e.detail.names !== undefined && e.detail.names !== null) {
|
||||
e.detail.pool.names = e.detail.names;
|
||||
}
|
||||
if (e.detail.category !== undefined) {
|
||||
if (e.detail.category !== undefined && e.detail.category !== null) {
|
||||
e.detail.pool.category = e.detail.category;
|
||||
}
|
||||
if (e.detail.description !== undefined) {
|
||||
if (e.detail.description !== undefined && e.detail.description !== null) {
|
||||
e.detail.pool.description = e.detail.description;
|
||||
}
|
||||
if (e.detail.posts !== undefined) {
|
||||
if (e.detail.posts !== undefined && e.detail.posts !== null) {
|
||||
e.detail.pool.posts.clear();
|
||||
for (let postId of e.detail.posts) {
|
||||
e.detail.pool.posts.add(
|
||||
|
@ -43,6 +43,8 @@ class PoolListController {
|
||||
this._headerView.addEventListener(
|
||||
"submit",
|
||||
(e) => this._evtSubmit(e),
|
||||
);
|
||||
this._headerView.addEventListener(
|
||||
"navigate",
|
||||
(e) => this._evtNavigate(e)
|
||||
);
|
||||
|
@ -14,6 +14,7 @@ const EmptyView = require("../views/empty_view.js");
|
||||
const fields = [
|
||||
"id",
|
||||
"thumbnailUrl",
|
||||
"customThumbnailUrl",
|
||||
"type",
|
||||
"safety",
|
||||
"score",
|
||||
|
@ -169,24 +169,20 @@ class PostMainController extends BasePostController {
|
||||
this._view.sidebarControl.disableForm();
|
||||
this._view.sidebarControl.clearMessages();
|
||||
const post = e.detail.post;
|
||||
if (e.detail.safety !== undefined) {
|
||||
if (e.detail.safety !== undefined && e.detail.safety !== null) {
|
||||
post.safety = e.detail.safety;
|
||||
}
|
||||
if (e.detail.flags !== undefined) {
|
||||
if (e.detail.flags !== undefined && e.detail.flags !== null) {
|
||||
post.flags = e.detail.flags;
|
||||
}
|
||||
if (e.detail.relations !== undefined) {
|
||||
if (e.detail.relations !== undefined && e.detail.relations !== null) {
|
||||
post.relations = e.detail.relations;
|
||||
}
|
||||
if (e.detail.content !== undefined) {
|
||||
post.newContent = e.detail.content;
|
||||
}
|
||||
if (e.detail.thumbnail !== undefined) {
|
||||
post.newThumbnail = e.detail.thumbnail;
|
||||
}
|
||||
if (e.detail.source !== undefined) {
|
||||
if (e.detail.source !== undefined && e.detail.source !== null) {
|
||||
post.source = e.detail.source;
|
||||
}
|
||||
post.newContent = e.detail.content;
|
||||
post.newThumbnail = e.detail.thumbnail;
|
||||
post.save().then(
|
||||
() => {
|
||||
this._view.sidebarControl.showSuccess("Post saved.");
|
||||
|
@ -95,13 +95,13 @@ class TagController {
|
||||
_evtUpdate(e) {
|
||||
this._view.clearMessages();
|
||||
this._view.disableForm();
|
||||
if (e.detail.names !== undefined) {
|
||||
if (e.detail.names !== undefined && e.detail.names !== null) {
|
||||
e.detail.tag.names = e.detail.names;
|
||||
}
|
||||
if (e.detail.category !== undefined) {
|
||||
if (e.detail.category !== undefined && e.detail.category !== null) {
|
||||
e.detail.tag.category = e.detail.category;
|
||||
}
|
||||
if (e.detail.description !== undefined) {
|
||||
if (e.detail.description !== undefined && e.detail.description !== null) {
|
||||
e.detail.tag.description = e.detail.description;
|
||||
}
|
||||
e.detail.tag.save().then(
|
||||
|
@ -175,21 +175,21 @@ class UserController {
|
||||
const isLoggedIn = api.isLoggedIn(e.detail.user);
|
||||
const infix = isLoggedIn ? "self" : "any";
|
||||
|
||||
if (e.detail.name !== undefined) {
|
||||
if (e.detail.name !== undefined && e.detail.name !== null) {
|
||||
e.detail.user.name = e.detail.name;
|
||||
}
|
||||
if (e.detail.email !== undefined) {
|
||||
if (e.detail.email !== undefined && e.detail.email !== null) {
|
||||
e.detail.user.email = e.detail.email;
|
||||
}
|
||||
if (e.detail.rank !== undefined) {
|
||||
if (e.detail.rank !== undefined && e.detail.rank !== null) {
|
||||
e.detail.user.rank = e.detail.rank;
|
||||
}
|
||||
|
||||
if (e.detail.password !== undefined) {
|
||||
if (e.detail.password !== undefined && e.detail.password !== null) {
|
||||
e.detail.user.password = e.detail.password;
|
||||
}
|
||||
|
||||
if (e.detail.avatarStyle !== undefined) {
|
||||
if (e.detail.avatarStyle !== undefined && e.detail.avatarStyle !== null) {
|
||||
e.detail.user.avatarStyle = e.detail.avatarStyle;
|
||||
if (e.detail.avatarContent) {
|
||||
e.detail.user.avatarContent = e.detail.avatarContent;
|
||||
@ -302,7 +302,7 @@ class UserController {
|
||||
this._view.clearMessages();
|
||||
this._view.disableForm();
|
||||
|
||||
if (e.detail.note !== undefined) {
|
||||
if (e.detail.note !== undefined && e.detail.note !== null) {
|
||||
e.detail.userToken.note = e.detail.note;
|
||||
}
|
||||
|
||||
|
@ -48,6 +48,12 @@ class FileDropperControl extends events.EventTarget {
|
||||
this._urlInputNode.addEventListener("keydown", (e) =>
|
||||
this._evtUrlInputKeyDown(e)
|
||||
);
|
||||
this._urlInputNode.addEventListener("paste", (e) => {
|
||||
// document.onpaste is used on the post-upload page.
|
||||
// And this event is used on the post edit page.
|
||||
if (document.getElementById("post-upload")) return;
|
||||
this._evtPaste(e)
|
||||
});
|
||||
}
|
||||
if (this._urlConfirmButtonNode) {
|
||||
this._urlConfirmButtonNode.addEventListener("click", (e) =>
|
||||
@ -55,6 +61,11 @@ class FileDropperControl extends events.EventTarget {
|
||||
);
|
||||
}
|
||||
|
||||
document.onpaste = (e) => {
|
||||
if (!document.getElementById("post-upload")) return;
|
||||
this._evtPaste(e)
|
||||
}
|
||||
|
||||
this._originalHtml = this._dropperNode.innerHTML;
|
||||
views.replaceContent(target, source);
|
||||
}
|
||||
@ -129,6 +140,17 @@ class FileDropperControl extends events.EventTarget {
|
||||
this._emitFiles(e.dataTransfer.files);
|
||||
}
|
||||
|
||||
_evtPaste(e) {
|
||||
const items = (e.clipboardData || e.originalEvent.clipboardData).items;
|
||||
const fileList = Array.from(items).map((x) => x.getAsFile()).filter(f => f);
|
||||
|
||||
if (!this._options.allowMultiple && fileList.length > 1) {
|
||||
window.alert("Cannot select multiple files.");
|
||||
} else if (fileList.length > 0) {
|
||||
this._emitFiles(fileList);
|
||||
}
|
||||
}
|
||||
|
||||
_evtUrlInputKeyDown(e) {
|
||||
if (e.which !== KEY_RETURN) {
|
||||
return;
|
||||
|
@ -103,6 +103,30 @@ class PostContentControl {
|
||||
}
|
||||
|
||||
_refreshSize() {
|
||||
if (window.innerWidth <= 800) {
|
||||
const buttons = document.querySelector(".sidebar > .buttons");
|
||||
if (buttons) {
|
||||
const content = document.querySelector(".content");
|
||||
content.insertBefore(buttons, content.querySelector(".post-container + *"));
|
||||
|
||||
const afterControls = document.querySelector(".content > .after-mobile-controls");
|
||||
if (afterControls) {
|
||||
afterControls.parentElement.parentElement.appendChild(afterControls);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
const buttons = document.querySelector(".content > .buttons");
|
||||
if (buttons) {
|
||||
const sidebar = document.querySelector(".sidebar");
|
||||
sidebar.insertBefore(buttons, sidebar.firstElementChild);
|
||||
}
|
||||
|
||||
const afterControls = document.querySelector(".content + .after-mobile-controls");
|
||||
if (afterControls) {
|
||||
document.querySelector(".content").appendChild(afterControls);
|
||||
}
|
||||
}
|
||||
|
||||
this._currentFitFunction();
|
||||
}
|
||||
|
||||
@ -119,9 +143,28 @@ class PostContentControl {
|
||||
post: this._post,
|
||||
autoplay: settings.get().autoplayVideos,
|
||||
});
|
||||
if (settings.get().transparencyGrid) {
|
||||
function load(argument) {
|
||||
if (settings.get().transparencyGrid) {
|
||||
newNode.classList.add("transparency-grid");
|
||||
}
|
||||
newNode.firstElementChild.style.backgroundImage = "";
|
||||
}
|
||||
if (["image", "flash"].includes(this._post.type)) {
|
||||
newNode.firstElementChild.style.backgroundImage = "url("+this._post.originalThumbnailUrl+")";
|
||||
}
|
||||
if (this._post.type == "image") {
|
||||
newNode.firstElementChild.addEventListener("load", load);
|
||||
} else if (settings.get().transparencyGrid) {
|
||||
newNode.classList.add("transparency-grid");
|
||||
}
|
||||
newNode.firstElementChild.addEventListener("error", (e) => {
|
||||
newNode.classList.add("post-error");
|
||||
if (["image", "animation"].includes(this._post.type)) {
|
||||
newNode.firstElementChild.removeEventListener("load", load);
|
||||
newNode.firstElementChild.style.backgroundImage = "url("+this._post.originalThumbnailUrl+")";
|
||||
newNode.firstElementChild.src = "data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7";
|
||||
}
|
||||
});
|
||||
if (this._postContentNode) {
|
||||
this._hostNode.replaceChild(newNode, this._postContentNode);
|
||||
} else {
|
||||
|
@ -138,10 +138,7 @@ class PostEditSidebarControl extends events.EventTarget {
|
||||
this._thumbnailRemovalLinkNode.addEventListener("click", (e) =>
|
||||
this._evtRemoveThumbnailClick(e)
|
||||
);
|
||||
this._thumbnailRemovalLinkNode.style.display = this._post
|
||||
.hasCustomThumbnail
|
||||
? "block"
|
||||
: "none";
|
||||
this._thumbnailRemovalLinkUpdate(this._post);
|
||||
}
|
||||
|
||||
if (this._addNoteLinkNode) {
|
||||
@ -249,12 +246,25 @@ class PostEditSidebarControl extends events.EventTarget {
|
||||
this._poolsExpander.title = `Pools (${this._post.pools.length})`;
|
||||
}
|
||||
|
||||
_thumbnailRemovalLinkUpdate(post) {
|
||||
if (this._thumbnailRemovalLinkNode) {
|
||||
this._thumbnailRemovalLinkNode.style.display = post
|
||||
.customThumbnailUrl
|
||||
? "block"
|
||||
: "none";
|
||||
}
|
||||
}
|
||||
|
||||
_evtPostContentChange(e) {
|
||||
this._contentFileDropper.reset();
|
||||
this._thumbnailRemovalLinkUpdate(e.detail.post);
|
||||
this._newPostContent = null;
|
||||
}
|
||||
|
||||
_evtPostThumbnailChange(e) {
|
||||
this._thumbnailFileDropper.reset();
|
||||
this._thumbnailRemovalLinkUpdate(e.detail.post);
|
||||
this._newPostThumbnail = undefined;
|
||||
}
|
||||
|
||||
_evtRemoveThumbnailClick(e) {
|
||||
@ -427,9 +437,7 @@ class PostEditSidebarControl extends events.EventTarget {
|
||||
: undefined,
|
||||
|
||||
thumbnail:
|
||||
this._newPostThumbnail !== undefined
|
||||
? this._newPostThumbnail
|
||||
: undefined,
|
||||
this._newPostThumbnail,
|
||||
|
||||
source: this._sourceInputNode
|
||||
? this._sourceInputNode.value
|
||||
|
@ -70,6 +70,14 @@ class Post extends events.EventTarget {
|
||||
return this._thumbnailUrl;
|
||||
}
|
||||
|
||||
get customThumbnailUrl() {
|
||||
return this._customThumbnailUrl;
|
||||
}
|
||||
|
||||
get originalThumbnailUrl() {
|
||||
return this._originalThumbnailUrl;
|
||||
}
|
||||
|
||||
get source() {
|
||||
return this._source;
|
||||
}
|
||||
@ -146,10 +154,6 @@ class Post extends events.EventTarget {
|
||||
return this._ownScore;
|
||||
}
|
||||
|
||||
get hasCustomThumbnail() {
|
||||
return this._hasCustomThumbnail;
|
||||
}
|
||||
|
||||
set flags(value) {
|
||||
this._flags = value;
|
||||
}
|
||||
@ -271,7 +275,7 @@ class Post extends events.EventTarget {
|
||||
if (this._newContent) {
|
||||
files.content = this._newContent;
|
||||
}
|
||||
if (this._newThumbnail !== undefined) {
|
||||
if (this._newThumbnail !== undefined && this._newThumbnail !== null) {
|
||||
files.thumbnail = this._newThumbnail;
|
||||
}
|
||||
if (this._source !== this._orig._source) {
|
||||
@ -477,7 +481,9 @@ class Post extends events.EventTarget {
|
||||
response.contentUrl,
|
||||
document.getElementsByTagName("base")[0].href
|
||||
).href,
|
||||
_thumbnailUrl: response.thumbnailUrl,
|
||||
_thumbnailUrl: response.customThumbnailUrl ? response.customThumbnailUrl : response.thumbnailUrl,
|
||||
_customThumbnailUrl: response.customThumbnailUrl,
|
||||
_originalThumbnailUrl: response.thumbnailUrl,
|
||||
_source: response.source,
|
||||
_canvasWidth: response.canvasWidth,
|
||||
_canvasHeight: response.canvasHeight,
|
||||
@ -491,7 +497,6 @@ class Post extends events.EventTarget {
|
||||
_favoriteCount: response.favoriteCount,
|
||||
_ownScore: response.ownScore,
|
||||
_ownFavorite: response.ownFavorite,
|
||||
_hasCustomThumbnail: response.hasCustomThumbnail,
|
||||
});
|
||||
|
||||
for (let obj of [this, this._orig]) {
|
||||
|
@ -49,7 +49,7 @@ function makeThumbnail(url) {
|
||||
style: `background-image: url(\'${url}\')`,
|
||||
}
|
||||
: { class: "thumbnail empty" },
|
||||
makeElement("img", { alt: "thumbnail", src: url })
|
||||
makeElement("img", { alt: "thumbnail", src: url, draggable: "false" })
|
||||
);
|
||||
}
|
||||
|
||||
@ -209,13 +209,13 @@ function makePostLink(id, includeHash) {
|
||||
}
|
||||
|
||||
function makeTagLink(name, includeHash, includeCount, tag) {
|
||||
const category = tag ? tag.category : "unknown";
|
||||
const category = tag && tag.category ? tag.category : "unknown";
|
||||
let text = misc.getPrettyName(name);
|
||||
if (includeHash === true) {
|
||||
text = "#" + text;
|
||||
}
|
||||
if (includeCount === true) {
|
||||
text += " (" + (tag ? tag.postCount : 0) + ")";
|
||||
text += " (" + (tag && tag.postCount ? tag.postCount : 0) + ")";
|
||||
}
|
||||
return api.hasPrivilege("tags:view")
|
||||
? makeElement(
|
||||
@ -234,15 +234,15 @@ function makeTagLink(name, includeHash, includeCount, tag) {
|
||||
}
|
||||
|
||||
function makePoolLink(id, includeHash, includeCount, pool, name) {
|
||||
const category = pool ? pool.category : "unknown";
|
||||
const category = pool && pool.category ? pool.category : "unknown";
|
||||
let text = misc.getPrettyName(
|
||||
name ? name : pool ? pool.names[0] : "unknown"
|
||||
name ? name : pool && pool.names ? pool.names[0] : "pool " + id
|
||||
);
|
||||
if (includeHash === true) {
|
||||
text = "#" + text;
|
||||
}
|
||||
if (includeCount === true) {
|
||||
text += " (" + (pool ? pool.postCount : 0) + ")";
|
||||
text += " (" + (pool && pool.postCount ? pool.postCount : 0) + ")";
|
||||
}
|
||||
return api.hasPrivilege("pools:view")
|
||||
? makeElement(
|
||||
@ -264,7 +264,7 @@ function makeUserLink(user) {
|
||||
let text = makeThumbnail(user ? user.avatarUrl : null);
|
||||
text += user && user.name ? misc.escapeHtml(user.name) : "Anonymous";
|
||||
const link =
|
||||
user && api.hasPrivilege("users:view")
|
||||
user && user.name && api.hasPrivilege("users:view")
|
||||
? makeElement(
|
||||
"a",
|
||||
{ href: uri.formatClientLink("user", user.name) },
|
||||
|
@ -401,6 +401,14 @@ class PostUploadView extends events.EventTarget {
|
||||
.addEventListener("click", (e) =>
|
||||
this._evtMoveClick(e, uploadable, 1)
|
||||
);
|
||||
if (uploadable.type == "video") {
|
||||
const video = rowNode.querySelector("video");
|
||||
if (video) {
|
||||
video.addEventListener("loadedmetadata", (e) => {
|
||||
if (!isNaN(video.duration)) video.currentTime = Math.floor(video.duration * 0.3)
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
_updateThumbnailNode(uploadable) {
|
||||
|
16
doc/API.md
16
doc/API.md
@ -54,7 +54,7 @@
|
||||
- [Deleting pool category](#deleting-pool-category)
|
||||
- [Setting default pool category](#setting-default-pool-category)
|
||||
- Pools
|
||||
- [Listing pools](#listing-pool)
|
||||
- [Listing pools](#listing-pools)
|
||||
- [Creating pool](#creating-pool)
|
||||
- [Updating pool](#updating-pool)
|
||||
- [Getting pool](#getting-pool)
|
||||
@ -165,9 +165,9 @@ way. The files, however, should be passed as regular fields appended with a
|
||||
accepts a file named `content`, the client should pass
|
||||
`{"contentUrl":"http://example.com/file.jpg"}` as a part of the JSON message
|
||||
body. When creating or updating post content using this method, the server can
|
||||
also be configured to employ [youtube-dl](https://github.com/ytdl-org/youtube-dl)
|
||||
to download content from popular sites such as youtube, gfycat, etc. Access to
|
||||
youtube-dl can be configured with the `'uploads:use_downloader'` permission
|
||||
also be configured to employ [yt-dlp](https://github.com/yt-dlp/yt-dlp) to
|
||||
download content from popular sites such as youtube, gfycat, etc. Access to
|
||||
yt-dlp can be configured with the `'uploads:use_downloader'` permission
|
||||
|
||||
Finally, in some cases the user might want to reuse one file between the
|
||||
requests to save the bandwidth (for example, reverse search + consecutive
|
||||
@ -323,7 +323,7 @@ data.
|
||||
{
|
||||
"name": <name>,
|
||||
"color": <color>,
|
||||
"order": <order> // optional
|
||||
"order": <order>
|
||||
}
|
||||
```
|
||||
|
||||
@ -789,7 +789,7 @@ data.
|
||||
| `fav-time` | alias of `fav-date` |
|
||||
| `feature-date` | featured at given date |
|
||||
| `feature-time` | alias of `feature-time` |
|
||||
| `safety` | having given safety. `<value>` can be either `safe`, `sketchy` (or `questionable`) or `unsafe`. |
|
||||
| `safety` | having given safety. `<value>` can be either `safe`, `sketchy` or `unsafe`. |
|
||||
| `rating` | alias of `safety` |
|
||||
|
||||
**Sort style tokens**
|
||||
@ -1389,7 +1389,7 @@ data.
|
||||
## Creating pool
|
||||
- **Request**
|
||||
|
||||
`POST /pools/create`
|
||||
`POST /pool`
|
||||
|
||||
- **Input**
|
||||
|
||||
@ -2491,7 +2491,7 @@ One file together with its metadata posted to the site.
|
||||
## Micro post
|
||||
**Description**
|
||||
|
||||
A [post resource](#post) stripped down to `name` and `thumbnailUrl` fields.
|
||||
A [post resource](#post) stripped down to `id` and `thumbnailUrl` fields.
|
||||
|
||||
## Note
|
||||
**Description**
|
||||
|
@ -1,5 +1,5 @@
|
||||
This assumes that you have Docker (version 17.05 or greater)
|
||||
and Docker Compose (version 1.6.0 or greater) already installed.
|
||||
This assumes that you have Docker (version 19.03 or greater)
|
||||
and the Docker Compose CLI (version 1.27.0 or greater) already installed.
|
||||
|
||||
### Prepare things
|
||||
|
||||
@ -38,7 +38,7 @@ and Docker Compose (version 1.6.0 or greater) already installed.
|
||||
|
||||
This pulls the latest containers from docker.io:
|
||||
```console
|
||||
user@host:szuru$ docker-compose pull
|
||||
user@host:szuru$ docker compose pull
|
||||
```
|
||||
|
||||
If you have modified the application's source and would like to manually
|
||||
@ -49,17 +49,17 @@ and Docker Compose (version 1.6.0 or greater) already installed.
|
||||
|
||||
For first run, it is recommended to start the database separately:
|
||||
```console
|
||||
user@host:szuru$ docker-compose up -d sql
|
||||
user@host:szuru$ docker compose up -d sql
|
||||
```
|
||||
|
||||
To start all containers:
|
||||
```console
|
||||
user@host:szuru$ docker-compose up -d
|
||||
user@host:szuru$ docker compose up -d
|
||||
```
|
||||
|
||||
To view/monitor the application logs:
|
||||
```console
|
||||
user@host:szuru$ docker-compose logs -f
|
||||
user@host:szuru$ docker compose logs -f
|
||||
# (CTRL+C to exit)
|
||||
```
|
||||
|
||||
@ -84,13 +84,13 @@ and Docker Compose (version 1.6.0 or greater) already installed.
|
||||
2. Build the containers:
|
||||
|
||||
```console
|
||||
user@host:szuru$ docker-compose build
|
||||
user@host:szuru$ docker compose build
|
||||
```
|
||||
|
||||
That will attempt to build both containers, but you can specify `client`
|
||||
or `server` to make it build only one.
|
||||
|
||||
If `docker-compose build` spits out:
|
||||
If `docker compose build` spits out:
|
||||
|
||||
```
|
||||
ERROR: Service 'server' failed to build: failed to parse platform : "" is an invalid component of "": platform specifier component must match "^[A-Za-z0-9_-]+$": invalid argument
|
||||
@ -102,7 +102,7 @@ and Docker Compose (version 1.6.0 or greater) already installed.
|
||||
user@host:szuru$ export DOCKER_BUILDKIT=1; export COMPOSE_DOCKER_CLI_BUILD=1
|
||||
```
|
||||
|
||||
...and run `docker-compose build` again.
|
||||
...and run `docker compose build` again.
|
||||
|
||||
*Note: If your changes are not taking effect in your builds, consider building
|
||||
with `--no-cache`.*
|
||||
@ -117,7 +117,7 @@ with `--no-cache`.*
|
||||
run from docker:
|
||||
|
||||
```console
|
||||
user@host:szuru$ docker-compose run server ./szuru-admin --help
|
||||
user@host:szuru$ docker compose run server ./szuru-admin --help
|
||||
```
|
||||
|
||||
will give you a breakdown on all available commands.
|
||||
|
@ -1,9 +1,7 @@
|
||||
## Example Docker Compose configuration
|
||||
##
|
||||
## Use this as a template to set up docker-compose, or as guide to set up other
|
||||
## Use this as a template to set up docker compose, or as guide to set up other
|
||||
## orchestration services
|
||||
version: '2'
|
||||
|
||||
services:
|
||||
|
||||
server:
|
||||
|
@ -23,15 +23,30 @@ RUN apk --no-cache add \
|
||||
py3-pillow \
|
||||
py3-pynacl \
|
||||
py3-tz \
|
||||
py3-pyrfc3339 \
|
||||
&& pip3 install --no-cache-dir --disable-pip-version-check \
|
||||
py3-pyrfc3339
|
||||
RUN pip3 install --no-cache-dir --disable-pip-version-check \
|
||||
"alembic>=0.8.5" \
|
||||
"coloredlogs==5.0" \
|
||||
"pyheif==0.6.1" \
|
||||
"heif-image-plugin>=0.3.2" \
|
||||
youtube_dl \
|
||||
"pillow-avif-plugin>=1.1.0" \
|
||||
&& apk --no-cache del py3-pip
|
||||
yt-dlp \
|
||||
"pillow-avif-plugin~=1.1.0"
|
||||
RUN apk --no-cache del py3-pip
|
||||
|
||||
# build and install mozjpeg
|
||||
RUN apk --no-cache add automake cmake nasm libpng-dev libpng curl
|
||||
RUN curl -fL "https://github.com/mozilla/mozjpeg/archive/refs/tags/v4.1.1.tar.gz" -o mozjpeg.tar.gz
|
||||
RUN tar xzf mozjpeg.tar.gz && cd mozjpeg-* \
|
||||
&& cmake -DCMAKE_INSTALL_PREFIX=/usr \
|
||||
-DCMAKE_INSTALL_LIBDIR=/usr/lib \
|
||||
-DBUILD_SHARED_LIBS=True \
|
||||
-DCMAKE_BUILD_TYPE=None \
|
||||
-DCMAKE_C_FLAGS="$CFLAGS" \
|
||||
-DWITH_JPEG8=1 \
|
||||
-DWITH_TURBOJPEG=1 \
|
||||
-DENABLE_STATIC=0 \
|
||||
&& make install && cd
|
||||
RUN apk --no-cache del automake nasm cmake curl
|
||||
|
||||
COPY ./ /opt/app/
|
||||
RUN rm -rf /opt/app/szurubooru/tests
|
||||
|
@ -3,7 +3,7 @@ certifi>=2017.11.5
|
||||
coloredlogs==5.0
|
||||
heif-image-plugin==0.3.2
|
||||
numpy>=1.8.2
|
||||
pillow-avif-plugin>=1.1.0
|
||||
pillow-avif-plugin~=1.1.0
|
||||
pillow>=4.3.0
|
||||
psycopg2-binary>=2.6.1
|
||||
pyheif==0.6.1
|
||||
@ -12,4 +12,4 @@ pyRFC3339>=1.0
|
||||
pytz>=2018.3
|
||||
pyyaml>=3.11
|
||||
SQLAlchemy>=1.0.12, <1.4
|
||||
youtube_dl
|
||||
yt-dlp
|
||||
|
@ -13,7 +13,7 @@ from getpass import getpass
|
||||
from sys import stderr
|
||||
|
||||
from szurubooru import config, db, errors, model
|
||||
from szurubooru.func import files, images
|
||||
from szurubooru.func import files, images, mime
|
||||
from szurubooru.func import posts as postfuncs
|
||||
from szurubooru.func import users as userfuncs
|
||||
|
||||
@ -95,7 +95,14 @@ def regenerate_thumbnails() -> None:
|
||||
for post in db.session.query(model.Post).all():
|
||||
print("Generating tumbnail for post %d ..." % post.post_id, end="\r")
|
||||
try:
|
||||
postfuncs.generate_post_thumbnail(post)
|
||||
content = files.get(postfuncs.get_post_content_path(post))
|
||||
postfuncs.generate_post_thumbnail(postfuncs.get_post_thumbnail_path(post), content, seek=False)
|
||||
|
||||
custom_content = files.get(postfuncs.get_post_custom_content_path(post))
|
||||
if custom_content:
|
||||
generate_post_thumbnail(get_post_custom_thumbnail_path(post), custom_content, seek=True)
|
||||
elif mime.is_video(post.mime_type):
|
||||
generate_post_thumbnail(get_post_custom_thumbnail_path(post), content, seek=True)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
@ -21,7 +21,7 @@ def _merge(left: Dict, right: Dict) -> Dict:
|
||||
return left
|
||||
|
||||
|
||||
def _docker_config() -> Dict:
|
||||
def _container_config() -> Dict:
|
||||
if "TEST_ENVIRONMENT" not in os.environ:
|
||||
for key in ["POSTGRES_USER", "POSTGRES_PASSWORD", "POSTGRES_HOST"]:
|
||||
if key not in os.environ:
|
||||
@ -49,6 +49,15 @@ def _file_config(filename: str) -> Dict:
|
||||
return yaml.load(handle.read(), Loader=yaml.SafeLoader) or {}
|
||||
|
||||
|
||||
def _running_inside_container() -> bool:
|
||||
env = os.environ.keys()
|
||||
return (
|
||||
os.path.exists("/.dockerenv")
|
||||
or "KUBERNETES_SERVICE_HOST" in env
|
||||
or "container" in env # set by lxc/podman
|
||||
)
|
||||
|
||||
|
||||
def _read_config() -> Dict:
|
||||
ret = _file_config("config.yaml.dist")
|
||||
if os.path.isfile("config.yaml"):
|
||||
@ -57,8 +66,8 @@ def _read_config() -> Dict:
|
||||
logger.warning(
|
||||
"'config.yaml' should be a file, not a directory, skipping"
|
||||
)
|
||||
if os.path.exists("/.dockerenv"):
|
||||
ret = _merge(ret, _docker_config())
|
||||
if _running_inside_container():
|
||||
ret = _merge(ret, _container_config())
|
||||
return ret
|
||||
|
||||
|
||||
|
@ -1,4 +1,5 @@
|
||||
import os
|
||||
import glob
|
||||
from typing import Any, List, Optional
|
||||
|
||||
from szurubooru import config
|
||||
@ -24,6 +25,10 @@ def scan(path: str) -> List[Any]:
|
||||
return []
|
||||
|
||||
|
||||
def find(path: str, pattern: str, recursive: bool = False) -> List[Any]:
|
||||
return glob.glob(glob.escape(_get_full_path(path) + "/") + pattern, recursive=recursive)
|
||||
|
||||
|
||||
def move(source_path: str, target_path: str) -> None:
|
||||
os.rename(_get_full_path(source_path), _get_full_path(target_path))
|
||||
|
||||
|
@ -24,6 +24,11 @@ def convert_heif_to_png(content: bytes) -> bytes:
|
||||
return img_byte_arr.getvalue()
|
||||
|
||||
|
||||
def check_for_loop(content: bytes) -> bytes:
|
||||
img = PILImage.open(BytesIO(content))
|
||||
return "loop" in img.info
|
||||
|
||||
|
||||
class Image:
|
||||
def __init__(self, content: bytes) -> None:
|
||||
self.content = content
|
||||
@ -41,7 +46,7 @@ class Image:
|
||||
def frames(self) -> int:
|
||||
return self.info["streams"][0]["nb_read_frames"]
|
||||
|
||||
def resize_fill(self, width: int, height: int) -> None:
|
||||
def resize_fill(self, width: int, height: int, keep_transparency: bool = True, seek=True) -> None:
|
||||
width_greater = self.width > self.height
|
||||
width, height = (-1, height) if width_greater else (width, -1)
|
||||
|
||||
@ -50,8 +55,12 @@ class Image:
|
||||
"{path}",
|
||||
"-f",
|
||||
"image2",
|
||||
"-filter:v",
|
||||
"scale='{width}:{height}'".format(width=width, height=height),
|
||||
"-filter_complex",
|
||||
(
|
||||
"format=rgb32,scale={width}:{height}:flags=bicubic"
|
||||
if keep_transparency else
|
||||
"[0:v]format=rgb32,scale={width}:{height}:flags=bicubic[a];color=white[b];[b][a]scale2ref[b][a];[b][a]overlay"
|
||||
).format(width=width, height=height),
|
||||
"-map",
|
||||
"0:v:0",
|
||||
"-vframes",
|
||||
@ -61,7 +70,8 @@ class Image:
|
||||
"-",
|
||||
]
|
||||
if (
|
||||
"duration" in self.info["format"]
|
||||
seek
|
||||
and "duration" in self.info["format"]
|
||||
and self.info["format"]["format_name"] != "swf"
|
||||
):
|
||||
duration = float(self.info["format"]["duration"])
|
||||
@ -96,24 +106,13 @@ class Image:
|
||||
def to_jpeg(self) -> bytes:
|
||||
return self._execute(
|
||||
[
|
||||
"-f",
|
||||
"lavfi",
|
||||
"-i",
|
||||
"color=white:s=%dx%d" % (self.width, self.height),
|
||||
"-i",
|
||||
"-quality",
|
||||
"85",
|
||||
"-sample",
|
||||
"1x1",
|
||||
"{path}",
|
||||
"-f",
|
||||
"image2",
|
||||
"-filter_complex",
|
||||
"overlay",
|
||||
"-map",
|
||||
"0:v:0",
|
||||
"-vframes",
|
||||
"1",
|
||||
"-vcodec",
|
||||
"mjpeg",
|
||||
"-",
|
||||
]
|
||||
],
|
||||
program="cjpeg",
|
||||
)
|
||||
|
||||
def to_webm(self) -> bytes:
|
||||
@ -274,7 +273,10 @@ class Image:
|
||||
with util.create_temp_file(suffix="." + extension) as handle:
|
||||
handle.write(self.content)
|
||||
handle.flush()
|
||||
cli = [program, "-loglevel", "32" if get_logs else "24"] + cli
|
||||
if program in ["ffmpeg", "ffprobe"]:
|
||||
cli = [program, "-loglevel", "32" if get_logs else "24"] + cli
|
||||
else:
|
||||
cli = [program] + cli
|
||||
cli = [part.format(path=handle.name) for part in cli]
|
||||
proc = subprocess.Popen(
|
||||
cli,
|
||||
@ -285,7 +287,7 @@ class Image:
|
||||
out, err = proc.communicate()
|
||||
if proc.returncode != 0:
|
||||
logger.warning(
|
||||
"Failed to execute ffmpeg command (cli=%r, err=%r)",
|
||||
"Failed to execute {program} command (cli=%r, err=%r)".format(program=program),
|
||||
" ".join(shlex.quote(arg) for arg in cli),
|
||||
err,
|
||||
)
|
||||
|
@ -64,7 +64,7 @@ def download(url: str, use_video_downloader: bool = False) -> bytes:
|
||||
|
||||
|
||||
def _get_youtube_dl_content_url(url: str) -> str:
|
||||
cmd = ["youtube-dl", "--format", "best", "--no-playlist"]
|
||||
cmd = ["yt-dlp", "--format", "best", "--no-playlist"]
|
||||
if config.config["user_agent"]:
|
||||
cmd.extend(["--user-agent", config.config["user_agent"]])
|
||||
cmd.extend(["--get-url", url])
|
||||
|
@ -117,7 +117,16 @@ def get_post_content_url(post: model.Post) -> str:
|
||||
|
||||
def get_post_thumbnail_url(post: model.Post) -> str:
|
||||
assert post
|
||||
return "%s/generated-thumbnails/%d_%s.jpg" % (
|
||||
return "%s/generated-thumbnails/sample_%d_%s.jpg" % (
|
||||
config.config["data_url"].rstrip("/"),
|
||||
post.post_id,
|
||||
get_post_security_hash(post.post_id),
|
||||
)
|
||||
|
||||
|
||||
def get_post_custom_thumbnail_url(post: model.Post) -> str:
|
||||
assert post
|
||||
return "%s/generated-thumbnails/custom-thumbnails/sample_%d_%s.jpg" % (
|
||||
config.config["data_url"].rstrip("/"),
|
||||
post.post_id,
|
||||
get_post_security_hash(post.post_id),
|
||||
@ -134,17 +143,26 @@ def get_post_content_path(post: model.Post) -> str:
|
||||
)
|
||||
|
||||
|
||||
def get_post_thumbnail_path(post: model.Post) -> str:
|
||||
def get_post_custom_content_path(post: model.Post) -> str:
|
||||
assert post
|
||||
return "generated-thumbnails/%d_%s.jpg" % (
|
||||
assert post.post_id
|
||||
return "posts/custom-thumbnails/%d_%s.dat" % (
|
||||
post.post_id,
|
||||
get_post_security_hash(post.post_id),
|
||||
)
|
||||
|
||||
|
||||
def get_post_thumbnail_backup_path(post: model.Post) -> str:
|
||||
def get_post_thumbnail_path(post: model.Post) -> str:
|
||||
assert post
|
||||
return "posts/custom-thumbnails/%d_%s.dat" % (
|
||||
return "generated-thumbnails/sample_%d_%s.jpg" % (
|
||||
post.post_id,
|
||||
get_post_security_hash(post.post_id),
|
||||
)
|
||||
|
||||
|
||||
def get_post_custom_thumbnail_path(post: model.Post) -> str:
|
||||
assert post
|
||||
return "generated-thumbnails/custom-thumbnails/sample_%d_%s.jpg" % (
|
||||
post.post_id,
|
||||
get_post_security_hash(post.post_id),
|
||||
)
|
||||
@ -180,6 +198,7 @@ class PostSerializer(serialization.BaseSerializer):
|
||||
"canvasHeight": self.serialize_canvas_height,
|
||||
"contentUrl": self.serialize_content_url,
|
||||
"thumbnailUrl": self.serialize_thumbnail_url,
|
||||
"customThumbnailUrl": self.serialize_custom_thumbnail_url,
|
||||
"flags": self.serialize_flags,
|
||||
"tags": self.serialize_tags,
|
||||
"relations": self.serialize_relations,
|
||||
@ -195,7 +214,6 @@ class PostSerializer(serialization.BaseSerializer):
|
||||
"featureCount": self.serialize_feature_count,
|
||||
"lastFeatureTime": self.serialize_last_feature_time,
|
||||
"favoritedBy": self.serialize_favorited_by,
|
||||
"hasCustomThumbnail": self.serialize_has_custom_thumbnail,
|
||||
"notes": self.serialize_notes,
|
||||
"comments": self.serialize_comments,
|
||||
"pools": self.serialize_pools,
|
||||
@ -319,8 +337,9 @@ class PostSerializer(serialization.BaseSerializer):
|
||||
for rel in self.post.favorited_by
|
||||
]
|
||||
|
||||
def serialize_has_custom_thumbnail(self) -> Any:
|
||||
return files.has(get_post_thumbnail_backup_path(self.post))
|
||||
def serialize_custom_thumbnail_url(self) -> Any:
|
||||
if files.has(get_post_custom_thumbnail_path(self.post)):
|
||||
return get_post_custom_thumbnail_url(self.post)
|
||||
|
||||
def serialize_notes(self) -> Any:
|
||||
return sorted(
|
||||
@ -357,7 +376,7 @@ def serialize_micro_post(
|
||||
post: model.Post, auth_user: model.User
|
||||
) -> Optional[rest.Response]:
|
||||
return serialize_post(
|
||||
post, auth_user=auth_user, options=["id", "thumbnailUrl"]
|
||||
post, auth_user=auth_user, options=["id", "thumbnailUrl", "customThumbnailUrl"]
|
||||
)
|
||||
|
||||
|
||||
@ -462,32 +481,28 @@ def _before_post_delete(
|
||||
) -> None:
|
||||
if post.post_id:
|
||||
if config.config["delete_source_files"]:
|
||||
files.delete(get_post_content_path(post))
|
||||
files.delete(get_post_thumbnail_path(post))
|
||||
pattern = f"{post.post_id}_*"
|
||||
for file in files.find("posts", "**/" + pattern, recursive=True) + files.find("generated-thumbnails", "**/sample_" + pattern, recursive=True):
|
||||
files.delete(file)
|
||||
|
||||
|
||||
def _sync_post_content(post: model.Post) -> None:
|
||||
regenerate_thumb = False
|
||||
|
||||
if hasattr(post, "__content"):
|
||||
content = getattr(post, "__content")
|
||||
files.save(get_post_content_path(post), content)
|
||||
generate_post_thumbnail(get_post_thumbnail_path(post), content, seek=False)
|
||||
if mime.is_video(post.mime_type):
|
||||
generate_post_thumbnail(get_post_custom_thumbnail_path(post), content, seek=True)
|
||||
delattr(post, "__content")
|
||||
regenerate_thumb = True
|
||||
|
||||
if hasattr(post, "__thumbnail"):
|
||||
if getattr(post, "__thumbnail"):
|
||||
files.save(
|
||||
get_post_thumbnail_backup_path(post),
|
||||
getattr(post, "__thumbnail"),
|
||||
)
|
||||
thumbnail = getattr(post, "__thumbnail")
|
||||
files.save(get_post_custom_content_path(post), thumbnail)
|
||||
generate_post_thumbnail(get_post_custom_thumbnail_path(post), thumbnail, seek=True)
|
||||
else:
|
||||
files.delete(get_post_thumbnail_backup_path(post))
|
||||
files.delete(get_post_custom_thumbnail_path(post))
|
||||
delattr(post, "__thumbnail")
|
||||
regenerate_thumb = True
|
||||
|
||||
if regenerate_thumb:
|
||||
generate_post_thumbnail(post)
|
||||
|
||||
|
||||
def generate_alternate_formats(
|
||||
@ -677,22 +692,19 @@ def update_post_thumbnail(
|
||||
setattr(post, "__thumbnail", content)
|
||||
|
||||
|
||||
def generate_post_thumbnail(post: model.Post) -> None:
|
||||
assert post
|
||||
if files.has(get_post_thumbnail_backup_path(post)):
|
||||
content = files.get(get_post_thumbnail_backup_path(post))
|
||||
else:
|
||||
content = files.get(get_post_content_path(post))
|
||||
def generate_post_thumbnail(path: str, content: bytes, seek=True) -> None:
|
||||
try:
|
||||
assert content
|
||||
image = images.Image(content)
|
||||
image.resize_fill(
|
||||
int(config.config["thumbnails"]["post_width"]),
|
||||
int(config.config["thumbnails"]["post_height"]),
|
||||
keep_transparency=False,
|
||||
seek=seek,
|
||||
)
|
||||
files.save(get_post_thumbnail_path(post), image.to_jpeg())
|
||||
files.save(path, image.to_jpeg())
|
||||
except errors.ProcessingError:
|
||||
files.save(get_post_thumbnail_path(post), EMPTY_PIXEL)
|
||||
files.save(path, EMPTY_PIXEL)
|
||||
|
||||
|
||||
def update_post_tags(
|
||||
|
@ -311,6 +311,8 @@ def update_user_avatar(
|
||||
image.resize_fill(
|
||||
int(config.config["thumbnails"]["avatar_width"]),
|
||||
int(config.config["thumbnails"]["avatar_height"]),
|
||||
keep_transparency=False,
|
||||
seek=False,
|
||||
)
|
||||
files.save(avatar_path, image.to_png())
|
||||
else:
|
||||
|
@ -51,7 +51,7 @@ class Context:
|
||||
use_video_downloader: bool = False,
|
||||
allow_tokens: bool = True,
|
||||
) -> bytes:
|
||||
if name in self._files and self._files[name]:
|
||||
if name in self._files:
|
||||
return self._files[name]
|
||||
|
||||
if name + "Url" in self._params:
|
||||
|
@ -122,6 +122,34 @@ def _pool_filter(
|
||||
)(query, criterion, negated)
|
||||
|
||||
|
||||
def _category_filter(
|
||||
query: SaQuery, criterion: Optional[criteria.BaseCriterion], negated: bool
|
||||
) -> SaQuery:
|
||||
assert criterion
|
||||
|
||||
# Step 1. find the id for the category
|
||||
q1 = db.session.query(model.TagCategory.tag_category_id).filter(
|
||||
model.TagCategory.name == criterion.value
|
||||
)
|
||||
|
||||
# Step 2. find the tags with that category
|
||||
q2 = db.session.query(model.Tag.tag_id).filter(
|
||||
model.Tag.category_id.in_(q1)
|
||||
)
|
||||
|
||||
# Step 3. find all posts that have at least one of those tags
|
||||
q3 = db.session.query(model.PostTag.post_id).filter(
|
||||
model.PostTag.tag_id.in_(q2)
|
||||
)
|
||||
|
||||
# Step 4. profit
|
||||
expr = model.Post.post_id.in_(q3)
|
||||
if negated:
|
||||
expr = ~expr
|
||||
|
||||
return query.filter(expr)
|
||||
|
||||
|
||||
class PostSearchConfig(BaseSearchConfig):
|
||||
def __init__(self) -> None:
|
||||
self.user = None # type: Optional[model.User]
|
||||
@ -349,6 +377,7 @@ class PostSearchConfig(BaseSearchConfig):
|
||||
),
|
||||
),
|
||||
(["pool"], _pool_filter),
|
||||
(["category"], _category_filter),
|
||||
]
|
||||
)
|
||||
|
||||
|
@ -41,7 +41,7 @@ def test_get_post_thumbnail_url(input_mime_type, config_injector):
|
||||
post.mime_type = input_mime_type
|
||||
assert (
|
||||
posts.get_post_thumbnail_url(post)
|
||||
== "http://example.com/generated-thumbnails/1_244c8840887984c4.jpg"
|
||||
== "http://example.com/generated-thumbnails/sample_1_244c8840887984c4.jpg"
|
||||
)
|
||||
|
||||
|
||||
@ -67,18 +67,18 @@ def test_get_post_thumbnail_path(input_mime_type):
|
||||
post.mime_type = input_mime_type
|
||||
assert (
|
||||
posts.get_post_thumbnail_path(post)
|
||||
== "generated-thumbnails/1_244c8840887984c4.jpg"
|
||||
== "generated-thumbnails/sample_1_244c8840887984c4.jpg"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("input_mime_type", ["image/jpeg", "image/gif"])
|
||||
def test_get_post_thumbnail_backup_path(input_mime_type):
|
||||
def test_get_post_custom_thumbnail_path(input_mime_type):
|
||||
post = model.Post()
|
||||
post.post_id = 1
|
||||
post.mime_type = input_mime_type
|
||||
assert (
|
||||
posts.get_post_thumbnail_backup_path(post)
|
||||
== "posts/custom-thumbnails/1_244c8840887984c4.dat"
|
||||
posts.get_post_custom_thumbnail_path(post)
|
||||
== "generated-thumbnails/custom-thumbnails/sample_1_244c8840887984c4.jpg"
|
||||
)
|
||||
|
||||
|
||||
@ -226,7 +226,9 @@ def test_serialize_post(
|
||||
"canvasHeight": 300,
|
||||
"contentUrl": "http://example.com/posts/1_244c8840887984c4.jpg",
|
||||
"thumbnailUrl": "http://example.com/"
|
||||
"generated-thumbnails/1_244c8840887984c4.jpg",
|
||||
"generated-thumbnails/sample_1_244c8840887984c4.jpg",
|
||||
"customThumbnailUrl": "http://example.com/"
|
||||
"generated-thumbnails/custom-thumbnails/sample_1_244c8840887984c4.jpg",
|
||||
"flags": ["loop"],
|
||||
"tags": [
|
||||
{
|
||||
@ -270,17 +272,27 @@ def test_serialize_post(
|
||||
"relationCount": 0,
|
||||
"lastFeatureTime": datetime(1999, 1, 1),
|
||||
"favoritedBy": ["fav1"],
|
||||
"hasCustomThumbnail": True,
|
||||
"mimeType": "image/jpeg",
|
||||
"comments": ["commenter1", "commenter2"],
|
||||
}
|
||||
|
||||
|
||||
def test_serialize_micro_post(post_factory, user_factory):
|
||||
def test_serialize_micro_post(tmpdir, config_injector, post_factory, user_factory):
|
||||
with patch("szurubooru.func.posts.get_post_thumbnail_url"):
|
||||
posts.get_post_thumbnail_url.return_value = (
|
||||
"https://example.com/thumb.png"
|
||||
)
|
||||
config_injector(
|
||||
{
|
||||
"data_dir": str(tmpdir.mkdir("data")),
|
||||
"thumbnails": {
|
||||
"post_width": 300,
|
||||
"post_height": 300,
|
||||
},
|
||||
"secret": "test",
|
||||
"allow_broken_uploads": False,
|
||||
}
|
||||
)
|
||||
auth_user = user_factory()
|
||||
post = post_factory()
|
||||
db.session.add(post)
|
||||
@ -288,6 +300,7 @@ def test_serialize_micro_post(post_factory, user_factory):
|
||||
assert posts.serialize_micro_post(post, auth_user) == {
|
||||
"id": post.post_id,
|
||||
"thumbnailUrl": "https://example.com/thumb.png",
|
||||
"customThumbnailUrl": None,
|
||||
}
|
||||
|
||||
|
||||
@ -605,7 +618,7 @@ def test_update_post_thumbnail_to_new_one(
|
||||
assert post.post_id
|
||||
generated_path = (
|
||||
"{}/data/generated-thumbnails/".format(tmpdir)
|
||||
+ "1_244c8840887984c4.jpg"
|
||||
+ "sample_1_244c8840887984c4.jpg"
|
||||
)
|
||||
source_path = (
|
||||
"{}/data/posts/custom-thumbnails/".format(tmpdir)
|
||||
@ -646,7 +659,7 @@ def test_update_post_thumbnail_to_default(
|
||||
assert post.post_id
|
||||
generated_path = (
|
||||
"{}/data/generated-thumbnails/".format(tmpdir)
|
||||
+ "1_244c8840887984c4.jpg"
|
||||
+ "sample_1_244c8840887984c4.jpg"
|
||||
)
|
||||
source_path = (
|
||||
"{}/data/posts/custom-thumbnails/".format(tmpdir)
|
||||
@ -686,7 +699,7 @@ def test_update_post_thumbnail_with_broken_thumbnail(
|
||||
assert post.post_id
|
||||
generated_path = (
|
||||
"{}/data/generated-thumbnails/".format(tmpdir)
|
||||
+ "1_244c8840887984c4.jpg"
|
||||
+ "sample_1_244c8840887984c4.jpg"
|
||||
)
|
||||
source_path = (
|
||||
"{}/data/posts/custom-thumbnails/".format(tmpdir)
|
||||
@ -705,8 +718,8 @@ def test_update_post_thumbnail_with_broken_thumbnail(
|
||||
assert handle.read() == read_asset("png-broken.png")
|
||||
with open(generated_path, "rb") as handle:
|
||||
image = images.Image(handle.read())
|
||||
assert image.width == 1
|
||||
assert image.height == 1
|
||||
assert image.width == 300
|
||||
assert image.height == 300
|
||||
|
||||
|
||||
def test_update_post_content_leaving_custom_thumbnail(
|
||||
@ -731,7 +744,7 @@ def test_update_post_content_leaving_custom_thumbnail(
|
||||
db.session.flush()
|
||||
generated_path = (
|
||||
"{}/data/generated-thumbnails/".format(tmpdir)
|
||||
+ "1_244c8840887984c4.jpg"
|
||||
+ "sample_1_244c8840887984c4.jpg"
|
||||
)
|
||||
source_path = (
|
||||
"{}/data/posts/custom-thumbnails/".format(tmpdir)
|
||||
@ -763,7 +776,7 @@ def test_update_post_content_convert_heif_to_png_when_processing(
|
||||
db.session.flush()
|
||||
generated_path = (
|
||||
"{}/data/generated-thumbnails/".format(tmpdir)
|
||||
+ "1_244c8840887984c4.jpg"
|
||||
+ "sample_1_244c8840887984c4.jpg"
|
||||
)
|
||||
source_path = (
|
||||
"{}/data/posts/custom-thumbnails/".format(tmpdir)
|
||||
|
@ -863,3 +863,55 @@ def test_tumbleweed(
|
||||
db.session.flush()
|
||||
verify_unpaged("special:tumbleweed", [4])
|
||||
verify_unpaged("-special:tumbleweed", [1, 2, 3])
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"input,expected_post_ids",
|
||||
[
|
||||
("category:cat1", [1, 2, 3]),
|
||||
("category:cat2", [3, 4]),
|
||||
],
|
||||
)
|
||||
def test_search_by_tag_category(
|
||||
verify_unpaged,
|
||||
post_factory,
|
||||
tag_factory,
|
||||
tag_category_factory,
|
||||
input,
|
||||
expected_post_ids,
|
||||
):
|
||||
cat1 = tag_category_factory(name="cat1")
|
||||
cat2 = tag_category_factory(name="cat2")
|
||||
tag1 = tag_factory(names=["t1"], category=cat1)
|
||||
tag2 = tag_factory(names=["t2"], category=cat1)
|
||||
tag3 = tag_factory(names=["t3"], category=cat2)
|
||||
|
||||
post1 = post_factory(id=1)
|
||||
post1.tags.append(tag1)
|
||||
|
||||
post2 = post_factory(id=2)
|
||||
post2.tags.append(tag2)
|
||||
|
||||
post3 = post_factory(id=3)
|
||||
post3.tags.append(tag1)
|
||||
post3.tags.append(tag3)
|
||||
|
||||
post4 = post_factory(id=4)
|
||||
post4.tags.append(tag3)
|
||||
|
||||
post5 = post_factory(id=5)
|
||||
|
||||
db.session.add_all(
|
||||
[
|
||||
tag1,
|
||||
tag2,
|
||||
tag3,
|
||||
post1,
|
||||
post2,
|
||||
post3,
|
||||
post4,
|
||||
post5,
|
||||
]
|
||||
)
|
||||
db.session.flush()
|
||||
verify_unpaged(input, expected_post_ids)
|
||||
|
Reference in New Issue
Block a user