diff options
author | Cheng-Han, Wu | 2016-03-04 23:17:35 +0800 |
---|---|---|
committer | Cheng-Han, Wu | 2016-03-04 23:17:35 +0800 |
commit | 845ef9bad6b9f5ff471b62505f9e39300297a3a4 (patch) | |
tree | 8ee0bcd9a2b0ba22330d7ca4015c681bf4ad2bcf /public | |
parent | c183002c14397c8b6e1ef26c1367197d987d7c62 (diff) |
Support export to and import from Google Drive
Diffstat (limited to '')
-rw-r--r-- | public/js/common.js | 3 | ||||
-rw-r--r-- | public/js/google-drive-picker.js | 118 | ||||
-rw-r--r-- | public/js/google-drive-upload.js | 269 | ||||
-rw-r--r-- | public/js/index.js | 106 | ||||
-rw-r--r-- | public/views/body.ejs | 19 | ||||
-rw-r--r-- | public/views/foot.ejs | 8 | ||||
-rw-r--r-- | public/views/header.ejs | 8 |
7 files changed, 526 insertions, 5 deletions
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">×</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> |