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
|
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
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -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 => {
|
||||||
|
|
|
@ -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,
|
||||||
};
|
};
|
||||||
|
|
|
@ -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) {
|
||||||
|
|
Loading…
Reference in New Issue