drive_contentmanager.js
662 lines
| 23.4 KiB
| application/javascript
|
JavascriptLexer
|
r18637 | // Copyright (c) IPython Development Team. | ||
// Distributed under the terms of the Modified BSD License. | ||||
define([ | ||||
'base/js/namespace', | ||||
'jquery', | ||||
'base/js/utils', | ||||
'base/js/dialog', | ||||
], function(IPython, $, utils, dialog) { | ||||
var FOLDER_MIME_TYPE = 'application/vnd.google-apps.folder'; | ||||
var ContentManager = function(options) { | ||||
// Constructor | ||||
// | ||||
// A contentmanager handles passing file operations | ||||
// to the back-end. This includes checkpointing | ||||
// with the normal file operations. | ||||
// | ||||
// Parameters: | ||||
// options: dictionary | ||||
// Dictionary of keyword arguments. | ||||
// events: $(Events) instance | ||||
// base_url: string | ||||
var that = this; | ||||
this.version = 0.1; | ||||
this.events = options.events; | ||||
this.base_url = options.base_url; | ||||
this.gapi_ready = $.Deferred(); | ||||
this.gapi_ready.fail(function(){ | ||||
// TODO: display a dialog | ||||
console.log('failed to load Google API'); | ||||
}); | ||||
// load Google API | ||||
$.getScript('https://apis.google.com/js/client.js'); | ||||
function poll_for_gapi_load() { | ||||
if (window.gapi && gapi.client) { | ||||
that.on_gapi_load(); | ||||
} else { | ||||
setTimeout(poll_for_gapi_load, 100); | ||||
} | ||||
} | ||||
poll_for_gapi_load(); | ||||
}; | ||||
/** | ||||
|
r18638 | * 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'; | ||||
/** | ||||
|
r18637 | * low level Google Drive functions | ||
|
r18638 | * | ||
* 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(...) | ||||
* ... | ||||
* }); | ||||
|
r18637 | */ | ||
/* | ||||
* Load Google Drive client library | ||||
|
r18638 | * @private | ||
|
r18637 | * @method on_gapi_load | ||
*/ | ||||
ContentManager.prototype.on_gapi_load = function() { | ||||
var that = this; | ||||
gapi.load('auth:client,drive-realtime,drive-share', function() { | ||||
gapi.client.load('drive', 'v2', function() { | ||||
that.authorize(false); | ||||
}); | ||||
}); | ||||
}; | ||||
/** | ||||
* Authorize using Google OAuth API. | ||||
* @method authorize | ||||
* @param {boolean} opt_withPopup If true, display popup without first | ||||
* trying to authorize without a popup. | ||||
*/ | ||||
ContentManager.prototype.authorize = function(opt_withPopup) { | ||||
var that = this; | ||||
var doAuthorize = function() { | ||||
gapi.auth.authorize({ | ||||
'client_id': '911569945122-tlvi6ucbj137ifhitpqpdikf3qo1mh9d.apps.googleusercontent.com', | ||||
'scope': ['https://www.googleapis.com/auth/drive'], | ||||
'immediate': !opt_withPopup | ||||
}, function(response) { | ||||
if (!response || response['error']) { | ||||
if (opt_withPopup) { | ||||
that.gapi_ready.reject(response ? response['error'] : null); | ||||
} else { | ||||
that.authorize(true); | ||||
} | ||||
return; | ||||
} | ||||
that.gapi_ready.resolve(); | ||||
}); | ||||
}; | ||||
// if no popup, calls the authorization function immediately | ||||
if (!opt_withPopup) { | ||||
doAuthorize(); | ||||
return; | ||||
} | ||||
// Gets user to initiate the authorization with a dialog, | ||||
// to prevent popup blockers. | ||||
var options = { | ||||
title: 'Authentication needed', | ||||
body: ('Accessing Google Drive requires authentication. Click' | ||||
+ ' ok to proceed.'), | ||||
buttons: { | ||||
'ok': { click : doAuthorize }, | ||||
'cancel': { click : that.gapi_ready.reject } | ||||
} | ||||
} | ||||
dialog.modal(options); | ||||
}; | ||||
/** | ||||
* 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. | ||||
|
r18638 | * @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 | ||||
|
r18637 | */ | ||
ContentManager.prototype.get_id_for_path = function(path, onSuccess, onFailure) { | ||||
// Use recursive strategy, with helper function | ||||
// get_id_for_relative_path. | ||||
// calls callbacks with the id for the sepcified path, treated as | ||||
// a relative path with base given by base_id. | ||||
function get_id_for_relative_path(base_id, path_components) { | ||||
if (path_components.length == 0) { | ||||
onSuccess(base_id); | ||||
return; | ||||
} | ||||
var this_component = path_components.pop(); | ||||
// Treat the empty string as a special case, and ignore it. | ||||
// This will result in ignoring leading and trailing slashes. | ||||
if (this_component == "") { | ||||
get_id_for_relative_path(base_id, path_components); | ||||
return; | ||||
} | ||||
var query = ('mimeType = \'' + FOLDER_MIME_TYPE + '\'' | ||||
|
r18638 | + ' and title = \'' + this_component + '\'' | ||
+ ' and trashed = false'); | ||||
|
r18637 | var request = gapi.client.drive.children.list({ | ||
'folderId': base_id, | ||||
'q': query | ||||
}); | ||||
request.execute(function(response) { | ||||
if (!response || response['error']) { | ||||
onFailure(response ? response['error'] : null); | ||||
return; | ||||
} | ||||
var child_folders = response['items']; | ||||
if (!child_folders) { | ||||
// 'directory does not exist' error. | ||||
onFailure(); | ||||
return; | ||||
} | ||||
if (child_folders.length > 1) { | ||||
// 'runtime error' this should not happen | ||||
onFailure(); | ||||
return; | ||||
} | ||||
get_id_for_relative_path(child_folders[0]['id'], | ||||
path_components); | ||||
}); | ||||
}; | ||||
get_id_for_relative_path('root', path.split('/').reverse()); | ||||
} | ||||
|
r18638 | |||
/** | ||||
* 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); | ||||
}); | ||||
}; | ||||
|
r18637 | |||
/** | ||||
* Notebook Functions | ||||
*/ | ||||
/** | ||||
|
r18638 | * 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); | ||||
}); | ||||
}; | ||||
/** | ||||
|
r18637 | * Creates a new notebook file at the specified path, and | ||
* opens that notebook in a new window. | ||||
* | ||||
* @method scroll_to_cell | ||||
* @param {String} path The path to create the new notebook at | ||||
*/ | ||||
ContentManager.prototype.new_notebook = function(path) { | ||||
|
r18638 | 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, | ||||
|
r18640 | 'contents', | ||
|
r18638 | path, | ||
filename | ||||
), | ||||
'_blank' | ||||
); | ||||
}, function(){}); | ||||
}, folder_id); | ||||
}) | ||||
}); | ||||
|
r18637 | }; | ||
ContentManager.prototype.delete_notebook = function(name, path) { | ||||
var settings = { | ||||
processData : false, | ||||
cache : false, | ||||
type : "DELETE", | ||||
dataType : "json", | ||||
success : $.proxy(this.events.trigger, this.events, | ||||
'notebook_deleted.ContentManager', | ||||
{ | ||||
name: name, | ||||
path: path | ||||
}), | ||||
error : utils.log_ajax_error | ||||
}; | ||||
var url = utils.url_join_encode( | ||||
this.base_url, | ||||
|
r18640 | 'api/contents', | ||
|
r18637 | path, | ||
name | ||||
); | ||||
$.ajax(url, settings); | ||||
}; | ||||
ContentManager.prototype.rename_notebook = function(path, name, new_name) { | ||||
var that = this; | ||||
var data = {name: new_name}; | ||||
var settings = { | ||||
processData : false, | ||||
cache : false, | ||||
type : "PATCH", | ||||
data : JSON.stringify(data), | ||||
dataType: "json", | ||||
headers : {'Content-Type': 'application/json'}, | ||||
success : function (json, status, xhr) { | ||||
that.events.trigger('notebook_rename_success.ContentManager', | ||||
json); | ||||
}, | ||||
error : function (xhr, status, error) { | ||||
that.events.trigger('notebook_rename_error.ContentManager', | ||||
[xhr, status, error]); | ||||
} | ||||
} | ||||
var url = utils.url_join_encode( | ||||
this.base_url, | ||||
|
r18640 | 'api/contents', | ||
|
r18637 | path, | ||
name | ||||
); | ||||
$.ajax(url, settings); | ||||
}; | ||||
ContentManager.prototype.save_notebook = function(path, name, content, | ||||
extra_settings) { | ||||
var that = notebook; | ||||
// Create a JSON model to be sent to the server. | ||||
var model = { | ||||
name : name, | ||||
path : path, | ||||
content : content | ||||
}; | ||||
// time the ajax call for autosave tuning purposes. | ||||
var start = new Date().getTime(); | ||||
// We do the call with settings so we can set cache to false. | ||||
var settings = { | ||||
processData : false, | ||||
cache : false, | ||||
type : "PUT", | ||||
data : JSON.stringify(model), | ||||
headers : {'Content-Type': 'application/json'}, | ||||
success : $.proxy(this.events.trigger, this.events, | ||||
'notebook_save_success.ContentManager', | ||||
$.extend(model, { start : start })), | ||||
error : function (xhr, status, error) { | ||||
that.events.trigger('notebook_save_error.ContentManager', | ||||
[xhr, status, error, model]); | ||||
} | ||||
}; | ||||
if (extra_settings) { | ||||
for (var key in extra_settings) { | ||||
settings[key] = extra_settings[key]; | ||||
} | ||||
} | ||||
var url = utils.url_join_encode( | ||||
this.base_url, | ||||
|
r18640 | 'api/contents', | ||
|
r18637 | path, | ||
name | ||||
); | ||||
$.ajax(url, settings); | ||||
}; | ||||
/** | ||||
* Checkpointing Functions | ||||
*/ | ||||
ContentManager.prototype.save_checkpoint = function() { | ||||
// This is not necessary - integrated into save | ||||
}; | ||||
ContentManager.prototype.restore_checkpoint = function(notebook, id) { | ||||
that = notebook; | ||||
this.events.trigger('notebook_restoring.Notebook', checkpoint); | ||||
var url = utils.url_join_encode( | ||||
this.base_url, | ||||
|
r18640 | 'api/contents', | ||
|
r18637 | this.notebook_path, | ||
this.notebook_name, | ||||
'checkpoints', | ||||
checkpoint | ||||
); | ||||
$.post(url).done( | ||||
$.proxy(that.restore_checkpoint_success, that) | ||||
).fail( | ||||
$.proxy(that.restore_checkpoint_error, that) | ||||
); | ||||
}; | ||||
ContentManager.prototype.list_checkpoints = function(notebook) { | ||||
that = notebook; | ||||
var url = utils.url_join_encode( | ||||
that.base_url, | ||||
|
r18640 | 'api/contents', | ||
|
r18637 | that.notebook_path, | ||
that.notebook_name, | ||||
'checkpoints' | ||||
); | ||||
$.get(url).done( | ||||
$.proxy(that.list_checkpoints_success, that) | ||||
).fail( | ||||
$.proxy(that.list_checkpoints_error, that) | ||||
); | ||||
}; | ||||
/** | ||||
* File management functions | ||||
*/ | ||||
/** | ||||
* List notebooks and directories at a given path | ||||
* | ||||
* On success, load_callback is called with an array of dictionaries | ||||
* representing individual files or directories. Each dictionary has | ||||
* the keys: | ||||
* type: "notebook" or "directory" | ||||
* name: the name of the file or directory | ||||
* created: created date | ||||
* last_modified: last modified dat | ||||
* path: the path | ||||
* @method list_notebooks | ||||
* @param {String} path The path to list notebooks in | ||||
* @param {Function} load_callback called with list of notebooks on success | ||||
* @param {Function} error_callback called with ajax results on error | ||||
*/ | ||||
ContentManager.prototype.list_contents = function(path, load_callback, | ||||
error_callback) { | ||||
var that = this; | ||||
this.gapi_ready.done(function() { | ||||
that.get_id_for_path(path, function(folder_id) { | ||||
query = ('(fileExtension = \'ipynb\' or' | ||||
+ ' mimeType = \'' + FOLDER_MIME_TYPE + '\')' | ||||
|
r18638 | + ' and \'' + folder_id + '\' in parents' | ||
+ ' and trashed = false'); | ||||
|
r18637 | var request = gapi.client.drive.files.list({ | ||
'maxResults' : 1000, | ||||
'q' : query | ||||
}); | ||||
request.execute(function(response) { | ||||
// On a drive API error, call error_callback | ||||
if (!response || response['error']) { | ||||
error_callback(response ? response['error'] : null); | ||||
return; | ||||
} | ||||
// Convert this list to the format that is passed to | ||||
// load_callback. Note that a files resource can represent | ||||
// a file or a directory. | ||||
// TODO: check that date formats are the same, and either | ||||
// convert to the IPython format, or document the difference. | ||||
var list = $.map(response['items'], function(files_resource) { | ||||
var type = files_resource['mimeType'] == FOLDER_MIME_TYPE ? 'directory' : 'notebook'; | ||||
return { | ||||
type: type, | ||||
name: files_resource['title'], | ||||
path: path, | ||||
created: files_resource['createdDate'], | ||||
last_modified: files_resource['modifiedDate'] | ||||
}; | ||||
}); | ||||
load_callback(list); | ||||
}); | ||||
}, error_callback); | ||||
}); | ||||
}; | ||||
IPython.ContentManager = ContentManager; | ||||
return {'ContentManager': ContentManager}; | ||||
}); | ||||