diff --git a/IPython/html/static/base/js/contentmanager.js b/IPython/html/static/base/js/contentmanager.js index e993af2..4404e44 100644 --- a/IPython/html/static/base/js/contentmanager.js +++ b/IPython/html/static/base/js/contentmanager.js @@ -29,6 +29,40 @@ define([ */ /** + * Load a notebook. + * + * Calls success_callback with notebook JSON object (as string), or + * error_callback with error. + * + * @method load_notebook + * @param {String} path + * @param {String} name + * @param {Function} success_callback + * @param {Function} error_callback + */ + ContentManager.prototype.load_notebook = function (path, name, success_callback, + error_callback) { + // We do the call with settings so we can set cache to false. + var settings = { + processData : false, + cache : false, + type : "GET", + dataType : "json", + success : success_callback, + error : error_callback, + }; + this.events.trigger('notebook_loading.Notebook'); + var url = utils.url_join_encode( + this.base_url, + 'api/notebooks', + path, + name + ); + $.ajax(url, settings); + }; + + + /** * Creates a new notebook file at the specified path, and * opens that notebook in a new window. * diff --git a/IPython/html/static/base/js/drive_contentmanager.js b/IPython/html/static/base/js/drive_contentmanager.js index f1e774d..bdf02c9 100644 --- a/IPython/html/static/base/js/drive_contentmanager.js +++ b/IPython/html/static/base/js/drive_contentmanager.js @@ -45,11 +45,40 @@ define([ }; /** + * Name of newly created notebook files. + * @type {string} + */ + ContentManager.NEW_NOTEBOOK_TITLE = 'Untitled'; + + /** + * Extension for notebook files. + * @type {string} + */ + ContentManager.NOTEBOOK_EXTENSION = 'ipynb'; + + + ContentManager.MULTIPART_BOUNDARY = '-------314159265358979323846'; + + ContentManager.NOTEBOOK_MIMETYPE = 'application/ipynb'; + + + /** * low level Google Drive functions + * + * NOTE: these functions should only be called after gapi_ready has been + * resolved, with the excpetion of authorize(), and on_gapi_load() which + * is private and should not be called at all. Typical usage is: + * + * var that = this; + * this.gapi_ready.done(function () { + * that.get_id_for_path(...) + * ... + * }); */ /* * Load Google Drive client library + * @private * @method on_gapi_load */ ContentManager.prototype.on_gapi_load = function() { @@ -111,6 +140,10 @@ define([ * Gets the Google Drive folder ID corresponding to a path. Since * the Google Drive API doesn't expose a path structure, it is necessary * to manually walk the path from root. + * @method get_id_for_path + * @param {String} path The path + * @param {Function} onSuccess called with the folder Id on success + * @param {Function} onFailure called with the error on Failure */ ContentManager.prototype.get_id_for_path = function(path, onSuccess, onFailure) { // Use recursive strategy, with helper function @@ -134,7 +167,8 @@ define([ } var query = ('mimeType = \'' + FOLDER_MIME_TYPE + '\'' - + ' and title = \'' + this_component + '\''); + + ' and title = \'' + this_component + '\'' + + ' and trashed = false'); var request = gapi.client.drive.children.list({ 'folderId': base_id, 'q': query @@ -164,13 +198,220 @@ define([ }; get_id_for_relative_path('root', path.split('/').reverse()); } - + + /** + * Gets the Google Drive folder ID corresponding to a path. Since + * the Google Drive API doesn't expose a path structure, it is necessary + * to manually walk the path from root. + * @method get_id_for_path + * @param {String} folder_id The google Drive folder id to search + * @param {String} filename The filename to find in folder_id + * @param {Function} onSuccess called with a files resource on success (see + * Google Drive API documentation for more information on the files + * resource). + * @param {Function} onFailure called with the error on Failure + */ + ContentManager.prototype.get_resource_for_filename = function( + folder_id, + filename, + onSuccess, + onFailure) { + var query = ('title = \'' + filename + '\'' + + ' and \'' + folder_id + '\' in parents' + + ' and trashed = false'); + var request = gapi.client.drive.files.list({ + 'q': query + }); + request.execute(function(response) { + if (!response || response['error']) { + onFailure(response ? response['error'] : null); + return; + } + + var files = response['items']; + if (!files) { + // 'directory does not exist' error. + onFailure(); + return; + } + + if (files.length > 1) { + // 'runtime error' this should not happen + onFailure(); + return; + } + + onSuccess(files[0]); + + }); + }; + + /** + * Uploads a notebook to Drive, either creating a new one or saving an + * existing one. + * + * @method upload_to_drive + * @param {string} data The file contents as a string + * @param {Object} metadata File metadata + * @param {function(gapi.client.drive.files.Resource)} success_callback callback for + * success + * @param {function(?):?} error_callback callback for error, takes response object + * @param {string=} opt_fileId file Id. If false, a new file is created. + * @param {Object?} opt_params a dictionary containing the following keys + * pinned: whether this save should be pinned + */ + ContentManager.prototype.upload_to_drive = function(data, metadata, + success_callback, error_callback, opt_fileId, opt_params) { + var params = opt_params || {}; + var delimiter = '\r\n--' + ContentManager.MULTIPART_BOUNDARY + '\r\n'; + var close_delim = '\r\n--' + ContentManager.MULTIPART_BOUNDARY + '--'; + var body = delimiter + + 'Content-Type: application/json\r\n\r\n' + + JSON.stringify(metadata) + + delimiter + + 'Content-Type: ' + ContentManager.NOTEBOOK_MIMETYPE + '\r\n' + + '\r\n' + + data + + close_delim; + + var path = '/upload/drive/v2/files'; + var method = 'POST'; + if (opt_fileId) { + path += '/' + opt_fileId; + method = 'PUT'; + } + + var request = gapi.client.request({ + 'path': path, + 'method': method, + 'params': { + 'uploadType': 'multipart', + 'pinned' : params['pinned'] + }, + 'headers': { + 'Content-Type': 'multipart/mixed; boundary="' + + ContentManager.MULTIPART_BOUNDARY + '"' + }, + 'body': body + }); + request.execute(function(response) { + if (!response || response['error']) { + error_callback(response ? response['error'] : null); + return; + } + + success_callback(response); + }); + }; + + /** + * Obtains the filename that should be used for a new file in a given folder. + * This is the next file in the series Untitled0, Untitled1, ... in the given + * drive folder. As a fallback, returns Untitled. + * + * @method get_new_filename + * @param {function(string)} callback Called with the name for the new file. + * @param {string} opt_folderId optinal Drive folder Id to search for + * filenames. Uses root, if none is specified. + */ + ContentManager.prototype.get_new_filename = function(callback, opt_folderId) { + /** @type {string} */ + var folderId = opt_folderId || 'root'; + var query = 'title contains \'' + ContentManager.NEW_NOTEBOOK_TITLE + '\'' + + ' and \'' + folderId + '\' in parents' + + ' and trashed = false'; + var request = gapi.client.drive.files.list({ + 'maxResults': 1000, + 'folderId' : folderId, + 'q': query + }); + + request.execute(function(response) { + // Use 'Untitled.ipynb' as a fallback in case of error + var fallbackFilename = ContentManager.NEW_NOTEBOOK_TITLE + '.' + + ContentManager.NOTEBOOK_EXTENSION; + if (!response || response['error']) { + callback(fallbackFilename); + return; + } + + var files = response['items'] || []; + var existingFilenames = $.map(files, function(filesResource) { + return filesResource['title']; + }); + + // Loop over file names Untitled0, ... , UntitledN where N is the number of + // elements in existingFilenames. Select the first file name that does not + // belong to existingFilenames. This is guaranteed to find a file name + // that does not belong to existingFilenames, since there are N + 1 file + // names tried, and existingFilenames contains N elements. + for (var i = 0; i <= existingFilenames.length; i++) { + /** @type {string} */ + var filename = ContentManager.NEW_NOTEBOOK_TITLE + i + '.' + + ContentManager.NOTEBOOK_EXTENSION; + if (existingFilenames.indexOf(filename) == -1) { + callback(filename); + return; + } + } + + // Control should not reach this point, so an error has occured + callback(fallbackFilename); + }); + }; /** * Notebook Functions */ /** + * Load a notebook. + * + * Calls success_callback with notebook JSON object (as string), or + * error_callback with error. + * + * @method load_notebook + * @param {String} path + * @param {String} name + * @param {Function} success_callback + * @param {Function} error_callback + */ + ContentManager.prototype.load_notebook = function (path, name, success_callback, + error_callback) { + var that = this; + this.gapi_ready.done(function() { + that.get_id_for_path(path, function(folder_id) { + that.get_resource_for_filename(folder_id, name, function(file_resource) { + // Sends request to load file to drive. + var token = gapi.auth.getToken()['access_token']; + var xhrRequest = new XMLHttpRequest(); + xhrRequest.open('GET', file_resource['downloadUrl'], true); + xhrRequest.setRequestHeader('Authorization', 'Bearer ' + token); + xhrRequest.onreadystatechange = function(e) { + if (xhrRequest.readyState == 4) { + if (xhrRequest.status == 200) { + var notebook_contents = xhrRequest.responseText; + //colab.nbformat.convertJsonNotebookToRealtime( + // notebook_contents, model); + var model = JSON.parse(notebook_contents); + + success_callback({ + content: model, + // A hack to deal with file/memory format conversions + name: model.metadata.name + }); + } else { + error_callback(xhrRequest); + } + } + }; + xhrRequest.send(); + }, error_callback) + }, error_callback); + }); + }; + + /** * Creates a new notebook file at the specified path, and * opens that notebook in a new window. * @@ -178,46 +419,46 @@ define([ * @param {String} path The path to create the new notebook at */ ContentManager.prototype.new_notebook = function(path) { - var base_url = this.base_url; - var settings = { - processData : false, - cache : false, - type : "POST", - dataType : "json", - async : false, - success : function (data, status, xhr){ - var notebook_name = data.name; - window.open( - utils.url_join_encode( - base_url, - 'notebooks', - path, - notebook_name - ), - '_blank' - ); - }, - error : function(xhr, status, error) { - utils.log_ajax_error(xhr, status, error); - var msg; - if (xhr.responseJSON && xhr.responseJSON.message) { - msg = xhr.responseJSON.message; - } else { - msg = xhr.statusText; - } - dialog.modal({ - title : 'Creating Notebook Failed', - body : "The error was: " + msg, - buttons : {'OK' : {'class' : 'btn-primary'}} - }); - } - }; - var url = utils.url_join_encode( - base_url, - 'api/notebooks', - path - ); - $.ajax(url,settings); + var that = this; + this.gapi_ready.done(function() { + that.get_id_for_path(path, function(folder_id) { + that.get_new_filename(function(filename) { + var data = { + 'worksheets': [{ + 'cells' : [{ + 'cell_type': 'code', + 'input': '', + 'outputs': [], + 'language': 'python' + }], + }], + 'metadata': { + 'name': filename, + }, + 'nbformat': 3, + 'nbformat_minor': 0 + }; + var metadata = { + 'parents' : [{'id' : folder_id}], + 'title' : filename, + 'description': 'IP[y] file', + 'mimeType': ContentManager.NOTEBOOK_MIMETYPE + } + that.upload_to_drive(JSON.stringify(data), metadata, function (data, status, xhr) { + var notebook_name = data.name; + window.open( + utils.url_join_encode( + that.base_url, + 'notebooks', + path, + filename + ), + '_blank' + ); + }, function(){}); + }, folder_id); + }) + }); }; ContentManager.prototype.delete_notebook = function(name, path) { @@ -380,7 +621,8 @@ define([ that.get_id_for_path(path, function(folder_id) { query = ('(fileExtension = \'ipynb\' or' + ' mimeType = \'' + FOLDER_MIME_TYPE + '\')' - + ' and \'' + folder_id + '\' in parents'); + + ' and \'' + folder_id + '\' in parents' + + ' and trashed = false'); var request = gapi.client.drive.files.list({ 'maxResults' : 1000, 'q' : query diff --git a/IPython/html/static/notebook/js/notebook.js b/IPython/html/static/notebook/js/notebook.js index e616ab0..9b349d3 100644 --- a/IPython/html/static/notebook/js/notebook.js +++ b/IPython/html/static/notebook/js/notebook.js @@ -2140,26 +2140,13 @@ define([ * @param {String} notebook_name and path A notebook to load */ Notebook.prototype.load_notebook = function (notebook_name, notebook_path) { - var that = this; this.notebook_name = notebook_name; this.notebook_path = notebook_path; - // We do the call with settings so we can set cache to false. - var settings = { - processData : false, - cache : false, - type : "GET", - dataType : "json", - success : $.proxy(this.load_notebook_success,this), - error : $.proxy(this.load_notebook_error,this), - }; - this.events.trigger('notebook_loading.Notebook'); - var url = utils.url_join_encode( - this.base_url, - 'api/contents', - this.notebook_path, - this.notebook_name - ); - $.ajax(url, settings); + this.content_manager.load_notebook( + notebook_path, + notebook_name, + $.proxy(this.load_notebook_success,this), + $.proxy(this.load_notebook_error,this)); }; /**