mirror of
https://github.com/rr-/szurubooru.git
synced 2025-07-17 08:26:24 +00:00
Compare commits
8 Commits
Author | SHA1 | Date | |
---|---|---|---|
39973386c6 | |||
141c9fcdc9 | |||
995cd4610d | |||
f1445b9c24 | |||
8c0fa7f49e | |||
9aa59a228e | |||
e71718c50d | |||
9d6a0e0173 |
45
API.md
45
API.md
@ -36,6 +36,7 @@
|
||||
- [Updating post](#updating-post)
|
||||
- [Getting post](#getting-post)
|
||||
- [Deleting post](#deleting-post)
|
||||
- [Merging posts](#merging-posts)
|
||||
- [Rating post](#rating-post)
|
||||
- [Adding post to favorites](#adding-post-to-favorites)
|
||||
- [Removing post from favorites](#removing-post-from-favorites)
|
||||
@ -617,10 +618,9 @@ data.
|
||||
|
||||
- **Description**
|
||||
|
||||
Removes source tag and merges all of its usages to the target tag. Source
|
||||
tag properties such as category, tag relations etc. do not get transferred
|
||||
and are discarded. The target tag effectively remains unchanged with the
|
||||
exception of the set of posts it's used in.
|
||||
Removes source tag and merges all of its usages, suggestions and
|
||||
implications to the target tag. Other tag properties such as category and
|
||||
aliases do not get transferred and are discarded.
|
||||
|
||||
## Listing tag siblings
|
||||
- **Request**
|
||||
@ -910,6 +910,43 @@ data.
|
||||
|
||||
Deletes existing post. Related posts and tags are kept.
|
||||
|
||||
## Merging posts
|
||||
- **Request**
|
||||
|
||||
`POST /post-merge/`
|
||||
|
||||
- **Input**
|
||||
|
||||
```json5
|
||||
{
|
||||
"removeVersion": <source-post-version>,
|
||||
"remove": <source-post-id>,
|
||||
"mergeToVersion": <target-post-version>,
|
||||
"mergeTo": <target-post-id>,
|
||||
"replaceContent": <true-or-false>
|
||||
}
|
||||
```
|
||||
|
||||
- **Output**
|
||||
|
||||
A [post resource](#post) containing the merged post.
|
||||
|
||||
- **Errors**
|
||||
|
||||
- the version of either post is outdated
|
||||
- the source or target post does not exist
|
||||
- the source post is the same as the target post
|
||||
- privileges are too low
|
||||
|
||||
- **Description**
|
||||
|
||||
Removes source post and merges all of its tags, relations, scores,
|
||||
favorites and comments to the target post. If `replaceContent` is set to
|
||||
true, content of the target post is replaced using the content of the
|
||||
source post; otherwise it remains unchanged. Source post properties such as
|
||||
its safety, source, whether to loop the video and other scalar values do
|
||||
not get transferred and are discarded.
|
||||
|
||||
## Rating post
|
||||
- **Request**
|
||||
|
||||
|
@ -65,10 +65,9 @@ input[type=radio], input[type=checkbox]
|
||||
.radio:before, .checkbox:before
|
||||
transition: border-color 0.1s linear
|
||||
position: absolute
|
||||
top: 50%
|
||||
left: 0
|
||||
top: 0.15em
|
||||
display: block
|
||||
margin-top: -10px
|
||||
width: 16px
|
||||
height: 16px
|
||||
background: $input-enabled-background-color
|
||||
@ -79,10 +78,10 @@ input[type=radio], input[type=checkbox]
|
||||
background: $main-color
|
||||
transition: opacity 0.1s linear
|
||||
position: absolute
|
||||
top: 50%
|
||||
left: 5px
|
||||
top: 0.15em
|
||||
margin-top: 5px
|
||||
display: block
|
||||
margin-top: -5px
|
||||
width: 10px
|
||||
height: 10px
|
||||
border-radius: 50%
|
||||
@ -92,10 +91,10 @@ input[type=radio], input[type=checkbox]
|
||||
.checkbox:after
|
||||
transition: opacity 0.1s linear
|
||||
position: absolute
|
||||
top: 50%
|
||||
top: 0.15em
|
||||
left: 6px
|
||||
display: block
|
||||
margin-top: -7px
|
||||
margin-top: 3px
|
||||
width: 5px
|
||||
height: 9px
|
||||
border-right: 3px solid $main-color
|
||||
|
33
client/css/post-detail-view.styl
Normal file
33
client/css/post-detail-view.styl
Normal file
@ -0,0 +1,33 @@
|
||||
#post
|
||||
width: 100%
|
||||
max-width: 40em
|
||||
h1
|
||||
margin-top: 0
|
||||
form
|
||||
width: 100%
|
||||
.buttons i
|
||||
margin-right: 0.5em
|
||||
.post-merge
|
||||
.left-post-container
|
||||
width: 47%
|
||||
float: left
|
||||
.right-post-container
|
||||
width: 47%
|
||||
float: right
|
||||
input[type=text]
|
||||
width: 8em
|
||||
margin-top: -2px
|
||||
.post-mirror
|
||||
margin-bottom: 1em
|
||||
&:after
|
||||
display: block
|
||||
height: 1px
|
||||
content: ' '
|
||||
clear: both
|
||||
.post-thumbnail .thumbnail
|
||||
width: 100%
|
||||
height: 9em
|
||||
.target-post .thumbnail
|
||||
margin-right: 0.35em
|
||||
.target-post, .target-post-content
|
||||
margin: 1em 0
|
@ -120,11 +120,12 @@
|
||||
margin-bottom: 1em
|
||||
|
||||
.safety
|
||||
&>label
|
||||
width: 100%
|
||||
.radio-wrapper
|
||||
display: flex
|
||||
flex-wrap: wrap
|
||||
label:not(.radio)
|
||||
width: 100%
|
||||
.radio
|
||||
.radio-wrapper label
|
||||
flex-grow: 1
|
||||
display: inline-block
|
||||
|
@ -39,20 +39,24 @@ $cancel-button-color = tomato
|
||||
margin-top: 1em
|
||||
|
||||
.uploadables-container
|
||||
line-height: 200%
|
||||
|
||||
li
|
||||
margin: 0 0 1.2em 0
|
||||
|
||||
.uploadable
|
||||
.file
|
||||
margin: 0.3em 0
|
||||
overflow: hidden
|
||||
white-space: nowrap
|
||||
text-align: left
|
||||
text-overflow: ellipsis
|
||||
|
||||
.anonymous
|
||||
margin: 0.3em 0
|
||||
|
||||
.safety
|
||||
margin: 0.3em 0
|
||||
label
|
||||
display: inline-block
|
||||
margin-right: 1em
|
||||
|
||||
.thumbnail-wrapper
|
||||
|
12
client/html/post_detail.tpl
Normal file
12
client/html/post_detail.tpl
Normal file
@ -0,0 +1,12 @@
|
||||
<div class='content-wrapper' id='post'>
|
||||
<h1>Post #<%- ctx.post.id %></h1>
|
||||
<nav class='buttons'><!--
|
||||
--><ul><!--
|
||||
--><li><a href='/post/<%- ctx.post.id %>'><i class='fa fa-reply'></i> Main view</a></li><!--
|
||||
--><% if (ctx.canMerge) { %><!--
|
||||
--><li data-name='merge'><a href='/post/<%- ctx.post.id %>/merge'>Merge with…</a></li><!--
|
||||
--><% } %><!--
|
||||
--></ul><!--
|
||||
--></nav>
|
||||
<div class='post-content-holder'></div>
|
||||
</div>
|
@ -7,6 +7,7 @@
|
||||
<% if (ctx.canEditPostSafety) { %>
|
||||
<section class='safety'>
|
||||
<label>Safety</label>
|
||||
<div class='radio-wrapper'>
|
||||
<%= ctx.makeRadio({
|
||||
name: 'safety',
|
||||
class: 'safety-safe',
|
||||
@ -25,6 +26,7 @@
|
||||
selectedValue: ctx.post.safety,
|
||||
class: 'safety-unsafe',
|
||||
text: 'Unsafe'}) %>
|
||||
</div>
|
||||
</section>
|
||||
<% } %>
|
||||
|
||||
@ -82,12 +84,15 @@
|
||||
</section>
|
||||
<% } %>
|
||||
|
||||
<% if (ctx.canFeaturePosts) { %>
|
||||
<% if (ctx.canFeaturePosts || ctx.canDeletePosts || ctx.canMergePosts) { %>
|
||||
<section class='management'>
|
||||
<ul>
|
||||
<% if (ctx.canFeaturePosts) { %>
|
||||
<li><a href class='feature'>Feature this post on main page</a></li>
|
||||
<% } %>
|
||||
<% if (ctx.canMergePosts) { %>
|
||||
<li><a href class='merge'>Merge this post with another</a></li>
|
||||
<% } %>
|
||||
<% if (ctx.canDeletePosts) { %>
|
||||
<li><a href class='delete'>Delete this post</a></li>
|
||||
<% } %>
|
||||
|
23
client/html/post_merge.tpl
Normal file
23
client/html/post_merge.tpl
Normal file
@ -0,0 +1,23 @@
|
||||
<div class='post-merge'>
|
||||
<form>
|
||||
<ul>
|
||||
<li class='post-mirror'>
|
||||
<div class='left-post-container'></div>
|
||||
<div class='right-post-container'></div>
|
||||
</li>
|
||||
|
||||
<li>
|
||||
<p>Tags, relations, scores, favorites and comments will be
|
||||
merged. All other properties need to be handled manually.</p>
|
||||
|
||||
<%= ctx.makeCheckbox({required: true, text: 'I confirm that I want to merge these posts.'}) %>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<div class='messages'></div>
|
||||
|
||||
<div class='buttons'>
|
||||
<input type='submit' value='Merge posts'/>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
48
client/html/post_merge_side.tpl
Normal file
48
client/html/post_merge_side.tpl
Normal file
@ -0,0 +1,48 @@
|
||||
<% if (ctx.editable) { %>
|
||||
<p>Post # <input type='text' pattern='^[0-9]+$' value='<%- ctx.post ? ctx.post.id : '' %>'/></p>
|
||||
<% } else { %>
|
||||
<p>Post # <input type='text' pattern='^[0-9]+$' value='<%- ctx.post ? ctx.post.id : '' %>' readonly/></p>
|
||||
<% } %>
|
||||
|
||||
<% if (ctx.post) { %>
|
||||
<div class='post-thumbnail'>
|
||||
<a rel='external' href='<%- ctx.post.contentUrl %>'>
|
||||
<%= ctx.makeThumbnail(ctx.post.thumbnailUrl) %>
|
||||
</a>
|
||||
</div>
|
||||
<div class='target-post'>
|
||||
<%= ctx.makeRadio({
|
||||
required: true,
|
||||
text: 'Merge to this post<br/><small>' +
|
||||
ctx.makeUserLink(ctx.post.user) +
|
||||
', ' +
|
||||
ctx.makeRelativeTime(ctx.post.creationTime) +
|
||||
'</small>',
|
||||
name: 'target-post',
|
||||
value: ctx.name,
|
||||
}) %>
|
||||
</div>
|
||||
<div class='target-post-content'>
|
||||
<%= ctx.makeRadio({
|
||||
required: true,
|
||||
text: 'Use this file<br/><small>' +
|
||||
ctx.makeFileSize(ctx.post.fileSize) + ' ' +
|
||||
{
|
||||
'image/gif': 'GIF',
|
||||
'image/jpeg': 'JPEG',
|
||||
'image/png': 'PNG',
|
||||
'video/webm': 'WEBM',
|
||||
'application/x-shockwave-flash': 'SWF',
|
||||
}[ctx.post.mimeType] +
|
||||
' (' +
|
||||
(ctx.post.canvasWidth ?
|
||||
`${ctx.post.canvasWidth}x${ctx.post.canvasHeight}` :
|
||||
'?') +
|
||||
')</small>',
|
||||
name: 'target-post-content',
|
||||
value: ctx.name,
|
||||
}) %>
|
||||
<p>
|
||||
</p>
|
||||
</div>
|
||||
<% } %>
|
@ -1,14 +1,14 @@
|
||||
<div class='tag-merge'>
|
||||
<form>
|
||||
<p>Proceeding will remove this tag and retag its posts with the tag
|
||||
specified below. Aliases, suggestions and implications are discarded
|
||||
and need to be handled manually.</p>
|
||||
|
||||
<ul>
|
||||
<li class='target'>
|
||||
<%= ctx.makeTextInput({required: true, text: 'Target tag', pattern: ctx.tagNamePattern}) %>
|
||||
</li>
|
||||
<li class='confirm'>
|
||||
|
||||
<li>
|
||||
<p>Usages in posts, suggestions and implications will be
|
||||
merged. Category and aliases need to be handled manually.</p>
|
||||
|
||||
<%= ctx.makeCheckbox({required: true, text: 'I confirm that I want to merge this tag.'}) %>
|
||||
</li>
|
||||
</ul>
|
||||
|
20
client/js/controllers/base_post_controller.js
Normal file
20
client/js/controllers/base_post_controller.js
Normal file
@ -0,0 +1,20 @@
|
||||
'use strict';
|
||||
|
||||
const api = require('../api.js');
|
||||
const topNavigation = require('../models/top_navigation.js');
|
||||
const EmptyView = require('../views/empty_view.js');
|
||||
|
||||
class BasePostController {
|
||||
constructor(ctx) {
|
||||
if (!api.hasPrivilege('posts:view')) {
|
||||
this._view = new EmptyView();
|
||||
this._view.showError('You don\'t have privileges to view posts.');
|
||||
return;
|
||||
}
|
||||
|
||||
topNavigation.activate('posts');
|
||||
topNavigation.setTitle('Post #' + ctx.parameters.id.toString());
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = BasePostController;
|
86
client/js/controllers/post_detail_controller.js
Normal file
86
client/js/controllers/post_detail_controller.js
Normal file
@ -0,0 +1,86 @@
|
||||
'use strict';
|
||||
|
||||
const router = require('../router.js');
|
||||
const api = require('../api.js');
|
||||
const misc = require('../util/misc.js');
|
||||
const settings = require('../models/settings.js');
|
||||
const Comment = require('../models/comment.js');
|
||||
const Post = require('../models/post.js');
|
||||
const PostList = require('../models/post_list.js');
|
||||
const PostDetailView = require('../views/post_detail_view.js');
|
||||
const BasePostController = require('./base_post_controller.js');
|
||||
const EmptyView = require('../views/empty_view.js');
|
||||
|
||||
class PostDetailController extends BasePostController {
|
||||
constructor(ctx, section) {
|
||||
super(ctx);
|
||||
|
||||
Post.get(ctx.parameters.id).then(post => {
|
||||
this._id = ctx.parameters.id;
|
||||
post.addEventListener('change', e => this._evtSaved(e, section));
|
||||
|
||||
this._view = new PostDetailView({
|
||||
post: post,
|
||||
section: section,
|
||||
canMerge: api.hasPrivilege('posts:merge'),
|
||||
});
|
||||
|
||||
this._view.addEventListener('select', e => this._evtSelect(e));
|
||||
this._view.addEventListener('merge', e => this._evtMerge(e));
|
||||
}, errorMessage => {
|
||||
this._view = new EmptyView();
|
||||
this._view.showError(errorMessage);
|
||||
});
|
||||
}
|
||||
|
||||
showSuccess(message) {
|
||||
this._view.showSuccess(message);
|
||||
}
|
||||
|
||||
_evtSelect(e) {
|
||||
this._view.clearMessages();
|
||||
this._view.disableForm();
|
||||
Post.get(e.detail.postId).then(post => {
|
||||
this._view.selectPost(post);
|
||||
this._view.enableForm();
|
||||
}, errorMessage => {
|
||||
this._view.showError(errorMessage);
|
||||
this._view.enableForm();
|
||||
});
|
||||
}
|
||||
|
||||
_evtSaved(e, section) {
|
||||
misc.disableExitConfirmation();
|
||||
if (this._id !== e.detail.post.id) {
|
||||
router.replace(
|
||||
'/post/' + e.detail.post.id + '/' + section, null, false);
|
||||
}
|
||||
}
|
||||
|
||||
_evtMerge(e) {
|
||||
this._view.clearMessages();
|
||||
this._view.disableForm();
|
||||
e.detail.post.merge(e.detail.targetPost.id, e.detail.useOldContent)
|
||||
.then(() => {
|
||||
this._view = new PostDetailView({
|
||||
post: e.detail.targetPost,
|
||||
section: 'merge',
|
||||
canMerge: api.hasPrivilege('posts:merge'),
|
||||
});
|
||||
this._view.showSuccess('Post merged.');
|
||||
router.replace(
|
||||
'/post/' + e.detail.targetPost.id + '/merge', null, false);
|
||||
}, errorMessage => {
|
||||
this._view.showError(errorMessage);
|
||||
this._view.enableForm();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = router => {
|
||||
router.enter(
|
||||
'/post/:id/merge',
|
||||
(ctx, next) => {
|
||||
ctx.controller = new PostDetailController(ctx, 'merge');
|
||||
});
|
||||
};
|
@ -7,26 +7,19 @@ const settings = require('../models/settings.js');
|
||||
const Comment = require('../models/comment.js');
|
||||
const Post = require('../models/post.js');
|
||||
const PostList = require('../models/post_list.js');
|
||||
const topNavigation = require('../models/top_navigation.js');
|
||||
const PostView = require('../views/post_view.js');
|
||||
const PostMainView = require('../views/post_main_view.js');
|
||||
const BasePostController = require('./base_post_controller.js');
|
||||
const EmptyView = require('../views/empty_view.js');
|
||||
|
||||
class PostController {
|
||||
constructor(id, editMode, ctx) {
|
||||
if (!api.hasPrivilege('posts:view')) {
|
||||
this._view = new EmptyView();
|
||||
this._view.showError('You don\'t have privileges to view posts.');
|
||||
return;
|
||||
}
|
||||
|
||||
topNavigation.activate('posts');
|
||||
topNavigation.setTitle('Post #' + id.toString());
|
||||
class PostMainController extends BasePostController {
|
||||
constructor(ctx, editMode) {
|
||||
super(ctx);
|
||||
|
||||
let parameters = ctx.parameters;
|
||||
Promise.all([
|
||||
Post.get(id),
|
||||
Post.get(ctx.parameters.id),
|
||||
PostList.getAround(
|
||||
id, this._decorateSearchQuery(
|
||||
ctx.parameters.id, this._decorateSearchQuery(
|
||||
parameters ? parameters.query : '')),
|
||||
]).then(responses => {
|
||||
const [post, aroundResponse] = responses;
|
||||
@ -36,13 +29,13 @@ class PostController {
|
||||
if (parameters.query) {
|
||||
ctx.state.parameters = parameters;
|
||||
const url = editMode ?
|
||||
'/post/' + id + '/edit' :
|
||||
'/post/' + id;
|
||||
'/post/' + ctx.parameters.id + '/edit' :
|
||||
'/post/' + ctx.parameters.id;
|
||||
router.replace(url, ctx.state, false);
|
||||
}
|
||||
|
||||
this._post = post;
|
||||
this._view = new PostView({
|
||||
this._view = new PostMainView({
|
||||
post: post,
|
||||
editMode: editMode,
|
||||
prevPostId: aroundResponse.prev ? aroundResponse.prev.id : null,
|
||||
@ -72,6 +65,8 @@ class PostController {
|
||||
'feature', e => this._evtFeaturePost(e));
|
||||
this._view.sidebarControl.addEventListener(
|
||||
'delete', e => this._evtDeletePost(e));
|
||||
this._view.sidebarControl.addEventListener(
|
||||
'merge', e => this._evtMergePost(e));
|
||||
}
|
||||
|
||||
if (this._view.commentFormControl) {
|
||||
@ -128,6 +123,10 @@ class PostController {
|
||||
});
|
||||
}
|
||||
|
||||
_evtMergePost(e) {
|
||||
router.show('/post/' + e.detail.post.id + '/merge');
|
||||
}
|
||||
|
||||
_evtDeletePost(e) {
|
||||
this._view.sidebarControl.disableForm();
|
||||
this._view.sidebarControl.clearMessages();
|
||||
@ -262,7 +261,7 @@ module.exports = router => {
|
||||
if (ctx.state.parameters) {
|
||||
Object.assign(ctx.parameters, ctx.state.parameters);
|
||||
}
|
||||
ctx.controller = new PostController(ctx.parameters.id, true, ctx);
|
||||
ctx.controller = new PostMainController(ctx, true);
|
||||
});
|
||||
router.enter(
|
||||
'/post/:id/:parameters(.*)?',
|
||||
@ -272,6 +271,6 @@ module.exports = router => {
|
||||
if (ctx.state.parameters) {
|
||||
Object.assign(ctx.parameters, ctx.state.parameters);
|
||||
}
|
||||
ctx.controller = new PostController(ctx.parameters.id, false, ctx);
|
||||
ctx.controller = new PostMainController(ctx, false);
|
||||
});
|
||||
};
|
@ -36,6 +36,7 @@ class PostEditSidebarControl extends events.EventTarget {
|
||||
canCreateAnonymousPosts: api.hasPrivilege('posts:create:anonymous'),
|
||||
canDeletePosts: api.hasPrivilege('posts:delete'),
|
||||
canFeaturePosts: api.hasPrivilege('posts:feature'),
|
||||
canMergePosts: api.hasPrivilege('posts:merge'),
|
||||
}));
|
||||
|
||||
new ExpanderControl(
|
||||
@ -108,6 +109,11 @@ class PostEditSidebarControl extends events.EventTarget {
|
||||
'click', e => this._evtFeatureClick(e));
|
||||
}
|
||||
|
||||
if (this._mergeLinkNode) {
|
||||
this._mergeLinkNode.addEventListener(
|
||||
'click', e => this._evtMergeClick(e));
|
||||
}
|
||||
|
||||
if (this._deleteLinkNode) {
|
||||
this._deleteLinkNode.addEventListener(
|
||||
'click', e => this._evtDeleteClick(e));
|
||||
@ -186,6 +192,15 @@ class PostEditSidebarControl extends events.EventTarget {
|
||||
}
|
||||
}
|
||||
|
||||
_evtMergeClick(e) {
|
||||
e.preventDefault();
|
||||
this.dispatchEvent(new CustomEvent('merge', {
|
||||
detail: {
|
||||
post: this._post,
|
||||
},
|
||||
}));
|
||||
}
|
||||
|
||||
_evtDeleteClick(e) {
|
||||
e.preventDefault();
|
||||
if (confirm('Are you sure you want to delete this post?')) {
|
||||
@ -244,7 +259,7 @@ class PostEditSidebarControl extends events.EventTarget {
|
||||
detail: {
|
||||
post: this._post,
|
||||
|
||||
safety: this._safetyButtonNodes.legnth ?
|
||||
safety: this._safetyButtonNodes.length ?
|
||||
Array.from(this._safetyButtonNodes)
|
||||
.filter(node => node.checked)[0]
|
||||
.value.toLowerCase() :
|
||||
@ -314,6 +329,10 @@ class PostEditSidebarControl extends events.EventTarget {
|
||||
return this._formNode.querySelector('.management .feature');
|
||||
}
|
||||
|
||||
get _mergeLinkNode() {
|
||||
return this._formNode.querySelector('.management .merge');
|
||||
}
|
||||
|
||||
get _deleteLinkNode() {
|
||||
return this._formNode.querySelector('.management .delete');
|
||||
}
|
||||
|
@ -34,7 +34,8 @@ controllers.push(require('./controllers/auth_controller.js'));
|
||||
controllers.push(require('./controllers/password_reset_controller.js'));
|
||||
controllers.push(require('./controllers/comments_controller.js'));
|
||||
controllers.push(require('./controllers/snapshots_controller.js'));
|
||||
controllers.push(require('./controllers/post_controller.js'));
|
||||
controllers.push(require('./controllers/post_detail_controller.js'));
|
||||
controllers.push(require('./controllers/post_main_controller.js'));
|
||||
controllers.push(require('./controllers/post_list_controller.js'));
|
||||
controllers.push(require('./controllers/post_upload_controller.js'));
|
||||
controllers.push(require('./controllers/tag_controller.js'));
|
||||
|
@ -190,6 +190,31 @@ class Post extends events.EventTarget {
|
||||
});
|
||||
}
|
||||
|
||||
merge(targetId, useOldContent) {
|
||||
return api.get('/post/' + encodeURIComponent(targetId))
|
||||
.then(response => {
|
||||
return api.post('/post-merge/', {
|
||||
removeVersion: this._version,
|
||||
remove: this._id,
|
||||
mergeToVersion: response.version,
|
||||
mergeTo: targetId,
|
||||
replaceContent: useOldContent,
|
||||
});
|
||||
}, response => {
|
||||
return Promise.reject(response);
|
||||
}).then(response => {
|
||||
this._updateFromResponse(response);
|
||||
this.dispatchEvent(new CustomEvent('change', {
|
||||
detail: {
|
||||
post: this,
|
||||
},
|
||||
}));
|
||||
return Promise.resolve();
|
||||
}, response => {
|
||||
return Promise.reject(response.description);
|
||||
});
|
||||
}
|
||||
|
||||
setScore(score) {
|
||||
return api.put('/post/' + this._id + '/score', {score: score})
|
||||
.then(response => {
|
||||
|
@ -53,7 +53,10 @@ function makeThumbnail(url) {
|
||||
|
||||
function makeRadio(options) {
|
||||
_imbueId(options);
|
||||
return makeVoidElement(
|
||||
return makeNonVoidElement(
|
||||
'label',
|
||||
{for: options.id},
|
||||
makeVoidElement(
|
||||
'input',
|
||||
{
|
||||
id: options.id,
|
||||
@ -64,12 +67,15 @@ function makeRadio(options) {
|
||||
disabled: options.readonly,
|
||||
required: options.required,
|
||||
}) +
|
||||
_makeLabel(options, {class: 'radio'});
|
||||
makeNonVoidElement('span', {class: 'radio'}, options.text));
|
||||
}
|
||||
|
||||
function makeCheckbox(options) {
|
||||
_imbueId(options);
|
||||
return makeVoidElement(
|
||||
return makeNonVoidElement(
|
||||
'label',
|
||||
{for: options.id},
|
||||
makeVoidElement(
|
||||
'input',
|
||||
{
|
||||
id: options.id,
|
||||
@ -81,7 +87,7 @@ function makeCheckbox(options) {
|
||||
disabled: options.readonly,
|
||||
required: options.required,
|
||||
}) +
|
||||
_makeLabel(options, {class: 'checkbox'});
|
||||
makeNonVoidElement('span', {class: 'checkbox'}, options.text));
|
||||
}
|
||||
|
||||
function makeSelect(options) {
|
||||
|
80
client/js/views/post_detail_view.js
Normal file
80
client/js/views/post_detail_view.js
Normal file
@ -0,0 +1,80 @@
|
||||
'use strict';
|
||||
|
||||
const events = require('../events.js');
|
||||
const views = require('../util/views.js');
|
||||
const PostMergeView = require('./post_merge_view.js');
|
||||
const EmptyView = require('../views/empty_view.js');
|
||||
|
||||
const template = views.getTemplate('post-detail');
|
||||
|
||||
class PostDetailView extends events.EventTarget {
|
||||
constructor(ctx) {
|
||||
super();
|
||||
|
||||
this._ctx = ctx;
|
||||
ctx.post.addEventListener('change', e => this._evtChange(e));
|
||||
ctx.section = ctx.section || 'summary';
|
||||
|
||||
this._hostNode = document.getElementById('content-holder');
|
||||
this._install();
|
||||
}
|
||||
|
||||
_install() {
|
||||
const ctx = this._ctx;
|
||||
views.replaceContent(this._hostNode, template(ctx));
|
||||
|
||||
for (let item of this._hostNode.querySelectorAll('[data-name]')) {
|
||||
item.classList.toggle(
|
||||
'active', item.getAttribute('data-name') === ctx.section);
|
||||
}
|
||||
|
||||
ctx.hostNode = this._hostNode.querySelector('.post-content-holder');
|
||||
if (ctx.section === 'merge') {
|
||||
if (!this._ctx.canMerge) {
|
||||
this._view = new EmptyView();
|
||||
this._view.showError(
|
||||
'You don\'t have privileges to merge posts.');
|
||||
} else {
|
||||
this._view = new PostMergeView(ctx);
|
||||
events.proxyEvent(this._view, this, 'select');
|
||||
events.proxyEvent(this._view, this, 'submit', 'merge');
|
||||
}
|
||||
|
||||
} else {
|
||||
// this._view = new PostSummaryView(ctx);
|
||||
}
|
||||
|
||||
views.syncScrollPosition();
|
||||
}
|
||||
|
||||
clearMessages() {
|
||||
this._view.clearMessages();
|
||||
}
|
||||
|
||||
enableForm() {
|
||||
this._view.enableForm();
|
||||
}
|
||||
|
||||
disableForm() {
|
||||
this._view.disableForm();
|
||||
}
|
||||
|
||||
showSuccess(message) {
|
||||
this._view.showSuccess(message);
|
||||
}
|
||||
|
||||
showError(message) {
|
||||
this._view.showError(message);
|
||||
}
|
||||
|
||||
selectPost(post) {
|
||||
this._view.selectPost(post);
|
||||
}
|
||||
|
||||
_evtChange(e) {
|
||||
this._ctx.post = e.detail.post;
|
||||
this._install(this._ctx);
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = PostDetailView;
|
@ -13,9 +13,9 @@ const PostEditSidebarControl =
|
||||
const CommentListControl = require('../controls/comment_list_control.js');
|
||||
const CommentFormControl = require('../controls/comment_form_control.js');
|
||||
|
||||
const template = views.getTemplate('post');
|
||||
const template = views.getTemplate('post-main');
|
||||
|
||||
class PostView {
|
||||
class PostMainView {
|
||||
constructor(ctx) {
|
||||
this._hostNode = document.getElementById('content-holder');
|
||||
|
||||
@ -118,4 +118,4 @@ class PostView {
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = PostView;
|
||||
module.exports = PostMainView;
|
129
client/js/views/post_merge_view.js
Normal file
129
client/js/views/post_merge_view.js
Normal file
@ -0,0 +1,129 @@
|
||||
'use strict';
|
||||
|
||||
const config = require('../config.js');
|
||||
const events = require('../events.js');
|
||||
const views = require('../util/views.js');
|
||||
|
||||
const KEY_RETURN = 13;
|
||||
const template = views.getTemplate('post-merge');
|
||||
const sideTemplate = views.getTemplate('post-merge-side');
|
||||
|
||||
class PostMergeView extends events.EventTarget {
|
||||
constructor(ctx) {
|
||||
super();
|
||||
|
||||
this._ctx = ctx;
|
||||
this._post = ctx.post;
|
||||
this._hostNode = ctx.hostNode;
|
||||
|
||||
this._leftPost = ctx.post;
|
||||
this._rightPost = null;
|
||||
views.replaceContent(this._hostNode, template(this._ctx));
|
||||
views.decorateValidator(this._formNode);
|
||||
|
||||
this._refreshLeftSide();
|
||||
this._refreshRightSide();
|
||||
|
||||
this._formNode.addEventListener('submit', e => this._evtSubmit(e));
|
||||
}
|
||||
|
||||
clearMessages() {
|
||||
views.clearMessages(this._hostNode);
|
||||
}
|
||||
|
||||
enableForm() {
|
||||
views.enableForm(this._formNode);
|
||||
}
|
||||
|
||||
disableForm() {
|
||||
views.disableForm(this._formNode);
|
||||
}
|
||||
|
||||
showSuccess(message) {
|
||||
views.showSuccess(this._hostNode, message);
|
||||
}
|
||||
|
||||
showError(message) {
|
||||
views.showError(this._hostNode, message);
|
||||
}
|
||||
|
||||
selectPost(post) {
|
||||
this._rightPost = post;
|
||||
this._refreshRightSide();
|
||||
}
|
||||
|
||||
_refreshLeftSide() {
|
||||
views.replaceContent(
|
||||
this._leftSideNode,
|
||||
sideTemplate(Object.assign({}, this._ctx, {
|
||||
post: this._leftPost,
|
||||
name: 'left',
|
||||
editable: false})));
|
||||
}
|
||||
|
||||
_refreshRightSide() {
|
||||
views.replaceContent(
|
||||
this._rightSideNode,
|
||||
sideTemplate(Object.assign({}, this._ctx, {
|
||||
post: this._rightPost,
|
||||
name: 'right',
|
||||
editable: true})));
|
||||
|
||||
if (this._targetPostFieldNode) {
|
||||
this._targetPostFieldNode.addEventListener(
|
||||
'keydown', e => this._evtTargetPostFieldKeyDown(e));
|
||||
}
|
||||
}
|
||||
|
||||
_evtSubmit(e) {
|
||||
e.preventDefault();
|
||||
const checkedTargetPost = this._formNode.querySelector(
|
||||
'.target-post :checked').value;
|
||||
const checkedTargetPostContent = this._formNode.querySelector(
|
||||
'.target-post-content :checked').value;
|
||||
this.dispatchEvent(new CustomEvent('submit', {
|
||||
detail: {
|
||||
post: checkedTargetPost == 'left' ?
|
||||
this._rightPost :
|
||||
this._leftPost,
|
||||
targetPost: checkedTargetPost == 'left' ?
|
||||
this._leftPost :
|
||||
this._rightPost,
|
||||
useOldContent: checkedTargetPostContent !== checkedTargetPost,
|
||||
},
|
||||
}));
|
||||
}
|
||||
|
||||
_evtTargetPostFieldKeyDown(e) {
|
||||
const key = e.which;
|
||||
if (key !== KEY_RETURN) {
|
||||
return;
|
||||
}
|
||||
e.target.blur();
|
||||
e.preventDefault();
|
||||
this.dispatchEvent(new CustomEvent('select', {
|
||||
detail: {
|
||||
postId: this._targetPostFieldNode.value,
|
||||
},
|
||||
}));
|
||||
}
|
||||
|
||||
get _formNode() {
|
||||
return this._hostNode.querySelector('form');
|
||||
}
|
||||
|
||||
get _leftSideNode() {
|
||||
return this._hostNode.querySelector('.left-post-container');
|
||||
}
|
||||
|
||||
get _rightSideNode() {
|
||||
return this._hostNode.querySelector('.right-post-container');
|
||||
}
|
||||
|
||||
get _targetPostFieldNode() {
|
||||
return this._formNode.querySelector(
|
||||
'.post-mirror input:not([readonly])[type=text]');
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = PostMergeView;
|
@ -80,6 +80,7 @@ privileges:
|
||||
'posts:feature': moderator
|
||||
'posts:delete': moderator
|
||||
'posts:score': regular
|
||||
'posts:merge': moderator
|
||||
'posts:favorite': regular
|
||||
|
||||
'tags:create': regular
|
||||
|
@ -124,6 +124,23 @@ def delete_post(ctx, params):
|
||||
return {}
|
||||
|
||||
|
||||
@routes.post('/post-merge/?')
|
||||
def merge_posts(ctx, _params=None):
|
||||
source_post_id = ctx.get_param_as_string('remove', required=True) or ''
|
||||
target_post_id = ctx.get_param_as_string('mergeTo', required=True) or ''
|
||||
replace_content = ctx.get_param_as_bool('replaceContent')
|
||||
source_post = posts.get_post_by_id(source_post_id)
|
||||
target_post = posts.get_post_by_id(target_post_id)
|
||||
versions.verify_version(source_post, ctx, 'removeVersion')
|
||||
versions.verify_version(target_post, ctx, 'mergeToVersion')
|
||||
versions.bump_version(target_post)
|
||||
auth.verify_privilege(ctx.user, 'posts:merge')
|
||||
posts.merge_posts(source_post, target_post, replace_content)
|
||||
snapshots.merge(source_post, target_post, ctx.user)
|
||||
ctx.session.commit()
|
||||
return _serialize_post(ctx, target_post)
|
||||
|
||||
|
||||
@routes.get('/featured-post/?')
|
||||
def get_featured_post(ctx, _params=None):
|
||||
post = posts.try_get_featured_post()
|
||||
|
@ -1,14 +1,14 @@
|
||||
from datetime import datetime
|
||||
|
||||
|
||||
class LruCacheItem(object):
|
||||
class LruCacheItem:
|
||||
def __init__(self, key, value):
|
||||
self.key = key
|
||||
self.value = value
|
||||
self.timestamp = datetime.utcnow()
|
||||
|
||||
|
||||
class LruCache(object):
|
||||
class LruCache:
|
||||
def __init__(self, length, delta=None):
|
||||
self.length = length
|
||||
self.delta = delta
|
||||
|
@ -14,7 +14,7 @@ _SCALE_FIT_FMT = \
|
||||
r'scale=iw*max({width}/iw\,{height}/ih):ih*max({width}/iw\,{height}/ih)'
|
||||
|
||||
|
||||
class Image(object):
|
||||
class Image:
|
||||
def __init__(self, content):
|
||||
self.content = content
|
||||
self._reload_info()
|
||||
|
@ -440,3 +440,84 @@ def feature_post(post, user):
|
||||
def delete(post):
|
||||
assert post
|
||||
db.session.delete(post)
|
||||
|
||||
|
||||
def merge_posts(source_post, target_post, replace_content):
|
||||
assert source_post
|
||||
assert target_post
|
||||
if source_post.post_id == target_post.post_id:
|
||||
raise InvalidPostRelationError('Cannot merge post with itself.')
|
||||
|
||||
def merge_tables(table, anti_dup_func, source_post_id, target_post_id):
|
||||
alias1 = table
|
||||
alias2 = sqlalchemy.orm.util.aliased(table)
|
||||
update_stmt = (sqlalchemy.sql.expression.update(alias1)
|
||||
.where(alias1.post_id == source_post_id))
|
||||
|
||||
if anti_dup_func is not None:
|
||||
update_stmt = (update_stmt
|
||||
.where(~sqlalchemy.exists()
|
||||
.where(anti_dup_func(alias1, alias2))
|
||||
.where(alias2.post_id == target_post_id)))
|
||||
|
||||
update_stmt = update_stmt.values(post_id=target_post_id)
|
||||
db.session.execute(update_stmt)
|
||||
|
||||
def merge_tags(source_post_id, target_post_id):
|
||||
merge_tables(
|
||||
db.PostTag,
|
||||
lambda alias1, alias2: alias1.tag_id == alias2.tag_id,
|
||||
source_post_id,
|
||||
target_post_id)
|
||||
|
||||
def merge_scores(source_post_id, target_post_id):
|
||||
merge_tables(
|
||||
db.PostScore,
|
||||
lambda alias1, alias2: alias1.user_id == alias2.user_id,
|
||||
source_post_id,
|
||||
target_post_id)
|
||||
|
||||
def merge_favorites(source_post_id, target_post_id):
|
||||
merge_tables(
|
||||
db.PostFavorite,
|
||||
lambda alias1, alias2: alias1.user_id == alias2.user_id,
|
||||
source_post_id,
|
||||
target_post_id)
|
||||
|
||||
def merge_comments(source_post_id, target_post_id):
|
||||
merge_tables(db.Comment, None, source_post_id, target_post_id)
|
||||
|
||||
def merge_relations(source_post_id, target_post_id):
|
||||
alias1 = db.PostRelation
|
||||
alias2 = sqlalchemy.orm.util.aliased(db.PostRelation)
|
||||
update_stmt = (sqlalchemy.sql.expression.update(alias1)
|
||||
.where(alias1.parent_id == source_post_id)
|
||||
.where(alias1.child_id != target_post_id)
|
||||
.where(~sqlalchemy.exists()
|
||||
.where(alias2.child_id == alias1.child_id)
|
||||
.where(alias2.parent_id == target_post_id))
|
||||
.values(parent_id=target_post_id))
|
||||
db.session.execute(update_stmt)
|
||||
|
||||
update_stmt = (sqlalchemy.sql.expression.update(alias1)
|
||||
.where(alias1.child_id == source_post_id)
|
||||
.where(alias1.parent_id != target_post_id)
|
||||
.where(~sqlalchemy.exists()
|
||||
.where(alias2.parent_id == alias1.parent_id)
|
||||
.where(alias2.child_id == target_post_id))
|
||||
.values(child_id=target_post_id))
|
||||
db.session.execute(update_stmt)
|
||||
|
||||
merge_tags(source_post.post_id, target_post.post_id)
|
||||
merge_comments(source_post.post_id, target_post.post_id)
|
||||
merge_scores(source_post.post_id, target_post.post_id)
|
||||
merge_favorites(source_post.post_id, target_post.post_id)
|
||||
merge_relations(source_post.post_id, target_post.post_id)
|
||||
|
||||
delete(source_post)
|
||||
|
||||
db.session.flush()
|
||||
|
||||
if replace_content:
|
||||
content = files.get(get_post_content_path(source_post))
|
||||
update_post_content(target_post, content)
|
||||
|
@ -223,16 +223,49 @@ def merge_tags(source_tag, target_tag):
|
||||
assert target_tag
|
||||
if source_tag.tag_id == target_tag.tag_id:
|
||||
raise InvalidTagRelationError('Cannot merge tag with itself.')
|
||||
pt1 = db.PostTag
|
||||
pt2 = sqlalchemy.orm.util.aliased(db.PostTag)
|
||||
|
||||
update_stmt = (sqlalchemy.sql.expression.update(pt1)
|
||||
.where(db.PostTag.tag_id == source_tag.tag_id)
|
||||
def merge_posts(source_tag_id, target_tag_id):
|
||||
alias1 = db.PostTag
|
||||
alias2 = sqlalchemy.orm.util.aliased(db.PostTag)
|
||||
update_stmt = (sqlalchemy.sql.expression.update(alias1)
|
||||
.where(alias1.tag_id == source_tag_id))
|
||||
update_stmt = (update_stmt
|
||||
.where(~sqlalchemy.exists()
|
||||
.where(pt2.post_id == pt1.post_id)
|
||||
.where(pt2.tag_id == target_tag.tag_id))
|
||||
.values(tag_id=target_tag.tag_id))
|
||||
.where(alias1.post_id == alias2.post_id)
|
||||
.where(alias2.tag_id == target_tag_id)))
|
||||
update_stmt = update_stmt.values(tag_id=target_tag_id)
|
||||
db.session.execute(update_stmt)
|
||||
|
||||
def merge_relations(table, source_tag_id, target_tag_id):
|
||||
alias1 = table
|
||||
alias2 = sqlalchemy.orm.util.aliased(table)
|
||||
update_stmt = (sqlalchemy.sql.expression.update(alias1)
|
||||
.where(alias1.parent_id == source_tag_id)
|
||||
.where(alias1.child_id != target_tag_id)
|
||||
.where(~sqlalchemy.exists()
|
||||
.where(alias2.child_id == alias1.child_id)
|
||||
.where(alias2.parent_id == target_tag_id))
|
||||
.values(parent_id=target_tag_id))
|
||||
db.session.execute(update_stmt)
|
||||
|
||||
update_stmt = (sqlalchemy.sql.expression.update(alias1)
|
||||
.where(alias1.child_id == source_tag_id)
|
||||
.where(alias1.parent_id != target_tag_id)
|
||||
.where(~sqlalchemy.exists()
|
||||
.where(alias2.parent_id == alias1.parent_id)
|
||||
.where(alias2.child_id == target_tag_id))
|
||||
.values(child_id=target_tag_id))
|
||||
db.session.execute(update_stmt)
|
||||
|
||||
def merge_suggestions(source_tag_id, target_tag_id):
|
||||
merge_relations(db.TagSuggestion, source_tag_id, target_tag_id)
|
||||
|
||||
def merge_implications(source_tag_id, target_tag_id):
|
||||
merge_relations(db.TagImplication, source_tag_id, target_tag_id)
|
||||
|
||||
merge_posts(source_tag.tag_id, target_tag.tag_id)
|
||||
merge_suggestions(source_tag.tag_id, target_tag.tag_id)
|
||||
merge_implications(source_tag.tag_id, target_tag.tag_id)
|
||||
delete(source_tag)
|
||||
|
||||
|
||||
|
@ -25,7 +25,7 @@ def _param_wrapper(func):
|
||||
return wrapper
|
||||
|
||||
|
||||
class Context():
|
||||
class Context:
|
||||
def __init__(self, method, url, headers=None, params=None, files=None):
|
||||
self.method = method
|
||||
self.url = url
|
||||
|
@ -1,7 +1,7 @@
|
||||
from szurubooru.search import tokens
|
||||
|
||||
|
||||
class BaseSearchConfig(object):
|
||||
class BaseSearchConfig:
|
||||
SORT_ASC = tokens.SortToken.SORT_ASC
|
||||
SORT_DESC = tokens.SortToken.SORT_DESC
|
||||
|
||||
|
@ -1,4 +1,4 @@
|
||||
class _BaseCriterion(object):
|
||||
class _BaseCriterion:
|
||||
def __init__(self, original_text):
|
||||
self.original_text = original_text
|
||||
|
||||
|
@ -20,7 +20,7 @@ def _get_order(order, default_order):
|
||||
return order
|
||||
|
||||
|
||||
class Executor(object):
|
||||
class Executor:
|
||||
'''
|
||||
Class for search parsing and execution. Handles plaintext parsing and
|
||||
delegates sqlalchemy filter decoration to SearchConfig instances.
|
||||
|
@ -67,7 +67,7 @@ def _parse_sort(value, negated):
|
||||
return tokens.SortToken(value, order)
|
||||
|
||||
|
||||
class SearchQuery():
|
||||
class SearchQuery:
|
||||
def __init__(self):
|
||||
self.anonymous_tokens = []
|
||||
self.named_tokens = []
|
||||
@ -82,7 +82,7 @@ class SearchQuery():
|
||||
tuple(self.sort_tokens)))
|
||||
|
||||
|
||||
class Parser(object):
|
||||
class Parser:
|
||||
def parse(self, query_text):
|
||||
query = SearchQuery()
|
||||
for chunk in re.split(r'\s+', (query_text or '').lower()):
|
||||
|
@ -1,4 +1,4 @@
|
||||
class AnonymousToken(object):
|
||||
class AnonymousToken:
|
||||
def __init__(self, criterion, negated):
|
||||
self.criterion = criterion
|
||||
self.negated = negated
|
||||
@ -16,7 +16,7 @@ class NamedToken(AnonymousToken):
|
||||
return hash((self.name, self.criterion, self.negated))
|
||||
|
||||
|
||||
class SortToken(object):
|
||||
class SortToken:
|
||||
SORT_DESC = 'desc'
|
||||
SORT_ASC = 'asc'
|
||||
SORT_DEFAULT = 'default'
|
||||
@ -30,7 +30,7 @@ class SortToken(object):
|
||||
return hash((self.name, self.order))
|
||||
|
||||
|
||||
class SpecialToken(object):
|
||||
class SpecialToken:
|
||||
def __init__(self, value, negated):
|
||||
self.value = value
|
||||
self.negated = negated
|
||||
|
89
server/szurubooru/tests/api/test_post_merging.py
Normal file
89
server/szurubooru/tests/api/test_post_merging.py
Normal file
@ -0,0 +1,89 @@
|
||||
from unittest.mock import patch
|
||||
import pytest
|
||||
from szurubooru import api, db, errors
|
||||
from szurubooru.func import posts, snapshots
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def inject_config(config_injector):
|
||||
config_injector({'privileges': {'posts:merge': db.User.RANK_REGULAR}})
|
||||
|
||||
|
||||
def test_merging(user_factory, context_factory, post_factory):
|
||||
auth_user = user_factory(rank=db.User.RANK_REGULAR)
|
||||
source_post = post_factory()
|
||||
target_post = post_factory()
|
||||
db.session.add_all([source_post, target_post])
|
||||
db.session.flush()
|
||||
with patch('szurubooru.func.posts.serialize_post'), \
|
||||
patch('szurubooru.func.posts.merge_posts'), \
|
||||
patch('szurubooru.func.snapshots.merge'):
|
||||
api.post_api.merge_posts(
|
||||
context_factory(
|
||||
params={
|
||||
'removeVersion': 1,
|
||||
'mergeToVersion': 1,
|
||||
'remove': source_post.post_id,
|
||||
'mergeTo': target_post.post_id,
|
||||
},
|
||||
user=auth_user))
|
||||
posts.merge_posts.called_once_with(source_post, target_post)
|
||||
snapshots.merge.assert_called_once_with(
|
||||
source_post, target_post, auth_user)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
'field', ['remove', 'mergeTo', 'removeVersion', 'mergeToVersion'])
|
||||
def test_trying_to_omit_mandatory_field(
|
||||
user_factory, post_factory, context_factory, field):
|
||||
source_post = post_factory()
|
||||
target_post = post_factory()
|
||||
db.session.add_all([source_post, target_post])
|
||||
db.session.commit()
|
||||
params = {
|
||||
'removeVersion': 1,
|
||||
'mergeToVersion': 1,
|
||||
'remove': source_post.post_id,
|
||||
'mergeTo': target_post.post_id,
|
||||
}
|
||||
del params[field]
|
||||
with pytest.raises(errors.ValidationError):
|
||||
api.post_api.merge_posts(
|
||||
context_factory(
|
||||
params=params,
|
||||
user=user_factory(rank=db.User.RANK_REGULAR)))
|
||||
|
||||
|
||||
def test_trying_to_merge_non_existing(
|
||||
user_factory, post_factory, context_factory):
|
||||
post = post_factory()
|
||||
db.session.add(post)
|
||||
db.session.commit()
|
||||
with pytest.raises(posts.PostNotFoundError):
|
||||
api.post_api.merge_posts(
|
||||
context_factory(
|
||||
params={'remove': post.post_id, 'mergeTo': 999},
|
||||
user=user_factory(rank=db.User.RANK_REGULAR)))
|
||||
with pytest.raises(posts.PostNotFoundError):
|
||||
api.post_api.merge_posts(
|
||||
context_factory(
|
||||
params={'remove': 999, 'mergeTo': post.post_id},
|
||||
user=user_factory(rank=db.User.RANK_REGULAR)))
|
||||
|
||||
|
||||
def test_trying_to_merge_without_privileges(
|
||||
user_factory, post_factory, context_factory):
|
||||
source_post = post_factory()
|
||||
target_post = post_factory()
|
||||
db.session.add_all([source_post, target_post])
|
||||
db.session.commit()
|
||||
with pytest.raises(errors.AuthError):
|
||||
api.post_api.merge_posts(
|
||||
context_factory(
|
||||
params={
|
||||
'removeVersion': 1,
|
||||
'mergeToVersion': 1,
|
||||
'remove': source_post.post_id,
|
||||
'mergeTo': target_post.post_id,
|
||||
},
|
||||
user=user_factory(rank=db.User.RANK_ANONYMOUS)))
|
@ -10,7 +10,7 @@ import sqlalchemy
|
||||
from szurubooru import config, db, rest
|
||||
|
||||
|
||||
class QueryCounter(object):
|
||||
class QueryCounter:
|
||||
def __init__(self):
|
||||
self._statements = []
|
||||
|
||||
@ -192,6 +192,30 @@ def comment_factory(user_factory, post_factory):
|
||||
return factory
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def post_score_factory(user_factory, post_factory):
|
||||
def factory(post=None, user=None, score=1):
|
||||
if user is None:
|
||||
user = user_factory()
|
||||
if post is None:
|
||||
post = post_factory()
|
||||
return db.PostScore(
|
||||
post=post, user=user, score=score, time=datetime(1999, 1, 1))
|
||||
return factory
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def post_favorite_factory(user_factory, post_factory):
|
||||
def factory(post=None, user=None):
|
||||
if user is None:
|
||||
user = user_factory()
|
||||
if post is None:
|
||||
post = post_factory()
|
||||
return db.PostFavorite(
|
||||
post=post, user=user, time=datetime(1999, 1, 1))
|
||||
return factory
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def read_asset():
|
||||
def get(path):
|
||||
|
@ -605,3 +605,251 @@ def test_delete(post_factory):
|
||||
posts.delete(post)
|
||||
db.session.flush()
|
||||
assert posts.get_post_count() == 0
|
||||
|
||||
|
||||
def test_merge_posts_deletes_source_post(post_factory):
|
||||
source_post = post_factory()
|
||||
target_post = post_factory()
|
||||
db.session.add_all([source_post, target_post])
|
||||
db.session.flush()
|
||||
posts.merge_posts(source_post, target_post, False)
|
||||
db.session.flush()
|
||||
assert posts.try_get_post_by_id(source_post.post_id) is None
|
||||
post = posts.get_post_by_id(target_post.post_id)
|
||||
assert post is not None
|
||||
|
||||
|
||||
def test_merge_posts_with_itself(post_factory):
|
||||
source_post = post_factory()
|
||||
db.session.add(source_post)
|
||||
db.session.flush()
|
||||
with pytest.raises(posts.InvalidPostRelationError):
|
||||
posts.merge_posts(source_post, source_post, False)
|
||||
|
||||
|
||||
def test_merge_posts_moves_tags(post_factory, tag_factory):
|
||||
source_post = post_factory()
|
||||
target_post = post_factory()
|
||||
tag = tag_factory()
|
||||
tag.posts = [source_post]
|
||||
db.session.add_all([source_post, target_post, tag])
|
||||
db.session.commit()
|
||||
assert source_post.tag_count == 1
|
||||
assert target_post.tag_count == 0
|
||||
posts.merge_posts(source_post, target_post, False)
|
||||
db.session.commit()
|
||||
assert posts.try_get_post_by_id(source_post.post_id) is None
|
||||
assert posts.get_post_by_id(target_post.post_id).tag_count == 1
|
||||
|
||||
|
||||
def test_merge_posts_doesnt_duplicate_tags(post_factory, tag_factory):
|
||||
source_post = post_factory()
|
||||
target_post = post_factory()
|
||||
tag = tag_factory()
|
||||
tag.posts = [source_post, target_post]
|
||||
db.session.add_all([source_post, target_post, tag])
|
||||
db.session.commit()
|
||||
assert source_post.tag_count == 1
|
||||
assert target_post.tag_count == 1
|
||||
posts.merge_posts(source_post, target_post, False)
|
||||
db.session.commit()
|
||||
assert posts.try_get_post_by_id(source_post.post_id) is None
|
||||
assert posts.get_post_by_id(target_post.post_id).tag_count == 1
|
||||
|
||||
|
||||
def test_merge_posts_moves_comments(post_factory, comment_factory):
|
||||
source_post = post_factory()
|
||||
target_post = post_factory()
|
||||
comment = comment_factory(post=source_post)
|
||||
db.session.add_all([source_post, target_post, comment])
|
||||
db.session.commit()
|
||||
assert source_post.comment_count == 1
|
||||
assert target_post.comment_count == 0
|
||||
posts.merge_posts(source_post, target_post, False)
|
||||
db.session.commit()
|
||||
assert posts.try_get_post_by_id(source_post.post_id) is None
|
||||
assert posts.get_post_by_id(target_post.post_id).comment_count == 1
|
||||
|
||||
|
||||
def test_merge_posts_moves_scores(post_factory, post_score_factory):
|
||||
source_post = post_factory()
|
||||
target_post = post_factory()
|
||||
score = post_score_factory(post=source_post, score=1)
|
||||
db.session.add_all([source_post, target_post, score])
|
||||
db.session.commit()
|
||||
assert source_post.score == 1
|
||||
assert target_post.score == 0
|
||||
posts.merge_posts(source_post, target_post, False)
|
||||
db.session.commit()
|
||||
assert posts.try_get_post_by_id(source_post.post_id) is None
|
||||
assert posts.get_post_by_id(target_post.post_id).score == 1
|
||||
|
||||
|
||||
def test_merge_posts_doesnt_duplicate_scores(
|
||||
post_factory, user_factory, post_score_factory):
|
||||
source_post = post_factory()
|
||||
target_post = post_factory()
|
||||
user = user_factory()
|
||||
score1 = post_score_factory(post=source_post, score=1, user=user)
|
||||
score2 = post_score_factory(post=target_post, score=1, user=user)
|
||||
db.session.add_all([source_post, target_post, score1, score2])
|
||||
db.session.commit()
|
||||
assert source_post.score == 1
|
||||
assert target_post.score == 1
|
||||
posts.merge_posts(source_post, target_post, False)
|
||||
db.session.commit()
|
||||
assert posts.try_get_post_by_id(source_post.post_id) is None
|
||||
assert posts.get_post_by_id(target_post.post_id).score == 1
|
||||
|
||||
|
||||
def test_merge_posts_moves_favorites(post_factory, post_favorite_factory):
|
||||
source_post = post_factory()
|
||||
target_post = post_factory()
|
||||
favorite = post_favorite_factory(post=source_post)
|
||||
db.session.add_all([source_post, target_post, favorite])
|
||||
db.session.commit()
|
||||
assert source_post.favorite_count == 1
|
||||
assert target_post.favorite_count == 0
|
||||
posts.merge_posts(source_post, target_post, False)
|
||||
db.session.commit()
|
||||
assert posts.try_get_post_by_id(source_post.post_id) is None
|
||||
assert posts.get_post_by_id(target_post.post_id).favorite_count == 1
|
||||
|
||||
|
||||
def test_merge_posts_doesnt_duplicate_favorites(
|
||||
post_factory, user_factory, post_favorite_factory):
|
||||
source_post = post_factory()
|
||||
target_post = post_factory()
|
||||
user = user_factory()
|
||||
favorite1 = post_favorite_factory(post=source_post, user=user)
|
||||
favorite2 = post_favorite_factory(post=target_post, user=user)
|
||||
db.session.add_all([source_post, target_post, favorite1, favorite2])
|
||||
db.session.commit()
|
||||
assert source_post.favorite_count == 1
|
||||
assert target_post.favorite_count == 1
|
||||
posts.merge_posts(source_post, target_post, False)
|
||||
db.session.commit()
|
||||
assert posts.try_get_post_by_id(source_post.post_id) is None
|
||||
assert posts.get_post_by_id(target_post.post_id).favorite_count == 1
|
||||
|
||||
|
||||
def test_merge_posts_moves_child_relations(post_factory):
|
||||
source_post = post_factory()
|
||||
target_post = post_factory()
|
||||
related_post = post_factory()
|
||||
source_post.relations = [related_post]
|
||||
db.session.add_all([source_post, target_post, related_post])
|
||||
db.session.commit()
|
||||
assert source_post.relation_count == 1
|
||||
assert target_post.relation_count == 0
|
||||
posts.merge_posts(source_post, target_post, False)
|
||||
db.session.commit()
|
||||
assert posts.try_get_post_by_id(source_post.post_id) is None
|
||||
assert posts.get_post_by_id(target_post.post_id).relation_count == 1
|
||||
|
||||
|
||||
def test_merge_posts_doesnt_duplicate_child_relations(post_factory):
|
||||
source_post = post_factory()
|
||||
target_post = post_factory()
|
||||
related_post = post_factory()
|
||||
source_post.relations = [related_post]
|
||||
target_post.relations = [related_post]
|
||||
db.session.add_all([source_post, target_post, related_post])
|
||||
db.session.commit()
|
||||
assert source_post.relation_count == 1
|
||||
assert target_post.relation_count == 1
|
||||
posts.merge_posts(source_post, target_post, False)
|
||||
db.session.commit()
|
||||
assert posts.try_get_post_by_id(source_post.post_id) is None
|
||||
assert posts.get_post_by_id(target_post.post_id).relation_count == 1
|
||||
|
||||
|
||||
def test_merge_posts_moves_parent_relations(post_factory):
|
||||
source_post = post_factory()
|
||||
target_post = post_factory()
|
||||
related_post = post_factory()
|
||||
related_post.relations = [source_post]
|
||||
db.session.add_all([source_post, target_post, related_post])
|
||||
db.session.commit()
|
||||
assert source_post.relation_count == 1
|
||||
assert target_post.relation_count == 0
|
||||
assert related_post.relation_count == 1
|
||||
posts.merge_posts(source_post, target_post, False)
|
||||
db.session.commit()
|
||||
assert posts.try_get_post_by_id(source_post.post_id) is None
|
||||
assert posts.get_post_by_id(target_post.post_id).relation_count == 1
|
||||
assert posts.get_post_by_id(related_post.post_id).relation_count == 1
|
||||
|
||||
|
||||
def test_merge_posts_doesnt_duplicate_parent_relations(post_factory):
|
||||
source_post = post_factory()
|
||||
target_post = post_factory()
|
||||
related_post = post_factory()
|
||||
related_post.relations = [source_post, target_post]
|
||||
db.session.add_all([source_post, target_post, related_post])
|
||||
db.session.commit()
|
||||
assert source_post.relation_count == 1
|
||||
assert target_post.relation_count == 1
|
||||
assert related_post.relation_count == 2
|
||||
posts.merge_posts(source_post, target_post, False)
|
||||
db.session.commit()
|
||||
assert posts.try_get_post_by_id(source_post.post_id) is None
|
||||
assert posts.get_post_by_id(target_post.post_id).relation_count == 1
|
||||
assert posts.get_post_by_id(related_post.post_id).relation_count == 1
|
||||
|
||||
|
||||
def test_merge_posts_doesnt_create_relation_loop_for_children(post_factory):
|
||||
source_post = post_factory()
|
||||
target_post = post_factory()
|
||||
source_post.relations = [target_post]
|
||||
db.session.add_all([source_post, target_post])
|
||||
db.session.commit()
|
||||
assert source_post.relation_count == 1
|
||||
assert target_post.relation_count == 1
|
||||
posts.merge_posts(source_post, target_post, False)
|
||||
db.session.commit()
|
||||
assert posts.try_get_post_by_id(source_post.post_id) is None
|
||||
assert posts.get_post_by_id(target_post.post_id).relation_count == 0
|
||||
|
||||
|
||||
def test_merge_posts_doesnt_create_relation_loop_for_parents(post_factory):
|
||||
source_post = post_factory()
|
||||
target_post = post_factory()
|
||||
target_post.relations = [source_post]
|
||||
db.session.add_all([source_post, target_post])
|
||||
db.session.commit()
|
||||
assert source_post.relation_count == 1
|
||||
assert target_post.relation_count == 1
|
||||
posts.merge_posts(source_post, target_post, False)
|
||||
db.session.commit()
|
||||
assert posts.try_get_post_by_id(source_post.post_id) is None
|
||||
assert posts.get_post_by_id(target_post.post_id).relation_count == 0
|
||||
|
||||
|
||||
def test_merge_posts_replaces_content(
|
||||
post_factory, config_injector, tmpdir, read_asset):
|
||||
config_injector({
|
||||
'data_dir': str(tmpdir.mkdir('data')),
|
||||
'data_url': 'example.com',
|
||||
'thumbnails': {
|
||||
'post_width': 300,
|
||||
'post_height': 300,
|
||||
},
|
||||
})
|
||||
source_post = post_factory()
|
||||
target_post = post_factory()
|
||||
content = read_asset('png.png')
|
||||
db.session.add_all([source_post, target_post])
|
||||
db.session.commit()
|
||||
posts.update_post_content(source_post, content)
|
||||
db.session.flush()
|
||||
assert os.path.exists(os.path.join(str(tmpdir), 'data/posts/1.png'))
|
||||
assert not os.path.exists(os.path.join(str(tmpdir), 'data/posts/2.dat'))
|
||||
assert not os.path.exists(os.path.join(str(tmpdir), 'data/posts/2.png'))
|
||||
posts.merge_posts(source_post, target_post, True)
|
||||
db.session.flush()
|
||||
assert posts.try_get_post_by_id(source_post.post_id) is None
|
||||
post = posts.get_post_by_id(target_post.post_id)
|
||||
assert post is not None
|
||||
assert os.path.exists(os.path.join(str(tmpdir), 'data/posts/1.png'))
|
||||
assert os.path.exists(os.path.join(str(tmpdir), 'data/posts/2.png'))
|
||||
|
@ -310,7 +310,7 @@ def test_delete(tag_factory):
|
||||
assert db.session.query(db.Tag).count() == 2
|
||||
|
||||
|
||||
def test_merge_tags_without_usages(tag_factory):
|
||||
def test_merge_tags_deletes_source_tag(tag_factory):
|
||||
source_tag = tag_factory(names=['source'])
|
||||
target_tag = tag_factory(names=['target'])
|
||||
db.session.add_all([source_tag, target_tag])
|
||||
@ -322,7 +322,15 @@ def test_merge_tags_without_usages(tag_factory):
|
||||
assert tag is not None
|
||||
|
||||
|
||||
def test_merge_tags_with_usages(tag_factory, post_factory):
|
||||
def test_merge_tags_with_itself(tag_factory):
|
||||
source_tag = tag_factory(names=['source'])
|
||||
db.session.add(source_tag)
|
||||
db.session.flush()
|
||||
with pytest.raises(tags.InvalidTagRelationError):
|
||||
tags.merge_tags(source_tag, source_tag)
|
||||
|
||||
|
||||
def test_merge_tags_moves_usages(tag_factory, post_factory):
|
||||
source_tag = tag_factory(names=['source'])
|
||||
target_tag = tag_factory(names=['target'])
|
||||
post = post_factory()
|
||||
@ -337,62 +345,7 @@ def test_merge_tags_with_usages(tag_factory, post_factory):
|
||||
assert tags.get_tag_by_name('target').post_count == 1
|
||||
|
||||
|
||||
def test_merge_tags_with_itself(tag_factory):
|
||||
source_tag = tag_factory(names=['source'])
|
||||
db.session.add(source_tag)
|
||||
db.session.flush()
|
||||
with pytest.raises(tags.InvalidTagRelationError):
|
||||
tags.merge_tags(source_tag, source_tag)
|
||||
|
||||
|
||||
def test_merge_tags_with_its_child_relation(tag_factory, post_factory):
|
||||
source_tag = tag_factory(names=['source'])
|
||||
target_tag = tag_factory(names=['target'])
|
||||
source_tag.suggestions = [target_tag]
|
||||
source_tag.implications = [target_tag]
|
||||
post = post_factory()
|
||||
post.tags = [source_tag, target_tag]
|
||||
db.session.add_all([source_tag, post])
|
||||
db.session.flush()
|
||||
tags.merge_tags(source_tag, target_tag)
|
||||
db.session.flush()
|
||||
assert tags.try_get_tag_by_name('source') is None
|
||||
assert tags.get_tag_by_name('target').post_count == 1
|
||||
|
||||
|
||||
def test_merge_tags_with_its_parent_relation(tag_factory, post_factory):
|
||||
source_tag = tag_factory(names=['source'])
|
||||
target_tag = tag_factory(names=['target'])
|
||||
target_tag.suggestions = [source_tag]
|
||||
target_tag.implications = [source_tag]
|
||||
post = post_factory()
|
||||
post.tags = [source_tag, target_tag]
|
||||
db.session.add_all([source_tag, target_tag, post])
|
||||
db.session.flush()
|
||||
tags.merge_tags(source_tag, target_tag)
|
||||
db.session.flush()
|
||||
assert tags.try_get_tag_by_name('source') is None
|
||||
assert tags.get_tag_by_name('target').post_count == 1
|
||||
|
||||
|
||||
def test_merge_tags_clears_relations(tag_factory):
|
||||
source_tag = tag_factory(names=['source'])
|
||||
target_tag = tag_factory(names=['target'])
|
||||
referring_tag = tag_factory(names=['parent'])
|
||||
referring_tag.suggestions = [source_tag]
|
||||
referring_tag.implications = [source_tag]
|
||||
db.session.add_all([source_tag, target_tag, referring_tag])
|
||||
db.session.flush()
|
||||
assert tags.try_get_tag_by_name('parent').implications != []
|
||||
assert tags.try_get_tag_by_name('parent').suggestions != []
|
||||
tags.merge_tags(source_tag, target_tag)
|
||||
db.session.commit()
|
||||
assert tags.try_get_tag_by_name('source') is None
|
||||
assert tags.try_get_tag_by_name('parent').implications == []
|
||||
assert tags.try_get_tag_by_name('parent').suggestions == []
|
||||
|
||||
|
||||
def test_merge_tags_when_target_exists(tag_factory, post_factory):
|
||||
def test_merge_tags_doesnt_duplicate_usages(tag_factory, post_factory):
|
||||
source_tag = tag_factory(names=['source'])
|
||||
target_tag = tag_factory(names=['target'])
|
||||
post = post_factory()
|
||||
@ -407,6 +360,103 @@ def test_merge_tags_when_target_exists(tag_factory, post_factory):
|
||||
assert tags.get_tag_by_name('target').post_count == 1
|
||||
|
||||
|
||||
def test_merge_tags_moves_child_relations(tag_factory):
|
||||
source_tag = tag_factory(names=['source'])
|
||||
target_tag = tag_factory(names=['target'])
|
||||
related_tag = tag_factory()
|
||||
source_tag.suggestions = [related_tag]
|
||||
source_tag.implications = [related_tag]
|
||||
db.session.add_all([source_tag, target_tag, related_tag])
|
||||
db.session.commit()
|
||||
assert source_tag.suggestion_count == 1
|
||||
assert source_tag.implication_count == 1
|
||||
assert target_tag.suggestion_count == 0
|
||||
assert target_tag.implication_count == 0
|
||||
tags.merge_tags(source_tag, target_tag)
|
||||
db.session.commit()
|
||||
assert tags.try_get_tag_by_name('source') is None
|
||||
assert tags.get_tag_by_name('target').suggestion_count == 1
|
||||
assert tags.get_tag_by_name('target').implication_count == 1
|
||||
|
||||
|
||||
def test_merge_tags_doesnt_duplicate_child_relations(tag_factory):
|
||||
source_tag = tag_factory(names=['source'])
|
||||
target_tag = tag_factory(names=['target'])
|
||||
related_tag = tag_factory()
|
||||
source_tag.suggestions = [related_tag]
|
||||
source_tag.implications = [related_tag]
|
||||
target_tag.suggestions = [related_tag]
|
||||
target_tag.implications = [related_tag]
|
||||
db.session.add_all([source_tag, target_tag, related_tag])
|
||||
db.session.commit()
|
||||
assert source_tag.suggestion_count == 1
|
||||
assert source_tag.implication_count == 1
|
||||
assert target_tag.suggestion_count == 1
|
||||
assert target_tag.implication_count == 1
|
||||
tags.merge_tags(source_tag, target_tag)
|
||||
db.session.commit()
|
||||
assert tags.try_get_tag_by_name('source') is None
|
||||
assert tags.get_tag_by_name('target').suggestion_count == 1
|
||||
assert tags.get_tag_by_name('target').implication_count == 1
|
||||
|
||||
|
||||
def test_merge_tags_moves_parent_relations(tag_factory):
|
||||
source_tag = tag_factory(names=['source'])
|
||||
target_tag = tag_factory(names=['target'])
|
||||
related_tag = tag_factory(names=['related'])
|
||||
related_tag.suggestions = [related_tag]
|
||||
related_tag.implications = [related_tag]
|
||||
db.session.add_all([source_tag, target_tag, related_tag])
|
||||
db.session.commit()
|
||||
assert source_tag.suggestion_count == 0
|
||||
assert source_tag.implication_count == 0
|
||||
assert target_tag.suggestion_count == 0
|
||||
assert target_tag.implication_count == 0
|
||||
tags.merge_tags(source_tag, target_tag)
|
||||
db.session.commit()
|
||||
assert tags.try_get_tag_by_name('source') is None
|
||||
assert tags.get_tag_by_name('related').suggestion_count == 1
|
||||
assert tags.get_tag_by_name('related').suggestion_count == 1
|
||||
assert tags.get_tag_by_name('target').suggestion_count == 0
|
||||
assert tags.get_tag_by_name('target').implication_count == 0
|
||||
|
||||
|
||||
def test_merge_tags_doesnt_create_relation_loop_for_children(tag_factory):
|
||||
source_tag = tag_factory(names=['source'])
|
||||
target_tag = tag_factory(names=['target'])
|
||||
source_tag.suggestions = [target_tag]
|
||||
source_tag.implications = [target_tag]
|
||||
db.session.add_all([source_tag, target_tag])
|
||||
db.session.commit()
|
||||
assert source_tag.suggestion_count == 1
|
||||
assert source_tag.implication_count == 1
|
||||
assert target_tag.suggestion_count == 0
|
||||
assert target_tag.implication_count == 0
|
||||
tags.merge_tags(source_tag, target_tag)
|
||||
db.session.commit()
|
||||
assert tags.try_get_tag_by_name('source') is None
|
||||
assert tags.get_tag_by_name('target').suggestion_count == 0
|
||||
assert tags.get_tag_by_name('target').implication_count == 0
|
||||
|
||||
|
||||
def test_merge_tags_doesnt_create_relation_loop_for_parents(tag_factory):
|
||||
source_tag = tag_factory(names=['source'])
|
||||
target_tag = tag_factory(names=['target'])
|
||||
target_tag.suggestions = [source_tag]
|
||||
target_tag.implications = [source_tag]
|
||||
db.session.add_all([source_tag, target_tag])
|
||||
db.session.commit()
|
||||
assert source_tag.suggestion_count == 0
|
||||
assert source_tag.implication_count == 0
|
||||
assert target_tag.suggestion_count == 1
|
||||
assert target_tag.implication_count == 1
|
||||
tags.merge_tags(source_tag, target_tag)
|
||||
db.session.commit()
|
||||
assert tags.try_get_tag_by_name('source') is None
|
||||
assert tags.get_tag_by_name('target').suggestion_count == 0
|
||||
assert tags.get_tag_by_name('target').implication_count == 0
|
||||
|
||||
|
||||
def test_create_tag(fake_datetime):
|
||||
with patch('szurubooru.func.tags.update_tag_names'), \
|
||||
patch('szurubooru.func.tags.update_tag_category_name'), \
|
||||
|
Reference in New Issue
Block a user