├── .gitignore ├── .prettierignore ├── .prettierrc ├── .travis.yml ├── LICENSE ├── README.md ├── docs ├── images │ ├── 1.open.projects.png │ ├── 12.apis.library.search.realtime.png │ ├── 13.apis.library.realtime.enable.png │ ├── 16.apis.library.search.picker.png │ ├── 17.apis.library.enable.picker.png │ ├── 18.apis.services.oauth.consent.png │ ├── 19.oauth.app.name.png │ ├── 2.new.project.png │ ├── 20.auth.add.scopes.png │ ├── 21.oauth.add.drive.scope.png │ ├── 22.oauth.drive.scope.added.png │ ├── 23.oauth.domain.policy.png │ ├── 24.create.credentials.png │ ├── 25.create.credentials.type.png │ ├── 26.web.app.credentials.png │ ├── 27.web.app.restrictions.png │ ├── 28.oauth.client.secret.png │ ├── 3.create.project.png │ ├── 4.open.projects.png │ ├── 5.activate.project.png │ ├── 6.apis.services.library.png │ ├── 7.apis.library.search.png │ ├── 8.apis.library.search.drive.png │ ├── 9.apis.library.drive.enable.png │ └── clientid.png ├── setup.md └── troubleshooting.md ├── package.json ├── schema └── drive.json ├── src ├── browser.ts ├── contents.ts ├── drive.ts ├── gapi.client.drive.d.ts ├── gapi.ts └── index.ts ├── style ├── account-dark.svg ├── account-light.svg ├── drive.png ├── drive_dark.png ├── drive_light.png ├── index.css └── share.svg ├── test ├── build-tests.sh ├── get-access-token │ ├── get-access-token.js │ └── package.json ├── karma.conf.js ├── run-tests.sh ├── src │ ├── contents.spec.ts │ ├── index.ts │ └── util.ts ├── tsconfig.json └── webpack.config.js ├── tsconfig.json ├── tslint.json └── yarn.lock /.gitignore: -------------------------------------------------------------------------------- 1 | *.bundle.* 2 | lib/ 3 | test/build/* 4 | node_modules/ 5 | npm-debug.log 6 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | **/lib 3 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "singleQuote": true 3 | } -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: 3 | - 'lts/*' 4 | sudo: false 5 | addons: 6 | firefox: latest 7 | notifications: 8 | email: false 9 | before_install: 10 | - wget https://repo.continuum.io/miniconda/Miniconda2-latest-Linux-x86_64.sh -O ~/miniconda.sh; 11 | - bash ~/miniconda.sh -b -p $HOME/miniconda 12 | - export PATH="$HOME/miniconda/bin:$PATH" 13 | - pip install --pre jupyterlab 14 | install: 15 | - jlpm install 16 | - jlpm run build 17 | before_script: 18 | - jlpm run build:test 19 | - export DISPLAY=:99.0 20 | - sh -e /etc/init.d/xvfb start 21 | script: 22 | - jlpm test 23 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2015-2016, Project Jupyter Contributors 2 | All rights reserved. 3 | 4 | Redistribution and use in source and binary forms, with or without 5 | modification, are permitted provided that the following conditions are met: 6 | 7 | 1. Redistributions of source code must retain the above copyright notice, this 8 | list of conditions and the following disclaimer. 9 | 10 | 2. Redistributions in binary form must reproduce the above copyright notice, 11 | this list of conditions and the following disclaimer in the documentation 12 | and/or other materials provided with the distribution. 13 | 14 | 3. Neither the name of the copyright holder nor the names of its 15 | contributors may be used to endorse or promote products derived from 16 | this software without specific prior written permission. 17 | 18 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" 19 | AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE 20 | IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 21 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE 22 | FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL 23 | DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR 24 | SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER 25 | CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, 26 | OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 27 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 28 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | **This project has been archived by lack of maintainers.** 2 | 3 | # jupyterlab-google-drive 4 | 5 | [![Build Status](https://travis-ci.org/jupyterlab/jupyterlab-google-drive.svg?branch=master)](https://travis-ci.org/jupyterlab/jupyterlab-google-drive) 6 | 7 | ## Cloud storage for JupyterLab through Google Drive. 8 | 9 | **NOTE: this is beta software and is rapidly changing.** 10 | 11 | This extension adds a Google Drive file browser to the left sidebar of JupyterLab. 12 | When you are logged into your Google account, you will have the 13 | files stored in your GDrive available to JupyterLab. 14 | 15 | If you run into trouble, see if the [troubleshooting guide](docs/troubleshooting.md) has a solution for you. 16 | 17 | ## Prerequisites 18 | 19 | - JupyterLab 1.x / 2.x 20 | - A Google Drive account 21 | 22 | ## Setting up credentials with Google 23 | 24 | To run this extension you need to authenticate your JupyterLab deployment 25 | (whether institutional or individual) with Google. 26 | In order to identify yourself to Google, you will need to register a web application 27 | with their Developers Console. 28 | Detailed instructions for setting up your application credentials can be found in 29 | [setup.md](docs/setup.md). 30 | 31 | ## Installation 32 | 33 | To install this extension into JupyterLab (requires node 6 or later), do the following: 34 | 35 | ```bash 36 | jupyter labextension install @jupyterlab/google-drive 37 | ``` 38 | 39 | ## Development 40 | 41 | For a development install, do the following in the repository directory: 42 | 43 | ```bash 44 | jlpm install 45 | jlpm run build 46 | jupyter labextension install . 47 | ``` 48 | 49 | You can then run JupyterLab in watch mode to automatically pick up changes to `@jupyterlab/google-drive`. 50 | Open a terminal in the `@jupyterlab/google-drive` repository directory and enter 51 | 52 | ```bash 53 | jlpm run watch 54 | ``` 55 | 56 | Then launch JupyterLab using 57 | 58 | ```bash 59 | jupyter lab --watch 60 | ``` 61 | 62 | This will automatically recompile `@jupyterlab/google-drive` upon changes, 63 | and JupyterLab will rebuild itself. You should then be able to refresh the 64 | page and see your changes. 65 | 66 | ## Getting Started from Scratch 67 | 68 | - Install JupyterLab 69 | 70 | ``` 71 | pip install jupyterlab 72 | ``` 73 | 74 | - Install the jupyterlab-google-drive extension 75 | 76 | ``` 77 | jupyter labextension install @jupyterlab/google-drive 78 | ``` 79 | 80 | - Set up your application credentials according to [this](docs/setup.md) guide. 81 | 82 | - Start JupyterLab 83 | 84 | ``` 85 | jupyter lab 86 | ``` 87 | 88 | - Click on the Google Drive tab in the left sidebar in the JupyterLab interface 89 | and log in to your Google Drive account. 90 | 91 | - Have someone share a notebook or markdown file with you. 92 | 93 | - You should now see the file in the **Shared with Me** folder in the file browser. 94 | Double-click to open the file and begin editing! 95 | 96 | ### Using Zero to JupyterHub (Z2JH) 97 | When using Zero to JupyterHub to deploy a containerized environment, you can minimize the need to sign into JupyterHub and Google Drive. If you are using Google OAuth to autenticate users for JupyterHub, you can simply add the additional scopes to to the `auth` block: 98 | 99 | ``` 100 | auth: 101 | type: google 102 | google: 103 | callbackUrl: "/hub/oauth_callback" 104 | scopes: 105 | - "openid" 106 | - "email" 107 | - "https://www.googleapis.com/auth/drive" 108 | ``` 109 | -------------------------------------------------------------------------------- /docs/images/1.open.projects.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jupyterlab/jupyterlab-google-drive/13d94b64a9605bf70fe0e5430f4817cfff088e83/docs/images/1.open.projects.png -------------------------------------------------------------------------------- /docs/images/12.apis.library.search.realtime.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jupyterlab/jupyterlab-google-drive/13d94b64a9605bf70fe0e5430f4817cfff088e83/docs/images/12.apis.library.search.realtime.png -------------------------------------------------------------------------------- /docs/images/13.apis.library.realtime.enable.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jupyterlab/jupyterlab-google-drive/13d94b64a9605bf70fe0e5430f4817cfff088e83/docs/images/13.apis.library.realtime.enable.png -------------------------------------------------------------------------------- /docs/images/16.apis.library.search.picker.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jupyterlab/jupyterlab-google-drive/13d94b64a9605bf70fe0e5430f4817cfff088e83/docs/images/16.apis.library.search.picker.png -------------------------------------------------------------------------------- /docs/images/17.apis.library.enable.picker.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jupyterlab/jupyterlab-google-drive/13d94b64a9605bf70fe0e5430f4817cfff088e83/docs/images/17.apis.library.enable.picker.png -------------------------------------------------------------------------------- /docs/images/18.apis.services.oauth.consent.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jupyterlab/jupyterlab-google-drive/13d94b64a9605bf70fe0e5430f4817cfff088e83/docs/images/18.apis.services.oauth.consent.png -------------------------------------------------------------------------------- /docs/images/19.oauth.app.name.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jupyterlab/jupyterlab-google-drive/13d94b64a9605bf70fe0e5430f4817cfff088e83/docs/images/19.oauth.app.name.png -------------------------------------------------------------------------------- /docs/images/2.new.project.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jupyterlab/jupyterlab-google-drive/13d94b64a9605bf70fe0e5430f4817cfff088e83/docs/images/2.new.project.png -------------------------------------------------------------------------------- /docs/images/20.auth.add.scopes.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jupyterlab/jupyterlab-google-drive/13d94b64a9605bf70fe0e5430f4817cfff088e83/docs/images/20.auth.add.scopes.png -------------------------------------------------------------------------------- /docs/images/21.oauth.add.drive.scope.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jupyterlab/jupyterlab-google-drive/13d94b64a9605bf70fe0e5430f4817cfff088e83/docs/images/21.oauth.add.drive.scope.png -------------------------------------------------------------------------------- /docs/images/22.oauth.drive.scope.added.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jupyterlab/jupyterlab-google-drive/13d94b64a9605bf70fe0e5430f4817cfff088e83/docs/images/22.oauth.drive.scope.added.png -------------------------------------------------------------------------------- /docs/images/23.oauth.domain.policy.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jupyterlab/jupyterlab-google-drive/13d94b64a9605bf70fe0e5430f4817cfff088e83/docs/images/23.oauth.domain.policy.png -------------------------------------------------------------------------------- /docs/images/24.create.credentials.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jupyterlab/jupyterlab-google-drive/13d94b64a9605bf70fe0e5430f4817cfff088e83/docs/images/24.create.credentials.png -------------------------------------------------------------------------------- /docs/images/25.create.credentials.type.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jupyterlab/jupyterlab-google-drive/13d94b64a9605bf70fe0e5430f4817cfff088e83/docs/images/25.create.credentials.type.png -------------------------------------------------------------------------------- /docs/images/26.web.app.credentials.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jupyterlab/jupyterlab-google-drive/13d94b64a9605bf70fe0e5430f4817cfff088e83/docs/images/26.web.app.credentials.png -------------------------------------------------------------------------------- /docs/images/27.web.app.restrictions.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jupyterlab/jupyterlab-google-drive/13d94b64a9605bf70fe0e5430f4817cfff088e83/docs/images/27.web.app.restrictions.png -------------------------------------------------------------------------------- /docs/images/28.oauth.client.secret.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jupyterlab/jupyterlab-google-drive/13d94b64a9605bf70fe0e5430f4817cfff088e83/docs/images/28.oauth.client.secret.png -------------------------------------------------------------------------------- /docs/images/3.create.project.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jupyterlab/jupyterlab-google-drive/13d94b64a9605bf70fe0e5430f4817cfff088e83/docs/images/3.create.project.png -------------------------------------------------------------------------------- /docs/images/4.open.projects.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jupyterlab/jupyterlab-google-drive/13d94b64a9605bf70fe0e5430f4817cfff088e83/docs/images/4.open.projects.png -------------------------------------------------------------------------------- /docs/images/5.activate.project.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jupyterlab/jupyterlab-google-drive/13d94b64a9605bf70fe0e5430f4817cfff088e83/docs/images/5.activate.project.png -------------------------------------------------------------------------------- /docs/images/6.apis.services.library.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jupyterlab/jupyterlab-google-drive/13d94b64a9605bf70fe0e5430f4817cfff088e83/docs/images/6.apis.services.library.png -------------------------------------------------------------------------------- /docs/images/7.apis.library.search.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jupyterlab/jupyterlab-google-drive/13d94b64a9605bf70fe0e5430f4817cfff088e83/docs/images/7.apis.library.search.png -------------------------------------------------------------------------------- /docs/images/8.apis.library.search.drive.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jupyterlab/jupyterlab-google-drive/13d94b64a9605bf70fe0e5430f4817cfff088e83/docs/images/8.apis.library.search.drive.png -------------------------------------------------------------------------------- /docs/images/9.apis.library.drive.enable.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jupyterlab/jupyterlab-google-drive/13d94b64a9605bf70fe0e5430f4817cfff088e83/docs/images/9.apis.library.drive.enable.png -------------------------------------------------------------------------------- /docs/images/clientid.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jupyterlab/jupyterlab-google-drive/13d94b64a9605bf70fe0e5430f4817cfff088e83/docs/images/clientid.png -------------------------------------------------------------------------------- /docs/setup.md: -------------------------------------------------------------------------------- 1 | # Setup 2 | 3 | The JupyterLab Google Drive extension makes authenticated requests to Google's servers, 4 | and as such, must be configured to have the correct credentials. 5 | In particular, the application must be registered with Google 6 | and the origin of the API requests must be pre-specified. 7 | By default, the `@jupyterlab/google-drive` package uses a registered web application 8 | that is configured to accept requests from `http://localhost`, ports `8888` through `8899`. 9 | This is probably sufficient for local usage of the extension, 10 | but if you are accessing the application from other origins 11 | (such as you might do using a JupyterHub deployment), 12 | or if you are using the extension extensively, 13 | you will likely want to set up your own credentials with Google. 14 | 15 | ### Google OAuth2 Setup instructions 16 | 17 | 1. Login to Google [Cloud Console](https://console.cloud.google.com) 18 | 2. Click on the Project drop-down 19 | ![header with selected project](images/1.open.projects.png) 20 | 3. Click: New Project (if you already have a project created, skip to step 6) 21 | ![new project link](images/2.new.project.png) 22 | 4. Fill in project details and click Create 23 | ![new project form](images/3.create.project.png) 24 | 5. Click the Project drop-down to show the list 25 | ![header with project drop-down](images/4.open.projects.png) 26 | 6. Click the project name from the list 27 | ![project listing](images/5.activate.project.png) 28 | 7. Open the API Library 29 | ![navigation bar](images/6.apis.services.library.png) 30 | 8. Activate the search 31 | ![search area](images/7.apis.library.search.png) 32 | 9. Search for `drive` and click `Google Drive API` 33 | ![search results for drive](images/8.apis.library.search.drive.png) 34 | 10. Click the `Enable` button 35 | ![google drive api details page](images/9.apis.library.drive.enable.png) 36 | 11. Open the API Library 37 | ![navigation bar](images/6.apis.services.library.png) 38 | 12. Activate the search 39 | ![search area](images/7.apis.library.search.png) 40 | 13. Search for `realtime` and click `Realtime API` 41 | ![search results for realtime](images/12.apis.library.search.realtime.png) 42 | 14. Click the `Enable` button 43 | ![realtime api details page](images/13.apis.library.realtime.enable.png) 44 | 15. Open the API Library 45 | ![navigation bar](images/6.apis.services.library.png) 46 | 16. Activate the search 47 | ![search area](images/7.apis.library.search.png) 48 | 17. Search for `picker` and click on `Google Picker API` 49 | ![search results for picker](images/16.apis.library.search.picker.png) 50 | 18. Click the `Enable` button 51 | ![google picker api details page](images/17.apis.library.enable.picker.png) 52 | 19. Navigate to the `OAuth consent screen` 53 | ![oauth consent screen navigation location](images/18.apis.services.oauth.consent.png) 54 | 20. Set the `Application Name` 55 | ![application name section of the form](images/19.oauth.app.name.png) 56 | 21. Click the `Add scope` button 57 | ![oauth scope section of form](images/20.auth.add.scopes.png) 58 | 22. Search `drive`, select the `../auth/drive` scope and then click `Add` 59 | ![scope selection pop-up with scope selected](images/21.oauth.add.drive.scope.png) 60 | 23. Confirm the scope has been added 61 | ![oauth form with new scope added](images/22.oauth.drive.scope.added.png) 62 | 24. Provide Domain and Policy links and then click Save 63 | ![oauth form domain and policy section with xip.io for IP based pseudo-domain](images/23.oauth.domain.policy.png) 64 | 25. Click `Create credentials` 65 | ![empty credentials list with create credentials button](images/24.create.credentials.png) 66 | 26. Click `OAuth client ID` 67 | ![credentials type selection list](images/25.create.credentials.type.png) 68 | 27. Select the Application type of `Web application` 69 | ![credentails application type](images/26.web.app.credentials.png) 70 | 28. Define name and restriction domains / paths 71 | ![credential usage restrctions](images/27.web.app.restrictions.png) 72 | 29. Capture your `Client ID` and `Secret` (you will need the Client ID to configure JupyterLab) 73 | ![oauth credential client id and secret](images/28.oauth.client.secret.png) 74 | 75 | 76 | Once these steps have been completed, you will be able to use these credentials in the extension. 77 | In the `jupyterlab.google-drive` settings of the settings registry, set the **clientID** field to be the client id provided by the developer console. If everything is configured properly, you should be able to use the application with your new credentials. 78 | ![Client ID](images/clientid.png) 79 | 80 | ### Seeding JupyterLab images with Google credentials 81 | 82 | While adding credentials via the settings functionality from within JupyterLab is possible, as described above, users may also wish to pre-seed these settings so the extension works out-of-the-box on start-up. 83 | 84 | The location of the `@jupyterlab/google-drive` plugin's settings can be found in `$SETTINGS_PATH/@jupyterlab/google-drive/drive.jupyterlab-settings`, where `$SETTINGS_PATH` can be found by entering `jupyter lab path` on your terminal from a running JupyterLab. 85 | 86 | For instance, the docker-stacks [base-notebook](https://github.com/jupyter/docker-stacks/blob/master/base-notebook/Dockerfile) comes pre-loaded with JupyterLab and if you were to add the google-drive extension, then given that the default user in that set-up is `jovyan`, the relevant path for the settings file would therefore be: 87 | 88 | `home/jovyan/.jupyter/lab/user-settings/@jupyterlab/google-drive/drive.jupyterlab-settings` 89 | 90 | As such, any file containing the credentials of the form `{ "clientId": "0123456789012-abcd2efghijklmnopqr2s9t2u6v4wxyz.apps.googleusercontent.com"}` (sample only) will need to get persisted to this location ahead of time. 91 | 92 | There are many ways to do this. A few to consider are: 93 | 94 | (i) adding the file as part of a docker image-build process 95 | 96 | One might include a `drive.jupyterlab-settings` file within a folder accessible to a Dockerfile used to build an image to be used to spawn JupyterLab. For example, one could extend the docker-stacks base-notebook by adding the google-drive extension and pre-seed the credentials as follows: 97 | 98 | ``` 99 | FROM jupyter/base-notebook 100 | RUN jupyter labextension install @jupyterlab/google-drive 101 | COPY drive.jupyterlab-settings /home/jovyan/.jupyter/lab/user-settings/@jupyterlab/google-drive/drive.jupyterlab-settings 102 | ``` 103 | 104 | (ii) injecting the credentials as part of an image-spawn process 105 | 106 | Alternatively, if one didn't want to bake-in the credentials to an image, one could pass them into a notebook server at spawn time. Taking the [zero-to-jupyterhub-k8s](https://github.com/jupyterhub/zero-to-jupyterhub-k8s) implementation (which uses kubespawner and is therefore kubernetes-centric), for example, one could use the `config.yaml` file to: 107 | 108 | (a) set the extraEnv to pass the clientId as an environment variable to the spawned container 109 | 110 | ``` 111 | hub 112 | extraEnv: 113 | GOOGLE_DRIVE_CLIENT_ID: "551338180476-snfu2vasacgjanovrso2j9q2j6e4capk.apps.googleusercontent.com" 114 | ``` 115 | 116 | (b) then pass that variable to the container file-system in a life-cycle hook command something like this 117 | 118 | ``` 119 | singleuser 120 | lifecycleHooks: 121 | postStart: 122 | exec: 123 | command: ["/bin/sh", "-c", "mkdir -p /home/jovyan/.jupyter/lab/user-settings/@jupyterlab/google-drive; echo '{\"clientId\":\"${GOOGLE_DRIVE_CLIENT_ID}\"}' > /home/jovyan/.jupyter/lab/user-settings/@jupyterlab/google-drive/drive.jupyterlab-settings"] 124 | ``` 125 | -------------------------------------------------------------------------------- /docs/troubleshooting.md: -------------------------------------------------------------------------------- 1 | # Troubleshooting 2 | 3 | The `@jupyterlab/google-drive` extension is a complex plugin. 4 | Adding to this complexity is that the extension must make authenticated calls to Google's servers. 5 | This is a list of problems you may run into, with possible solutions. 6 | 7 | In order to better debug, it is always helpful to look at the Javascript console 8 | in your browser to see if any errors are being shown. 9 | 10 | ### The Google Drive panel doesn't show a login button. 11 | 12 | This means that the plugin may not be loading the Google API libraries, 13 | or they may not be initializing correctly. 14 | Check your internet connection and look at the Javascript console 15 | to see if any errors are being shown. 16 | 17 | ### `Not a valid origin for the client` error. 18 | 19 | If you have not set up your own client ID, then you are running the plugin 20 | with the default one. This is configured to only work if you are running the 21 | Jupyter notebook server on `localhost`, ports `8888`-`8899`. 22 | If you are running on any other origins or ports, Google's servers will reject requests. 23 | If you cannot change your notebook server location, consider setting up your own client ID. 24 | 25 | If you have set up your own client ID for your JupyterLab deployment, 26 | then something is likely wrong with the configuration. 27 | Try looking through [setup.md](./setup.md) for a solution. 28 | 29 | ### `Failed to read the 'localStorage' property from 'Window'` error. 30 | 31 | You may have your browser configured to block third-party cookies which is preventing Google Login. 32 | Either allow third-party cookies, or add an exception to the whitelist for `accounts.google.com`. 33 | 34 | ### I am unable to load images from Google Drive into my notebooks or markdown files 35 | 36 | You may have your browser configured to block third-party cookies which is preventing embedded images. 37 | Either allow third-party cookies, or add an exception to the whitelist for `drive.google.com`. 38 | 39 | ### I have shared a document with another person, but they don't seem to by synchronizing! 40 | 41 | If two users are trying to collaborate on a document, but are using instances of JupyterLab 42 | configured with different client IDs, then synchronization WILL NOT WORK. 43 | Ensure that everybody is using the same client ID. 44 | You can check the client ID in the JupyterLab settings editor: 45 | ![Client ID](images/clientid.png) 46 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@jupyterlab/google-drive", 3 | "version": "2.0.0", 4 | "description": "Cloud storage with JupyterLab through Google Drive", 5 | "keywords": [ 6 | "google drive", 7 | "jupyter", 8 | "jupyterlab", 9 | "jupyterlab-extension" 10 | ], 11 | "homepage": "https://github.com/jupyterlab/jupyterlab-google-drive", 12 | "bugs": { 13 | "url": "https://github.com/jupyterlab/jupyterlab-google-drive/issues" 14 | }, 15 | "repository": { 16 | "type": "git", 17 | "url": "https://github.com/jupyterlab/jupyterlab-google-drive.git" 18 | }, 19 | "license": "BSD-3-Clause", 20 | "author": "Ian Rose", 21 | "files": [ 22 | "lib/*/*d.ts", 23 | "lib/*/*.js", 24 | "lib/*.d.ts", 25 | "lib/*.js", 26 | "schema/*.json", 27 | "style/*.*" 28 | ], 29 | "main": "lib/index.js", 30 | "types": "lib/index.d.ts", 31 | "directories": { 32 | "lib": "lib/" 33 | }, 34 | "scripts": { 35 | "build": "tsc", 36 | "build:test": "cd test && ./build-tests.sh", 37 | "clean": "rimraf lib", 38 | "precommit": "lint-staged", 39 | "prettier": "prettier --write '**/*{.ts,.tsx,.js,.jsx,.css,.json,.md}'", 40 | "test": "cd test && ./run-tests.sh", 41 | "watch": "tsc -w" 42 | }, 43 | "lint-staged": { 44 | "**/*{.ts,.tsx,.css,.json,.md}": [ 45 | "prettier --write", 46 | "git add" 47 | ] 48 | }, 49 | "dependencies": { 50 | "@jupyterlab/application": "^2.0.0", 51 | "@jupyterlab/apputils": "^2.0.0", 52 | "@jupyterlab/coreutils": "^4.0.0", 53 | "@jupyterlab/docmanager": "^2.0.0", 54 | "@jupyterlab/docregistry": "^2.0.0", 55 | "@jupyterlab/filebrowser": "^2.0.0", 56 | "@jupyterlab/mainmenu": "^2.0.0", 57 | "@jupyterlab/observables": "^3.0.0", 58 | "@jupyterlab/services": "^5.0.0", 59 | "@jupyterlab/settingregistry": "^2.0.0", 60 | "@lumino/algorithm": "^1.2.3", 61 | "@lumino/commands": "^1.10.1", 62 | "@lumino/coreutils": "^1.4.2", 63 | "@lumino/signaling": "^1.3.5", 64 | "@lumino/widgets": "^1.11.1" 65 | }, 66 | "devDependencies": { 67 | "@types/expect.js": "^0.3.29", 68 | "@types/gapi": "^0.0.36", 69 | "@types/gapi.auth2": "^0.0.50", 70 | "@types/mocha": "^5.2.7", 71 | "@types/node": "^12.0.10", 72 | "css-loader": "^3.0.0", 73 | "expect.js": "^0.3.1", 74 | "file-loader": "^4.0.0", 75 | "husky": "^2.4.1", 76 | "karma": "^4.1.0", 77 | "karma-chrome-launcher": "^2.2.0", 78 | "karma-firefox-launcher": "^1.1.0", 79 | "karma-mocha": "^1.3.0", 80 | "karma-mocha-reporter": "^2.2.5", 81 | "karma-sourcemap-loader": "^0.3.7", 82 | "lint-staged": "^8.2.1", 83 | "mocha": "^6.1.4", 84 | "prettier": "^1.18.2", 85 | "rimraf": "^2.6.3", 86 | "style-loader": "^0.23.1", 87 | "tslint": "^5.18.0", 88 | "tslint-config-prettier": "^1.18.0", 89 | "tslint-plugin-prettier": "^2.0.1", 90 | "typescript": "^3.5.2", 91 | "url-loader": "^2.0.0", 92 | "webpack": "^4.35.0", 93 | "webpack-cli": "^3.3.11" 94 | }, 95 | "jupyterlab": { 96 | "extension": true, 97 | "schemaDir": "schema" 98 | } 99 | } 100 | -------------------------------------------------------------------------------- /schema/drive.json: -------------------------------------------------------------------------------- 1 | { 2 | "jupyter.lab.setting-icon-class": "jp-GoogleDrive-logo", 3 | "jupyter.lab.setting-icon-label": "Google Drive", 4 | "title": "Google Drive", 5 | "description": "Settings for the Google Drive plugin.", 6 | "properties": { 7 | "clientId": { 8 | "type": "string", 9 | "title": "Client ID", 10 | "default": "" 11 | } 12 | }, 13 | "type": "object" 14 | } 15 | -------------------------------------------------------------------------------- /src/browser.ts: -------------------------------------------------------------------------------- 1 | // Copyright (c) Jupyter Development Team. 2 | // Distributed under the terms of the Modified BSD License. 3 | 4 | import { Widget, PanelLayout } from '@lumino/widgets'; 5 | 6 | import { CommandRegistry } from '@lumino/commands'; 7 | 8 | import { showDialog, Dialog, ToolbarButton } from '@jupyterlab/apputils'; 9 | 10 | import { ISettingRegistry } from '@jupyterlab/settingregistry'; 11 | 12 | import { IDocumentManager } from '@jupyterlab/docmanager'; 13 | 14 | import { DocumentRegistry } from '@jupyterlab/docregistry'; 15 | 16 | import { FileBrowser, IFileBrowserFactory } from '@jupyterlab/filebrowser'; 17 | 18 | import { 19 | gapiAuthorized, 20 | initializeGapi, 21 | signIn, 22 | signOut, 23 | getCurrentUserProfile 24 | } from './gapi'; 25 | 26 | /** 27 | * Google Drive filebrowser plugin state namespace. 28 | */ 29 | export const NAMESPACE = 'google-drive-filebrowser'; 30 | 31 | /** 32 | * CSS class for the filebrowser container. 33 | */ 34 | const GOOGLE_DRIVE_FILEBROWSER_CLASS = 'jp-GoogleDriveFileBrowser'; 35 | 36 | /** 37 | * CSS class for login panel. 38 | */ 39 | const LOGIN_SCREEN = 'jp-GoogleLoginScreen'; 40 | 41 | /** 42 | * Widget for hosting the Google Drive filebrowser. 43 | */ 44 | export class GoogleDriveFileBrowser extends Widget { 45 | /** 46 | * Construct the browser widget. 47 | */ 48 | constructor( 49 | driveName: string, 50 | registry: DocumentRegistry, 51 | commands: CommandRegistry, 52 | manager: IDocumentManager, 53 | factory: IFileBrowserFactory, 54 | settingsPromise: Promise, 55 | hasOpenDocuments: () => boolean 56 | ) { 57 | super(); 58 | this.addClass(GOOGLE_DRIVE_FILEBROWSER_CLASS); 59 | this.layout = new PanelLayout(); 60 | 61 | // Initialize with the Login screen. 62 | this._loginScreen = new GoogleDriveLogin(settingsPromise); 63 | (this.layout as PanelLayout).addWidget(this._loginScreen); 64 | 65 | this._hasOpenDocuments = hasOpenDocuments; 66 | 67 | // Keep references to the createFileBrowser arguments for 68 | // when we need to construct it. 69 | this._factory = factory; 70 | this._driveName = driveName; 71 | 72 | // After authorization and we are ready to use the 73 | // drive, swap out the widgets. 74 | gapiAuthorized.promise.then(() => { 75 | this._createBrowser(); 76 | }); 77 | } 78 | 79 | /** 80 | * Whether the widget has been disposed. 81 | */ 82 | get isDisposed(): boolean { 83 | return this._isDisposed; 84 | } 85 | 86 | /** 87 | * Dispose of the resource held by the widget. 88 | */ 89 | dispose(): void { 90 | if (this.isDisposed) { 91 | return; 92 | } 93 | this._isDisposed = true; 94 | this._loginScreen.dispose(); 95 | this._browser.dispose(); 96 | super.dispose(); 97 | } 98 | 99 | private _createBrowser(): void { 100 | // Create the file browser 101 | this._browser = this._factory.createFileBrowser(NAMESPACE, { 102 | driveName: this._driveName 103 | }); 104 | 105 | // Create the logout button. 106 | const userProfile = getCurrentUserProfile(); 107 | this._logoutButton = new ToolbarButton({ 108 | onClick: () => { 109 | this._onLogoutClicked(); 110 | }, 111 | tooltip: `Sign Out (${userProfile.getEmail()})`, 112 | iconClass: 'jp-GoogleUserBadge jp-Icon jp-Icon-16' 113 | }); 114 | 115 | this._browser.toolbar.addItem('logout', this._logoutButton); 116 | this._loginScreen.parent = null; 117 | (this.layout as PanelLayout).addWidget(this._browser); 118 | } 119 | 120 | private _onLogoutClicked(): void { 121 | if (this._hasOpenDocuments()) { 122 | showDialog({ 123 | title: 'Sign Out', 124 | body: 'Please close all documents in Google Drive before signing out', 125 | buttons: [Dialog.okButton({ label: 'OK' })] 126 | }); 127 | return; 128 | } 129 | 130 | // Change to the root directory, so an invalid path 131 | // is not cached, then sign out. 132 | this._browser.model.cd('/').then(async () => { 133 | // Swap out the file browser for the login screen. 134 | this._browser.parent = null; 135 | (this.layout as PanelLayout).addWidget(this._loginScreen); 136 | this._browser.dispose(); 137 | this._logoutButton.dispose(); 138 | 139 | // Do the actual sign-out. 140 | await signOut(); 141 | // After sign-out, set up a new listener 142 | // for authorization, should the user log 143 | // back in. 144 | await gapiAuthorized.promise; 145 | this._createBrowser(); 146 | }); 147 | } 148 | 149 | private _isDisposed = false; 150 | private _browser: FileBrowser; 151 | private _loginScreen: GoogleDriveLogin; 152 | private _logoutButton: ToolbarButton; 153 | private _factory: IFileBrowserFactory; 154 | private _driveName: string; 155 | private _hasOpenDocuments: () => boolean; 156 | } 157 | 158 | export class GoogleDriveLogin extends Widget { 159 | /** 160 | * Construct the login panel. 161 | */ 162 | constructor(settingsPromise: Promise) { 163 | super(); 164 | this.addClass(LOGIN_SCREEN); 165 | 166 | // Add the logo. 167 | const logo = document.createElement('div'); 168 | logo.className = 'jp-GoogleDrive-logo'; 169 | this.node.appendChild(logo); 170 | 171 | // Add the text. 172 | const text = document.createElement('div'); 173 | text.className = 'jp-GoogleDrive-text'; 174 | text.textContent = 'Google Drive'; 175 | this.node.appendChild(text); 176 | 177 | // Add the login button. 178 | this._button = document.createElement('button'); 179 | this._button.title = 'Log into your Google account'; 180 | this._button.textContent = 'SIGN IN'; 181 | this._button.className = 'jp-Dialog-button jp-mod-styled jp-mod-accept'; 182 | this._button.onclick = this._onLoginClicked.bind(this); 183 | this._button.style.visibility = 'hidden'; 184 | this.node.appendChild(this._button); 185 | 186 | // Attempt to authorize on construction without using 187 | // a popup dialog. If the user is logged into the browser with 188 | // a Google account, this will likely succeed. Otherwise, they 189 | // will need to login explicitly. 190 | settingsPromise.then(async settings => { 191 | this._clientId = settings.get('clientId').composite as string; 192 | if (!this._clientId) { 193 | console.warn( 194 | 'Warning: no Client ID found. The Google Drive plugin will not work until the Client ID has been set, and the page refreshed.' 195 | ); 196 | return; 197 | } 198 | try { 199 | const loggedIn = await initializeGapi(this._clientId); 200 | if (!loggedIn) { 201 | this._button.style.visibility = 'visible'; 202 | } else { 203 | await gapiAuthorized.promise; 204 | // Set the button style to visible in the 205 | // eventuality that the user logs out. 206 | this._button.style.visibility = 'visible'; 207 | } 208 | } catch (err) { 209 | showDialog({ 210 | title: 'Google API Error', 211 | body: err, 212 | buttons: [Dialog.okButton({ label: 'OK' })] 213 | }); 214 | } 215 | }); 216 | } 217 | 218 | /** 219 | * Handle a click of the login button. 220 | */ 221 | private _onLoginClicked(): void { 222 | signIn(); 223 | } 224 | 225 | private _button: HTMLElement; 226 | private _clientId: string; 227 | } 228 | -------------------------------------------------------------------------------- /src/contents.ts: -------------------------------------------------------------------------------- 1 | // Copyright (c) Jupyter Development Team. 2 | // Distributed under the terms of the Modified BSD License. 3 | 4 | import { Signal, ISignal } from '@lumino/signaling'; 5 | 6 | import { PathExt } from '@jupyterlab/coreutils'; 7 | 8 | import { DocumentRegistry } from '@jupyterlab/docregistry'; 9 | 10 | import { Contents, ServerConnection } from '@jupyterlab/services'; 11 | 12 | import * as drive from './drive'; 13 | 14 | import { makeError } from './gapi'; 15 | 16 | /** 17 | * A contents manager that passes file operations to the server. 18 | * 19 | * This includes checkpointing with the normal file operations. 20 | */ 21 | export class GoogleDrive implements Contents.IDrive { 22 | /** 23 | * Construct a new contents manager object. 24 | * 25 | * @param options - The options used to initialize the object. 26 | */ 27 | constructor(registry: DocumentRegistry) { 28 | this._docRegistry = registry; 29 | // Construct a function to make a best-guess IFileType 30 | // for a given path. 31 | this._fileTypeForPath = (path: string) => { 32 | const fileTypes = registry.getFileTypesForPath(path); 33 | return fileTypes.length === 0 34 | ? registry.getFileType('text')! 35 | : fileTypes[0]; 36 | }; 37 | // Construct a function to return a best-guess IFileType 38 | // for a given contents model. 39 | this._fileTypeForContentsModel = (model: Partial) => { 40 | return registry.getFileTypeForModel(model); 41 | }; 42 | } 43 | 44 | /** 45 | * The name of the drive. 46 | */ 47 | get name(): 'GDrive' { 48 | return 'GDrive'; 49 | } 50 | 51 | /** 52 | * Server settings (unused for interfacing with Google Drive). 53 | */ 54 | readonly serverSettings: ServerConnection.ISettings; 55 | 56 | /** 57 | * A signal emitted when a file operation takes place. 58 | */ 59 | get fileChanged(): ISignal { 60 | return this._fileChanged; 61 | } 62 | 63 | /** 64 | * Test whether the manager has been disposed. 65 | */ 66 | get isDisposed(): boolean { 67 | return this._isDisposed; 68 | } 69 | 70 | /**h 71 | * Dispose of the resources held by the manager. 72 | */ 73 | dispose(): void { 74 | if (this.isDisposed) { 75 | return; 76 | } 77 | this._isDisposed = true; 78 | Signal.clearData(this); 79 | } 80 | 81 | /** 82 | * Get the base url of the manager. 83 | */ 84 | get baseUrl(): string { 85 | return this._baseUrl; 86 | } 87 | 88 | /** 89 | * Get a file or directory. 90 | * 91 | * @param path: The path to the file. 92 | * 93 | * @param options: The options used to fetch the file. 94 | * 95 | * @returns A promise which resolves with the file content. 96 | */ 97 | async get( 98 | path: string, 99 | options?: Contents.IFetchOptions 100 | ): Promise { 101 | const getContent = options ? !!options.content : true; 102 | // TODO: the contents manager probably should not be passing in '.'. 103 | path = path === '.' ? '' : path; 104 | const contents = await drive.contentsModelForPath( 105 | path, 106 | getContent, 107 | this._fileTypeForPath 108 | ); 109 | try { 110 | Contents.validateContentsModel(contents); 111 | } catch (error) { 112 | throw makeError(200, error.message); 113 | } 114 | return contents; 115 | } 116 | 117 | /** 118 | * Get an encoded download url given a file path. 119 | * 120 | * @param path - An absolute POSIX file path on the server. 121 | * 122 | * #### Notes 123 | * It is expected that the path contains no relative paths, 124 | * use [[ContentsManager.getAbsolutePath]] to get an absolute 125 | * path if necessary. 126 | */ 127 | getDownloadUrl(path: string): Promise { 128 | return drive.urlForFile(path); 129 | } 130 | 131 | /** 132 | * Create a new untitled file or directory in the specified directory path. 133 | * 134 | * @param options: The options used to create the file. 135 | * 136 | * @returns A promise which resolves with the created file content when the 137 | * file is created. 138 | */ 139 | async newUntitled( 140 | options: Contents.ICreateOptions = {} 141 | ): Promise { 142 | // Set default values. 143 | let ext = ''; 144 | let baseName = 'Untitled'; 145 | let path = ''; 146 | let contentType: Contents.ContentType = 'notebook'; 147 | let fileType: DocumentRegistry.IFileType; 148 | 149 | if (options) { 150 | // Add leading `.` to extension if necessary. 151 | ext = options.ext ? PathExt.normalizeExtension(options.ext) : ext; 152 | // If we are not creating in the root directory. 153 | path = options.path || ''; 154 | contentType = options.type || 'notebook'; 155 | } 156 | 157 | let model: Partial; 158 | if (contentType === 'notebook') { 159 | fileType = DocumentRegistry.defaultNotebookFileType; 160 | ext = ext || fileType.extensions[0]; 161 | baseName = 'Untitled'; 162 | const modelFactory = this._docRegistry.getModelFactory('Notebook'); 163 | if (!modelFactory) { 164 | throw Error('No model factory is registered with the DocRegistry'); 165 | } 166 | model = { 167 | type: fileType.contentType, 168 | content: modelFactory.createNew().toJSON(), 169 | mimetype: fileType.mimeTypes[0], 170 | format: fileType.fileFormat 171 | }; 172 | } else if (contentType === 'file') { 173 | fileType = DocumentRegistry.defaultTextFileType; 174 | ext = ext || fileType.extensions[0]; 175 | baseName = 'untitled'; 176 | model = { 177 | type: fileType.contentType, 178 | content: '', 179 | mimetype: fileType.mimeTypes[0], 180 | format: fileType.fileFormat 181 | }; 182 | } else if (contentType === 'directory') { 183 | fileType = DocumentRegistry.defaultDirectoryFileType; 184 | ext = ext || ''; 185 | baseName = 'Untitled Folder'; 186 | model = { 187 | type: fileType.contentType, 188 | content: [], 189 | format: fileType.fileFormat 190 | }; 191 | } else { 192 | throw new Error('Unrecognized type ' + contentType); 193 | } 194 | 195 | const name = await this._getNewFilename(path, ext, baseName); 196 | const m = { ...model, name }; 197 | path = PathExt.join(path, name); 198 | const contents = await drive.uploadFile( 199 | path, 200 | m, 201 | fileType, 202 | false, 203 | this._fileTypeForPath 204 | ); 205 | try { 206 | Contents.validateContentsModel(contents); 207 | } catch (error) { 208 | throw makeError(201, error.message); 209 | } 210 | this._fileChanged.emit({ 211 | type: 'new', 212 | oldValue: null, 213 | newValue: contents 214 | }); 215 | return contents; 216 | } 217 | 218 | /** 219 | * Delete a file. 220 | * 221 | * @param path - The path to the file. 222 | * 223 | * @returns A promise which resolves when the file is deleted. 224 | */ 225 | async delete(path: string): Promise { 226 | await drive.deleteFile(path); 227 | this._fileChanged.emit({ 228 | type: 'delete', 229 | oldValue: { path }, 230 | newValue: null 231 | }); 232 | } 233 | 234 | /** 235 | * Rename a file or directory. 236 | * 237 | * @param path - The original file path. 238 | * 239 | * @param newPath - The new file path. 240 | * 241 | * @returns A promise which resolves with the new file contents model when 242 | * the file is renamed. 243 | */ 244 | async rename(path: string, newPath: string): Promise { 245 | if (path === newPath) { 246 | return this.get(path); 247 | } 248 | const contents = await drive.moveFile(path, newPath, this._fileTypeForPath); 249 | try { 250 | Contents.validateContentsModel(contents); 251 | } catch (error) { 252 | throw makeError(200, error.message); 253 | } 254 | this._fileChanged.emit({ 255 | type: 'rename', 256 | oldValue: { path }, 257 | newValue: contents 258 | }); 259 | return contents; 260 | } 261 | 262 | /** 263 | * Save a file. 264 | * 265 | * @param path - The desired file path. 266 | * 267 | * @param options - Optional overrides to the model. 268 | * 269 | * @returns A promise which resolves with the file content model when the 270 | * file is saved. 271 | */ 272 | async save( 273 | path: string, 274 | options: Partial 275 | ): Promise { 276 | const fileType = this._fileTypeForContentsModel(options); 277 | const contents = await this.get(path).then( 278 | contents => { 279 | // The file exists. 280 | if (options) { 281 | // Overwrite the existing file. 282 | return drive.uploadFile( 283 | path, 284 | options, 285 | fileType, 286 | true, 287 | this._fileTypeForPath 288 | ); 289 | } else { 290 | // File exists, but we are not saving anything 291 | // to it? Just return the contents. 292 | return contents; 293 | } 294 | }, 295 | () => { 296 | // The file does not exist already, create a new one. 297 | return drive.uploadFile( 298 | path, 299 | options, 300 | fileType, 301 | false, 302 | this._fileTypeForPath 303 | ); 304 | } 305 | ); 306 | try { 307 | Contents.validateContentsModel(contents); 308 | } catch (error) { 309 | throw makeError(200, error.message); 310 | } 311 | this._fileChanged.emit({ 312 | type: 'save', 313 | oldValue: null, 314 | newValue: contents 315 | }); 316 | return contents; 317 | } 318 | 319 | /** 320 | * Copy a file into a given directory. 321 | * 322 | * @param path - The original file path. 323 | * 324 | * @param toDir - The destination directory path. 325 | * 326 | * @returns A promise which resolves with the new contents model when the 327 | * file is copied. 328 | */ 329 | async copy(fromFile: string, toDir: string): Promise { 330 | let fileBasename = PathExt.basename(fromFile).split('.')[0]; 331 | fileBasename += '-Copy'; 332 | const ext = PathExt.extname(fromFile); 333 | 334 | const name = await this._getNewFilename(toDir, ext, fileBasename); 335 | const contents = await drive.copyFile( 336 | fromFile, 337 | PathExt.join(toDir, name), 338 | this._fileTypeForPath 339 | ); 340 | try { 341 | Contents.validateContentsModel(contents); 342 | } catch (error) { 343 | throw makeError(201, error.message); 344 | } 345 | this._fileChanged.emit({ 346 | type: 'new', 347 | oldValue: null, 348 | newValue: contents 349 | }); 350 | return contents; 351 | } 352 | 353 | /** 354 | * Create a checkpoint for a file. 355 | * 356 | * @param path - The path of the file. 357 | * 358 | * @returns A promise which resolves with the new checkpoint model when the 359 | * checkpoint is created. 360 | */ 361 | async createCheckpoint(path: string): Promise { 362 | const checkpoint = await drive.pinCurrentRevision(path); 363 | try { 364 | Contents.validateCheckpointModel(checkpoint); 365 | } catch (error) { 366 | throw makeError(200, error.message); 367 | } 368 | return checkpoint; 369 | } 370 | 371 | /** 372 | * List available checkpoints for a file. 373 | * 374 | * @param path - The path of the file. 375 | * 376 | * @returns A promise which resolves with a list of checkpoint models for 377 | * the file. 378 | */ 379 | async listCheckpoints(path: string): Promise { 380 | const checkpoints = await drive.listRevisions(path); 381 | try { 382 | for (let checkpoint of checkpoints) { 383 | Contents.validateCheckpointModel(checkpoint); 384 | } 385 | } catch (error) { 386 | throw makeError(200, error.message); 387 | } 388 | return checkpoints; 389 | } 390 | 391 | /** 392 | * Restore a file to a known checkpoint state. 393 | * 394 | * @param path - The path of the file. 395 | * 396 | * @param checkpointID - The id of the checkpoint to restore. 397 | * 398 | * @returns A promise which resolves when the checkpoint is restored. 399 | */ 400 | async restoreCheckpoint(path: string, checkpointID: string): Promise { 401 | // TODO: should this emit a signal? 402 | const fileType = this._fileTypeForPath(path); 403 | return drive.revertToRevision(path, checkpointID, fileType); 404 | } 405 | 406 | /** 407 | * Delete a checkpoint for a file. 408 | * 409 | * @param path - The path of the file. 410 | * 411 | * @param checkpointID - The id of the checkpoint to delete. 412 | * 413 | * @returns A promise which resolves when the checkpoint is deleted. 414 | */ 415 | async deleteCheckpoint(path: string, checkpointID: string): Promise { 416 | return drive.unpinRevision(path, checkpointID); 417 | } 418 | 419 | /** 420 | * Obtains the filename that should be used for a new file in a given 421 | * folder. This is the next file in the series Untitled0, Untitled1, ... in 422 | * the given drive folder. As a fallback, returns Untitled. 423 | * 424 | * @param path - The path of the directory in which we are making the file. 425 | * @param ext - The file extension. 426 | * @param baseName - The base name of the new file 427 | * @return A promise fullfilled with the new filename. 428 | */ 429 | private async _getNewFilename( 430 | path: string, 431 | ext: string, 432 | baseName: string 433 | ): Promise { 434 | // Check that the target directory is a valid 435 | // directory (i.e., not the pseudo-root or 436 | // the "Shared with me" directory). 437 | if (drive.isDummy(path)) { 438 | throw makeError( 439 | 400, 440 | `Google Drive: "${path}"` + ' is not a valid save directory' 441 | ); 442 | } 443 | // Get the file listing for the directory. 444 | const query = 445 | "name contains '" + baseName + "' and name contains '" + ext + "'"; 446 | const resourceList = await drive.searchDirectory(path, query); 447 | const existingNames: any = {}; 448 | for (let i = 0; i < resourceList.length; i++) { 449 | existingNames[resourceList[i].name!] = true; 450 | } 451 | 452 | // Loop over the list and select the first name that 453 | // does not exist. Note that the loop is N+1 iterations, 454 | // so is guaranteed to come up with a name that is not 455 | // in `existingNames`. 456 | for (let i = 0; i <= resourceList.length; i++) { 457 | const filename = baseName + (i > 0 ? String(i) : '') + ext; 458 | if (!existingNames[filename]) { 459 | return filename; 460 | } 461 | } 462 | // Should not get here. 463 | throw Error('Could not find a valid filename'); 464 | } 465 | 466 | private _baseUrl = 'https://www.googleapis.com/drive/v3'; 467 | private _isDisposed = false; 468 | private _docRegistry: DocumentRegistry; 469 | private _fileTypeForPath: (path: string) => DocumentRegistry.IFileType; 470 | private _fileTypeForContentsModel: ( 471 | model: Partial 472 | ) => DocumentRegistry.IFileType; 473 | private _fileChanged = new Signal(this); 474 | } 475 | -------------------------------------------------------------------------------- /src/drive.ts: -------------------------------------------------------------------------------- 1 | // Copyright (c) Jupyter Development Team. 2 | // Distributed under the terms of the Modified BSD License. 3 | 4 | // tslint:disable-next-line 5 | /// 6 | 7 | import { map, filter, toArray } from '@lumino/algorithm'; 8 | 9 | import { Contents } from '@jupyterlab/services'; 10 | 11 | import { PathExt } from '@jupyterlab/coreutils'; 12 | 13 | import { DocumentRegistry } from '@jupyterlab/docregistry'; 14 | 15 | import { 16 | driveApiRequest, 17 | gapiAuthorized, 18 | gapiInitialized, 19 | makeError 20 | } from './gapi'; 21 | 22 | /** 23 | * Fields to request for File resources. 24 | */ 25 | const RESOURCE_FIELDS = 26 | 'kind,id,name,mimeType,trashed,headRevisionId,' + 27 | 'parents,modifiedTime,createdTime,capabilities,' + 28 | 'webContentLink,teamDriveId'; 29 | 30 | /** 31 | * Fields to request for Team Drive resources. 32 | */ 33 | const TEAMDRIVE_FIELDS = 'kind,id,name,capabilities'; 34 | 35 | /** 36 | * Fields to request for Revision resources. 37 | */ 38 | const REVISION_FIELDS = 'id, modifiedTime, keepForever'; 39 | 40 | /** 41 | * Fields to request for File listings. 42 | */ 43 | const FILE_LIST_FIELDS = 'nextPageToken'; 44 | 45 | /** 46 | * Fields to reuest for Team Drive listings. 47 | */ 48 | const TEAMDRIVE_LIST_FIELDS = 'nextPageToken'; 49 | 50 | /** 51 | * Fields to reuest for Team Drive listings. 52 | */ 53 | const REVISION_LIST_FIELDS = 'nextPageToken'; 54 | 55 | /** 56 | * Page size for file listing (max allowable). 57 | */ 58 | const FILE_PAGE_SIZE = 1000; 59 | 60 | /** 61 | * Page size for team drive listing (max allowable). 62 | */ 63 | const TEAMDRIVE_PAGE_SIZE = 100; 64 | 65 | /** 66 | * Page size for revision listing (max allowable). 67 | */ 68 | const REVISION_PAGE_SIZE = 1000; 69 | 70 | export const RT_MIMETYPE = 'application/vnd.google-apps.drive-sdk'; 71 | export const FOLDER_MIMETYPE = 'application/vnd.google-apps.folder'; 72 | export const FILE_MIMETYPE = 'application/vnd.google-apps.file'; 73 | 74 | const MULTIPART_BOUNDARY = '-------314159265358979323846'; 75 | 76 | /** 77 | * Type alias for a files resource returned by 78 | * the Google Drive API. 79 | */ 80 | export type FileResource = gapi.client.drive.File; 81 | 82 | /** 83 | * Type alias for a Google Drive revision resource. 84 | */ 85 | export type RevisionResource = gapi.client.drive.Revision; 86 | 87 | /** 88 | * Type stub for a Team Drive resource. 89 | */ 90 | export type TeamDriveResource = gapi.client.drive.TeamDrive; 91 | 92 | /** 93 | * An API response which may be paginated. 94 | */ 95 | type PaginatedResponse = 96 | | gapi.client.drive.FileList 97 | | gapi.client.drive.TeamDriveList 98 | | gapi.client.drive.RevisionList; 99 | 100 | /** 101 | * Alias for directory IFileType. 102 | */ 103 | const directoryFileType = DocumentRegistry.defaultDirectoryFileType; 104 | 105 | /** 106 | * The name of the dummy "Shared with me" folder. 107 | */ 108 | const SHARED_DIRECTORY = 'Shared with me'; 109 | 110 | /** 111 | * The path of the dummy pseudo-root folder. 112 | */ 113 | const COLLECTIONS_DIRECTORY = ''; 114 | 115 | /** 116 | * A dummy files resource for the "Shared with me" folder. 117 | */ 118 | const SHARED_DIRECTORY_RESOURCE: FileResource = { 119 | kind: 'dummy', 120 | name: SHARED_DIRECTORY 121 | }; 122 | 123 | /** 124 | * A dummy files resource for the pseudo-root folder. 125 | */ 126 | const COLLECTIONS_DIRECTORY_RESOURCE: FileResource = { 127 | kind: 'dummy', 128 | name: '' 129 | }; 130 | 131 | /* ****** Functions for uploading/downloading files ******** */ 132 | 133 | /** 134 | * Get a download URL for a file path. 135 | * 136 | * @param path - the path corresponding to the file. 137 | * 138 | * @returns a promise that resolves with the download URL. 139 | */ 140 | export async function urlForFile(path: string): Promise { 141 | const resource = await getResourceForPath(path); 142 | return resource.webContentLink!; 143 | } 144 | 145 | /** 146 | * Given a path and `Contents.IModel`, upload the contents to Google Drive. 147 | * 148 | * @param path - the path to which to upload the contents. 149 | * 150 | * @param model - the `Contents.IModel` to upload. 151 | * 152 | * @param fileType - a candidate DocumentRegistry.IFileType for the given file. 153 | * 154 | * @param exisiting - whether the file exists. 155 | * 156 | * @returns a promise fulfulled with the `Contents.IModel` that has been uploaded, 157 | * or throws an Error if it fails. 158 | */ 159 | export async function uploadFile( 160 | path: string, 161 | model: Partial, 162 | fileType: DocumentRegistry.IFileType, 163 | existing: boolean = false, 164 | fileTypeForPath: 165 | | ((path: string) => DocumentRegistry.IFileType) 166 | | undefined = undefined 167 | ): Promise { 168 | if (isDummy(PathExt.dirname(path)) && !existing) { 169 | throw makeError( 170 | 400, 171 | `Google Drive: "${path}"` + ' is not a valid save directory' 172 | ); 173 | } 174 | let resourceReadyPromise: Promise; 175 | if (existing) { 176 | resourceReadyPromise = getResourceForPath(path); 177 | } else { 178 | resourceReadyPromise = new Promise( 179 | async (resolve, reject) => { 180 | let enclosingFolderPath = PathExt.dirname(path); 181 | const resource: FileResource = fileResourceFromContentsModel( 182 | model, 183 | fileType 184 | ); 185 | const parentFolderResource = await getResourceForPath( 186 | enclosingFolderPath 187 | ); 188 | if (!isDirectory(parentFolderResource)) { 189 | throw new Error('Google Drive: expected a folder: ' + path); 190 | } 191 | if (parentFolderResource.kind === 'drive#teamDrive') { 192 | resource.teamDriveId = parentFolderResource.id; 193 | } else if (parentFolderResource.teamDriveId) { 194 | resource.teamDriveId = parentFolderResource.teamDriveId; 195 | } 196 | resource.parents = [parentFolderResource.id!]; 197 | resolve(resource); 198 | } 199 | ); 200 | } 201 | const resource = await resourceReadyPromise; 202 | // Construct the HTTP request: first the metadata, 203 | // then the content of the uploaded file. 204 | 205 | const delimiter = '\r\n--' + MULTIPART_BOUNDARY + '\r\n'; 206 | const closeDelim = '\r\n--' + MULTIPART_BOUNDARY + '--'; 207 | 208 | // Metatdata part. 209 | let body = delimiter + 'Content-Type: application/json\r\n\r\n'; 210 | // Don't update metadata if the file already exists. 211 | if (!existing) { 212 | body += JSON.stringify(resource); 213 | } 214 | body += delimiter; 215 | 216 | // Content of the file. 217 | body += 'Content-Type: ' + resource.mimeType + '\r\n'; 218 | // It is not well documented, but as can be seen in 219 | // filebrowser/src/model.ts, anything that is not a 220 | // notebook is a base64 encoded string. 221 | if (model.format === 'base64') { 222 | body += 'Content-Transfer-Encoding: base64\r\n'; 223 | body += '\r\n' + model.content + closeDelim; 224 | } else if (model.format === 'text') { 225 | // If it is already a text string, just send that. 226 | body += '\r\n' + model.content + closeDelim; 227 | } else { 228 | // Notebook case. 229 | body += '\r\n' + JSON.stringify(model.content) + closeDelim; 230 | } 231 | 232 | let apiPath = '/upload/drive/v3/files'; 233 | let method = 'POST'; 234 | 235 | if (existing) { 236 | method = 'PATCH'; 237 | apiPath = apiPath + '/' + resource.id; 238 | } 239 | 240 | const createRequest = () => { 241 | return gapi.client.request({ 242 | path: apiPath, 243 | method: method, 244 | params: { 245 | uploadType: 'multipart', 246 | supportsTeamDrives: !!resource.teamDriveId, 247 | fields: RESOURCE_FIELDS 248 | }, 249 | headers: { 250 | 'Content-Type': 251 | 'multipart/related; boundary="' + MULTIPART_BOUNDARY + '"' 252 | }, 253 | body: body 254 | }); 255 | }; 256 | 257 | const result = await driveApiRequest(createRequest); 258 | // Update the cache. 259 | Private.resourceCache.set(path, result); 260 | 261 | return contentsModelFromFileResource( 262 | result, 263 | path, 264 | fileType, 265 | true, 266 | fileTypeForPath 267 | ); 268 | } 269 | 270 | /** 271 | * Given a files resource, construct a Contents.IModel. 272 | * 273 | * @param resource - the files resource. 274 | * 275 | * @param path - the path at which the resource exists in the filesystem. 276 | * This should include the name of the file itself. 277 | * 278 | * @param fileType - a candidate DocumentRegistry.IFileType for the given file. 279 | * 280 | * @param includeContents - whether to download the actual text/json/binary 281 | * content from the server. This takes much more bandwidth, so should only 282 | * be used when required. 283 | * 284 | * @param fileTypeForPath - A function that, given a path argument, returns 285 | * and DocumentRegistry.IFileType that is consistent with the path. 286 | * 287 | * @returns a promise fulfilled with the Contents.IModel for the resource. 288 | */ 289 | export async function contentsModelFromFileResource( 290 | resource: FileResource, 291 | path: string, 292 | fileType: DocumentRegistry.IFileType, 293 | includeContents: boolean, 294 | fileTypeForPath: 295 | | ((path: string) => DocumentRegistry.IFileType) 296 | | undefined = undefined 297 | ): Promise { 298 | // Handle the exception of the dummy directories 299 | if (resource.kind === 'dummy') { 300 | return contentsModelFromDummyFileResource( 301 | resource, 302 | path, 303 | includeContents, 304 | fileTypeForPath 305 | ); 306 | } 307 | // Handle the case of getting the contents of a directory. 308 | if (isDirectory(resource)) { 309 | // Enter contents metadata. 310 | const contents: Contents.IModel = { 311 | name: resource.name!, 312 | path: path, 313 | type: 'directory', 314 | writable: resource.capabilities!.canEdit || true, 315 | created: resource.createdTime || '', 316 | last_modified: resource.modifiedTime || '', 317 | mimetype: fileType.mimeTypes[0], 318 | content: null, 319 | format: 'json' 320 | }; 321 | 322 | // Get directory listing if applicable. 323 | if (includeContents) { 324 | if (!fileTypeForPath) { 325 | throw Error( 326 | 'Must include fileTypeForPath argument to get directory listing' 327 | ); 328 | } 329 | const fileList: FileResource[] = []; 330 | const resources = await searchDirectory(path); 331 | // Update the cache. 332 | Private.clearCacheForDirectory(path); 333 | Private.populateCacheForDirectory(path, resources); 334 | 335 | let currentContents = Promise.resolve({}); 336 | 337 | for (let i = 0; i < resources.length; i++) { 338 | const currentResource = resources[i]; 339 | const resourcePath = path 340 | ? path + '/' + currentResource.name! 341 | : currentResource.name!; 342 | const resourceFileType = fileTypeForPath(resourcePath); 343 | currentContents = contentsModelFromFileResource( 344 | currentResource, 345 | resourcePath, 346 | resourceFileType, 347 | false 348 | ); 349 | fileList.push(await currentContents); 350 | } 351 | return { ...contents, content: fileList }; 352 | } else { 353 | return contents; 354 | } 355 | } else { 356 | // Handle the case of getting the contents of a file. 357 | const contents: Contents.IModel = { 358 | name: resource.name!, 359 | path: path, 360 | type: fileType.contentType, 361 | writable: resource.capabilities!.canEdit || true, 362 | created: resource.createdTime || '', 363 | last_modified: resource.modifiedTime || '', 364 | mimetype: fileType.mimeTypes[0], 365 | content: null, 366 | format: fileType.fileFormat 367 | }; 368 | // Download the contents from the server if necessary. 369 | if (includeContents) { 370 | const result: any = await downloadResource( 371 | resource, 372 | false, 373 | contents.format 374 | ); 375 | let content = result; 376 | return { ...contents, content }; 377 | } else { 378 | return contents; 379 | } 380 | } 381 | } 382 | 383 | /** 384 | * There are two fake directories that we expose in the file browser 385 | * in order to have access to the "Shared with me" directory. This is 386 | * not a proper directory in the Google Drive system, just a collection 387 | * of files that have a `sharedWithMe` flag, so we have to treat it 388 | * separately. This constructs Contents.IModels from our dummy directories. 389 | * 390 | * @param resource: the dummy files resource. 391 | * 392 | * @param path: the path for the dummy resource. 393 | * 394 | * @param includeContents: whether to include the directory listing 395 | * for the dummy directory. 396 | * 397 | * @param fileTypeForPath - A function that, given a path argument, returns 398 | * and DocumentRegistry.IFileType that is consistent with the path. 399 | * 400 | * @returns a promise fulfilled with the a Contents.IModel for the resource. 401 | */ 402 | async function contentsModelFromDummyFileResource( 403 | resource: FileResource, 404 | path: string, 405 | includeContents: boolean, 406 | fileTypeForPath: ((path: string) => DocumentRegistry.IFileType) | undefined 407 | ): Promise { 408 | // Construct the empty Contents.IModel. 409 | const contents: Contents.IModel = { 410 | name: resource.name!, 411 | path: path, 412 | type: 'directory', 413 | writable: false, 414 | created: '', 415 | last_modified: '', 416 | content: null, 417 | mimetype: '', 418 | format: 'json' 419 | }; 420 | if (includeContents && !fileTypeForPath) { 421 | throw Error( 422 | 'Must include fileTypeForPath argument to get directory listing' 423 | ); 424 | } 425 | if (resource.name === SHARED_DIRECTORY && includeContents) { 426 | // If `resource` is the SHARED_DIRECTORY_RESOURCE, and we 427 | // need the file listing for it, then get them. 428 | const fileList: Contents.IModel[] = []; 429 | const resources = await searchSharedFiles(); 430 | // Update the cache. 431 | Private.clearCacheForDirectory(path); 432 | Private.populateCacheForDirectory(path, resources); 433 | 434 | let currentContents: Promise | undefined; 435 | 436 | for (let i = 0; i < resources.length; i++) { 437 | const currentResource = resources[i]; 438 | const resourcePath = path 439 | ? path + '/' + currentResource.name 440 | : currentResource.name!; 441 | const resourceFileType = fileTypeForPath!(resourcePath); 442 | currentContents = contentsModelFromFileResource( 443 | currentResource, 444 | resourcePath, 445 | resourceFileType, 446 | false, 447 | fileTypeForPath 448 | ); 449 | fileList.push(await currentContents); 450 | } 451 | const content = fileList; 452 | return { ...contents, content }; 453 | } else if (resource.name === COLLECTIONS_DIRECTORY && includeContents) { 454 | // If `resource` is the pseudo-root directory, construct 455 | // a contents model for it. 456 | const sharedContentsPromise = contentsModelFromFileResource( 457 | SHARED_DIRECTORY_RESOURCE, 458 | SHARED_DIRECTORY, 459 | directoryFileType, 460 | false, 461 | undefined 462 | ); 463 | const rootContentsPromise = resourceFromFileId('root').then( 464 | rootResource => { 465 | return contentsModelFromFileResource( 466 | rootResource, 467 | rootResource.name || '', 468 | directoryFileType, 469 | false, 470 | undefined 471 | ); 472 | } 473 | ); 474 | const teamDrivesContentsPromise = listTeamDrives().then(drives => { 475 | const drivePromises: Promise[] = []; 476 | for (let drive of drives) { 477 | drivePromises.push( 478 | contentsModelFromFileResource( 479 | drive, 480 | drive.name!, 481 | directoryFileType, 482 | false, 483 | undefined 484 | ) 485 | ); 486 | } 487 | return Promise.all(drivePromises); 488 | }); 489 | 490 | const c = await Promise.all([ 491 | rootContentsPromise, 492 | sharedContentsPromise, 493 | teamDrivesContentsPromise 494 | ]); 495 | const rootItems = c[2]; 496 | rootItems.unshift(c[1]); 497 | rootItems.unshift(c[0]); 498 | return { ...contents, content: rootItems }; 499 | } else { 500 | // Otherwise return the (mostly) empty contents model. 501 | return contents; 502 | } 503 | } 504 | 505 | /** 506 | * Given a path, get a `Contents.IModel` corresponding to that file. 507 | * 508 | * @param path - the path of the file. 509 | * 510 | * @param includeContents - whether to include the binary/text/contents of the file. 511 | * If false, just get the metadata. 512 | * 513 | * @param fileTypeForPath - A function that, given a path argument, returns 514 | * and DocumentRegistry.IFileType that is consistent with the path. 515 | * 516 | * @returns a promise fulfilled with the `Contents.IModel` of the appropriate file. 517 | * Otherwise, throws an error. 518 | */ 519 | export async function contentsModelForPath( 520 | path: string, 521 | includeContents: boolean, 522 | fileTypeForPath: (path: string) => DocumentRegistry.IFileType 523 | ): Promise { 524 | const fileType = fileTypeForPath(path); 525 | const resource = await getResourceForPath(path); 526 | const contents = await contentsModelFromFileResource( 527 | resource, 528 | path, 529 | fileType, 530 | includeContents, 531 | fileTypeForPath 532 | ); 533 | return contents; 534 | } 535 | 536 | /* ********* Functions for file creation/deletion ************** */ 537 | 538 | /** 539 | * Give edit permissions to a Google drive user. 540 | * 541 | * @param resource: the FileResource to share. 542 | * 543 | * @param emailAddresses - the email addresses of the users for which 544 | * to create the permissions. 545 | * 546 | * @returns a promise fulfilled when the permissions are created. 547 | */ 548 | export async function createPermissions( 549 | resource: FileResource, 550 | emailAddresses: string[] 551 | ): Promise { 552 | // Do nothing for an empty list. 553 | if (emailAddresses.length === 0) { 554 | return; 555 | } 556 | const createRequest = () => { 557 | // Create a batch request for permissions. 558 | // Note: the typings for gapi.client are missing 559 | // the newBatch() function, which creates an HttpBatchRequest 560 | const batch: any = (gapi as any).client.newBatch(); 561 | for (let address of emailAddresses) { 562 | const permissionRequest = { 563 | type: 'user', 564 | role: 'writer', 565 | emailAddress: address 566 | }; 567 | const request = gapi.client.drive.permissions.create({ 568 | fileId: resource.id!, 569 | emailMessage: `${resource.name} has been shared with you`, 570 | sendNotificationEmail: true, 571 | resource: permissionRequest, 572 | supportsTeamDrives: !!resource.teamDriveId 573 | }); 574 | batch.add(request); 575 | } 576 | return batch; 577 | }; 578 | // Submit the batch request. 579 | await driveApiRequest(createRequest); 580 | return; 581 | } 582 | 583 | /** 584 | * Delete a file from the users Google Drive. 585 | * 586 | * @param path - the path of the file to delete. 587 | * 588 | * @returns a promise fulfilled when the file has been deleted. 589 | */ 590 | export async function deleteFile(path: string): Promise { 591 | const resource = await getResourceForPath(path); 592 | const createRequest = () => { 593 | return gapi.client.drive.files.delete({ 594 | fileId: resource.id!, 595 | supportsTeamDrives: !!resource.teamDriveId 596 | }); 597 | }; 598 | await driveApiRequest(createRequest, 204); 599 | // Update the cache 600 | Private.resourceCache.delete(path); 601 | return; 602 | } 603 | 604 | /* ****** Functions for file system querying/manipulation ***** */ 605 | 606 | /** 607 | * Search a directory. 608 | * 609 | * @param path - the path of the directory on the server. 610 | * 611 | * @param query - a query string, following the format of 612 | * query strings for the Google Drive v3 API, which 613 | * narrows down search results. An empty query string 614 | * corresponds to just listing the contents of the directory. 615 | * 616 | * @returns a promise fulfilled with a list of files resources, 617 | * corresponding to the files that are in the directory and 618 | * match the query string. 619 | */ 620 | export async function searchDirectory( 621 | path: string, 622 | query: string = '' 623 | ): Promise { 624 | const resource = await getResourceForPath(path); 625 | // Check to make sure this is a folder. 626 | if (!isDirectory(resource)) { 627 | throw new Error('Google Drive: expected a folder: ' + path); 628 | } 629 | // Construct the query. 630 | let fullQuery: string = 631 | `\'${resource.id}\' in parents ` + 'and trashed = false'; 632 | if (query) { 633 | fullQuery += ' and ' + query; 634 | } 635 | 636 | const getPage = (pageToken?: string) => { 637 | let createRequest: () => gapi.client.HttpRequest< 638 | gapi.client.drive.FileList 639 | >; 640 | if (resource.teamDriveId) { 641 | // Case of a directory in a team drive. 642 | createRequest = () => { 643 | return gapi.client.drive.files.list({ 644 | q: fullQuery, 645 | pageSize: FILE_PAGE_SIZE, 646 | pageToken, 647 | fields: `${FILE_LIST_FIELDS}, files(${RESOURCE_FIELDS})`, 648 | corpora: 'teamDrive', 649 | includeTeamDriveItems: true, 650 | supportsTeamDrives: true, 651 | teamDriveId: resource.teamDriveId 652 | }); 653 | }; 654 | } else if (resource.kind === 'drive#teamDrive') { 655 | // Case of the root of a team drive. 656 | createRequest = () => { 657 | return gapi.client.drive.files.list({ 658 | q: fullQuery, 659 | pageSize: FILE_PAGE_SIZE, 660 | pageToken, 661 | fields: `${FILE_LIST_FIELDS}, files(${RESOURCE_FIELDS})`, 662 | corpora: 'teamDrive', 663 | includeTeamDriveItems: true, 664 | supportsTeamDrives: true, 665 | teamDriveId: resource.id! 666 | }); 667 | }; 668 | } else { 669 | // Case of the user directory. 670 | createRequest = () => { 671 | return gapi.client.drive.files.list({ 672 | q: fullQuery, 673 | pageSize: FILE_PAGE_SIZE, 674 | pageToken, 675 | fields: `${FILE_LIST_FIELDS}, files(${RESOURCE_FIELDS})` 676 | }); 677 | }; 678 | } 679 | return driveApiRequest(createRequest); 680 | }; 681 | return depaginate(getPage, 'files'); 682 | } 683 | 684 | /** 685 | * Search the list of files that have been shared with the user. 686 | * 687 | * @param query - a query string, following the format of 688 | * query strings for the Google Drive v3 API, which 689 | * narrows down search results. An empty query string 690 | * corresponds to just listing the shared files. 691 | * 692 | * @returns a promise fulfilled with the files that have been 693 | * shared with the user. 694 | * 695 | * ### Notes 696 | * This does not search Team Drives. 697 | */ 698 | export async function searchSharedFiles( 699 | query: string = '' 700 | ): Promise { 701 | await gapiInitialized.promise; 702 | // Construct the query. 703 | let fullQuery = 'sharedWithMe = true'; 704 | if (query) { 705 | fullQuery += ' and ' + query; 706 | } 707 | 708 | const getPage = (pageToken?: string) => { 709 | const createRequest = () => { 710 | return gapi.client.drive.files.list({ 711 | q: fullQuery, 712 | pageSize: FILE_PAGE_SIZE, 713 | pageToken, 714 | fields: `${FILE_LIST_FIELDS}, files(${RESOURCE_FIELDS})` 715 | }); 716 | }; 717 | return driveApiRequest(createRequest); 718 | }; 719 | return depaginate(getPage, 'files'); 720 | } 721 | 722 | /** 723 | * Move a file in Google Drive. Can also be used to rename the file. 724 | * 725 | * @param oldPath - The initial location of the file (where the path 726 | * includes the filename). 727 | * 728 | * @param newPath - The new location of the file (where the path 729 | * includes the filename). 730 | * 731 | * @param fileTypeForPath - A function that, given a path argument, returns 732 | * and DocumentRegistry.IFileType that is consistent with the path. 733 | * 734 | * @returns a promise fulfilled with the `Contents.IModel` of the moved file. 735 | * Otherwise, throws an error. 736 | */ 737 | export async function moveFile( 738 | oldPath: string, 739 | newPath: string, 740 | fileTypeForPath: (path: string) => DocumentRegistry.IFileType 741 | ): Promise { 742 | if (isDummy(PathExt.dirname(newPath))) { 743 | throw makeError( 744 | 400, 745 | `Google Drive: "${newPath}" ` + 'is not a valid save directory' 746 | ); 747 | } 748 | if (oldPath === newPath) { 749 | return contentsModelForPath(oldPath, true, fileTypeForPath); 750 | } else { 751 | let newFolderPath = PathExt.dirname(newPath); 752 | 753 | // Get a promise that resolves with the resource in the current position. 754 | const resourcePromise = getResourceForPath(oldPath); 755 | // Get a promise that resolves with the resource of the new folder. 756 | const newFolderPromise = getResourceForPath(newFolderPath); 757 | 758 | // Check the new path to make sure there isn't already a file 759 | // with the same name there. 760 | const newName = PathExt.basename(newPath); 761 | const directorySearchPromise = searchDirectory( 762 | newFolderPath, 763 | "name = '" + newName + "'" 764 | ); 765 | 766 | // Once we have all the required information, 767 | // update the metadata with the new parent directory 768 | // for the file. 769 | const values = await Promise.all([ 770 | resourcePromise, 771 | newFolderPromise, 772 | directorySearchPromise 773 | ]); 774 | const resource = values[0]; 775 | const newFolder = values[1]; 776 | const directorySearch = values[2]; 777 | 778 | if (directorySearch.length !== 0) { 779 | throw new Error( 780 | 'Google Drive: File with the same name ' + 781 | 'already exists in the destination directory' 782 | ); 783 | } else { 784 | const createRequest = () => { 785 | return gapi.client.drive.files.update({ 786 | fileId: resource.id!, 787 | addParents: newFolder.id!, 788 | removeParents: resource.parents ? resource.parents[0] : undefined, 789 | resource: { 790 | name: newName 791 | }, 792 | fields: RESOURCE_FIELDS, 793 | supportsTeamDrives: !!(resource.teamDriveId || newFolder.teamDriveId) 794 | }); 795 | }; 796 | const response = await driveApiRequest(createRequest); 797 | // Update the cache. 798 | Private.resourceCache.delete(oldPath); 799 | Private.resourceCache.set(newPath, response); 800 | 801 | return contentsModelForPath(newPath, true, fileTypeForPath); 802 | } 803 | } 804 | } 805 | 806 | /** 807 | * Copy a file in Google Drive. It is assumed that the new filename has 808 | * been determined previous to invoking this function, and does not conflict 809 | * with any files in the new directory. 810 | * 811 | * @param oldPath - The initial location of the file (where the path 812 | * includes the filename). 813 | * 814 | * @param newPath - The location of the copy (where the path 815 | * includes the filename). This cannot be the same as `oldPath`. 816 | * 817 | * @param fileTypeForPath - A function that, given a path argument, returns 818 | * and DocumentRegistry.IFileType that is consistent with the path. 819 | * 820 | * @returns a promise fulfilled with the `Contents.IModel` of the copy. 821 | * Otherwise, throws an error. 822 | */ 823 | export async function copyFile( 824 | oldPath: string, 825 | newPath: string, 826 | fileTypeForPath: (path: string) => DocumentRegistry.IFileType 827 | ): Promise { 828 | if (isDummy(PathExt.dirname(newPath))) { 829 | throw makeError( 830 | 400, 831 | `Google Drive: "${newPath}"` + ' is not a valid save directory' 832 | ); 833 | } 834 | if (oldPath === newPath) { 835 | throw makeError( 836 | 400, 837 | 'Google Drive: cannot copy a file with' + 838 | ' the same name to the same directory' 839 | ); 840 | } else { 841 | let newFolderPath = PathExt.dirname(newPath); 842 | 843 | // Get a promise that resolves with the resource in the current position. 844 | const resourcePromise = getResourceForPath(oldPath); 845 | // Get a promise that resolves with the resource of the new folder. 846 | const newFolderPromise = getResourceForPath(newFolderPath); 847 | 848 | // Check the new path to make sure there isn't already a file 849 | // with the same name there. 850 | const newName = PathExt.basename(newPath); 851 | const directorySearchPromise = searchDirectory( 852 | newFolderPath, 853 | "name = '" + newName + "'" 854 | ); 855 | 856 | // Once we have all the required information, 857 | // perform the copy. 858 | const values = await Promise.all([ 859 | resourcePromise, 860 | newFolderPromise, 861 | directorySearchPromise 862 | ]); 863 | const resource = values[0]; 864 | const newFolder = values[1]; 865 | const directorySearch = values[2]; 866 | 867 | if (directorySearch.length !== 0) { 868 | throw new Error( 869 | 'Google Drive: File with the same name ' + 870 | 'already exists in the destination directory' 871 | ); 872 | } else { 873 | const createRequest = () => { 874 | return gapi.client.drive.files.copy({ 875 | fileId: resource.id!, 876 | resource: { 877 | parents: [newFolder.id!], 878 | name: newName 879 | }, 880 | fields: RESOURCE_FIELDS, 881 | supportsTeamDrives: !!(newFolder.teamDriveId || resource.teamDriveId) 882 | }); 883 | }; 884 | const response = await driveApiRequest(createRequest); 885 | // Update the cache. 886 | Private.resourceCache.set(newPath, response); 887 | return contentsModelForPath(newPath, true, fileTypeForPath); 888 | } 889 | } 890 | } 891 | 892 | /** 893 | * Invalidate the resource cache. 894 | * 895 | * #### Notes 896 | * The resource cache is mostly private to this module, and 897 | * is essential to not be rate-limited by Google. 898 | * 899 | * This should only be called when the user signs out, and 900 | * the cached information about their directory structure 901 | * is no longer valid. 902 | */ 903 | export function clearCache(): void { 904 | Private.resourceCache.clear(); 905 | } 906 | 907 | /* ******** Functions for dealing with revisions ******** */ 908 | 909 | /** 910 | * List the revisions for a file in Google Drive. 911 | * 912 | * @param path - the path of the file. 913 | * 914 | * @returns a promise fulfilled with a list of `Contents.ICheckpointModel` 915 | * that correspond to the file revisions stored on drive. 916 | */ 917 | export async function listRevisions( 918 | path: string 919 | ): Promise { 920 | const resource = await getResourceForPath(path); 921 | const getPage = (pageToken?: string) => { 922 | const createRequest = () => { 923 | return gapi.client.drive.revisions.list({ 924 | fileId: resource.id!, 925 | pageSize: REVISION_PAGE_SIZE, 926 | pageToken, 927 | fields: `${REVISION_LIST_FIELDS}, revisions(${REVISION_FIELDS})` 928 | }); 929 | }; 930 | return driveApiRequest(createRequest); 931 | }; 932 | const listing = await depaginate(getPage, 'revisions'); 933 | const revisions = map( 934 | filter(listing || [], (revision: RevisionResource) => { 935 | return revision.keepForever!; 936 | }), 937 | (revision: RevisionResource) => { 938 | return { id: revision.id!, last_modified: revision.modifiedTime! }; 939 | } 940 | ); 941 | return toArray(revisions); 942 | } 943 | 944 | /** 945 | * Tell Google drive to keep the current revision. Without doing 946 | * this the revision would eventually be cleaned up. 947 | * 948 | * @param path - the path of the file to pin. 949 | * 950 | * @returns a promise fulfilled with an `ICheckpointModel` corresponding 951 | * to the newly pinned revision. 952 | */ 953 | export async function pinCurrentRevision( 954 | path: string 955 | ): Promise { 956 | const resource = await getResourceForPath(path); 957 | const createRequest = () => { 958 | return gapi.client.drive.revisions.update({ 959 | fileId: resource.id!, 960 | revisionId: resource.headRevisionId!, 961 | resource: { 962 | keepForever: true 963 | } 964 | }); 965 | }; 966 | const revision = await driveApiRequest(createRequest); 967 | return { id: revision.id!, last_modified: revision.modifiedTime! }; 968 | } 969 | 970 | /** 971 | * Tell Google drive not to keep the current revision. 972 | * Eventually the revision will then be cleaned up. 973 | * 974 | * @param path - the path of the file to unpin. 975 | * 976 | * @param revisionId - the id of the revision to unpin. 977 | * 978 | * @returns a promise fulfilled when the revision is unpinned. 979 | */ 980 | export async function unpinRevision( 981 | path: string, 982 | revisionId: string 983 | ): Promise { 984 | const resource = await getResourceForPath(path); 985 | const createRequest = () => { 986 | return gapi.client.drive.revisions.update({ 987 | fileId: resource.id!, 988 | revisionId: revisionId, 989 | resource: { 990 | keepForever: false 991 | } 992 | }); 993 | }; 994 | await driveApiRequest(createRequest); 995 | return; 996 | } 997 | 998 | /** 999 | * Revert a file to a particular revision id. 1000 | * 1001 | * @param path - the path of the file. 1002 | * 1003 | * @param revisionId - the id of the revision to revert. 1004 | * 1005 | * @param fileType - a candidate DocumentRegistry.IFileType for the given file. 1006 | * 1007 | * @returns a promise fulfilled when the file is reverted. 1008 | */ 1009 | export async function revertToRevision( 1010 | path: string, 1011 | revisionId: string, 1012 | fileType: DocumentRegistry.IFileType 1013 | ): Promise { 1014 | // Get the correct file resource. 1015 | const revisionResource = await getResourceForPath(path); 1016 | const content = await downloadRevision( 1017 | revisionResource, 1018 | revisionId, 1019 | fileType.fileFormat 1020 | ); 1021 | const contents: Contents.IModel = { 1022 | name: revisionResource.name!, 1023 | path: path, 1024 | type: fileType.contentType, 1025 | writable: revisionResource.capabilities!.canEdit || true, 1026 | created: String(revisionResource.createdTime), 1027 | // TODO What is the appropriate modified time? 1028 | last_modified: String(revisionResource.modifiedTime), 1029 | mimetype: fileType.mimeTypes[0], 1030 | content, 1031 | format: fileType.fileFormat 1032 | }; 1033 | 1034 | // Reupload the reverted file to the head revision. 1035 | await uploadFile(path, contents, fileType, true, undefined); 1036 | return; 1037 | } 1038 | 1039 | /* *********Utility functions ********* */ 1040 | 1041 | /** 1042 | * Construct a minimal files resource object from a 1043 | * contents model. 1044 | * 1045 | * @param contents - The contents model. 1046 | * 1047 | * @param fileType - a candidate DocumentRegistry.IFileType for the given file. 1048 | * 1049 | * @returns a files resource object for the Google Drive API. 1050 | * 1051 | * #### Notes 1052 | * This does not include any of the binary/text/json content of the 1053 | * `contents`, just some metadata (`name` and `mimeType`). 1054 | */ 1055 | function fileResourceFromContentsModel( 1056 | contents: Partial, 1057 | fileType: DocumentRegistry.IFileType 1058 | ): FileResource { 1059 | let mimeType: string; 1060 | switch (contents.type) { 1061 | case 'notebook': 1062 | // The Contents API does not specify a notebook mimetype, 1063 | // but the Google Drive API requires one. 1064 | mimeType = 'application/x-ipynb+json'; 1065 | break; 1066 | case 'directory': 1067 | mimeType = FOLDER_MIMETYPE; 1068 | break; 1069 | default: 1070 | mimeType = fileType.mimeTypes[0]; 1071 | break; 1072 | } 1073 | return { 1074 | name: contents.name || PathExt.basename(contents.path || ''), 1075 | mimeType 1076 | }; 1077 | } 1078 | 1079 | /** 1080 | * Obtains the Google Drive Files resource for a file or folder relative 1081 | * to the a given folder. The path should be a file or a subfolder, and 1082 | * should not contain multiple levels of folders (hence the name 1083 | * pathComponent). It should also not contain any leading or trailing 1084 | * slashes. 1085 | * 1086 | * @param pathComponent - The file/folder to find 1087 | * 1088 | * @param type - type of resource (file or folder) 1089 | * 1090 | * @param folderId - The Google Drive folder id 1091 | * 1092 | * @returns A promise fulfilled by either the files resource for the given 1093 | * file/folder, or rejected with an Error object. 1094 | */ 1095 | async function getResourceForRelativePath( 1096 | pathComponent: string, 1097 | folderId: string, 1098 | teamDriveId: string = '' 1099 | ): Promise { 1100 | await gapiInitialized.promise; 1101 | // Construct a search query for the file at hand. 1102 | const query = 1103 | `name = \'${pathComponent}\' and trashed = false ` + 1104 | `and \'${folderId}\' in parents`; 1105 | // Construct a request for the files matching the query. 1106 | let createRequest: () => gapi.client.HttpRequest; 1107 | if (teamDriveId) { 1108 | createRequest = () => { 1109 | return gapi.client.drive.files.list({ 1110 | q: query, 1111 | pageSize: FILE_PAGE_SIZE, 1112 | fields: `${FILE_LIST_FIELDS}, files(${RESOURCE_FIELDS})`, 1113 | supportsTeamDrives: true, 1114 | includeTeamDriveItems: true, 1115 | corpora: 'teamDrive', 1116 | teamDriveId: teamDriveId 1117 | }); 1118 | }; 1119 | } else { 1120 | createRequest = () => { 1121 | return gapi.client.drive.files.list({ 1122 | q: query, 1123 | pageSize: FILE_PAGE_SIZE, 1124 | fields: `${FILE_LIST_FIELDS}, files(${RESOURCE_FIELDS})` 1125 | }); 1126 | }; 1127 | } 1128 | // Make the request. 1129 | const result = await driveApiRequest( 1130 | createRequest 1131 | ); 1132 | const files: FileResource[] = result.files || []; 1133 | if (!files || files.length === 0) { 1134 | throw Error( 1135 | 'Google Drive: cannot find the specified file/folder: ' + pathComponent 1136 | ); 1137 | } else if (files.length > 1) { 1138 | throw Error('Google Drive: multiple files/folders match: ' + pathComponent); 1139 | } 1140 | return files[0]; 1141 | } 1142 | 1143 | /** 1144 | * Given the unique id string for a file in Google Drive, 1145 | * get the files resource metadata associated with it. 1146 | * 1147 | * @param id - The file ID. 1148 | * 1149 | * @returns A promise that resolves with the files resource 1150 | * corresponding to `id`. 1151 | * 1152 | * ### Notes 1153 | * This does not support Team Drives. 1154 | */ 1155 | async function resourceFromFileId(id: string): Promise { 1156 | await gapiInitialized.promise; 1157 | const createRequest = () => { 1158 | return gapi.client.drive.files.get({ 1159 | fileId: id, 1160 | fields: RESOURCE_FIELDS 1161 | }); 1162 | }; 1163 | return driveApiRequest(createRequest); 1164 | } 1165 | 1166 | /** 1167 | * Given a name, find the user's root drive resource, 1168 | * or a Team Drive resource with the same name. 1169 | * 1170 | * @param name - The Team Drive name. 1171 | */ 1172 | async function driveForName( 1173 | name: string 1174 | ): Promise { 1175 | const rootResource = resourceFromFileId('root'); 1176 | const teamDriveResources = listTeamDrives(); 1177 | const result = await Promise.all([rootResource, teamDriveResources]); 1178 | const root = result[0]; 1179 | const teamDrives = result[1]; 1180 | if (root.name === name) { 1181 | return root; 1182 | } 1183 | for (let drive of teamDrives) { 1184 | if (drive.name === name) { 1185 | return drive; 1186 | } 1187 | } 1188 | throw Error(`Google Drive: cannot find Team Drive: ${name}`); 1189 | } 1190 | 1191 | /** 1192 | * List the Team Drives accessible to a user. 1193 | * 1194 | * @returns a list of team drive resources. 1195 | */ 1196 | async function listTeamDrives(): Promise { 1197 | await gapiAuthorized.promise; 1198 | const getPage = ( 1199 | pageToken: string 1200 | ): Promise => { 1201 | const createRequest = () => { 1202 | return gapi.client.drive.teamdrives.list({ 1203 | fields: `${TEAMDRIVE_LIST_FIELDS}, teamDrives(${TEAMDRIVE_FIELDS})`, 1204 | pageSize: TEAMDRIVE_PAGE_SIZE, 1205 | pageToken 1206 | }); 1207 | }; 1208 | return driveApiRequest(createRequest); 1209 | }; 1210 | return depaginate(getPage, 'teamDrives'); 1211 | } 1212 | 1213 | /** 1214 | * Split a path into path components 1215 | */ 1216 | function splitPath(path: string): string[] { 1217 | return path.split('/').filter((s, i, a) => Boolean(s)); 1218 | } 1219 | 1220 | /** 1221 | * Whether a path is a dummy directory. 1222 | */ 1223 | export function isDummy(path: string): boolean { 1224 | return path === COLLECTIONS_DIRECTORY || path === SHARED_DIRECTORY; 1225 | } 1226 | 1227 | /** 1228 | * Whether a resource is a directory (or Team Drive), 1229 | * which may contain items. 1230 | */ 1231 | export function isDirectory(resource: FileResource): boolean { 1232 | return !!( 1233 | resource.kind === 'drive#teamDrive' || resource.mimeType === FOLDER_MIMETYPE 1234 | ); 1235 | } 1236 | 1237 | /** 1238 | * Depaginate a series of requests into a single array. 1239 | */ 1240 | async function depaginate< 1241 | T extends FileResource | TeamDriveResource, 1242 | L extends PaginatedResponse 1243 | >( 1244 | getPage: (pageToken?: string) => Promise, 1245 | listName: keyof L, 1246 | pageToken?: string 1247 | ): Promise { 1248 | const list = await getPage(pageToken); 1249 | const total = (list[listName] as any) as T[]; 1250 | if (list.nextPageToken) { 1251 | return depaginate(getPage, listName, list.nextPageToken).then( 1252 | next => { 1253 | return [...total, ...next]; 1254 | } 1255 | ); 1256 | } else { 1257 | return total; 1258 | } 1259 | } 1260 | 1261 | /** 1262 | * Gets the Google Drive Files resource corresponding to a path. The path 1263 | * is always treated as an absolute path, no matter whether it contains 1264 | * leading or trailing slashes. In fact, all leading, trailing and 1265 | * consecutive slashes are ignored. 1266 | * 1267 | * @param path - The path of the file. 1268 | * 1269 | * @param type - The type (file or folder) 1270 | * 1271 | * @returns A promise fulfilled with the files resource for the given path. 1272 | * or an Error object on error. 1273 | */ 1274 | export async function getResourceForPath(path: string): Promise { 1275 | // First check the cache. 1276 | if (Private.resourceCache.has(path)) { 1277 | return Private.resourceCache.get(path)!; 1278 | } 1279 | 1280 | const components = splitPath(path); 1281 | 1282 | if (components.length === 0) { 1283 | // Handle the case for the pseudo folders 1284 | // (i.e., the view onto the "My Drive" and "Shared 1285 | // with me" directories, as well as the pseudo-root). 1286 | return COLLECTIONS_DIRECTORY_RESOURCE; 1287 | } else if (components.length === 1 && components[0] === SHARED_DIRECTORY) { 1288 | return SHARED_DIRECTORY_RESOURCE; 1289 | } else { 1290 | // Create a Promise of a FileResource to walk the path until 1291 | // we find the right file. 1292 | let currentResource: FileResource; 1293 | 1294 | // Current path component index. 1295 | let idx = 0; 1296 | 1297 | // Team Drive id for the path, or the empty string if 1298 | // the path is not in a Team Drive. 1299 | let teamDriveId = ''; 1300 | 1301 | if (components[0] === SHARED_DIRECTORY) { 1302 | // Handle the case of the `Shared With Me` directory. 1303 | const shared = await searchSharedFiles("name = '" + components[1] + "'"); 1304 | if (!shared || shared.length === 0) { 1305 | throw Error( 1306 | 'Google Drive: cannot find the specified file/folder: ' + 1307 | components[1] 1308 | ); 1309 | } else if (shared.length > 1) { 1310 | throw Error( 1311 | 'Google Drive: multiple files/folders match: ' + components[1] 1312 | ); 1313 | } 1314 | currentResource = shared[0]; 1315 | idx = 2; // Set the component index to the third component. 1316 | } else { 1317 | // Handle the case of a `My Drive` or a Team Drive 1318 | try { 1319 | const drive = await driveForName(components[0]); 1320 | if (drive.kind === 'drive#teamDrive') { 1321 | teamDriveId = drive.id!; 1322 | } 1323 | currentResource = drive; 1324 | idx = 1; 1325 | } catch { 1326 | throw Error(`Unexpected file in root directory: ${components[0]}`); 1327 | } 1328 | } 1329 | 1330 | // Loop over the components, updating the current resource. 1331 | // Start the loop at idx to skip the pseudo-root. 1332 | for (; idx < components.length; idx++) { 1333 | const component = components[idx]; 1334 | currentResource = await getResourceForRelativePath( 1335 | component, 1336 | currentResource.id!, 1337 | teamDriveId 1338 | ); 1339 | } 1340 | 1341 | // Update the cache. 1342 | Private.resourceCache.set(path, currentResource); 1343 | // Resolve with the final value of currentResource. 1344 | return currentResource; 1345 | } 1346 | } 1347 | 1348 | /** 1349 | * Download the contents of a file from Google Drive. 1350 | * 1351 | * @param resource - the files resource metadata object. 1352 | * 1353 | * @param format - the format of the file ('json', 'text', or 'base64') 1354 | * 1355 | * @returns a promise fulfilled with the contents of the file. 1356 | */ 1357 | async function downloadResource( 1358 | resource: FileResource, 1359 | picked: boolean = false, 1360 | format: Contents.FileFormat 1361 | ): Promise { 1362 | await gapiInitialized.promise; 1363 | 1364 | if (format !== 'base64') { 1365 | const token = gapi.auth.getToken().access_token; 1366 | const url = `https://www.googleapis.com/drive/v3/files/${ 1367 | resource.id 1368 | }?alt=media&supportsTeamDrives=${!!resource.teamDriveId}`; 1369 | const response = await fetch(url, { 1370 | method: 'GET', 1371 | headers: { 1372 | Authorization: `Bearer ${token}` 1373 | } 1374 | }); 1375 | const data = await response.text(); 1376 | return format === 'text' ? data : JSON.parse(data); 1377 | } else { 1378 | const createRequest = () => { 1379 | return gapi.client.drive.files.get({ 1380 | fileId: resource.id!, 1381 | alt: 'media', 1382 | supportsTeamDrives: !!resource.teamDriveId 1383 | }); 1384 | }; 1385 | return driveApiRequest(createRequest).then(result => { 1386 | return btoa(result); 1387 | }); 1388 | } 1389 | } 1390 | 1391 | /** 1392 | * Download a revision of a file from Google Drive. 1393 | * 1394 | * @param resource - the files resource metadata object. 1395 | * 1396 | * @param revisionId - the id of the revision to download. 1397 | * 1398 | * @param format - the format of the file ('json', 'text', or 'base64') 1399 | * 1400 | * @returns a promise fulfilled with the contents of the file. 1401 | */ 1402 | async function downloadRevision( 1403 | resource: FileResource, 1404 | revisionId: string, 1405 | format: Contents.FileFormat 1406 | ): Promise { 1407 | await gapiInitialized.promise; 1408 | 1409 | if (format !== 'base64') { 1410 | const token = gapi.auth.getToken().access_token; 1411 | const url = `https://www.googleapis.com/drive/v3/files/${ 1412 | resource.id 1413 | }/revisions/${revisionId}?alt=media`; 1414 | const response = await fetch(url, { 1415 | method: 'GET', 1416 | headers: { 1417 | Authorization: `Bearer ${token}` 1418 | } 1419 | }); 1420 | const data = await response.text(); 1421 | return format === 'text' ? data : JSON.parse(data); 1422 | } else { 1423 | const createRequest = () => { 1424 | return gapi.client.drive.revisions.get({ 1425 | fileId: resource.id!, 1426 | revisionId: revisionId, 1427 | alt: 'media' 1428 | }); 1429 | }; 1430 | return driveApiRequest(createRequest).then(result => { 1431 | return btoa(result); 1432 | }); 1433 | } 1434 | } 1435 | 1436 | namespace Private { 1437 | /** 1438 | * A Map associating file paths with cached files 1439 | * resources. This can significantly cut down on 1440 | * API requests. 1441 | */ 1442 | export const resourceCache = new Map(); 1443 | 1444 | /** 1445 | * When we list the contents of a directory we can 1446 | * use that opportunity to refresh the cached values 1447 | * for that directory. This function clears all 1448 | * the cached resources that are in a given directory. 1449 | */ 1450 | export function clearCacheForDirectory(path: string): void { 1451 | resourceCache.forEach((value, key) => { 1452 | let enclosingFolderPath = PathExt.dirname(key); 1453 | if (path === enclosingFolderPath) { 1454 | resourceCache.delete(key); 1455 | } 1456 | }); 1457 | } 1458 | 1459 | /** 1460 | * Given a list of resources in a directory, put them in 1461 | * the resource cache. This strips any duplicates, since 1462 | * the path-based contents manager can't handle those correctly. 1463 | */ 1464 | export function populateCacheForDirectory( 1465 | path: string, 1466 | resourceList: FileResource[] 1467 | ) { 1468 | // Identify duplicates in the list: we can't handle those 1469 | // correctly, so don't insert them. 1470 | const duplicatePaths: string[] = []; 1471 | const candidatePaths: string[] = []; 1472 | for (let resource of resourceList) { 1473 | const filePath = PathExt.join(path, resource.name!); 1474 | if (candidatePaths.indexOf(filePath) !== -1) { 1475 | duplicatePaths.push(filePath); 1476 | } else { 1477 | candidatePaths.push(filePath); 1478 | } 1479 | } 1480 | 1481 | // Insert non-duplicates into the cache. 1482 | for (let resource of resourceList) { 1483 | const filePath = PathExt.join(path, resource.name!); 1484 | if (duplicatePaths.indexOf(filePath) === -1) { 1485 | Private.resourceCache.set(filePath, resource); 1486 | } 1487 | } 1488 | } 1489 | } 1490 | -------------------------------------------------------------------------------- /src/gapi.ts: -------------------------------------------------------------------------------- 1 | // Copyright (c) Jupyter Development Team. 2 | // Distributed under the terms of the Modified BSD License. 3 | 4 | import { PromiseDelegate } from '@lumino/coreutils'; 5 | 6 | import { ServerConnection } from '@jupyterlab/services'; 7 | 8 | import { clearCache } from './drive'; 9 | 10 | /** 11 | * Scope for the permissions needed for this extension. 12 | */ 13 | const DRIVE_SCOPE = 'https://www.googleapis.com/auth/drive'; 14 | const DISCOVERY_DOCS = [ 15 | 'https://www.googleapis.com/discovery/v1/apis/drive/v3/rest' 16 | ]; 17 | 18 | /** 19 | * Aliases for common API errors. 20 | */ 21 | const INVALID_CREDENTIALS_ERROR = 401; 22 | const FORBIDDEN_ERROR = 403; 23 | const BACKEND_ERROR = 500; 24 | const RATE_LIMIT_REASON = 'userRateLimitExceeded'; 25 | 26 | /** 27 | * A promise delegate that is resolved when the google client 28 | * libraries are loaded onto the page. 29 | */ 30 | export const gapiLoaded = new PromiseDelegate(); 31 | 32 | /** 33 | * A promise delegate that is resolved when the gapi client 34 | * libraries are initialized. 35 | */ 36 | export const gapiInitialized = new PromiseDelegate(); 37 | 38 | /** 39 | * A promise delegate that is resolved when the user authorizes 40 | * the app to access their Drive account. 41 | * 42 | * #### Notes 43 | * This promise will be reassigned if the user logs out. 44 | */ 45 | export let gapiAuthorized = new PromiseDelegate(); 46 | 47 | /** 48 | * Load the gapi scripts onto the page. 49 | * 50 | * @returns a promise that resolves when the gapi scripts are loaded. 51 | */ 52 | export function loadGapi(): Promise { 53 | return new Promise((resolve, reject) => { 54 | // Get the gapi script from Google. 55 | const gapiScript = document.createElement('script'); 56 | gapiScript.src = 'https://apis.google.com/js/api.js'; 57 | gapiScript.type = 'text/javascript'; 58 | gapiScript.async = true; 59 | 60 | // Load overall API scripts onto the page. 61 | gapiScript.onload = () => { 62 | // Load the specific client libraries we need. 63 | const libs = 'client:auth2'; 64 | gapi.load(libs, () => { 65 | gapiLoaded.resolve(void 0); 66 | resolve(void 0); 67 | }); 68 | }; 69 | gapiScript.onerror = () => { 70 | console.error('Unable to load Google APIs'); 71 | gapiLoaded.reject(void 0); 72 | reject(void 0); 73 | }; 74 | document.head!.appendChild(gapiScript); 75 | }); 76 | } 77 | 78 | /** 79 | * Initialize the gapi client libraries. 80 | * 81 | * @param clientId: The client ID for the project from the 82 | * Google Developer Console. If not given, defaults to 83 | * a testing project client ID. However, if you are deploying 84 | * your own Jupyter server, or are making heavy use of the 85 | * API, it is probably a good idea to set up your own client ID. 86 | * 87 | * @returns a promise that resolves when the client libraries are loaded. 88 | * The return value of the promise is a boolean indicating whether 89 | * the user was automatically signed in by the initialization. 90 | */ 91 | export function initializeGapi(clientId: string): Promise { 92 | return new Promise(async (resolve, reject) => { 93 | await gapiLoaded.promise; 94 | gapi.client 95 | .init({ 96 | discoveryDocs: DISCOVERY_DOCS, 97 | clientId: clientId, 98 | scope: DRIVE_SCOPE 99 | }) 100 | .then( 101 | () => { 102 | // Check if the user is logged in and we are 103 | // authomatically authorized. 104 | const googleAuth = gapi.auth2.getAuthInstance(); 105 | if (googleAuth.isSignedIn.get()) { 106 | // Resolve the relevant promises. 107 | gapiAuthorized.resolve(void 0); 108 | gapiInitialized.resolve(void 0); 109 | resolve(true); 110 | } else { 111 | gapiInitialized.resolve(void 0); 112 | resolve(false); 113 | } 114 | }, 115 | (err: any) => { 116 | gapiInitialized.reject(err); 117 | // A useful error message is in err.details. 118 | reject(err.details); 119 | } 120 | ); 121 | }); 122 | } 123 | 124 | /** 125 | * Constants used when attempting exponential backoff. 126 | */ 127 | const MAX_API_REQUESTS = 7; 128 | const BACKOFF_FACTOR = 2.0; 129 | const INITIAL_DELAY = 250; // 250 ms. 130 | 131 | /** 132 | * Wrapper function for making API requests to Google Drive. 133 | * 134 | * @param createRequest: a function that creates a request object for 135 | * the Google Drive APIs. This is typically created by the Javascript 136 | * client library. We use a request factory to create additional requests 137 | * should we need to try exponential backoff. 138 | * 139 | * @param successCode: the code to check against for success of the request, defaults 140 | * to 200. 141 | * 142 | * @param attemptNumber: the number of times this request has been made 143 | * (used when attempting exponential backoff). 144 | * 145 | * @returns a promse that resolves with the result of the request. 146 | */ 147 | export function driveApiRequest( 148 | createRequest: () => gapi.client.HttpRequest, 149 | successCode: number = 200, 150 | attemptNumber: number = 0 151 | ): Promise { 152 | if (attemptNumber === MAX_API_REQUESTS) { 153 | throw Error('Maximum number of API retries reached.'); 154 | } 155 | return new Promise(async (resolve, reject) => { 156 | await gapiAuthorized.promise; 157 | const request = createRequest(); 158 | request.then( 159 | response => { 160 | if (response.status !== successCode) { 161 | // Handle an HTTP error. 162 | let result: any = response.result; 163 | reject(makeError(result.error.code, result.error.message)); 164 | } else { 165 | // If the response is note JSON-able, then `response.result` 166 | // will be `false`, and the raw data will be in `response.body`. 167 | // This happens, e.g., in the case of downloading raw image 168 | // data. This fix is a bit of a hack, but seems to work. 169 | if ((response.result as any) !== false) { 170 | resolve(response.result); 171 | } else { 172 | resolve(response.body as any); 173 | } 174 | } 175 | }, 176 | async response => { 177 | // Some error happened. 178 | if ( 179 | response.status === BACKEND_ERROR || 180 | (response.status === FORBIDDEN_ERROR && 181 | (response.result.error as any).errors[0].reason === 182 | RATE_LIMIT_REASON) 183 | ) { 184 | // If we are being rate limited, or if there is a backend error, 185 | // attempt exponential backoff. 186 | console.warn( 187 | `gapi: ${response.status} error, exponential ` + 188 | `backoff attempt number ${attemptNumber}...` 189 | ); 190 | window.setTimeout(() => { 191 | // Try again after a delay. 192 | driveApiRequest( 193 | createRequest, 194 | successCode, 195 | attemptNumber + 1 196 | ).then(result => { 197 | resolve(result); 198 | }); 199 | }, INITIAL_DELAY * Math.pow(BACKOFF_FACTOR, attemptNumber)); 200 | } else if (response.status === INVALID_CREDENTIALS_ERROR) { 201 | // If we have invalid credentials, try to refresh 202 | // the authorization, then retry the request. 203 | await Private.refreshAuthToken(); 204 | try { 205 | const result = await driveApiRequest( 206 | createRequest, 207 | successCode, 208 | attemptNumber + 1 209 | ); 210 | resolve(result); 211 | } catch (err) { 212 | let result: any = response.result; 213 | reject(makeError(result.error.code, result.error.message)); 214 | } 215 | } else { 216 | let result: any = response.result; 217 | reject(makeError(result.error.code, result.error.message)); 218 | } 219 | } 220 | ); 221 | }); 222 | } 223 | 224 | /** 225 | * Ask the user for permission to use their Google Drive account. 226 | * First it tries to authorize without a popup, and if it fails, it 227 | * creates a popup. If the argument `allowPopup` is false, then it will 228 | * not try to authorize with a popup. 229 | * 230 | * @returns: a promise that resolves with a boolean for whether permission 231 | * has been granted. 232 | */ 233 | export async function signIn(): Promise { 234 | return new Promise(async (resolve, reject) => { 235 | await gapiInitialized.promise; 236 | const googleAuth = gapi.auth2.getAuthInstance(); 237 | if (!googleAuth.isSignedIn.get()) { 238 | googleAuth.signIn({ prompt: 'select_account' }).then(() => { 239 | // Resolve the exported promise. 240 | gapiAuthorized.resolve(void 0); 241 | resolve(true); 242 | }); 243 | } else { 244 | // Otherwise we are already signed in. 245 | gapiAuthorized.resolve(void 0); 246 | resolve(true); 247 | } 248 | }); 249 | } 250 | 251 | /** 252 | * Sign a user out of their Google account. 253 | * 254 | * @returns a promise resolved when sign-out is complete. 255 | */ 256 | export async function signOut(): Promise { 257 | const googleAuth = gapi.auth2.getAuthInstance(); 258 | // Invalidate the gapiAuthorized promise and set up a new one. 259 | gapiAuthorized = new PromiseDelegate(); 260 | await googleAuth.signOut(); 261 | clearCache(); 262 | } 263 | 264 | /** 265 | * Get the basic profile of the currently signed-in user. 266 | * 267 | * @returns a `gapi.auth2.BasicProfile instance. 268 | */ 269 | export function getCurrentUserProfile(): gapi.auth2.BasicProfile { 270 | const user = gapi.auth2.getAuthInstance().currentUser.get(); 271 | return user.getBasicProfile(); 272 | } 273 | 274 | /** 275 | * Wrap an API error in a hacked-together error object 276 | * masquerading as an `ServerConnection.ResponseError`. 277 | */ 278 | export function makeError( 279 | code: number, 280 | message: string 281 | ): ServerConnection.ResponseError { 282 | const response = new Response(message, { status: code, statusText: message }); 283 | return new ServerConnection.ResponseError(response, message); 284 | } 285 | 286 | /** 287 | * A namespace for private functions and values. 288 | */ 289 | namespace Private { 290 | /** 291 | * Try to manually refresh the authorization if we run 292 | * into credential problems. 293 | */ 294 | export function refreshAuthToken(): Promise { 295 | return new Promise((resolve, reject) => { 296 | const googleAuth = gapi.auth2.getAuthInstance(); 297 | const user = googleAuth.currentUser.get(); 298 | user.reloadAuthResponse().then( 299 | authResponse => { 300 | resolve(void 0); 301 | }, 302 | err => { 303 | console.error('gapi: Error on refreshing authorization!'); 304 | reject(err); 305 | } 306 | ); 307 | }); 308 | } 309 | } 310 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | // Copyright (c) Jupyter Development Team. 2 | // Distributed under the terms of the Modified BSD License. 3 | 4 | import '../style/index.css'; 5 | 6 | import { Widget } from '@lumino/widgets'; 7 | 8 | import { map, toArray } from '@lumino/algorithm'; 9 | 10 | import { 11 | ILayoutRestorer, 12 | JupyterFrontEnd, 13 | JupyterFrontEndPlugin 14 | } from '@jupyterlab/application'; 15 | 16 | import { showDialog, Dialog, ICommandPalette } from '@jupyterlab/apputils'; 17 | 18 | import { PathExt } from '@jupyterlab/coreutils'; 19 | 20 | import { ISettingRegistry } from '@jupyterlab/settingregistry'; 21 | 22 | import { IDocumentManager } from '@jupyterlab/docmanager'; 23 | 24 | import { IFileBrowserFactory } from '@jupyterlab/filebrowser'; 25 | 26 | import { IMainMenu } from '@jupyterlab/mainmenu'; 27 | 28 | import { GoogleDriveFileBrowser, NAMESPACE } from './browser'; 29 | 30 | import { getResourceForPath, createPermissions } from './drive'; 31 | 32 | import { GoogleDrive } from './contents'; 33 | 34 | import { loadGapi } from './gapi'; 35 | 36 | /** 37 | * The command IDs used by the plugins. 38 | */ 39 | namespace CommandIDs { 40 | export const shareCurrent = `google-drive:share-current`; 41 | 42 | export const shareBrowser = `google-drive:share-browser-item`; 43 | } 44 | 45 | /** 46 | * The JupyterLab plugin for the Google Drive Filebrowser. 47 | */ 48 | const fileBrowserPlugin: JupyterFrontEndPlugin = { 49 | id: '@jupyterlab/google-drive:drive', 50 | requires: [ 51 | ICommandPalette, 52 | IDocumentManager, 53 | IFileBrowserFactory, 54 | ILayoutRestorer, 55 | IMainMenu, 56 | ISettingRegistry 57 | ], 58 | activate: activateFileBrowser, 59 | autoStart: true 60 | }; 61 | 62 | /** 63 | * Activate the file browser. 64 | */ 65 | function activateFileBrowser( 66 | app: JupyterFrontEnd, 67 | palette: ICommandPalette, 68 | manager: IDocumentManager, 69 | factory: IFileBrowserFactory, 70 | restorer: ILayoutRestorer, 71 | mainMenu: IMainMenu, 72 | settingRegistry: ISettingRegistry 73 | ): void { 74 | const { commands } = app; 75 | const id = fileBrowserPlugin.id; 76 | 77 | // Load the Google Client libraries 78 | loadGapi(); 79 | 80 | // Add the Google Drive backend to the contents manager. 81 | const drive = new GoogleDrive(app.docRegistry); 82 | manager.services.contents.addDrive(drive); 83 | 84 | // Construct a function that determines whether any documents 85 | // associated with this filebrowser are currently open. 86 | const hasOpenDocuments = () => { 87 | const iterator = app.shell.widgets('main'); 88 | let widget = iterator.next(); 89 | while (widget) { 90 | const context = manager.contextForWidget(widget); 91 | if ( 92 | context && 93 | manager.services.contents.driveName(context.path) === drive.name 94 | ) { 95 | return true; 96 | } 97 | widget = iterator.next(); 98 | } 99 | return false; 100 | }; 101 | 102 | // Create the file browser. 103 | const browser = new GoogleDriveFileBrowser( 104 | drive.name, 105 | app.docRegistry, 106 | commands, 107 | manager, 108 | factory, 109 | settingRegistry.load(id), 110 | hasOpenDocuments 111 | ); 112 | 113 | browser.title.iconClass = 'jp-GoogleDrive-icon jp-SideBar-tabIcon'; 114 | browser.title.caption = 'Google Drive'; 115 | browser.id = 'google-drive-file-browser'; 116 | 117 | // Add the file browser widget to the application restorer. 118 | restorer.add(browser, NAMESPACE); 119 | app.shell.add(browser, 'left', { rank: 101 }); 120 | 121 | // Share files with another Google Drive user. 122 | const shareFiles = async (paths: string[]): Promise => { 123 | // Only share files in Google Drive. 124 | const toShare = paths.filter(path => { 125 | if (manager.services.contents.driveName(path) !== drive.name) { 126 | // Don't share if this file is not in the user's Google Drive. 127 | console.warn(`Cannot share ${path} outside of Google Drive`); 128 | return false; 129 | } 130 | return true; 131 | }); 132 | if (toShare.length === 0) { 133 | return; 134 | } 135 | 136 | // Otherwise open the sharing dialog and share the files. 137 | const name = 138 | toShare.length === 1 ? `"${PathExt.basename(toShare[0])}"` : 'files'; 139 | const result = await showDialog({ 140 | title: `Share ${name}`, 141 | body: new Private.EmailAddressWidget(), 142 | focusNodeSelector: 'input', 143 | buttons: [Dialog.cancelButton(), Dialog.okButton({ label: 'SHARE' })] 144 | }); 145 | if (result.button.accept) { 146 | await Promise.all( 147 | toShare.map(async path => { 148 | // Get the file resource for the path and create 149 | // permissions for the valid email addresses. 150 | const addresses = result.value!; 151 | const localPath = manager.services.contents.localPath(path); 152 | const resource = await getResourceForPath(localPath!); 153 | await createPermissions(resource, addresses); 154 | }) 155 | ); 156 | } 157 | return; 158 | }; 159 | 160 | // Add the share-current command to the command registry. 161 | commands.addCommand(CommandIDs.shareCurrent, { 162 | execute: () => { 163 | const widget = app.shell.currentWidget; 164 | const context = widget ? manager.contextForWidget(widget) : undefined; 165 | if (context) { 166 | return shareFiles([context.path]); 167 | } 168 | }, 169 | isEnabled: () => { 170 | const { currentWidget } = app.shell; 171 | if (!currentWidget) { 172 | return false; 173 | } 174 | const context = manager.contextForWidget(currentWidget); 175 | if (!context) { 176 | return false; 177 | } 178 | return manager.services.contents.driveName(context.path) === drive.name; 179 | }, 180 | label: () => { 181 | const { currentWidget } = app.shell; 182 | let fileType = 'File'; 183 | if (currentWidget) { 184 | const context = manager.contextForWidget(currentWidget); 185 | if (context) { 186 | const fts = app.docRegistry.getFileTypesForPath(context.path); 187 | if (fts.length && fts[0].displayName) { 188 | fileType = fts[0].displayName!; 189 | } 190 | } 191 | } 192 | return `Share ${fileType} with Google Drive…`; 193 | } 194 | }); 195 | 196 | // Add the share-browser command to the command registry. 197 | commands.addCommand(CommandIDs.shareBrowser, { 198 | execute: () => { 199 | const browser = factory.tracker.currentWidget; 200 | if (!browser || browser.model.driveName !== drive.name) { 201 | return; 202 | } 203 | const paths = toArray(map(browser.selectedItems(), item => item.path)); 204 | return shareFiles(paths); 205 | }, 206 | iconClass: 'jp-MaterialIcon jp-GoogleDrive-icon', 207 | isEnabled: () => { 208 | const browser = factory.tracker.currentWidget; 209 | return !!browser && browser.model.driveName === drive.name; 210 | }, 211 | label: 'Share with Google Drive…' 212 | }); 213 | 214 | // matches only non-directory items in the Google Drive browser. 215 | const selector = 216 | '.jp-GoogleDriveFileBrowser .jp-DirListing-item[data-isdir="false"]'; 217 | 218 | app.contextMenu.addItem({ 219 | command: CommandIDs.shareBrowser, 220 | selector, 221 | rank: 100 222 | }); 223 | 224 | palette.addItem({ 225 | command: CommandIDs.shareCurrent, 226 | category: 'File Operations' 227 | }); 228 | 229 | mainMenu.fileMenu.addGroup([{ command: CommandIDs.shareCurrent }], 20); 230 | 231 | return; 232 | } 233 | 234 | /** 235 | * Export the plugins as default. 236 | */ 237 | const plugins: JupyterFrontEndPlugin[] = [fileBrowserPlugin]; 238 | export default plugins; 239 | 240 | /** 241 | * A namespace for private data. 242 | */ 243 | namespace Private { 244 | /** 245 | * A widget the reads and parses email addresses. 246 | */ 247 | export class EmailAddressWidget extends Widget { 248 | /** 249 | * Construct a new EmailAddressWidget. 250 | */ 251 | constructor() { 252 | super(); 253 | const text = document.createElement('p'); 254 | text.textContent = 255 | 'Enter collaborator Gmail address. ' + 256 | 'Multiple addresses may be separated by commas'; 257 | this._inputNode = document.createElement('input'); 258 | this.node.appendChild(text); 259 | this.node.appendChild(this._inputNode); 260 | // Set 'multiple' and 'type=email' attributes, 261 | // which strips leading and trailing whitespace from 262 | // the email adresses. 263 | this._inputNode.setAttribute('type', 'email'); 264 | this._inputNode.setAttribute('multiple', ''); 265 | } 266 | 267 | /** 268 | * Get the value for the widget. 269 | */ 270 | getValue(): string[] { 271 | // Pick out the valid email addresses 272 | const candidateAddresses = this._inputNode.value.split(','); 273 | const addresses: string[] = []; 274 | for (let address of candidateAddresses) { 275 | if (isEmail(address)) { 276 | addresses.push(address); 277 | } else { 278 | console.warn(`"${address}" is not a valid email address`); 279 | } 280 | } 281 | return addresses; 282 | } 283 | 284 | private _inputNode: HTMLInputElement; 285 | } 286 | 287 | /** 288 | * Return whether an email address is valid. 289 | * Uses a regexp given in the html spec here: 290 | * https://html.spec.whatwg.org/multipage/input.html#e-mail-state-(type=email) 291 | * 292 | * #### Notes: this is not a perfect test, but it should be 293 | * good enough for most use cases. 294 | * 295 | * @param email: the canditate email address. 296 | * 297 | * @returns a boolean for whether it is a valid email. 298 | */ 299 | function isEmail(email: string): boolean { 300 | const re = RegExp( 301 | /^[a-zA-Z0-9.!#$%&'*+\/=?^_`{|}~-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$/ 302 | ); 303 | return re.test(email); 304 | } 305 | } 306 | -------------------------------------------------------------------------------- /style/account-dark.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /style/account-light.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /style/drive.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jupyterlab/jupyterlab-google-drive/13d94b64a9605bf70fe0e5430f4817cfff088e83/style/drive.png -------------------------------------------------------------------------------- /style/drive_dark.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jupyterlab/jupyterlab-google-drive/13d94b64a9605bf70fe0e5430f4817cfff088e83/style/drive_dark.png -------------------------------------------------------------------------------- /style/drive_light.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jupyterlab/jupyterlab-google-drive/13d94b64a9605bf70fe0e5430f4817cfff088e83/style/drive_light.png -------------------------------------------------------------------------------- /style/index.css: -------------------------------------------------------------------------------- 1 | /*----------------------------------------------------------------------------- 2 | | Copyright (c) Jupyter Development Team. 3 | | Distributed under the terms of the Modified BSD License. 4 | |----------------------------------------------------------------------------*/ 5 | 6 | .jp-GoogleDriveFileBrowser { 7 | background-color: var(--jp-layout-color1); 8 | height: 100%; 9 | } 10 | 11 | .jp-GoogleDriveFileBrowser .jp-FileBrowser { 12 | flex-grow: 1; 13 | height: 100%; 14 | } 15 | 16 | .jp-GoogleLoginScreen { 17 | height: 100%; 18 | display: flex; 19 | flex-direction: column; 20 | justify-content: center; 21 | align-items: center; 22 | } 23 | 24 | .jp-GoogleDrive-logo { 25 | background-size: 100%; 26 | background-position: center; 27 | background-image: url(drive.png); 28 | } 29 | 30 | .jp-GoogleLoginScreen .jp-GoogleDrive-logo { 31 | width: 150px; 32 | height: 150px; 33 | } 34 | 35 | /** 36 | * The application context menu is outside of the application shell, 37 | * so the normal CSS selector for themeing the icon won't work. 38 | * We *can* accomplish it, however, by using a CSS sibling selector. 39 | * Not pretty, but it works. 40 | */ 41 | .jp-ApplicationShell[data-theme-light='true'] ~ .p-Menu .jp-GoogleDrive-icon { 42 | background-image: url(drive_light.png); 43 | } 44 | .jp-ApplicationShell[data-theme-light='false'] ~ .p-Menu .jp-GoogleDrive-icon { 45 | background-image: url(drive_dark.png); 46 | } 47 | 48 | /** 49 | * Choose the google drive icon depending on the theme. 50 | */ 51 | [data-jp-theme-light='true'] .jp-GoogleDrive-icon { 52 | background-image: url(drive_light.png); 53 | } 54 | [data-jp-theme-light='false'] .jp-GoogleDrive-icon { 55 | background-image: url(drive_dark.png); 56 | } 57 | 58 | #setting-editor .jp-PluginList-icon.jp-GoogleDrive-logo { 59 | background-size: 85%; 60 | background-repeat: no-repeat; 61 | background-position: center; 62 | } 63 | 64 | .jp-GoogleDrive-text { 65 | font-size: var(--jp-ui-font-size3); 66 | color: var(--jp-ui-font-color1); 67 | padding: 12px; 68 | } 69 | 70 | [data-theme-light='true'] .jp-GoogleUserBadge { 71 | background-image: url(account-light.svg); 72 | } 73 | 74 | [data-theme-light='false'] .jp-GoogleUserBadge { 75 | background-image: url(account-dark.svg); 76 | } 77 | 78 | .jp-ShareIcon { 79 | background-image: url(share.svg); 80 | } 81 | -------------------------------------------------------------------------------- /style/share.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /test/build-tests.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | 4 | # Conditionally build drive tests if we have the proper environment variables 5 | if [ ! -z $CLIENT_ID ] && [ ! -z $CLIENT_SECRET ] && [ ! -z $REFRESH_TOKEN ]; then 6 | 7 | echo "Building remote Google Drive tests" 8 | 9 | # Run the script to get the access token. 10 | cd get-access-token 11 | jlpm install 12 | node get-access-token.js > token.txt 13 | source token.txt && rm token.txt 14 | cd .. 15 | 16 | # Patch the access token into the appropriate file 17 | sed -i "s/const ACCESS_TOKEN.*$/const ACCESS_TOKEN = '$ACCESS_TOKEN'/" src/util.ts 18 | sed -i "s/const CLIENT_ID.*$/const CLIENT_ID = '$CLIENT_ID'/" src/util.ts 19 | 20 | fi 21 | 22 | tsc 23 | webpack --config webpack.config.js 24 | -------------------------------------------------------------------------------- /test/get-access-token/get-access-token.js: -------------------------------------------------------------------------------- 1 | var google = require('googleapis'); 2 | var OAuth2Client = google.auth.OAuth2; 3 | 4 | const CLIENT_ID = process.env.CLIENT_ID; 5 | const CLIENT_SECRET = process.env.CLIENT_SECRET; 6 | const REFRESH_TOKEN = process.env.REFRESH_TOKEN; 7 | 8 | const client = new OAuth2Client(CLIENT_ID, CLIENT_SECRET); 9 | client.setCredentials({ 10 | refresh_token: REFRESH_TOKEN, 11 | access_token: '', 12 | expiry_date: true 13 | }); 14 | 15 | client.getAccessToken((err, token) => { 16 | console.log('export ACCESS_TOKEN=' + token); 17 | }); 18 | -------------------------------------------------------------------------------- /test/get-access-token/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "gapi-access-token-generator", 3 | "version": "0.1.0", 4 | "description": "Generate access token for testing purposes.", 5 | "main": "authorize.js", 6 | "scripts": { 7 | "test": "echo \"Error: no test specified\" && exit 1" 8 | }, 9 | "author": "", 10 | "license": "ISC", 11 | "dependencies": { 12 | "googleapis": "^21.3.0" 13 | }, 14 | "repository": { 15 | "type": "git", 16 | "url": "https://github.com/jupyterlab/jupyterlab-google-drive.git" 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /test/karma.conf.js: -------------------------------------------------------------------------------- 1 | module.exports = function(config) { 2 | config.set({ 3 | basePath: '.', 4 | frameworks: ['mocha'], 5 | reporters: ['mocha'], 6 | plugins: [ 7 | 'karma-chrome-launcher', 8 | 'karma-firefox-launcher', 9 | 'karma-mocha', 10 | 'karma-mocha-reporter', 11 | 'karma-sourcemap-loader' 12 | ], 13 | client: { 14 | mocha: { 15 | timeout: 10000, // 10 seconds: Google Drive can be slow. 16 | retries: 3 // Allow for slow server on CI. 17 | } 18 | }, 19 | files: [ 20 | '../node_modules/es6-promise/dist/es6-promise.js', 21 | './build/bundle.js' 22 | ], 23 | preprocessors: { 24 | 'build/bundle.js': ['sourcemap'] 25 | }, 26 | port: 8888, 27 | colors: true, 28 | singleRun: true, 29 | logLevel: config.LOG_INFO 30 | }); 31 | }; 32 | -------------------------------------------------------------------------------- /test/run-tests.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | karma start --browsers=Firefox karma.conf.js 4 | 5 | # Conditionally run drive tests if we have the proper environment variables 6 | if [ ! -z $CLIENT_ID ] && [ ! -z $CLIENT_SECRET ] && [ ! -z $REFRESH_TOKEN ]; then 7 | 8 | echo "Running remote Google Drive tests" 9 | karma start --browsers=Firefox karma.conf.js || karma start --browsers=Firefox karma.conf.js 10 | 11 | fi 12 | -------------------------------------------------------------------------------- /test/src/contents.spec.ts: -------------------------------------------------------------------------------- 1 | // Copyright (c) Jupyter Development Team. 2 | // Distributed under the terms of the Modified BSD License. 3 | 4 | import expect = require('expect.js'); 5 | 6 | import { loadGapi } from '../../lib/gapi'; 7 | 8 | import { DocumentRegistry } from '@jupyterlab/docregistry'; 9 | 10 | import { Contents } from '@jupyterlab/services'; 11 | 12 | import { JSONExt, UUID } from '@lumino/coreutils'; 13 | 14 | import { GoogleDrive } from '../../lib/contents'; 15 | 16 | import { authorizeGapiTesting, expectFailure, expectAjaxError } from './util'; 17 | 18 | const DEFAULT_DIRECTORY: Contents.IModel = { 19 | name: 'jupyterlab_test_directory', 20 | path: 'My Drive/jupyterlab_test_directory', 21 | type: 'directory', 22 | created: 'yesterday', 23 | last_modified: 'today', 24 | writable: false, 25 | mimetype: '', 26 | content: undefined, 27 | format: 'json' 28 | }; 29 | 30 | const DEFAULT_TEXT_FILE: Contents.IModel = { 31 | name: 'jupyterlab_test_file_', 32 | path: 'My Drive/jupyterlab_test_directory/jupyterlab_test_file_', 33 | type: 'file', 34 | created: 'yesterday', 35 | last_modified: 'today', 36 | writable: false, 37 | mimetype: '', 38 | content: 'This is a text file with unicode: 클래스의 정의한 함수', 39 | format: 'text' 40 | }; 41 | 42 | const DEFAULT_NOTEBOOK: Contents.IModel = { 43 | name: 'jupyterlab_test_notebook_', 44 | path: 'My Drive/jupyterlab_test_directory/jupyterlab_test_notebook_', 45 | type: 'notebook', 46 | created: 'yesterday', 47 | last_modified: 'today', 48 | writable: false, 49 | mimetype: '', 50 | content: { 51 | cells: [ 52 | { 53 | cell_type: 'markdown', 54 | metadata: {}, 55 | source: ['Here is some content. 클래스의 정의한 함수'] 56 | }, 57 | { 58 | cell_type: 'code', 59 | execution_count: 1, 60 | metadata: {}, 61 | outputs: [ 62 | { 63 | name: 'stdout', 64 | output_type: 'stream', 65 | text: ['3\n'] 66 | } 67 | ], 68 | source: ['print(1+2)'] 69 | } 70 | ], 71 | metadata: { 72 | kernelspec: { 73 | display_name: 'Python 3', 74 | language: 'python', 75 | name: 'python3' 76 | }, 77 | language_info: { 78 | codemirror_mode: { 79 | name: 'ipython', 80 | version: 3 81 | } 82 | } 83 | }, 84 | nbformat: 4, 85 | nbformat_minor: 2 86 | }, 87 | format: 'json' 88 | }; 89 | 90 | describe('GoogleDrive', () => { 91 | let registry: DocumentRegistry; 92 | let drive: GoogleDrive; 93 | 94 | before(async () => { 95 | try { 96 | registry = new DocumentRegistry(); 97 | await loadGapi(); 98 | await authorizeGapiTesting(); 99 | } catch (err) { 100 | console.error(err); 101 | } 102 | }); 103 | 104 | beforeEach(() => { 105 | drive = new GoogleDrive(registry); 106 | }); 107 | 108 | afterEach(() => { 109 | drive.dispose(); 110 | }); 111 | 112 | describe('#constructor()', () => { 113 | it('should create a new Google Drive object', () => { 114 | let newDrive = new GoogleDrive(registry); 115 | expect(newDrive).to.be.a(GoogleDrive); 116 | newDrive.dispose(); 117 | }); 118 | }); 119 | 120 | describe('#name', () => { 121 | it('should return "GDrive"', () => { 122 | expect(drive.name).to.be('GDrive'); 123 | }); 124 | }); 125 | 126 | describe('#get()', () => { 127 | it('should get the contents of the pseudo-root', async () => { 128 | const contents = await drive.get(''); 129 | expect(contents.name).to.be(''); 130 | expect(contents.format).to.be('json'); 131 | expect(contents.type).to.be('directory'); 132 | expect(contents.writable).to.be(false); 133 | }); 134 | 135 | it('should get the contents of `My Drive`', async () => { 136 | const contents = await drive.get('My Drive'); 137 | expect(contents.name).to.be('My Drive'); 138 | expect(contents.format).to.be('json'); 139 | expect(contents.type).to.be('directory'); 140 | expect(contents.writable).to.be(true); 141 | }); 142 | 143 | it('should get the contents of `Shared with me`', async () => { 144 | const contents = await drive.get('Shared with me'); 145 | expect(contents.name).to.be('Shared with me'); 146 | expect(contents.format).to.be('json'); 147 | expect(contents.type).to.be('directory'); 148 | expect(contents.writable).to.be(false); 149 | }); 150 | }); 151 | 152 | describe('#save()', () => { 153 | it('should save a file', async () => { 154 | let id = UUID.uuid4(); 155 | let contents = { 156 | ...DEFAULT_TEXT_FILE, 157 | name: DEFAULT_TEXT_FILE.name + String(id), 158 | path: DEFAULT_TEXT_FILE.path + String(id) 159 | }; 160 | const model = await drive.save(contents.path, contents); 161 | expect(model.name).to.be(contents.name); 162 | expect(model.content).to.be(contents.content); 163 | await drive.delete(model.path); 164 | }); 165 | 166 | it('should be able to get an identical file back', async () => { 167 | let id = UUID.uuid4(); 168 | let contents = { 169 | ...DEFAULT_TEXT_FILE, 170 | name: DEFAULT_TEXT_FILE.name + String(id), 171 | path: DEFAULT_TEXT_FILE.path + String(id) 172 | }; 173 | await drive.save(contents.path, contents); 174 | const model = await drive.get(contents.path); 175 | expect(model.name).to.be(contents.name); 176 | expect(model.content).to.be(contents.content); 177 | await drive.delete(model.path); 178 | }); 179 | 180 | it('should save a notebook', async () => { 181 | let id = UUID.uuid4(); 182 | // Note, include .ipynb to interpret the result as a notebook. 183 | let contents = { 184 | ...DEFAULT_NOTEBOOK, 185 | name: DEFAULT_NOTEBOOK.name + String(id) + '.ipynb', 186 | path: DEFAULT_NOTEBOOK.path + String(id) + '.ipynb' 187 | }; 188 | const model = await drive.save(contents.path, contents); 189 | expect(model.name).to.be(contents.name); 190 | expect(JSONExt.deepEqual(model.content, contents.content)).to.be(true); 191 | await drive.delete(model.path); 192 | }); 193 | 194 | it('should be able to get an identical notebook back', async () => { 195 | let id = UUID.uuid4(); 196 | // Note, include .ipynb to interpret the result as a notebook. 197 | let contents = { 198 | ...DEFAULT_NOTEBOOK, 199 | name: DEFAULT_NOTEBOOK.name + String(id) + '.ipynb', 200 | path: DEFAULT_NOTEBOOK.path + String(id) + '.ipynb' 201 | }; 202 | await drive.save(contents.path, contents); 203 | const model = await drive.get(contents.path); 204 | expect(model.name).to.be(contents.name); 205 | expect(JSONExt.deepEqual(model.content, contents.content)).to.be(true); 206 | await drive.delete(model.path); 207 | }); 208 | 209 | it('should emit the fileChanged signal', async () => { 210 | let id = UUID.uuid4(); 211 | let contents = { 212 | ...DEFAULT_TEXT_FILE, 213 | name: DEFAULT_TEXT_FILE.name + String(id), 214 | path: DEFAULT_TEXT_FILE.path + String(id) 215 | }; 216 | let called = false; 217 | const onFileChanged = (sender, args) => { 218 | expect(args.type).to.be('save'); 219 | expect(args.oldValue).to.be(null); 220 | expect(args.newValue.path).to.be(contents.path); 221 | called = true; 222 | }; 223 | drive.fileChanged.connect(onFileChanged); 224 | const model = await drive.save(contents.path, contents); 225 | drive.fileChanged.disconnect(onFileChanged); 226 | await drive.delete(model.path); 227 | expect(called).to.be(true); 228 | }); 229 | }); 230 | 231 | describe('#fileChanged', () => { 232 | it('should be emitted when a file changes', async () => { 233 | let called = false; 234 | const onFileChanged = (sender, args) => { 235 | expect(sender).to.be(drive); 236 | expect(args.type).to.be('new'); 237 | expect(args.oldValue).to.be(null); 238 | expect(args.newValue.name.indexOf('untitled') === -1).to.be(false); 239 | called = true; 240 | }; 241 | drive.fileChanged.connect(onFileChanged); 242 | const model = await drive.newUntitled({ 243 | path: DEFAULT_DIRECTORY.path, 244 | type: 'file' 245 | }); 246 | drive.fileChanged.disconnect(onFileChanged); 247 | await drive.delete(model.path); 248 | expect(called).to.be(true); 249 | }); 250 | }); 251 | 252 | describe('#isDisposed', () => { 253 | it('should test whether the drive is disposed', () => { 254 | expect(drive.isDisposed).to.be(false); 255 | drive.dispose(); 256 | expect(drive.isDisposed).to.be(true); 257 | }); 258 | }); 259 | 260 | describe('#dispose()', () => { 261 | it('should dispose of the resources used by the drive', () => { 262 | expect(drive.isDisposed).to.be(false); 263 | drive.dispose(); 264 | expect(drive.isDisposed).to.be(true); 265 | drive.dispose(); 266 | expect(drive.isDisposed).to.be(true); 267 | }); 268 | }); 269 | 270 | describe('#getDownloadUrl()', () => { 271 | let contents: Contents.IModel; 272 | 273 | before(async () => { 274 | let id = UUID.uuid4(); 275 | contents = { 276 | ...DEFAULT_TEXT_FILE, 277 | name: DEFAULT_TEXT_FILE.name + String(id), 278 | path: DEFAULT_TEXT_FILE.path + String(id) 279 | }; 280 | await drive.save(contents.path, contents); 281 | }); 282 | 283 | after(async () => { 284 | await drive.delete(contents.path); 285 | }); 286 | 287 | it('should get the url of a file', async () => { 288 | const url = await drive.getDownloadUrl(contents.path); 289 | expect(url.length > 0).to.be(true); 290 | }); 291 | 292 | it('should not handle relative paths', async () => { 293 | let url = drive.getDownloadUrl('My Drive/../' + contents.path); 294 | await expectFailure(url); 295 | }); 296 | }); 297 | 298 | describe('#newUntitled()', () => { 299 | it('should create a file', async () => { 300 | const model = await drive.newUntitled({ 301 | path: DEFAULT_DIRECTORY.path, 302 | type: 'file', 303 | ext: 'test' 304 | }); 305 | expect(model.path).to.be(DEFAULT_DIRECTORY.path + '/' + model.name); 306 | expect(model.name.indexOf('untitled') === -1).to.be(false); 307 | expect(model.name.indexOf('test') === -1).to.be(false); 308 | await drive.delete(model.path); 309 | }); 310 | 311 | it('should create a directory', async () => { 312 | let options: Contents.ICreateOptions = { 313 | path: DEFAULT_DIRECTORY.path, 314 | type: 'directory' 315 | }; 316 | const model = await drive.newUntitled(options); 317 | expect(model.path).to.be(DEFAULT_DIRECTORY.path + '/' + model.name); 318 | expect(model.name.indexOf('Untitled Folder') === -1).to.be(false); 319 | await drive.delete(model.path); 320 | }); 321 | 322 | it('should emit the fileChanged signal', async () => { 323 | let called = false; 324 | const onFileChanged = (sender, args) => { 325 | expect(args.type).to.be('new'); 326 | expect(args.oldValue).to.be(null); 327 | expect(args.newValue.path).to.be( 328 | DEFAULT_DIRECTORY.path + '/' + args.newValue.name 329 | ); 330 | expect(args.newValue.name.indexOf('untitled') === -1).to.be(false); 331 | expect(args.newValue.name.indexOf('test') === -1).to.be(false); 332 | called = true; 333 | }; 334 | drive.fileChanged.connect(onFileChanged); 335 | const model = await drive.newUntitled({ 336 | type: 'file', 337 | ext: 'test', 338 | path: DEFAULT_DIRECTORY.path 339 | }); 340 | drive.fileChanged.disconnect(onFileChanged); 341 | await drive.delete(model.path); 342 | expect(called).to.be(true); 343 | }); 344 | }); 345 | 346 | describe('#delete()', () => { 347 | it('should delete a file', async () => { 348 | let id = UUID.uuid4(); 349 | let contents = { 350 | ...DEFAULT_TEXT_FILE, 351 | name: DEFAULT_TEXT_FILE.name + String(id), 352 | path: DEFAULT_TEXT_FILE.path + String(id) 353 | }; 354 | const model = await drive.save(contents.path, contents); 355 | await drive.delete(model.path); 356 | }); 357 | 358 | it('should emit the fileChanged signal', async () => { 359 | let id = UUID.uuid4(); 360 | let contents = { 361 | ...DEFAULT_TEXT_FILE, 362 | name: DEFAULT_TEXT_FILE.name + String(id), 363 | path: DEFAULT_TEXT_FILE.path + String(id) 364 | }; 365 | const model = await drive.save(contents.path, contents); 366 | drive.fileChanged.connect((sender, args) => { 367 | expect(args.type).to.be('delete'); 368 | expect(args.oldValue.path).to.be(contents.path); 369 | }); 370 | await drive.delete(contents.path); 371 | }); 372 | }); 373 | 374 | describe('#rename()', () => { 375 | it('should rename a file', async () => { 376 | let id1 = UUID.uuid4(); 377 | let id2 = UUID.uuid4(); 378 | let path2 = DEFAULT_TEXT_FILE.path + id2; 379 | let contents = { 380 | ...DEFAULT_TEXT_FILE, 381 | name: DEFAULT_TEXT_FILE.name + id1, 382 | path: DEFAULT_TEXT_FILE.path + id1 383 | }; 384 | await drive.save(contents.path, contents); 385 | const model = await drive.rename(contents.path, path2); 386 | expect(model.name).to.be(DEFAULT_TEXT_FILE.name + id2); 387 | expect(model.path).to.be(path2); 388 | expect(model.content).to.be(contents.content); 389 | await drive.delete(model.path); 390 | }); 391 | 392 | it('should emit the fileChanged signal', async () => { 393 | let id1 = UUID.uuid4(); 394 | let id2 = UUID.uuid4(); 395 | let path2 = DEFAULT_TEXT_FILE.path + id2; 396 | let contents = { 397 | ...DEFAULT_TEXT_FILE, 398 | name: DEFAULT_TEXT_FILE.name + id1, 399 | path: DEFAULT_TEXT_FILE.path + id1 400 | }; 401 | let called = false; 402 | const onFileChanged = (sender, args) => { 403 | expect(args.type).to.be('rename'); 404 | expect(args.oldValue.path).to.be(contents.path); 405 | expect(args.newValue.path).to.be(path2); 406 | called = true; 407 | }; 408 | await drive.save(contents.path, contents); 409 | drive.fileChanged.connect(onFileChanged); 410 | const model = await drive.rename(contents.path, path2); 411 | drive.fileChanged.disconnect(onFileChanged); 412 | await drive.delete(model.path); 413 | expect(called).to.be(true); 414 | }); 415 | }); 416 | 417 | describe('#copy()', () => { 418 | it('should copy a file', async () => { 419 | let id = UUID.uuid4(); 420 | let contents = { 421 | ...DEFAULT_TEXT_FILE, 422 | name: DEFAULT_TEXT_FILE.name + id, 423 | path: DEFAULT_TEXT_FILE.path + id 424 | }; 425 | await drive.save(contents.path, contents); 426 | const model = await drive.copy(contents.path, DEFAULT_DIRECTORY.path); 427 | expect(model.name.indexOf(contents.name) === -1).to.be(false); 428 | expect(model.name.indexOf('Copy') === -1).to.be(false); 429 | expect(model.content).to.be(contents.content); 430 | 431 | let first = drive.delete(contents.path); 432 | let second = drive.delete(model.path); 433 | await Promise.all([first, second]); 434 | }); 435 | 436 | it('should emit the fileChanged signal', async () => { 437 | let id = UUID.uuid4(); 438 | let contents = { 439 | ...DEFAULT_TEXT_FILE, 440 | name: DEFAULT_TEXT_FILE.name + id, 441 | path: DEFAULT_TEXT_FILE.path + id 442 | }; 443 | let called = false; 444 | const onFileChanged = (sender, args) => { 445 | expect(args.type).to.be('new'); 446 | expect(args.oldValue).to.be(null); 447 | expect(args.newValue.content).to.be(contents.content); 448 | expect(args.newValue.name.indexOf(contents.name) === -1).to.be(false); 449 | expect(args.newValue.name.indexOf('Copy') === -1).to.be(false); 450 | called = true; 451 | }; 452 | await drive.save(contents.path, contents); 453 | drive.fileChanged.connect(onFileChanged); 454 | const model = await drive.copy(contents.path, DEFAULT_DIRECTORY.path); 455 | drive.fileChanged.disconnect(onFileChanged); 456 | let first = drive.delete(contents.path); 457 | let second = drive.delete(model.path); 458 | await Promise.all([first, second]); 459 | expect(called).to.be(true); 460 | }); 461 | }); 462 | 463 | describe('#createCheckpoint()', () => { 464 | it('should create a checkpoint', async () => { 465 | let id = UUID.uuid4(); 466 | let contents = { 467 | ...DEFAULT_TEXT_FILE, 468 | name: DEFAULT_TEXT_FILE.name + id, 469 | path: DEFAULT_TEXT_FILE.path + id 470 | }; 471 | await drive.save(contents.path, contents); 472 | const cp = await drive.createCheckpoint(contents.path); 473 | expect(cp.last_modified.length > 0).to.be(true); 474 | expect(cp.id.length > 0).to.be(true); 475 | await drive.delete(contents.path); 476 | }); 477 | }); 478 | 479 | describe('#listCheckpoints()', () => { 480 | it('should list the checkpoints', async () => { 481 | let id = UUID.uuid4(); 482 | let contents = { 483 | ...DEFAULT_TEXT_FILE, 484 | name: DEFAULT_TEXT_FILE.name + id, 485 | path: DEFAULT_TEXT_FILE.path + id 486 | }; 487 | await drive.save(contents.path, contents); 488 | const cp = await drive.createCheckpoint(contents.path); 489 | const cps = await drive.listCheckpoints(contents.path); 490 | expect(cps.filter(c => c.id === cp.id).length === 0).to.be(false); 491 | await drive.delete(contents.path); 492 | }); 493 | }); 494 | 495 | describe('#restoreCheckpoint()', () => { 496 | it('should restore a text file from a checkpoint', async () => { 497 | let id = UUID.uuid4(); 498 | let contents = { 499 | ...DEFAULT_TEXT_FILE, 500 | name: DEFAULT_TEXT_FILE.name + id, 501 | path: DEFAULT_TEXT_FILE.path + id 502 | }; 503 | let newContents = { 504 | ...contents, 505 | content: 'This is some new text' 506 | }; 507 | 508 | await drive.save(contents.path, contents); 509 | const cp = await drive.createCheckpoint(contents.path); 510 | const model = await drive.save(contents.path, newContents); 511 | expect(model.content).to.be(newContents.content); 512 | await drive.restoreCheckpoint(contents.path, cp.id); 513 | const oldModel = await drive.get(contents.path); 514 | expect(oldModel.content).to.be(contents.content); 515 | await drive.delete(contents.path); 516 | }); 517 | 518 | it('should restore a notebook from a checkpoint', async () => { 519 | let id = UUID.uuid4(); 520 | // Note, include .ipynb to interpret the result as a notebook. 521 | let contents = { 522 | ...DEFAULT_NOTEBOOK, 523 | name: DEFAULT_NOTEBOOK.name + String(id) + '.ipynb', 524 | path: DEFAULT_NOTEBOOK.path + String(id) + '.ipynb' 525 | }; 526 | let newContents = { 527 | ...contents, 528 | content: 'This is some new text' 529 | }; 530 | 531 | await drive.save(contents.path, contents); 532 | const cp = await drive.createCheckpoint(contents.path); 533 | const model = await drive.save(contents.path, newContents); 534 | expect(model.content).to.be(newContents.content); 535 | await drive.restoreCheckpoint(contents.path, cp.id); 536 | const oldModel = await drive.get(contents.path); 537 | expect(JSONExt.deepEqual(oldModel.content, contents.content)).to.be(true); 538 | await drive.delete(contents.path); 539 | }); 540 | }); 541 | 542 | describe('#deleteCheckpoint()', () => { 543 | it('should delete a checkpoint', async () => { 544 | let id = UUID.uuid4(); 545 | let contents = { 546 | ...DEFAULT_TEXT_FILE, 547 | name: DEFAULT_TEXT_FILE.name + id, 548 | path: DEFAULT_TEXT_FILE.path + id 549 | }; 550 | await drive.save(contents.path, contents); 551 | const cp = await drive.createCheckpoint(contents.path); 552 | await drive.deleteCheckpoint(contents.path, cp.id); 553 | const cps = await drive.listCheckpoints(contents.path); 554 | expect(cps.filter(c => c.id === cp.id).length === 0).to.be(true); 555 | }); 556 | }); 557 | }); 558 | -------------------------------------------------------------------------------- /test/src/index.ts: -------------------------------------------------------------------------------- 1 | // Copyright (c) Jupyter Development Team. 2 | // Distributed under the terms of the Modified BSD License. 3 | 4 | import './contents.spec'; 5 | -------------------------------------------------------------------------------- /test/src/util.ts: -------------------------------------------------------------------------------- 1 | // Copyright (c) Jupyter Development Team. 2 | // Distributed under the terms of the Modified BSD License. 3 | 4 | import { UUID } from '@lumino/coreutils'; 5 | 6 | import { 7 | TextModelFactory, 8 | DocumentRegistry, 9 | Context 10 | } from '@jupyterlab/docregistry'; 11 | 12 | import { IModelDB } from '@jupyterlab/observables'; 13 | 14 | import { 15 | IRenderMime, 16 | RenderMimeRegistry, 17 | RenderedHTML, 18 | standardRendererFactories 19 | } from '@jupyterlab/rendermime'; 20 | 21 | import { ServiceManager, ServerConnection } from '@jupyterlab/services'; 22 | 23 | import { PromiseDelegate } from '@lumino/coreutils'; 24 | 25 | import { gapiAuthorized, gapiInitialized } from '../../lib/gapi'; 26 | 27 | /** 28 | * Create a context for a file. 29 | */ 30 | export function createFileContext( 31 | path?: string, 32 | manager?: ServiceManager.IManager 33 | ): Context { 34 | manager = manager || Private.manager; 35 | let factory = Private.textFactory; 36 | path = path || UUID.uuid4() + '.txt'; 37 | return new Context({ manager, factory, path }); 38 | } 39 | 40 | /** 41 | * Function to load and authorize gapi with a test account. 42 | */ 43 | export function authorizeGapiTesting(): Promise { 44 | const CLIENT_ID = ''; 45 | const ACCESS_TOKEN = ''; 46 | const DRIVE_SCOPE = 'https://www.googleapis.com/auth/drive'; 47 | const DISCOVERY_DOCS = [ 48 | 'https://www.googleapis.com/discovery/v1/apis/drive/v3/rest' 49 | ]; 50 | 51 | return new Promise(resolve => { 52 | gapi.client 53 | .init({ 54 | discoveryDocs: DISCOVERY_DOCS, 55 | clientId: CLIENT_ID, 56 | scope: DRIVE_SCOPE 57 | }) 58 | .then(() => { 59 | (gapi.client as any).setToken({ 60 | access_token: ACCESS_TOKEN 61 | }); 62 | gapiInitialized.resolve(void 0); 63 | gapiAuthorized.resolve(void 0); 64 | resolve(void 0); 65 | }) 66 | .catch(err => { 67 | console.error(err); 68 | }); 69 | }); 70 | } 71 | 72 | /** 73 | * Expect a failure on a promise with the given message, then call `done`. 74 | */ 75 | export function expectFailure( 76 | promise: Promise, 77 | message?: string 78 | ): Promise { 79 | return promise.then( 80 | (msg: any) => { 81 | throw Error('Expected failure did not occur'); 82 | }, 83 | (error: Error) => { 84 | if (message && error.message.indexOf(message) === -1) { 85 | throw Error(`Error "${message}" not in: "${error.message}"`); 86 | } 87 | } 88 | ); 89 | } 90 | 91 | /** 92 | * Expect an Ajax failure with a given message. 93 | */ 94 | export function expectAjaxError( 95 | promise: Promise, 96 | done: () => void, 97 | message: string 98 | ): Promise { 99 | return promise 100 | .then( 101 | (msg: any) => { 102 | throw Error('Expected failure did not occur'); 103 | }, 104 | (error: ServerConnection.ResponseError) => { 105 | if (error.message !== message) { 106 | throw Error(`Error "${message}" not equal to "${error.message}"`); 107 | } 108 | } 109 | ) 110 | .then(done, done); 111 | } 112 | 113 | /** 114 | * A namespace for private data. 115 | */ 116 | namespace Private { 117 | export const manager = new ServiceManager(); 118 | 119 | export const textFactory = new TextModelFactory(); 120 | 121 | export const rendermime = new RenderMimeRegistry({ 122 | initialFactories: standardRendererFactories 123 | }); 124 | } 125 | -------------------------------------------------------------------------------- /test/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "noImplicitAny": false, 4 | "noEmitOnError": true, 5 | "module": "commonjs", 6 | "moduleResolution": "node", 7 | "target": "ES6", 8 | "outDir": "./build", 9 | "lib": ["ES6", "DOM"], 10 | "types": ["mocha", "expect.js", "node"] 11 | }, 12 | "include": ["src/*"] 13 | } 14 | -------------------------------------------------------------------------------- /test/webpack.config.js: -------------------------------------------------------------------------------- 1 | var path = require('path'); 2 | 3 | module.exports = { 4 | entry: './build/index.js', 5 | output: { 6 | path: __dirname + '/build', 7 | filename: 'bundle.js', 8 | publicPath: './build/' 9 | }, 10 | bail: true, 11 | devtool: 'inline-source-map', 12 | module: { 13 | rules: [ 14 | { test: /\.css$/, loader: 'style-loader!css-loader' }, 15 | { test: /\.md$/, loader: 'raw-loader' }, 16 | { test: /\.(jpg|png|gif|eot|woff|ttf)$/, use: 'file-loader' }, 17 | { test: /\.html$/, loader: 'file-loader?name=[name].[ext]' }, 18 | { test: /\.ipynb$/, loader: 'json-loader' }, 19 | { test: /\.json$/, loader: 'json-loader' }, 20 | { test: /\.js.map$/, loader: 'file-loader' }, 21 | { 22 | test: /\.svg(\?v=\d+\.\d+\.\d+)?$/, 23 | use: 'url-loader?limit=10000&mimetype=image/svg+xml' 24 | } 25 | ] 26 | } 27 | }; 28 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "declaration": true, 4 | "noImplicitAny": true, 5 | "strictNullChecks": true, 6 | "skipLibCheck": true, 7 | "noEmitOnError": true, 8 | "noUnusedLocals": true, 9 | "lib": ["DOM", "ES6"], 10 | "module": "commonjs", 11 | "moduleResolution": "node", 12 | "target": "ES6", 13 | "outDir": "./lib", 14 | "jsx": "react" 15 | }, 16 | "include": ["src/*"] 17 | } 18 | -------------------------------------------------------------------------------- /tslint.json: -------------------------------------------------------------------------------- 1 | { 2 | "rulesDirectory": ["tslint-plugin-prettier"], 3 | "rules": { 4 | "prettier": [true, { "singleQuote": true }], 5 | "align": [true, "parameters", "statements"], 6 | "ban": [ 7 | true, 8 | ["_", "forEach"], 9 | ["_", "each"], 10 | ["$", "each"], 11 | ["angular", "forEach"] 12 | ], 13 | "class-name": true, 14 | "comment-format": [true, "check-space"], 15 | "curly": true, 16 | "eofline": true, 17 | "forin": false, 18 | "indent": [true, "spaces", 2], 19 | "interface-name": [true, "always-prefix"], 20 | "jsdoc-format": true, 21 | "label-position": true, 22 | "max-line-length": [false], 23 | "member-access": false, 24 | "member-ordering": [false], 25 | "new-parens": true, 26 | "no-angle-bracket-type-assertion": true, 27 | "no-any": false, 28 | "no-arg": true, 29 | "no-bitwise": true, 30 | "no-conditional-assignment": true, 31 | "no-consecutive-blank-lines": false, 32 | "no-console": [true, "debug", "info", "time", "timeEnd", "trace"], 33 | "no-construct": true, 34 | "no-debugger": true, 35 | "no-default-export": false, 36 | "no-duplicate-variable": true, 37 | "no-empty": true, 38 | "no-eval": true, 39 | "no-inferrable-types": false, 40 | "no-internal-module": true, 41 | "no-invalid-this": [true, "check-function-in-method"], 42 | "no-null-keyword": false, 43 | "no-reference": true, 44 | "no-require-imports": false, 45 | "no-shadowed-variable": false, 46 | "no-string-literal": false, 47 | "no-switch-case-fall-through": true, 48 | "no-trailing-whitespace": true, 49 | "no-unused-expression": true, 50 | "no-use-before-declare": false, 51 | "no-var-keyword": true, 52 | "no-var-requires": true, 53 | "object-literal-sort-keys": false, 54 | "one-line": [ 55 | true, 56 | "check-open-brace", 57 | "check-catch", 58 | "check-else", 59 | "check-finally", 60 | "check-whitespace" 61 | ], 62 | "one-variable-per-declaration": [true, "ignore-for-loop"], 63 | "quotemark": [true, "single", "avoid-escape"], 64 | "radix": true, 65 | "semicolon": [true, "always", "ignore-bound-class-methods"], 66 | "switch-default": true, 67 | "trailing-comma": [ 68 | false, 69 | { 70 | "multiline": "never", 71 | "singleline": "never" 72 | } 73 | ], 74 | "triple-equals": [true, "allow-null-check", "allow-undefined-check"], 75 | "typedef": [false], 76 | "typedef-whitespace": [ 77 | false, 78 | { 79 | "call-signature": "nospace", 80 | "index-signature": "nospace", 81 | "parameter": "nospace", 82 | "property-declaration": "nospace", 83 | "variable-declaration": "nospace" 84 | }, 85 | { 86 | "call-signature": "space", 87 | "index-signature": "space", 88 | "parameter": "space", 89 | "property-declaration": "space", 90 | "variable-declaration": "space" 91 | } 92 | ], 93 | "use-isnan": true, 94 | "use-strict": [false], 95 | "variable-name": [ 96 | true, 97 | "check-format", 98 | "allow-leading-underscore", 99 | "ban-keywords" 100 | ], 101 | "whitespace": [ 102 | true, 103 | "check-branch", 104 | "check-operator", 105 | "check-separator", 106 | "check-type" 107 | ] 108 | } 109 | } 110 | --------------------------------------------------------------------------------