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) { %>
+
+ <% for (let lookalike of ctx.uploadable.lookalikes) { %>
+ -
+
+ <%= ctx.makeThumbnail(lookalike.post.thumbnailUrl) %>
+
+
+ Similar post: <%= ctx.makePostLink(lookalike.post.id, true) %>
+
+ <%- Math.round((1-lookalike.distance) * 100) %>% match
+
+
+ <%= ctx.makeCheckbox({text: 'Copy tags', name: 'copy-tags'}) %>
+
+ <%= ctx.makeCheckbox({text: 'Add relation', name: 'add-relation'}) %>
+
+
+ <% } %>
+
+ <% } %>
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) {