summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--lib/response.js2
-rw-r--r--public/js/common.js3
-rw-r--r--public/js/google-drive-picker.js118
-rw-r--r--public/js/google-drive-upload.js269
-rw-r--r--public/js/index.js106
-rw-r--r--public/views/body.ejs19
-rw-r--r--public/views/foot.ejs8
-rw-r--r--public/views/header.ejs8
8 files changed, 528 insertions, 5 deletions
diff --git a/lib/response.js b/lib/response.js
index d8f82a9f..488cf864 100644
--- a/lib/response.js
+++ b/lib/response.js
@@ -329,6 +329,8 @@ function actionDownload(req, res, noteId) {
filename = encodeURIComponent(filename);
res.writeHead(200, {
'Access-Control-Allow-Origin': '*', //allow CORS as API
+ 'Access-Control-Allow-Headers': 'Range',
+ 'Access-Control-Expose-Headers': 'Cache-Control, Content-Encoding, Content-Range',
'Content-Type': 'text/markdown; charset=UTF-8',
'Cache-Control': 'private',
'Content-disposition': 'attachment; filename=' + filename + '.md',
diff --git a/public/js/common.js b/public/js/common.js
index e84bc169..799c9581 100644
--- a/public/js/common.js
+++ b/public/js/common.js
@@ -2,6 +2,9 @@
var domain = 'change this'; // domain name
var urlpath = ''; // sub url path, like: www.example.com/<urlpath>
+var GOOGLE_API_KEY = 'change this';
+var GOOGLE_CLIENT_ID = 'change this';
+
var port = window.location.port;
var serverurl = window.location.protocol + '//' + domain + (port ? ':' + port : '') + (urlpath ? '/' + urlpath : '');
var noteid = urlpath ? window.location.pathname.slice(urlpath.length + 1, window.location.pathname.length).split('/')[1] : window.location.pathname.split('/')[1];
diff --git a/public/js/google-drive-picker.js b/public/js/google-drive-picker.js
new file mode 100644
index 00000000..e653653c
--- /dev/null
+++ b/public/js/google-drive-picker.js
@@ -0,0 +1,118 @@
+/**!
+ * Google Drive File Picker Example
+ * By Daniel Lo Nigro (http://dan.cx/)
+ */
+(function() {
+ /**
+ * Initialise a Google Driver file picker
+ */
+ var FilePicker = window.FilePicker = function(options) {
+ // Config
+ this.apiKey = options.apiKey;
+ this.clientId = options.clientId;
+
+ // Elements
+ this.buttonEl = options.buttonEl;
+
+ // Events
+ this.onSelect = options.onSelect;
+ this.buttonEl.on('click', this.open.bind(this));
+
+ // Disable the button until the API loads, as it won't work properly until then.
+ this.buttonEl.prop('disabled', true);
+
+ // Load the drive API
+ gapi.client.setApiKey(this.apiKey);
+ gapi.client.load('drive', 'v2', this._driveApiLoaded.bind(this));
+ google.load('picker', '1', { callback: this._pickerApiLoaded.bind(this) });
+ }
+
+ FilePicker.prototype = {
+ /**
+ * Open the file picker.
+ */
+ open: function() {
+ // Check if the user has already authenticated
+ var token = gapi.auth.getToken();
+ if (token) {
+ this._showPicker();
+ } else {
+ // The user has not yet authenticated with Google
+ // We need to do the authentication before displaying the Drive picker.
+ this._doAuth(false, function() { this._showPicker(); }.bind(this));
+ }
+ },
+
+ /**
+ * Show the file picker once authentication has been done.
+ * @private
+ */
+ _showPicker: function() {
+ var accessToken = gapi.auth.getToken().access_token;
+ var view = new google.picker.DocsView();
+ view.setMimeTypes("text/markdown,text/html");
+ view.setIncludeFolders(true);
+ this.picker = new google.picker.PickerBuilder().
+ enableFeature(google.picker.Feature.NAV_HIDDEN).
+ addView(view).
+ setAppId(this.clientId).
+ setOAuthToken(accessToken).
+ setCallback(this._pickerCallback.bind(this)).
+ build().
+ setVisible(true);
+ },
+
+ /**
+ * Called when a file has been selected in the Google Drive file picker.
+ * @private
+ */
+ _pickerCallback: function(data) {
+ if (data[google.picker.Response.ACTION] == google.picker.Action.PICKED) {
+ var file = data[google.picker.Response.DOCUMENTS][0],
+ id = file[google.picker.Document.ID],
+ request = gapi.client.drive.files.get({
+ fileId: id
+ });
+
+ request.execute(this._fileGetCallback.bind(this));
+ }
+ },
+ /**
+ * Called when file details have been retrieved from Google Drive.
+ * @private
+ */
+ _fileGetCallback: function(file) {
+ if (this.onSelect) {
+ this.onSelect(file);
+ }
+ },
+
+ /**
+ * Called when the Google Drive file picker API has finished loading.
+ * @private
+ */
+ _pickerApiLoaded: function() {
+ this.buttonEl.prop('disabled', false);
+ },
+
+ /**
+ * Called when the Google Drive API has finished loading.
+ * @private
+ */
+ _driveApiLoaded: function() {
+ this._doAuth(true);
+ },
+
+ /**
+ * Authenticate with Google Drive via the Google JavaScript API.
+ * @private
+ */
+ _doAuth: function(immediate, callback) {
+ gapi.auth.authorize({
+ client_id: this.clientId,
+ scope: 'https://www.googleapis.com/auth/drive.readonly',
+ immediate: immediate
+ }, callback ? callback : function() {});
+ }
+ };
+}()); \ No newline at end of file
diff --git a/public/js/google-drive-upload.js b/public/js/google-drive-upload.js
new file mode 100644
index 00000000..2e503af9
--- /dev/null
+++ b/public/js/google-drive-upload.js
@@ -0,0 +1,269 @@
+/**
+ * Helper for implementing retries with backoff. Initial retry
+ * delay is 1 second, increasing by 2x (+jitter) for subsequent retries
+ *
+ * @constructor
+ */
+var RetryHandler = function() {
+ this.interval = 1000; // Start at one second
+ this.maxInterval = 60 * 1000; // Don't wait longer than a minute
+};
+
+/**
+ * Invoke the function after waiting
+ *
+ * @param {function} fn Function to invoke
+ */
+RetryHandler.prototype.retry = function(fn) {
+ setTimeout(fn, this.interval);
+ this.interval = this.nextInterval_();
+};
+
+/**
+ * Reset the counter (e.g. after successful request.)
+ */
+RetryHandler.prototype.reset = function() {
+ this.interval = 1000;
+};
+
+/**
+ * Calculate the next wait time.
+ * @return {number} Next wait interval, in milliseconds
+ *
+ * @private
+ */
+RetryHandler.prototype.nextInterval_ = function() {
+ var interval = this.interval * 2 + this.getRandomInt_(0, 1000);
+ return Math.min(interval, this.maxInterval);
+};
+
+/**
+ * Get a random int in the range of min to max. Used to add jitter to wait times.
+ *
+ * @param {number} min Lower bounds
+ * @param {number} max Upper bounds
+ * @private
+ */
+RetryHandler.prototype.getRandomInt_ = function(min, max) {
+ return Math.floor(Math.random() * (max - min + 1) + min);
+};
+
+
+/**
+ * Helper class for resumable uploads using XHR/CORS. Can upload any Blob-like item, whether
+ * files or in-memory constructs.
+ *
+ * @example
+ * var content = new Blob(["Hello world"], {"type": "text/plain"});
+ * var uploader = new MediaUploader({
+ * file: content,
+ * token: accessToken,
+ * onComplete: function(data) { ... }
+ * onError: function(data) { ... }
+ * });
+ * uploader.upload();
+ *
+ * @constructor
+ * @param {object} options Hash of options
+ * @param {string} options.token Access token
+ * @param {blob} options.file Blob-like item to upload
+ * @param {string} [options.fileId] ID of file if replacing
+ * @param {object} [options.params] Additional query parameters
+ * @param {string} [options.contentType] Content-type, if overriding the type of the blob.
+ * @param {object} [options.metadata] File metadata
+ * @param {function} [options.onComplete] Callback for when upload is complete
+ * @param {function} [options.onProgress] Callback for status for the in-progress upload
+ * @param {function} [options.onError] Callback if upload fails
+ */
+var MediaUploader = function(options) {
+ var noop = function() {};
+ this.file = options.file;
+ this.contentType = options.contentType || this.file.type || 'application/octet-stream';
+ this.metadata = options.metadata || {
+ 'title': this.file.name,
+ 'mimeType': this.contentType
+ };
+ this.token = options.token;
+ this.onComplete = options.onComplete || noop;
+ this.onProgress = options.onProgress || noop;
+ this.onError = options.onError || noop;
+ this.offset = options.offset || 0;
+ this.chunkSize = options.chunkSize || 0;
+ this.retryHandler = new RetryHandler();
+
+ this.url = options.url;
+ if (!this.url) {
+ var params = options.params || {};
+ params.uploadType = 'resumable';
+ this.url = this.buildUrl_(options.fileId, params, options.baseUrl);
+ }
+ this.httpMethod = options.fileId ? 'PUT' : 'POST';
+};
+
+/**
+ * Initiate the upload.
+ */
+MediaUploader.prototype.upload = function() {
+ var self = this;
+ var xhr = new XMLHttpRequest();
+
+ xhr.open(this.httpMethod, this.url, true);
+ xhr.setRequestHeader('Authorization', 'Bearer ' + this.token);
+ xhr.setRequestHeader('Content-Type', 'application/json');
+ xhr.setRequestHeader('X-Upload-Content-Length', this.file.size);
+ xhr.setRequestHeader('X-Upload-Content-Type', this.contentType);
+
+ xhr.onload = function(e) {
+ if (e.target.status < 400) {
+ var location = e.target.getResponseHeader('Location');
+ this.url = location;
+ this.sendFile_();
+ } else {
+ this.onUploadError_(e);
+ }
+ }.bind(this);
+ xhr.onerror = this.onUploadError_.bind(this);
+ xhr.send(JSON.stringify(this.metadata));
+};
+
+/**
+ * Send the actual file content.
+ *
+ * @private
+ */
+MediaUploader.prototype.sendFile_ = function() {
+ var content = this.file;
+ var end = this.file.size;
+
+ if (this.offset || this.chunkSize) {
+ // Only bother to slice the file if we're either resuming or uploading in chunks
+ if (this.chunkSize) {
+ end = Math.min(this.offset + this.chunkSize, this.file.size);
+ }
+ content = content.slice(this.offset, end);
+ }
+
+ var xhr = new XMLHttpRequest();
+ xhr.open('PUT', this.url, true);
+ xhr.setRequestHeader('Content-Type', this.contentType);
+ xhr.setRequestHeader('Content-Range', "bytes " + this.offset + "-" + (end - 1) + "/" + this.file.size);
+ xhr.setRequestHeader('X-Upload-Content-Type', this.file.type);
+ if (xhr.upload) {
+ xhr.upload.addEventListener('progress', this.onProgress);
+ }
+ xhr.onload = this.onContentUploadSuccess_.bind(this);
+ xhr.onerror = this.onContentUploadError_.bind(this);
+ xhr.send(content);
+};
+
+/**
+ * Query for the state of the file for resumption.
+ *
+ * @private
+ */
+MediaUploader.prototype.resume_ = function() {
+ var xhr = new XMLHttpRequest();
+ xhr.open('PUT', this.url, true);
+ xhr.setRequestHeader('Content-Range', "bytes */" + this.file.size);
+ xhr.setRequestHeader('X-Upload-Content-Type', this.file.type);
+ if (xhr.upload) {
+ xhr.upload.addEventListener('progress', this.onProgress);
+ }
+ xhr.onload = this.onContentUploadSuccess_.bind(this);
+ xhr.onerror = this.onContentUploadError_.bind(this);
+ xhr.send();
+};
+
+/**
+ * Extract the last saved range if available in the request.
+ *
+ * @param {XMLHttpRequest} xhr Request object
+ */
+MediaUploader.prototype.extractRange_ = function(xhr) {
+ var range = xhr.getResponseHeader('Range');
+ if (range) {
+ this.offset = parseInt(range.match(/\d+/g).pop(), 10) + 1;
+ }
+};
+
+/**
+ * Handle successful responses for uploads. Depending on the context,
+ * may continue with uploading the next chunk of the file or, if complete,
+ * invokes the caller's callback.
+ *
+ * @private
+ * @param {object} e XHR event
+ */
+MediaUploader.prototype.onContentUploadSuccess_ = function(e) {
+ if (e.target.status == 200 || e.target.status == 201) {
+ this.onComplete(e.target.response);
+ } else if (e.target.status == 308) {
+ this.extractRange_(e.target);
+ this.retryHandler.reset();
+ this.sendFile_();
+ } else {
+ this.onContentUploadError_(e);
+ }
+};
+
+/**
+ * Handles errors for uploads. Either retries or aborts depending
+ * on the error.
+ *
+ * @private
+ * @param {object} e XHR event
+ */
+MediaUploader.prototype.onContentUploadError_ = function(e) {
+ if (e.target.status && e.target.status < 500) {
+ this.onError(e.target.response);
+ } else {
+ this.retryHandler.retry(this.resume_.bind(this));
+ }
+};
+
+/**
+ * Handles errors for the initial request.
+ *
+ * @private
+ * @param {object} e XHR event
+ */
+MediaUploader.prototype.onUploadError_ = function(e) {
+ this.onError(e.target.response); // TODO - Retries for initial upload
+};
+
+/**
+ * Construct a query string from a hash/object
+ *
+ * @private
+ * @param {object} [params] Key/value pairs for query string
+ * @return {string} query string
+ */
+MediaUploader.prototype.buildQuery_ = function(params) {
+ params = params || {};
+ return Object.keys(params).map(function(key) {
+ return encodeURIComponent(key) + '=' + encodeURIComponent(params[key]);
+ }).join('&');
+};
+
+/**
+ * Build the drive upload URL
+ *
+ * @private
+ * @param {string} [id] File ID if replacing
+ * @param {object} [params] Query parameters
+ * @return {string} URL
+ */
+MediaUploader.prototype.buildUrl_ = function(id, params, baseUrl) {
+ var url = baseUrl || 'https://www.googleapis.com/upload/drive/v2/files/';
+ if (id) {
+ url += id;
+ }
+ var query = this.buildQuery_(params);
+ if (query) {
+ url += '?' + query;
+ }
+ return url;
+};
+
+
+
diff --git a/public/js/index.js b/public/js/index.js
index ec62f440..fd85d6db 100644
--- a/public/js/index.js
+++ b/public/js/index.js
@@ -486,10 +486,12 @@ var ui = {
},
export: {
dropbox: $(".ui-save-dropbox"),
+ googleDrive: $(".ui-save-google-drive"),
gist: $(".ui-save-gist")
},
import: {
dropbox: $(".ui-import-dropbox"),
+ googleDrive: $(".ui-import-google-drive"),
clipboard: $(".ui-import-clipboard")
},
beta: {
@@ -994,6 +996,22 @@ function closestIndex(arr, closestTo) {
return index; // return the value
}
+function showMessageModal(title, header, href, text, success) {
+ var modal = $('.message-modal');
+ modal.find('.modal-title').html(title);
+ modal.find('.modal-body h5').html(header);
+ if (href)
+ modal.find('.modal-body a').attr('href', href).text(text);
+ else
+ modal.find('.modal-body a').removeAttr('href').text(text);
+ modal.find('.modal-footer button').removeClass('btn-default btn-success btn-danger')
+ if (success)
+ modal.find('.modal-footer button').addClass('btn-success');
+ else
+ modal.find('.modal-footer button').addClass('btn-danger');
+ modal.modal('show');
+}
+
//button actions
//share
ui.toolbar.publish.attr("href", noteurl + "/publish");
@@ -1031,6 +1049,53 @@ ui.toolbar.export.dropbox.click(function () {
};
Dropbox.save(options);
});
+function uploadToGoogleDrive(accessToken) {
+ ui.spinner.show();
+ var filename = renderFilename(ui.area.markdown) + '.md';
+ var markdown = editor.getValue();
+ var blob = new Blob([markdown], {
+ type: "text/markdown;charset=utf-8"
+ });
+ blob.name = filename;
+ var uploader = new MediaUploader({
+ file: blob,
+ token: accessToken,
+ onComplete: function(data) {
+ data = JSON.parse(data);
+ showMessageModal('<i class="fa fa-cloud-upload"></i> Export to Google Drive', 'Export Complete!', data.alternateLink, 'Click here to view your file', true);
+ ui.spinner.hide();
+ },
+ onError: function(data) {
+ var modal = $('.export-modal');
+ showMessageModal('<i class="fa fa-cloud-upload"></i> Export to Google Drive', 'Export Error :(', '', data, false);
+ ui.spinner.hide();
+ }
+ });
+ uploader.upload();
+}
+function googleApiAuth(immediate, callback) {
+ gapi.auth.authorize(
+ {
+ 'client_id': GOOGLE_CLIENT_ID,
+ 'scope': 'https://www.googleapis.com/auth/drive.file',
+ 'immediate': immediate
+ }, callback ? callback : function() {});
+}
+function onGoogleClientLoaded() {
+ googleApiAuth(true);
+ buildImportFromGoogleDrive();
+}
+// export to google drive
+ui.toolbar.export.googleDrive.click(function (e) {
+ var token = gapi.auth.getToken();
+ if (token) {
+ uploadToGoogleDrive(token.access_token);
+ } else {
+ googleApiAuth(false, function(result) {
+ uploadToGoogleDrive(result.access_token);
+ });
+ }
+});
//export to gist
ui.toolbar.export.gist.attr("href", noteurl + "/gist");
//import from dropbox
@@ -1047,6 +1112,41 @@ ui.toolbar.import.dropbox.click(function () {
};
Dropbox.choose(options);
});
+// import from google drive
+var picker = null;
+function buildImportFromGoogleDrive() {
+ picker = new FilePicker({
+ apiKey: GOOGLE_API_KEY,
+ clientId: GOOGLE_CLIENT_ID,
+ buttonEl: ui.toolbar.import.googleDrive,
+ onSelect: function(file) {
+ if (file.downloadUrl) {
+ ui.spinner.show();
+ var accessToken = gapi.auth.getToken().access_token;
+ $.ajax({
+ type: 'GET',
+ beforeSend: function (request)
+ {
+ request.setRequestHeader('Authorization', 'Bearer ' + accessToken);
+ },
+ url: file.downloadUrl,
+ success: function(data) {
+ if (file.fileExtension == 'html')
+ parseToEditor(data);
+ else
+ replaceAll(data);
+ },
+ error: function (data) {
+ showMessageModal('<i class="fa fa-cloud-download"></i> Import from Google Drive', 'Import failed :(', '', data, false);
+ },
+ complete: function () {
+ ui.spinner.hide();
+ }
+ });
+ }
+ }
+ });
+}
//import from clipboard
ui.toolbar.import.clipboard.click(function () {
//na
@@ -1188,7 +1288,7 @@ function importFromUrl(url) {
//console.log(url);
if (url == null) return;
if (!isValidURL(url)) {
- alert('Not valid URL :(');
+ showMessageModal('<i class="fa fa-cloud-download"></i> Import from URL', 'Not valid URL :(', '', '', false);
return;
}
$.ajax({
@@ -1201,8 +1301,8 @@ function importFromUrl(url) {
else
replaceAll(data);
},
- error: function () {
- alert('Import failed :(');
+ error: function (data) {
+ showMessageModal('<i class="fa fa-cloud-download"></i> Import from URL', 'Import failed :(', '', data, false);
},
complete: function () {
ui.spinner.hide();
diff --git a/public/views/body.ejs b/public/views/body.ejs
index dc557067..805c11e1 100644
--- a/public/views/body.ejs
+++ b/public/views/body.ejs
@@ -135,4 +135,23 @@
</div>
</div>
</div>
+</div>
+<!-- message modal -->
+<div class="modal fade message-modal" tabindex="-1" role="dialog" aria-labelledby="myModalLabel" aria-hidden="true">
+ <div class="modal-dialog modal-sm">
+ <div class="modal-content">
+ <div class="modal-header">
+ <button type="button" class="close" data-dismiss="modal" aria-label="Close"><span aria-hidden="true">&times;</span>
+ </button>
+ <h4 class="modal-title" id="myModalLabel"></h4>
+ </div>
+ <div class="modal-body" style="color:black;">
+ <h5></h5>
+ <a target="_blank"></a>
+ </div>
+ <div class="modal-footer">
+ <button type="button" class="btn btn-default" data-dismiss="modal">OK</button>
+ </div>
+ </div>
+ </div>
</div> \ No newline at end of file
diff --git a/public/views/foot.ejs b/public/views/foot.ejs
index 20db896c..48761d0f 100644
--- a/public/views/foot.ejs
+++ b/public/views/foot.ejs
@@ -61,7 +61,10 @@
<script src="<%- url %>/vendor/md-toc.js" defer></script>
<script src="<%- url %>/vendor/showup/showup.js" defer></script>
<script src="<%- url %>/vendor/randomColor.js" defer></script>
-<script type="text/javascript" src="https://www.dropbox.com/static/api/2/dropins.js" id="dropboxjs" data-app-key="rdoizrlnkuha23r" async defer></script>
+<script type="text/javascript" src="https://www.dropbox.com/static/api/2/dropins.js" id="dropboxjs" data-app-key="change this" async defer></script>
+<script src="https://www.google.com/jsapi" defer></script>
+<script src="<%- url %>/js/google-drive-upload.js" defer></script>
+<script src="<%- url %>/js/google-drive-picker.js" defer></script>
<script type="text/x-mathjax-config">
MathJax.Hub.Config({ messageStyle: "none", skipStartupTypeset: true ,tex2jax: {inlineMath: [['$','$'], ['\\(','\\)']], processEscapes: true }});
</script>
@@ -71,4 +74,5 @@
<script src="<%- url %>/js/render.js" defer></script>
<script src="<%- url %>/js/history.js" defer></script>
<script src="<%- url %>/js/index.js" defer></script>
-<script src="<%- url %>/js/syncscroll.js" defer></script> \ No newline at end of file
+<script src="<%- url %>/js/syncscroll.js" defer></script>
+<script src="https://apis.google.com/js/client:plusone.js?onload=onGoogleClientLoaded" defer></script> \ No newline at end of file
diff --git a/public/views/header.ejs b/public/views/header.ejs
index 3cd129e1..410886d1 100644
--- a/public/views/header.ejs
+++ b/public/views/header.ejs
@@ -36,12 +36,16 @@
<li class="dropdown-header">Export</li>
<li role="presentation"><a role="menuitem" class="ui-save-dropbox" tabindex="-1" href="#" target="_self"><i class="fa fa-dropbox fa-fw"></i> Dropbox</a>
</li>
+ <li role="presentation"><a role="menuitem" class="ui-save-google-drive" tabindex="-1" href="#" target="_self"><i class="fa fa-cloud-upload fa-fw"></i> Google Drive</a>
+ </li>
<li role="presentation"><a role="menuitem" class="ui-save-gist" tabindex="-1" href="#" target="_blank"><i class="fa fa-github fa-fw"></i> Gist</a>
</li>
<li class="divider"></li>
<li class="dropdown-header">Import</li>
<li role="presentation"><a role="menuitem" class="ui-import-dropbox" tabindex="-1" href="#" target="_self"><i class="fa fa-dropbox fa-fw"></i> Dropbox</a>
</li>
+ <li role="presentation"><a role="menuitem" class="ui-import-google-drive" tabindex="-1" href="#" target="_self"><i class="fa fa-cloud-download fa-fw"></i> Google Drive</a>
+ </li>
<li role="presentation"><a role="menuitem" class="ui-import-clipboard" href="#" data-toggle="modal" data-target="#clipboardModal"><i class="fa fa-clipboard fa-fw"></i> Clipboard</a>
</li>
<li class="divider"></li>
@@ -113,12 +117,16 @@
<li class="dropdown-header">Export</li>
<li role="presentation"><a role="menuitem" class="ui-save-dropbox" tabindex="-1" href="#" target="_self"><i class="fa fa-dropbox fa-fw"></i> Dropbox</a>
</li>
+ <li role="presentation"><a role="menuitem" class="ui-save-google-drive" tabindex="-1" href="#" target="_self"><i class="fa fa-cloud-upload fa-fw"></i> Google Drive</a>
+ </li>
<li role="presentation"><a role="menuitem" class="ui-save-gist" tabindex="-1" href="#" target="_blank"><i class="fa fa-github fa-fw"></i> Gist</a>
</li>
<li class="divider"></li>
<li class="dropdown-header">Import</li>
<li role="presentation"><a role="menuitem" class="ui-import-dropbox" tabindex="-1" href="#" target="_self"><i class="fa fa-dropbox fa-fw"></i> Dropbox</a>
</li>
+ <li role="presentation"><a role="menuitem" class="ui-import-google-drive" tabindex="-1" href="#" target="_self"><i class="fa fa-cloud-download fa-fw"></i> Google Drive</a>
+ </li>
<li role="presentation"><a role="menuitem" class="ui-import-clipboard" href="#" data-toggle="modal" data-target="#clipboardModal"><i class="fa fa-clipboard fa-fw"></i> Clipboard</a>
</li>
<li class="divider"></li>