client/posts: search for similar posts on upload
This commit is contained in:
parent
d1bb33ecf0
commit
f00cc5f3fa
|
@ -49,7 +49,7 @@ $cancel-button-color = tomato
|
|||
margin: 0 0 1.2em 0
|
||||
padding-left: 13em
|
||||
|
||||
.thumbnail-wrapper
|
||||
&>.thumbnail-wrapper
|
||||
float: left
|
||||
width: 12em
|
||||
height: 8em
|
||||
|
@ -115,6 +115,34 @@ $cancel-button-color = tomato
|
|||
.message:last-child
|
||||
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
|
||||
color: $inactive-link-color
|
||||
&:last-child .move-down
|
||||
|
|
|
@ -75,6 +75,29 @@
|
|||
</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>
|
||||
</li>
|
||||
|
|
|
@ -54,22 +54,27 @@ class PostUploadController {
|
|||
|
||||
e.detail.uploadables.reduce(
|
||||
(promise, uploadable) =>
|
||||
promise.then(() =>
|
||||
this._uploadSinglePost(
|
||||
uploadable, e.detail.skipDuplicates)),
|
||||
promise.then(() => this._uploadSinglePost(
|
||||
uploadable, e.detail.skipDuplicates)),
|
||||
Promise.resolve())
|
||||
.then(() => {
|
||||
this._view.clearMessages();
|
||||
misc.disableExitConfirmation();
|
||||
const ctx = router.show('/posts');
|
||||
ctx.controller.showSuccess('Posts uploaded.');
|
||||
}, errorContext => {
|
||||
if (errorContext.constructor === Array) {
|
||||
const [errorMessage, uploadable] = errorContext;
|
||||
this._view.showError(genericErrorMessage);
|
||||
this._view.showError(errorMessage, uploadable);
|
||||
}, ([errorMessage, uploadable, similarPostResults]) => {
|
||||
if (uploadable) {
|
||||
if (similarPostResults) {
|
||||
uploadable.lookalikes = similarPostResults;
|
||||
this._view.updateUploadable(uploadable);
|
||||
this._view.showInfo(genericErrorMessage);
|
||||
this._view.showInfo(errorMessage, uploadable);
|
||||
} else {
|
||||
this._view.showError(genericErrorMessage);
|
||||
this._view.showError(errorMessage, uploadable);
|
||||
}
|
||||
} else {
|
||||
this._view.showError(errorContext);
|
||||
this._view.showError(errorMessage);
|
||||
}
|
||||
this._view.enableForm();
|
||||
return Promise.reject();
|
||||
|
@ -77,32 +82,70 @@ class PostUploadController {
|
|||
}
|
||||
|
||||
_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();
|
||||
post.safety = uploadable.safety;
|
||||
post.flags = uploadable.flags;
|
||||
|
||||
post.tags = uploadable.tags;
|
||||
post.relations = uploadable.relations;
|
||||
if (uploadable.url) {
|
||||
post.newContentUrl = uploadable.url;
|
||||
} else {
|
||||
post.newContent = uploadable.file;
|
||||
}
|
||||
|
||||
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;
|
||||
return post;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -69,6 +69,36 @@ class Post extends events.EventTarget {
|
|||
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) {
|
||||
return api.get('/post/' + id)
|
||||
.then(response => {
|
||||
|
|
|
@ -217,6 +217,19 @@ function escapeSearchTerm(text) {
|
|||
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 = {
|
||||
range: range,
|
||||
formatUrlParameters: formatUrlParameters,
|
||||
|
@ -236,4 +249,5 @@ module.exports = {
|
|||
arraysDiffer: arraysDiffer,
|
||||
decamelize: decamelize,
|
||||
escapeSearchTerm: escapeSearchTerm,
|
||||
dataURItoBlob: dataURItoBlob,
|
||||
};
|
||||
|
|
|
@ -23,8 +23,12 @@ function _mimeTypeToPostType(mimeType) {
|
|||
class Uploadable extends events.EventTarget {
|
||||
constructor() {
|
||||
super();
|
||||
this.lookalikes = [];
|
||||
this.lookalikesConfirmed = false;
|
||||
this.safety = 'safe';
|
||||
this.flags = [];
|
||||
this.tags = [];
|
||||
this.relations = [];
|
||||
this.anonymous = false;
|
||||
this.order = globalOrder;
|
||||
globalOrder++;
|
||||
|
@ -242,6 +246,11 @@ class PostUploadView extends events.EventTarget {
|
|||
}
|
||||
}
|
||||
|
||||
updateUploadable(uploadable) {
|
||||
uploadable.lookalikesConfirmed = true;
|
||||
this._renderRowNode(uploadable);
|
||||
}
|
||||
|
||||
_evtFilesAdded(e) {
|
||||
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')) {
|
||||
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) {
|
||||
|
|
Loading…
Reference in New Issue