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)
|
- [Updating post](#updating-post)
|
||||||
- [Getting post](#getting-post)
|
- [Getting post](#getting-post)
|
||||||
- [Deleting post](#deleting-post)
|
- [Deleting post](#deleting-post)
|
||||||
|
- [Merging posts](#merging-posts)
|
||||||
- [Rating post](#rating-post)
|
- [Rating post](#rating-post)
|
||||||
- [Adding post to favorites](#adding-post-to-favorites)
|
- [Adding post to favorites](#adding-post-to-favorites)
|
||||||
- [Removing post from favorites](#removing-post-from-favorites)
|
- [Removing post from favorites](#removing-post-from-favorites)
|
||||||
@ -617,10 +618,9 @@ data.
|
|||||||
|
|
||||||
- **Description**
|
- **Description**
|
||||||
|
|
||||||
Removes source tag and merges all of its usages to the target tag. Source
|
Removes source tag and merges all of its usages, suggestions and
|
||||||
tag properties such as category, tag relations etc. do not get transferred
|
implications to the target tag. Other tag properties such as category and
|
||||||
and are discarded. The target tag effectively remains unchanged with the
|
aliases do not get transferred and are discarded.
|
||||||
exception of the set of posts it's used in.
|
|
||||||
|
|
||||||
## Listing tag siblings
|
## Listing tag siblings
|
||||||
- **Request**
|
- **Request**
|
||||||
@ -910,6 +910,43 @@ data.
|
|||||||
|
|
||||||
Deletes existing post. Related posts and tags are kept.
|
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
|
## Rating post
|
||||||
- **Request**
|
- **Request**
|
||||||
|
|
||||||
|
@ -65,10 +65,9 @@ input[type=radio], input[type=checkbox]
|
|||||||
.radio:before, .checkbox:before
|
.radio:before, .checkbox:before
|
||||||
transition: border-color 0.1s linear
|
transition: border-color 0.1s linear
|
||||||
position: absolute
|
position: absolute
|
||||||
top: 50%
|
|
||||||
left: 0
|
left: 0
|
||||||
|
top: 0.15em
|
||||||
display: block
|
display: block
|
||||||
margin-top: -10px
|
|
||||||
width: 16px
|
width: 16px
|
||||||
height: 16px
|
height: 16px
|
||||||
background: $input-enabled-background-color
|
background: $input-enabled-background-color
|
||||||
@ -79,10 +78,10 @@ input[type=radio], input[type=checkbox]
|
|||||||
background: $main-color
|
background: $main-color
|
||||||
transition: opacity 0.1s linear
|
transition: opacity 0.1s linear
|
||||||
position: absolute
|
position: absolute
|
||||||
top: 50%
|
|
||||||
left: 5px
|
left: 5px
|
||||||
|
top: 0.15em
|
||||||
|
margin-top: 5px
|
||||||
display: block
|
display: block
|
||||||
margin-top: -5px
|
|
||||||
width: 10px
|
width: 10px
|
||||||
height: 10px
|
height: 10px
|
||||||
border-radius: 50%
|
border-radius: 50%
|
||||||
@ -92,10 +91,10 @@ input[type=radio], input[type=checkbox]
|
|||||||
.checkbox:after
|
.checkbox:after
|
||||||
transition: opacity 0.1s linear
|
transition: opacity 0.1s linear
|
||||||
position: absolute
|
position: absolute
|
||||||
top: 50%
|
top: 0.15em
|
||||||
left: 6px
|
left: 6px
|
||||||
display: block
|
display: block
|
||||||
margin-top: -7px
|
margin-top: 3px
|
||||||
width: 5px
|
width: 5px
|
||||||
height: 9px
|
height: 9px
|
||||||
border-right: 3px solid $main-color
|
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
|
margin-bottom: 1em
|
||||||
|
|
||||||
.safety
|
.safety
|
||||||
display: flex
|
&>label
|
||||||
flex-wrap: wrap
|
|
||||||
label:not(.radio)
|
|
||||||
width: 100%
|
width: 100%
|
||||||
.radio
|
.radio-wrapper
|
||||||
|
display: flex
|
||||||
|
flex-wrap: wrap
|
||||||
|
.radio-wrapper label
|
||||||
flex-grow: 1
|
flex-grow: 1
|
||||||
display: inline-block
|
display: inline-block
|
||||||
|
|
@ -39,20 +39,24 @@ $cancel-button-color = tomato
|
|||||||
margin-top: 1em
|
margin-top: 1em
|
||||||
|
|
||||||
.uploadables-container
|
.uploadables-container
|
||||||
line-height: 200%
|
|
||||||
|
|
||||||
li
|
li
|
||||||
margin: 0 0 1.2em 0
|
margin: 0 0 1.2em 0
|
||||||
|
|
||||||
.uploadable
|
.uploadable
|
||||||
.file
|
.file
|
||||||
|
margin: 0.3em 0
|
||||||
overflow: hidden
|
overflow: hidden
|
||||||
white-space: nowrap
|
white-space: nowrap
|
||||||
text-align: left
|
text-align: left
|
||||||
text-overflow: ellipsis
|
text-overflow: ellipsis
|
||||||
|
|
||||||
|
.anonymous
|
||||||
|
margin: 0.3em 0
|
||||||
|
|
||||||
.safety
|
.safety
|
||||||
|
margin: 0.3em 0
|
||||||
label
|
label
|
||||||
|
display: inline-block
|
||||||
margin-right: 1em
|
margin-right: 1em
|
||||||
|
|
||||||
.thumbnail-wrapper
|
.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,24 +7,26 @@
|
|||||||
<% if (ctx.canEditPostSafety) { %>
|
<% if (ctx.canEditPostSafety) { %>
|
||||||
<section class='safety'>
|
<section class='safety'>
|
||||||
<label>Safety</label>
|
<label>Safety</label>
|
||||||
<%= ctx.makeRadio({
|
<div class='radio-wrapper'>
|
||||||
name: 'safety',
|
<%= ctx.makeRadio({
|
||||||
class: 'safety-safe',
|
name: 'safety',
|
||||||
value: 'safe',
|
class: 'safety-safe',
|
||||||
selectedValue: ctx.post.safety,
|
value: 'safe',
|
||||||
text: 'Safe'}) %>
|
selectedValue: ctx.post.safety,
|
||||||
<%= ctx.makeRadio({
|
text: 'Safe'}) %>
|
||||||
name: 'safety',
|
<%= ctx.makeRadio({
|
||||||
class: 'safety-sketchy',
|
name: 'safety',
|
||||||
value: 'sketchy',
|
class: 'safety-sketchy',
|
||||||
selectedValue: ctx.post.safety,
|
value: 'sketchy',
|
||||||
text: 'Sketchy'}) %>
|
selectedValue: ctx.post.safety,
|
||||||
<%= ctx.makeRadio({
|
text: 'Sketchy'}) %>
|
||||||
name: 'safety',
|
<%= ctx.makeRadio({
|
||||||
value: 'unsafe',
|
name: 'safety',
|
||||||
selectedValue: ctx.post.safety,
|
value: 'unsafe',
|
||||||
class: 'safety-unsafe',
|
selectedValue: ctx.post.safety,
|
||||||
text: 'Unsafe'}) %>
|
class: 'safety-unsafe',
|
||||||
|
text: 'Unsafe'}) %>
|
||||||
|
</div>
|
||||||
</section>
|
</section>
|
||||||
<% } %>
|
<% } %>
|
||||||
|
|
||||||
@ -82,12 +84,15 @@
|
|||||||
</section>
|
</section>
|
||||||
<% } %>
|
<% } %>
|
||||||
|
|
||||||
<% if (ctx.canFeaturePosts) { %>
|
<% if (ctx.canFeaturePosts || ctx.canDeletePosts || ctx.canMergePosts) { %>
|
||||||
<section class='management'>
|
<section class='management'>
|
||||||
<ul>
|
<ul>
|
||||||
<% if (ctx.canFeaturePosts) { %>
|
<% if (ctx.canFeaturePosts) { %>
|
||||||
<li><a href class='feature'>Feature this post on main page</a></li>
|
<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) { %>
|
<% if (ctx.canDeletePosts) { %>
|
||||||
<li><a href class='delete'>Delete this post</a></li>
|
<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'>
|
<div class='tag-merge'>
|
||||||
<form>
|
<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>
|
<ul>
|
||||||
<li class='target'>
|
<li class='target'>
|
||||||
<%= ctx.makeTextInput({required: true, text: 'Target tag', pattern: ctx.tagNamePattern}) %>
|
<%= ctx.makeTextInput({required: true, text: 'Target tag', pattern: ctx.tagNamePattern}) %>
|
||||||
</li>
|
</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.'}) %>
|
<%= ctx.makeCheckbox({required: true, text: 'I confirm that I want to merge this tag.'}) %>
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</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 Comment = require('../models/comment.js');
|
||||||
const Post = require('../models/post.js');
|
const Post = require('../models/post.js');
|
||||||
const PostList = require('../models/post_list.js');
|
const PostList = require('../models/post_list.js');
|
||||||
const topNavigation = require('../models/top_navigation.js');
|
const PostMainView = require('../views/post_main_view.js');
|
||||||
const PostView = require('../views/post_view.js');
|
const BasePostController = require('./base_post_controller.js');
|
||||||
const EmptyView = require('../views/empty_view.js');
|
const EmptyView = require('../views/empty_view.js');
|
||||||
|
|
||||||
class PostController {
|
class PostMainController extends BasePostController {
|
||||||
constructor(id, editMode, ctx) {
|
constructor(ctx, editMode) {
|
||||||
if (!api.hasPrivilege('posts:view')) {
|
super(ctx);
|
||||||
this._view = new EmptyView();
|
|
||||||
this._view.showError('You don\'t have privileges to view posts.');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
topNavigation.activate('posts');
|
|
||||||
topNavigation.setTitle('Post #' + id.toString());
|
|
||||||
|
|
||||||
let parameters = ctx.parameters;
|
let parameters = ctx.parameters;
|
||||||
Promise.all([
|
Promise.all([
|
||||||
Post.get(id),
|
Post.get(ctx.parameters.id),
|
||||||
PostList.getAround(
|
PostList.getAround(
|
||||||
id, this._decorateSearchQuery(
|
ctx.parameters.id, this._decorateSearchQuery(
|
||||||
parameters ? parameters.query : '')),
|
parameters ? parameters.query : '')),
|
||||||
]).then(responses => {
|
]).then(responses => {
|
||||||
const [post, aroundResponse] = responses;
|
const [post, aroundResponse] = responses;
|
||||||
@ -36,13 +29,13 @@ class PostController {
|
|||||||
if (parameters.query) {
|
if (parameters.query) {
|
||||||
ctx.state.parameters = parameters;
|
ctx.state.parameters = parameters;
|
||||||
const url = editMode ?
|
const url = editMode ?
|
||||||
'/post/' + id + '/edit' :
|
'/post/' + ctx.parameters.id + '/edit' :
|
||||||
'/post/' + id;
|
'/post/' + ctx.parameters.id;
|
||||||
router.replace(url, ctx.state, false);
|
router.replace(url, ctx.state, false);
|
||||||
}
|
}
|
||||||
|
|
||||||
this._post = post;
|
this._post = post;
|
||||||
this._view = new PostView({
|
this._view = new PostMainView({
|
||||||
post: post,
|
post: post,
|
||||||
editMode: editMode,
|
editMode: editMode,
|
||||||
prevPostId: aroundResponse.prev ? aroundResponse.prev.id : null,
|
prevPostId: aroundResponse.prev ? aroundResponse.prev.id : null,
|
||||||
@ -72,6 +65,8 @@ class PostController {
|
|||||||
'feature', e => this._evtFeaturePost(e));
|
'feature', e => this._evtFeaturePost(e));
|
||||||
this._view.sidebarControl.addEventListener(
|
this._view.sidebarControl.addEventListener(
|
||||||
'delete', e => this._evtDeletePost(e));
|
'delete', e => this._evtDeletePost(e));
|
||||||
|
this._view.sidebarControl.addEventListener(
|
||||||
|
'merge', e => this._evtMergePost(e));
|
||||||
}
|
}
|
||||||
|
|
||||||
if (this._view.commentFormControl) {
|
if (this._view.commentFormControl) {
|
||||||
@ -128,6 +123,10 @@ class PostController {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
_evtMergePost(e) {
|
||||||
|
router.show('/post/' + e.detail.post.id + '/merge');
|
||||||
|
}
|
||||||
|
|
||||||
_evtDeletePost(e) {
|
_evtDeletePost(e) {
|
||||||
this._view.sidebarControl.disableForm();
|
this._view.sidebarControl.disableForm();
|
||||||
this._view.sidebarControl.clearMessages();
|
this._view.sidebarControl.clearMessages();
|
||||||
@ -262,7 +261,7 @@ module.exports = router => {
|
|||||||
if (ctx.state.parameters) {
|
if (ctx.state.parameters) {
|
||||||
Object.assign(ctx.parameters, 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(
|
router.enter(
|
||||||
'/post/:id/:parameters(.*)?',
|
'/post/:id/:parameters(.*)?',
|
||||||
@ -272,6 +271,6 @@ module.exports = router => {
|
|||||||
if (ctx.state.parameters) {
|
if (ctx.state.parameters) {
|
||||||
Object.assign(ctx.parameters, 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'),
|
canCreateAnonymousPosts: api.hasPrivilege('posts:create:anonymous'),
|
||||||
canDeletePosts: api.hasPrivilege('posts:delete'),
|
canDeletePosts: api.hasPrivilege('posts:delete'),
|
||||||
canFeaturePosts: api.hasPrivilege('posts:feature'),
|
canFeaturePosts: api.hasPrivilege('posts:feature'),
|
||||||
|
canMergePosts: api.hasPrivilege('posts:merge'),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
new ExpanderControl(
|
new ExpanderControl(
|
||||||
@ -108,6 +109,11 @@ class PostEditSidebarControl extends events.EventTarget {
|
|||||||
'click', e => this._evtFeatureClick(e));
|
'click', e => this._evtFeatureClick(e));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (this._mergeLinkNode) {
|
||||||
|
this._mergeLinkNode.addEventListener(
|
||||||
|
'click', e => this._evtMergeClick(e));
|
||||||
|
}
|
||||||
|
|
||||||
if (this._deleteLinkNode) {
|
if (this._deleteLinkNode) {
|
||||||
this._deleteLinkNode.addEventListener(
|
this._deleteLinkNode.addEventListener(
|
||||||
'click', e => this._evtDeleteClick(e));
|
'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) {
|
_evtDeleteClick(e) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (confirm('Are you sure you want to delete this post?')) {
|
if (confirm('Are you sure you want to delete this post?')) {
|
||||||
@ -244,7 +259,7 @@ class PostEditSidebarControl extends events.EventTarget {
|
|||||||
detail: {
|
detail: {
|
||||||
post: this._post,
|
post: this._post,
|
||||||
|
|
||||||
safety: this._safetyButtonNodes.legnth ?
|
safety: this._safetyButtonNodes.length ?
|
||||||
Array.from(this._safetyButtonNodes)
|
Array.from(this._safetyButtonNodes)
|
||||||
.filter(node => node.checked)[0]
|
.filter(node => node.checked)[0]
|
||||||
.value.toLowerCase() :
|
.value.toLowerCase() :
|
||||||
@ -314,6 +329,10 @@ class PostEditSidebarControl extends events.EventTarget {
|
|||||||
return this._formNode.querySelector('.management .feature');
|
return this._formNode.querySelector('.management .feature');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
get _mergeLinkNode() {
|
||||||
|
return this._formNode.querySelector('.management .merge');
|
||||||
|
}
|
||||||
|
|
||||||
get _deleteLinkNode() {
|
get _deleteLinkNode() {
|
||||||
return this._formNode.querySelector('.management .delete');
|
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/password_reset_controller.js'));
|
||||||
controllers.push(require('./controllers/comments_controller.js'));
|
controllers.push(require('./controllers/comments_controller.js'));
|
||||||
controllers.push(require('./controllers/snapshots_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_list_controller.js'));
|
||||||
controllers.push(require('./controllers/post_upload_controller.js'));
|
controllers.push(require('./controllers/post_upload_controller.js'));
|
||||||
controllers.push(require('./controllers/tag_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) {
|
setScore(score) {
|
||||||
return api.put('/post/' + this._id + '/score', {score: score})
|
return api.put('/post/' + this._id + '/score', {score: score})
|
||||||
.then(response => {
|
.then(response => {
|
||||||
|
@ -53,35 +53,41 @@ function makeThumbnail(url) {
|
|||||||
|
|
||||||
function makeRadio(options) {
|
function makeRadio(options) {
|
||||||
_imbueId(options);
|
_imbueId(options);
|
||||||
return makeVoidElement(
|
return makeNonVoidElement(
|
||||||
'input',
|
'label',
|
||||||
{
|
{for: options.id},
|
||||||
id: options.id,
|
makeVoidElement(
|
||||||
name: options.name,
|
'input',
|
||||||
value: options.value,
|
{
|
||||||
type: 'radio',
|
id: options.id,
|
||||||
checked: options.selectedValue === options.value,
|
name: options.name,
|
||||||
disabled: options.readonly,
|
value: options.value,
|
||||||
required: options.required,
|
type: 'radio',
|
||||||
}) +
|
checked: options.selectedValue === options.value,
|
||||||
_makeLabel(options, {class: 'radio'});
|
disabled: options.readonly,
|
||||||
|
required: options.required,
|
||||||
|
}) +
|
||||||
|
makeNonVoidElement('span', {class: 'radio'}, options.text));
|
||||||
}
|
}
|
||||||
|
|
||||||
function makeCheckbox(options) {
|
function makeCheckbox(options) {
|
||||||
_imbueId(options);
|
_imbueId(options);
|
||||||
return makeVoidElement(
|
return makeNonVoidElement(
|
||||||
'input',
|
'label',
|
||||||
{
|
{for: options.id},
|
||||||
id: options.id,
|
makeVoidElement(
|
||||||
name: options.name,
|
'input',
|
||||||
value: options.value,
|
{
|
||||||
type: 'checkbox',
|
id: options.id,
|
||||||
checked: options.checked !== undefined ?
|
name: options.name,
|
||||||
options.checked : false,
|
value: options.value,
|
||||||
disabled: options.readonly,
|
type: 'checkbox',
|
||||||
required: options.required,
|
checked: options.checked !== undefined ?
|
||||||
}) +
|
options.checked : false,
|
||||||
_makeLabel(options, {class: 'checkbox'});
|
disabled: options.readonly,
|
||||||
|
required: options.required,
|
||||||
|
}) +
|
||||||
|
makeNonVoidElement('span', {class: 'checkbox'}, options.text));
|
||||||
}
|
}
|
||||||
|
|
||||||
function makeSelect(options) {
|
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 CommentListControl = require('../controls/comment_list_control.js');
|
||||||
const CommentFormControl = require('../controls/comment_form_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) {
|
constructor(ctx) {
|
||||||
this._hostNode = document.getElementById('content-holder');
|
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:feature': moderator
|
||||||
'posts:delete': moderator
|
'posts:delete': moderator
|
||||||
'posts:score': regular
|
'posts:score': regular
|
||||||
|
'posts:merge': moderator
|
||||||
'posts:favorite': regular
|
'posts:favorite': regular
|
||||||
|
|
||||||
'tags:create': regular
|
'tags:create': regular
|
||||||
|
@ -124,6 +124,23 @@ def delete_post(ctx, params):
|
|||||||
return {}
|
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/?')
|
@routes.get('/featured-post/?')
|
||||||
def get_featured_post(ctx, _params=None):
|
def get_featured_post(ctx, _params=None):
|
||||||
post = posts.try_get_featured_post()
|
post = posts.try_get_featured_post()
|
||||||
|
@ -1,14 +1,14 @@
|
|||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
|
||||||
|
|
||||||
class LruCacheItem(object):
|
class LruCacheItem:
|
||||||
def __init__(self, key, value):
|
def __init__(self, key, value):
|
||||||
self.key = key
|
self.key = key
|
||||||
self.value = value
|
self.value = value
|
||||||
self.timestamp = datetime.utcnow()
|
self.timestamp = datetime.utcnow()
|
||||||
|
|
||||||
|
|
||||||
class LruCache(object):
|
class LruCache:
|
||||||
def __init__(self, length, delta=None):
|
def __init__(self, length, delta=None):
|
||||||
self.length = length
|
self.length = length
|
||||||
self.delta = delta
|
self.delta = delta
|
||||||
|
@ -14,7 +14,7 @@ _SCALE_FIT_FMT = \
|
|||||||
r'scale=iw*max({width}/iw\,{height}/ih):ih*max({width}/iw\,{height}/ih)'
|
r'scale=iw*max({width}/iw\,{height}/ih):ih*max({width}/iw\,{height}/ih)'
|
||||||
|
|
||||||
|
|
||||||
class Image(object):
|
class Image:
|
||||||
def __init__(self, content):
|
def __init__(self, content):
|
||||||
self.content = content
|
self.content = content
|
||||||
self._reload_info()
|
self._reload_info()
|
||||||
|
@ -440,3 +440,84 @@ def feature_post(post, user):
|
|||||||
def delete(post):
|
def delete(post):
|
||||||
assert post
|
assert post
|
||||||
db.session.delete(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
|
assert target_tag
|
||||||
if source_tag.tag_id == target_tag.tag_id:
|
if source_tag.tag_id == target_tag.tag_id:
|
||||||
raise InvalidTagRelationError('Cannot merge tag with itself.')
|
raise InvalidTagRelationError('Cannot merge tag with itself.')
|
||||||
pt1 = db.PostTag
|
|
||||||
pt2 = sqlalchemy.orm.util.aliased(db.PostTag)
|
|
||||||
|
|
||||||
update_stmt = (sqlalchemy.sql.expression.update(pt1)
|
def merge_posts(source_tag_id, target_tag_id):
|
||||||
.where(db.PostTag.tag_id == source_tag.tag_id)
|
alias1 = db.PostTag
|
||||||
.where(~sqlalchemy.exists()
|
alias2 = sqlalchemy.orm.util.aliased(db.PostTag)
|
||||||
.where(pt2.post_id == pt1.post_id)
|
update_stmt = (sqlalchemy.sql.expression.update(alias1)
|
||||||
.where(pt2.tag_id == target_tag.tag_id))
|
.where(alias1.tag_id == source_tag_id))
|
||||||
.values(tag_id=target_tag.tag_id))
|
update_stmt = (update_stmt
|
||||||
db.session.execute(update_stmt)
|
.where(~sqlalchemy.exists()
|
||||||
|
.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)
|
delete(source_tag)
|
||||||
|
|
||||||
|
|
||||||
|
@ -25,7 +25,7 @@ def _param_wrapper(func):
|
|||||||
return wrapper
|
return wrapper
|
||||||
|
|
||||||
|
|
||||||
class Context():
|
class Context:
|
||||||
def __init__(self, method, url, headers=None, params=None, files=None):
|
def __init__(self, method, url, headers=None, params=None, files=None):
|
||||||
self.method = method
|
self.method = method
|
||||||
self.url = url
|
self.url = url
|
||||||
|
@ -1,7 +1,7 @@
|
|||||||
from szurubooru.search import tokens
|
from szurubooru.search import tokens
|
||||||
|
|
||||||
|
|
||||||
class BaseSearchConfig(object):
|
class BaseSearchConfig:
|
||||||
SORT_ASC = tokens.SortToken.SORT_ASC
|
SORT_ASC = tokens.SortToken.SORT_ASC
|
||||||
SORT_DESC = tokens.SortToken.SORT_DESC
|
SORT_DESC = tokens.SortToken.SORT_DESC
|
||||||
|
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
class _BaseCriterion(object):
|
class _BaseCriterion:
|
||||||
def __init__(self, original_text):
|
def __init__(self, original_text):
|
||||||
self.original_text = original_text
|
self.original_text = original_text
|
||||||
|
|
||||||
|
@ -20,7 +20,7 @@ def _get_order(order, default_order):
|
|||||||
return order
|
return order
|
||||||
|
|
||||||
|
|
||||||
class Executor(object):
|
class Executor:
|
||||||
'''
|
'''
|
||||||
Class for search parsing and execution. Handles plaintext parsing and
|
Class for search parsing and execution. Handles plaintext parsing and
|
||||||
delegates sqlalchemy filter decoration to SearchConfig instances.
|
delegates sqlalchemy filter decoration to SearchConfig instances.
|
||||||
|
@ -67,7 +67,7 @@ def _parse_sort(value, negated):
|
|||||||
return tokens.SortToken(value, order)
|
return tokens.SortToken(value, order)
|
||||||
|
|
||||||
|
|
||||||
class SearchQuery():
|
class SearchQuery:
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
self.anonymous_tokens = []
|
self.anonymous_tokens = []
|
||||||
self.named_tokens = []
|
self.named_tokens = []
|
||||||
@ -82,7 +82,7 @@ class SearchQuery():
|
|||||||
tuple(self.sort_tokens)))
|
tuple(self.sort_tokens)))
|
||||||
|
|
||||||
|
|
||||||
class Parser(object):
|
class Parser:
|
||||||
def parse(self, query_text):
|
def parse(self, query_text):
|
||||||
query = SearchQuery()
|
query = SearchQuery()
|
||||||
for chunk in re.split(r'\s+', (query_text or '').lower()):
|
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):
|
def __init__(self, criterion, negated):
|
||||||
self.criterion = criterion
|
self.criterion = criterion
|
||||||
self.negated = negated
|
self.negated = negated
|
||||||
@ -16,7 +16,7 @@ class NamedToken(AnonymousToken):
|
|||||||
return hash((self.name, self.criterion, self.negated))
|
return hash((self.name, self.criterion, self.negated))
|
||||||
|
|
||||||
|
|
||||||
class SortToken(object):
|
class SortToken:
|
||||||
SORT_DESC = 'desc'
|
SORT_DESC = 'desc'
|
||||||
SORT_ASC = 'asc'
|
SORT_ASC = 'asc'
|
||||||
SORT_DEFAULT = 'default'
|
SORT_DEFAULT = 'default'
|
||||||
@ -30,7 +30,7 @@ class SortToken(object):
|
|||||||
return hash((self.name, self.order))
|
return hash((self.name, self.order))
|
||||||
|
|
||||||
|
|
||||||
class SpecialToken(object):
|
class SpecialToken:
|
||||||
def __init__(self, value, negated):
|
def __init__(self, value, negated):
|
||||||
self.value = value
|
self.value = value
|
||||||
self.negated = negated
|
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
|
from szurubooru import config, db, rest
|
||||||
|
|
||||||
|
|
||||||
class QueryCounter(object):
|
class QueryCounter:
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
self._statements = []
|
self._statements = []
|
||||||
|
|
||||||
@ -192,6 +192,30 @@ def comment_factory(user_factory, post_factory):
|
|||||||
return 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
|
@pytest.fixture
|
||||||
def read_asset():
|
def read_asset():
|
||||||
def get(path):
|
def get(path):
|
||||||
|
@ -605,3 +605,251 @@ def test_delete(post_factory):
|
|||||||
posts.delete(post)
|
posts.delete(post)
|
||||||
db.session.flush()
|
db.session.flush()
|
||||||
assert posts.get_post_count() == 0
|
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
|
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'])
|
source_tag = tag_factory(names=['source'])
|
||||||
target_tag = tag_factory(names=['target'])
|
target_tag = tag_factory(names=['target'])
|
||||||
db.session.add_all([source_tag, target_tag])
|
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
|
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'])
|
source_tag = tag_factory(names=['source'])
|
||||||
target_tag = tag_factory(names=['target'])
|
target_tag = tag_factory(names=['target'])
|
||||||
post = post_factory()
|
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
|
assert tags.get_tag_by_name('target').post_count == 1
|
||||||
|
|
||||||
|
|
||||||
def test_merge_tags_with_itself(tag_factory):
|
def test_merge_tags_doesnt_duplicate_usages(tag_factory, post_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):
|
|
||||||
source_tag = tag_factory(names=['source'])
|
source_tag = tag_factory(names=['source'])
|
||||||
target_tag = tag_factory(names=['target'])
|
target_tag = tag_factory(names=['target'])
|
||||||
post = post_factory()
|
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
|
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):
|
def test_create_tag(fake_datetime):
|
||||||
with patch('szurubooru.func.tags.update_tag_names'), \
|
with patch('szurubooru.func.tags.update_tag_names'), \
|
||||||
patch('szurubooru.func.tags.update_tag_category_name'), \
|
patch('szurubooru.func.tags.update_tag_category_name'), \
|
||||||
|
Reference in New Issue
Block a user