8 Commits
2.0 ... 2.1

37 changed files with 1239 additions and 169 deletions

45
API.md
View File

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

View File

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

View 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

View File

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

View File

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

View 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&hellip;</a></li><!--
--><% } %><!--
--></ul><!--
--></nav>
<div class='post-content-holder'></div>
</div>

View File

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

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

View 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>
<% } %>

View File

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

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

View 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');
});
};

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View 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)))

View File

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

View File

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

View File

@ -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'), \