Lurch web app user interface

source

google-drive-utilities.js


/**
 * Functions for communicating with the Google Drive API.  This file abstracts
 * just the few key ways that Lurch wants to talk to a user's Google Drive, plus
 * the few Google-owned popup windows that may need to be presented, and exposes
 * them in a small API that makes it easier than making raw gapi calls.
 * 
 * The app will not import this module directly, but will instead import the
 * {@link module:GoogleDriveUI Google Drive UI module}, which uses this one
 * under the hood.
 * 
 * @module GoogleDriveUtilities
 */

import { loadScript } from './utilities.js'

// Bring several constants to the top for better organization
const GoogleDriveAPI = 'https://apis.google.com/js/api.js'
const GoogleSignInAPI = 'https://accounts.google.com/gsi/client'
const fileScope = 'https://www.googleapis.com/auth/drive.file'
const uploadEndpoint = 'https://www.googleapis.com/upload/drive/v3/files?uploadType=multipart'
const googleFolderMIMEType = 'application/vnd.google-apps.folder'
const lurchMimeType = 'text/html'

// Internal use only
// Keeps track of whether we have user's Google credentials yet or not
let user = null
let tokenClient = null
let lastToken = null
const getToken = () => new Promise( ( resolve, reject ) => {
    if ( lastToken !== null ) {
        resolve( lastToken )
        return
    }
    if ( tokenClient == null )
        tokenClient = google.accounts.oauth2.initTokenClient( {
            client_id : window.LurchClientId,
            scope : fileScope,
            prompt : 'none',
            callback : response => {
                if ( response.error === undefined ) {
                    lastToken = response.access_token
                    setTimeout(
                        () => lastToken = null,
                        ( response.expires_in - 1 ) * 1000
                    )
                    resolve( response.access_token )
                } else {
                    reject( response.error )
                }
            }
        } )
    tokenClient.requestAccessToken( { login_hint : user.sub } )
} )

// Ensure that we have loaded
//   (a) our API and Client keys,
//   (b) the Google sign-in API, and
//   (c) the Google Drive API.
// Note that we load one API key if we are developing on localhost (a secret key
// we do not share with anyone or publish in our repo, because it could be
// misused by anyone who launches our app on their own machine) but we load a
// different API key if we are deploying the app on lurchmath.github.io (a public
// key we can share with anyone, because it will not function except on that one
// domain, which we control).
loadScript( window.location.hostname == 'localhost' ?
            'google-api-key-secret.js' :
            'google-api-key-public.js' ).then( () => {
loadScript( GoogleSignInAPI ).then( () => {
loadScript( GoogleDriveAPI ).then( () => {
    google.accounts.id.initialize( {
        client_id : window.LurchClientId,
        auto_select : true,
        use_fedcm_for_prompt : true,
        callback : response => {
            const parts = response.credential.split( '.' )
            const payload = parts[1].replace( /\-/g, '+' ).replace( /_/g, '/' )
            user = JSON.parse( atob( payload ) )
        }
    } )
    google.accounts.id.prompt()
    gapi.load( 'client', () => gapi.client.load( 'drive', 'v3' ) )
} ) } ) } )

/**
 * Read a file from the current user's Google Drive.  The client must pass a
 * file ID, which can be obtained by allowing the user to select a file from
 * their drive, using a function such as
 * {@link module:GoogleDriveUtilities.showOpenFilePicker showOpenFilePicker()}.
 * 
 * @param {string} fileId a file ID from Google Drive
 * @returns {Promise} a promise that resolves if the file can be read, passing a
 *   response object whose `body` field contains the file's contents, or that
 *   rejects if the file cannot be read
 * @function
 * @see {@link module:GoogleDriveUtilities.writeNewFileToDrive writeNewFileToDrive()}
 * @see {@link module:GoogleDriveUtilities.updateFileInDrive updateFileInDrive()}
 * @see {@link module:GoogleDriveUtilities.listFilesFromFolder listFilesFromFolder()}
 * @see {@link module:GoogleDriveUtilities.showOpenFilePicker showOpenFilePicker()}
 */
export const readFileFromDrive = fileId => gapi.client.drive.files.get( {
    fileId : fileId,
    alt : 'media'
} )

/**
 * Write a new file to the current user's Google Drive.  The client must pass a
 * filename and folder ID, along with the content they want in the file.  Note
 * that in Google Drive, unlike many filesystems, you can have multiple files
 * with the same name in the same folder, so this function always creates a new
 * file.  To get a folder ID, use a function such as
 * {@link module:GoogleDriveUtilities.showSaveFolderPicker showSaveFolderPicker()}
 * 
 * If you want to update/overwrite an existing file, use
 * {@link module:GoogleDriveUtilities.updateFileInDrive updateFileInDrive()}
 * instead.
 * 
 * This function always creates files with MIME type text/html, because that is
 * the MIME type we are currently using for files created by the Lurch app.
 * 
 * @param {string} filename the name of the new file to create
 * @param {string} folderId a folder ID from Google Drive
 * @param {string} content the data to write into the new file
 * @returns {Promise} a promise that resolves once the file is created, or
 *   rejects with an error message if the write attempt fails
 * @function
 * @see {@link module:GoogleDriveUtilities.readFileFromDrive readFileFromDrive()}
 * @see {@link module:GoogleDriveUtilities.updateFileInDrive updateFileInDrive()}
 */
export const writeNewFileToDrive = ( filename, folderId, content ) => {
    const metadata = {
        name : filename,
        mimeType : lurchMimeType,
        parents : [ folderId ]
    }
    const formData = new FormData()
    formData.append( 'metadata', new Blob(
        [ JSON.stringify( metadata ) ],
        { type : 'application/json' }
    ) )
    formData.append( 'file', new Blob( [ content ], { type : lurchMimeType } ) )
    getToken().then( token => {
        fetch( uploadEndpoint, {
            method : 'POST',
            headers : new Headers( { 'Authorization': 'Bearer ' + token } ),
            body : formData
        } )
    } )
}

/**
 * Update an existing file in the user's Google Drive by replacing its old
 * content with new content.  The client must pass a file ID plus the new
 * content to put into the file.  To get a file ID, use a function such as
 * {@link module:GoogleDriveUtilities.showOpenFilePicker showOpenFilePicker()}.
 * 
 * If you want to create a new file, use
 * {@link module:GoogleDriveUtilities.writeNewFileToDrive writeNewFileToDrive()}
 * instead.
 * 
 * This function always creates files with MIME type text/html, because that is
 * the MIME type we are currently using for files created by the Lurch app.
 * 
 * @param {string} filename the name of the new file to create
 * @param {string} folderId a folder ID from Google Drive
 * @param {string} content the data to write into the new file
 * @returns {Promise} a promise that resolves once the file is created, or
 *   rejects with an error message if the write attempt fails
 * @function
 * @see {@link module:GoogleDriveUtilities.readFileFromDrive readFileFromDrive()}
 * @see {@link module:GoogleDriveUtilities.writeNewFileToDrive writeNewFileToDrive()}
 */
export const updateFileInDrive = ( fileId, newContent ) => {
    const URL = uploadEndpoint.replace( 'files?', `files/${fileId}?` )
    getToken().then( token => {
        fetch( URL, {   
            method : 'PATCH',
            headers : new Headers( { 'Authorization': 'Bearer ' + token } ),
            body: new Blob( [ newContent ], { type : lurchMimeType } )
        } )
    } )
}

/**
 * This function retrieves a list of all files in a given Google Drive folder
 * that are of the MIME type used by this app (text/html).  The files may be
 * fetched in multiple pages, but this function fetches all necessary pages and
 * passes the concatenated files list to its promise resolution function.
 * 
 * @param {string} folderId the folder whose contents should be listed
 * @returns {Promise} a promise that resolves when the full list of files has
 *   been fetched, passing the array of filenames to the resolve function, or
 *   rejects if an error occurs during the reading process
 * @function
 * @see {@link module:GoogleDriveUtilities.readFileFromDrive readFileFromDrive()}
 */
export const listFilesFromFolder = ( folderId = 'root' ) => new Promise( ( resolve, reject ) => {
    const parameters = {
        pageSize : 10,
        fields : 'nextPageToken, files(id, name)',
        q : `'${folderId}' in parents and mimeType = '${lurchMimeType}'`
    }
    const result = [ ]
    const collect = response => {
        response.result.files.forEach( file => result.push( file ) )
        if ( !response.nextPageToken ) return resolve( result )
        parameters.pageToken = response.nextPageToken
        gapi.client.drive.files.list( parameters ).then( collect ).catch( reject )
    }
    gapi.client.drive.files.list( parameters ).then( collect ).catch( reject )
} )

/**
 * Show the user a file picker designed by Google, showing all text/html files
 * in their Google Drive.  The user may navigate among folders to choose files.
 * If they select one, the resulting promise resolves with that file's ID.
 * 
 * @returns {Promise} a promise that resolves with the selected file ID if the
 *   user chooses one, or never resolves if they cancel
 * @function
 * @see {@link module:GoogleDriveUI.showFileOpenDialog showFileOpenDialog()}
 * @see {@link module:GoogleDriveUI.showSaveFolderPicker showSaveFolderPicker()}
 * @see {@link module:GoogleDriveUtilities.readFileFromDrive readFileFromDrive()}
 */
export const showOpenFilePicker = () => new Promise( ( resolve, _ ) => {
    getToken().then( token => {
        gapi.load( 'picker', () => {
            const view = new google.picker.DocsView()
            view.setMode( google.picker.DocsViewMode.LIST )
            view.setIncludeFolders( true )
            view.setMimeTypes( lurchMimeType )
            const picker = new google.picker.PickerBuilder()
                .setTitle( 'Choose a file to open' )
                .setDeveloperKey( window.LurchAPIKey )
                .setAppId( window.LurchClientId )
                .setOAuthToken( token )
                .addView( view )
                .setCallback( event => {
                    if ( event[google.picker.Response.ACTION] === google.picker.Action.PICKED ) {
                        const file = event[google.picker.Response.DOCUMENTS][0]
                        const fileId = file[google.picker.Document.ID]
                        resolve( fileId )
                    }
                } )
                .build()
            picker.setVisible( true )
        } )
    } )
} )

/**
 * Show the user a folder picker designed by Google, allowing the user to pick
 * a folder (other than the root folder) from their Google Drive.
 * 
 * @returns {Promise} a promise that resolves with the selected folder ID if the
 *   user chooses one, or never resolves if they cancel
 * @function
 * @see {@link module:GoogleDriveUI.showOpenFilePicker showOpenFilePicker()}
 * @see {@link module:GoogleDriveUI.showFileOpenDialog showFileOpenDialog()}
 * @see {@link module:GoogleDriveUtilities.writeNewFileToDrive writeNewFileToDrive()}
 */
export const showSaveFolderPicker = () => new Promise( ( resolve, reject ) => {
    getToken().then( token => {
        console.log( 'gonna show save picker with', token )
        gapi.load( 'picker', () => {
            const view = new google.picker.DocsView( google.picker.ViewId.FOLDERS )
            view.setMode( google.picker.DocsViewMode.LIST )
            view.setIncludeFolders( true )
            view.setSelectFolderEnabled( true )
            view.setMimeTypes( googleFolderMIMEType )
            const picker = new google.picker.PickerBuilder()
                .setTitle( 'Choose a folder in which to save your file' )
                .setDeveloperKey( window.LurchAPIKey )
                .setAppId( window.LurchClientId )
                .setOAuthToken( token )
                .addView( view )
                .setCallback( event => {
                    if ( event[google.picker.Response.ACTION] === google.picker.Action.PICKED ) {
                        const pickedItems = event[google.picker.Response.DOCUMENTS]
                        if ( pickedItems.length == 0 ) return reject()
                        resolve( {
                            id : pickedItems[0][google.picker.Document.ID],
                            name : pickedItems[0][google.picker.Document.NAME]
                        } )
                    }
                } )
                .build()
            picker.setVisible( true )
        } )
    } )
} )