From f00cc5f3fa3a16a1f7055c772c3008d030f41fef Mon Sep 17 00:00:00 2001 From: rr- Date: Sat, 7 Jan 2017 11:07:51 +0100 Subject: [PATCH] client/posts: search for similar posts on upload --- client/css/post-upload.styl | 30 +++++- client/html/post_upload_row.tpl | 23 +++++ .../js/controllers/post_upload_controller.js | 97 +++++++++++++------ client/js/models/post.js | 30 ++++++ client/js/util/misc.js | 14 +++ client/js/views/post_upload_view.js | 21 ++++ 6 files changed, 187 insertions(+), 28 deletions(-) diff --git a/client/css/post-upload.styl b/client/css/post-upload.styl index 9465a87..4da53a7 100644 --- a/client/css/post-upload.styl +++ b/client/css/post-upload.styl @@ -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 diff --git a/client/html/post_upload_row.tpl b/client/html/post_upload_row.tpl index a8a04db..d62ecaf 100644 --- a/client/html/post_upload_row.tpl +++ b/client/html/post_upload_row.tpl @@ -75,6 +75,29 @@
+ + <% if (ctx.uploadable.lookalikes.length) { %> + + <% } %> diff --git a/client/js/controllers/post_upload_controller.js b/client/js/controllers/post_upload_controller.js index 9cf3d4b..ca40741 100644 --- a/client/js/controllers/post_upload_controller.js +++ b/client/js/controllers/post_upload_controller.js @@ -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; } } diff --git a/client/js/models/post.js b/client/js/models/post.js index 8002a0c..18a6223 100644 --- a/client/js/models/post.js +++ b/client/js/models/post.js @@ -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 => { diff --git a/client/js/util/misc.js b/client/js/util/misc.js index 56eb66e..3037b10 100644 --- a/client/js/util/misc.js +++ b/client/js/util/misc.js @@ -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, }; diff --git a/client/js/views/post_upload_view.js b/client/js/views/post_upload_view.js index 185f3dd..5e2cdb0 100644 --- a/client/js/views/post_upload_view.js +++ b/client/js/views/post_upload_view.js @@ -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) {