client/posts: search for similar posts on upload

This commit is contained in:
rr- 2017-01-07 11:07:51 +01:00
parent d1bb33ecf0
commit f00cc5f3fa
6 changed files with 187 additions and 28 deletions

View File

@ -49,7 +49,7 @@ $cancel-button-color = tomato
margin: 0 0 1.2em 0 margin: 0 0 1.2em 0
padding-left: 13em padding-left: 13em
.thumbnail-wrapper &>.thumbnail-wrapper
float: left float: left
width: 12em width: 12em
height: 8em height: 8em
@ -115,6 +115,34 @@ $cancel-button-color = tomato
.message:last-child .message:last-child
margin-bottom: 0 margin-bottom: 0
.lookalikes
list-style-type: none
margin: 0
padding: 0
li
clear: both
margin: 1em 0 0 0
padding-left: 7em
font-size: 90%
.thumbnail-wrapper
float: left
width: 6em
height: 4em
margin: 0 0 0 -7em
.thumbnail
width: 100%
height: 100%
.description
margin-right: 0.5em
display: inline-block
.controls
float: right
display: inline-block
&:first-child .move-up &:first-child .move-up
color: $inactive-link-color color: $inactive-link-color
&:last-child .move-down &:last-child .move-down

View File

@ -75,6 +75,29 @@
</div> </div>
<div class='messages'></div> <div class='messages'></div>
<% if (ctx.uploadable.lookalikes.length) { %>
<ul class='lookalikes'>
<% for (let lookalike of ctx.uploadable.lookalikes) { %>
<li>
<a class='thumbnail-wrapper' title='@<%- lookalike.post.id %>'
href='<%= ctx.canViewPosts ? ctx.getPostUrl(lookalike.post.id) : "" %>'>
<%= ctx.makeThumbnail(lookalike.post.thumbnailUrl) %>
</a>
<div class='description'>
Similar post: <%= ctx.makePostLink(lookalike.post.id, true) %>
<br/>
<%- Math.round((1-lookalike.distance) * 100) %>% match
</div>
<div class='controls'>
<%= ctx.makeCheckbox({text: 'Copy tags', name: 'copy-tags'}) %>
<br/>
<%= ctx.makeCheckbox({text: 'Add relation', name: 'add-relation'}) %>
</div>
</li>
<% } %>
</ul>
<% } %>
</div> </div>
</div> </div>
</li> </li>

View File

@ -54,22 +54,27 @@ class PostUploadController {
e.detail.uploadables.reduce( e.detail.uploadables.reduce(
(promise, uploadable) => (promise, uploadable) =>
promise.then(() => promise.then(() => this._uploadSinglePost(
this._uploadSinglePost( uploadable, e.detail.skipDuplicates)),
uploadable, e.detail.skipDuplicates)),
Promise.resolve()) Promise.resolve())
.then(() => { .then(() => {
this._view.clearMessages(); this._view.clearMessages();
misc.disableExitConfirmation(); misc.disableExitConfirmation();
const ctx = router.show('/posts'); const ctx = router.show('/posts');
ctx.controller.showSuccess('Posts uploaded.'); ctx.controller.showSuccess('Posts uploaded.');
}, errorContext => { }, ([errorMessage, uploadable, similarPostResults]) => {
if (errorContext.constructor === Array) { if (uploadable) {
const [errorMessage, uploadable] = errorContext; if (similarPostResults) {
this._view.showError(genericErrorMessage); uploadable.lookalikes = similarPostResults;
this._view.showError(errorMessage, uploadable); this._view.updateUploadable(uploadable);
this._view.showInfo(genericErrorMessage);
this._view.showInfo(errorMessage, uploadable);
} else {
this._view.showError(genericErrorMessage);
this._view.showError(errorMessage, uploadable);
}
} else { } else {
this._view.showError(errorContext); this._view.showError(errorMessage);
} }
this._view.enableForm(); this._view.enableForm();
return Promise.reject(); return Promise.reject();
@ -77,32 +82,70 @@ class PostUploadController {
} }
_uploadSinglePost(uploadable, skipDuplicates) { _uploadSinglePost(uploadable, skipDuplicates) {
let reverseSearchPromise = Promise.resolve();
if (!uploadable.lookalikesConfirmed &&
['image'].includes(uploadable.type)) {
reverseSearchPromise = uploadable.url ?
Post.reverseSearchFromUrl(uploadable.url) :
Post.reverseSearchFromFile(uploadable.file);
}
this._lastCancellablePromise = reverseSearchPromise;
return reverseSearchPromise.then(searchResult => {
if (searchResult) {
// notify about exact duplicate
if (searchResult.exactPost && !skipDuplicates) {
return Promise.reject([
`Post already uploaded (@${searchResult.exactPost.id})`,
uploadable,
null]);
}
// notify about similar posts
if (!searchResult.exactPost
&& searchResult.similarPosts.length) {
return Promise.reject([
`Found ${searchResult.similarPosts.length} similar ` +
'posts.\nYou can resume or discard this upload.',
uploadable,
searchResult.similarPosts]);
}
}
// no duplicates, proceed with saving
let post = this._uploadableToPost(uploadable);
let apiSavePromise = post.save(uploadable.anonymous);
let returnedSavePromise = apiSavePromise
.then(() => {
this._view.removeUploadable(uploadable);
return Promise.resolve();
}, errorMessage => {
return Promise.reject([errorMessage, uploadable, null]);
});
returnedSavePromise.abort = () => {
apiSavePromise.abort();
};
this._lastCancellablePromise = returnedSavePromise;
return returnedSavePromise;
}, errorMessage => {
return Promise.reject([errorMessage, uploadable, null]);
});
}
_uploadableToPost(uploadable) {
let post = new Post(); let post = new Post();
post.safety = uploadable.safety; post.safety = uploadable.safety;
post.flags = uploadable.flags; post.flags = uploadable.flags;
post.tags = uploadable.tags;
post.relations = uploadable.relations;
if (uploadable.url) { if (uploadable.url) {
post.newContentUrl = uploadable.url; post.newContentUrl = uploadable.url;
} else { } else {
post.newContent = uploadable.file; post.newContent = uploadable.file;
} }
return post;
let savePromise = post.save(uploadable.anonymous)
.then(() => {
this._view.removeUploadable(uploadable);
return Promise.resolve();
}, errorMessage => {
// XXX:
// lame, API eats error codes so we need to match
// messages instead
if (skipDuplicates &&
errorMessage.match(/already uploaded/)) {
return Promise.resolve();
}
return Promise.reject([errorMessage, uploadable, null]);
});
this._lastCancellablePromise = savePromise;
return savePromise;
} }
} }

View File

@ -69,6 +69,36 @@ class Post extends events.EventTarget {
return ret; return ret;
} }
static reverseSearchFromFile(content) {
return Post._reverseSearch({content: content});
}
static reverseSearchFromUrl(imageUrl) {
return Post._reverseSearch({contentUrl: imageUrl});
}
static _reverseSearch(request) {
let apiPromise = api.post('/posts/reverse-search', {}, request);
let returnedPromise = apiPromise
.then(response => {
if (response.exactPost) {
response.exactPost = Post.fromResponse(response.exactPost);
}
for (let item of response.similarPosts) {
item.post = Post.fromResponse(item.post);
}
return Promise.resolve(response);
}, response => {
return Promise.reject(response.description);
});
returnedPromise.abort = () => {
apiPromise.abort();
};
return returnedPromise;
}
static get(id) { static get(id) {
return api.get('/post/' + id) return api.get('/post/' + id)
.then(response => { .then(response => {

View File

@ -217,6 +217,19 @@ function escapeSearchTerm(text) {
return text.replace(/([a-z_-]):/g, '$1\\:'); return text.replace(/([a-z_-]):/g, '$1\\:');
} }
function dataURItoBlob(dataURI) {
const chunks = dataURI.split(',');
const byteString = chunks[0].indexOf('base64') >= 0 ?
window.atob(chunks[1]) :
unescape(chunks[1]);
const mimeString = chunks[0].split(':')[1].split(';')[0];
const data = new Uint8Array(byteString.length);
for (var i = 0; i < byteString.length; i++) {
data[i] = byteString.charCodeAt(i);
}
return new Blob([data], {type: mimeString});
}
module.exports = { module.exports = {
range: range, range: range,
formatUrlParameters: formatUrlParameters, formatUrlParameters: formatUrlParameters,
@ -236,4 +249,5 @@ module.exports = {
arraysDiffer: arraysDiffer, arraysDiffer: arraysDiffer,
decamelize: decamelize, decamelize: decamelize,
escapeSearchTerm: escapeSearchTerm, escapeSearchTerm: escapeSearchTerm,
dataURItoBlob: dataURItoBlob,
}; };

View File

@ -23,8 +23,12 @@ function _mimeTypeToPostType(mimeType) {
class Uploadable extends events.EventTarget { class Uploadable extends events.EventTarget {
constructor() { constructor() {
super(); super();
this.lookalikes = [];
this.lookalikesConfirmed = false;
this.safety = 'safe'; this.safety = 'safe';
this.flags = []; this.flags = [];
this.tags = [];
this.relations = [];
this.anonymous = false; this.anonymous = false;
this.order = globalOrder; this.order = globalOrder;
globalOrder++; globalOrder++;
@ -242,6 +246,11 @@ class PostUploadView extends events.EventTarget {
} }
} }
updateUploadable(uploadable) {
uploadable.lookalikesConfirmed = true;
this._renderRowNode(uploadable);
}
_evtFilesAdded(e) { _evtFilesAdded(e) {
this.addUploadables(e.detail.files.map(file => new File(file))); this.addUploadables(e.detail.files.map(file => new File(file)));
} }
@ -273,6 +282,18 @@ class PostUploadView extends events.EventTarget {
if (rowNode.querySelector('.loop-video input:checked')) { if (rowNode.querySelector('.loop-video input:checked')) {
uploadable.flags.push('loop'); uploadable.flags.push('loop');
} }
uploadable.tags = [];
uploadable.relations = [];
for (let [i, lookalike] of uploadable.lookalikes.entries()) {
let lookalikeNode = rowNode.querySelector(
`.lookalikes li:nth-child(${i + 1})`);
if (lookalikeNode.querySelector('[name=copy-tags]').checked) {
uploadable.tags = uploadable.tags.concat(lookalike.post.tags);
}
if (lookalikeNode.querySelector('[name=add-relation]').checked) {
uploadable.relations.push(lookalike.post.id);
}
}
} }
_evtRemoveClick(e, uploadable) { _evtRemoveClick(e, uploadable) {