Source: drive/driveOps.js

// @ts-check
/** @module */
/**
 * @file Handles talking to the Google Spreadsheet API 
 *  ## Links to Google Drive documentation   
 * [mime-types](https://developers.google.com/drive/api/v3/mime-types)   
 * [all file meta data](https://developers.google.com/drive/api/v3/reference/files)   
 * [search parameters](https://developers.google.com/drive/api/v3/search-parameters)   
 * a few meta types we aren't using that might be interesting are `starred, shared, description`
 *   
 * NOTE: Before using init() **MUST** be called and a driveService passed in.  
 * @author Tod Gentille <tod-gentille@pluralsight.com>
 * @license GPL-3.0-or-later [Full Text](https://spdx.org/licenses/GPL-3.0-or-later.html)
 */
const ds = require('./driveService')
const logger = require('../utils/logger')
const MAX_FILES_PER_PAGE = 1000

let _driveService


/** Enum for the currently supported google mime-types */
const mimeType = {
  /** @type {number} */
  FOLDER: 1, /**  FILE is not a google recognized value, used to mean anything other than a folder. */
  FILE: 2, /** @type {number} */
  SPREADSHEET: 3, /** @type {number} */
  DOC: 4,
  /** place to store the actual strings that google uses for mime-types */
  properties: {
    1: {type: 'application/vnd.google-apps.folder'},
    2: {type: 'N/A'},
    3: {type: 'application/vnd.google-apps.spreadsheet'},
    4: {type: 'application/vnd.google-apps.document'},
  },
  /**  Convenience function to return the google mime-types- called with our ENUM value */
  getType(value) {return mimeType.properties[value].type},

}

const FILE_META_FOR_NAME_SEARCH = 'files(id, name)'
const FILE_META_FOR_FOLDER_SEARCH = 'files(id, name, mimeType)'

/** Allow access to google drive APIs via the driveService (this version for testing) */
const init = (driveService) => {
  _driveService = driveService
}

/** In production just call this to set up access to the drive APIs */
const autoInit = () => {
  _driveService = ds.init()
}


/**
 * Get a list of files/folders that match  
 * @param   {{withName:string,exactMatch:boolean}} fileOptions
  * @returns {Promise<Array.<{id:String,name:String}>>}  
  * @example getFiles({withName:"someName", exactMatch:true})
 */
const getFiles = async (fileOptions) => {
  const {withName} = fileOptions
  const {exactMatch} = fileOptions
  // NOTE: The filename has to be quoted  
  const query = `name ${exactMatch ? ' = ' : 'contains'} '${withName}'`
  const response = await _driveService.files.list(
    {
      q: query,
      pageSize: MAX_FILES_PER_PAGE,
      fields: `nextPageToken, ${FILE_META_FOR_NAME_SEARCH}`,
    })
    .catch(googleError => {
      const {errors} = googleError.response.data.error
      const errMsg = JSON.stringify(errors[0], null, 2)
      logger.error(errMsg)
      throw (`For ${withName} - The Google Drive API returned:${errMsg}`)
    })
  const {files} = response.data
  return files
}

/**
 * Get a single file for the passed name. If a single file isn't found an error is thrown.  
// @ts-ignore
 * @param {{withName:String}} withName
 * @returns {Promise<{id:String,name:String}>}  a single object that has the FILE_META_FOR_NAME_SEARCH properties
 * @example getFile({withName:"someName"})  //forces  exactMatch:true
 */
const getFile = async ({withName}) => {
  const files = await getFiles({withName, exactMatch: true})
  if (files.length !== 1) {
    throw (`Found ${files.length} files.`)
  }
  return files[0]
}


/**
 * Convenience function that returns the id for a file  
 * @param {{withName:String,exactMatch:Boolean}} withNameObj
 * @returns {Promise<string>} google id for the file
 * @example getFileId({withName:"SomeName"})
 *  */
const getFileId = async (withNameObj) => {
  const file = await getFile(withNameObj)
  return file.id
}

/**
 * Just get the files for the user. Will only return the google API max
 * of 1000 files.  
 * @returns {Promise<Array.<{FILE_META_FOR_FOLDER_SEARCH}>>} array of objects, where each object
 * has the properties specified by the constant `FILE_META_FOR_FOLDER_SEARCH`
  * @example listFiles()
  * */
const listFiles = async () => {
  const response = await _driveService.files.list({
    fields: `${FILE_META_FOR_FOLDER_SEARCH}`,
    pageSize: MAX_FILES_PER_PAGE,
  })
    .catch(error => {throw (error)})

  return response.data.files
}


/**
 * Example of how to use the nextPageToken to get all the files
 * in a folder when there are more than 1000
 */
// const countAllFiles = async () => {
//   let nextPage = null // start on first page
//   let totalCount = 0
//   do {
//     const response = await _driveService.files.list({
//       pageToken: nextPage,
//       fields: `nextPageToken, ${FILE_META_FOR_FOLDER_SEARCH}`,
//       pageSize: MAX_FILES_PER_PAGE,
//     })
//       .catch(error => {
//         logger.error(JSON.stringify(error))
//       })
//     nextPage = response.data.nextPageToken
//     if (response.data.files !== undefined) {
//       totalCount += response.data.files.length
//     }

//     logger.debug(nextPage)
//   } while (nextPage !== undefined && totalCount < 20000)
//   logger.info(`Total file count: ${totalCount}`)
// }


/**
 * Get all the Files in the passed folderId   (ofType is optional)  
 * @param {{withFolderId:String,ofType:any}} folderOptions
 * @returns {Promise<Array<{name, id, mimeType}>>} array of file objects where each object has the properties
 * specified by the constant `FILE_META_FOR_FOLDER_SEARCH`
 * @example getFilesInFolder({withFolderId:"someId", ofType:mimeType:SPREADSHEET})
 */
const getFilesInFolder = async (folderOptions) => {
  const {withFolderId} = folderOptions
  const {ofType} = folderOptions
  const mimeClause = getMimeTypeClause(ofType)
  const response = await _driveService.files.list(
    {
      q: `parents in '${withFolderId}' ${mimeClause}`,
      pageSize: MAX_FILES_PER_PAGE,
      fields: `nextPageToken, ${FILE_META_FOR_FOLDER_SEARCH}`,
    })
    .catch(error => {
      const errMsg = JSON.stringify(error, null, 2)
      throw (`\r\nFor parent folder ${withFolderId} - files.list() returned:${errMsg}`)
    })
  const nextToken = response.data.nextPageToken
  if (nextToken !== undefined) {
    logger.debug(`NEXT PAGE TOKEN ${response.data.nextPageToken}`)
  }

  const {files} = response.data
  return files
}


/**
 * Get just the names of the files in the folder (ofType is optional)  
 * @param {{withFolderId:String,ofType:number}} folderOptions
 * @returns {Promise<Array.<string>>} array of strings containing filenames
 * @example getFileNamesInFolder({withFolderId:"someId", ofType:mimeType.SPREADSHEET)
 */
const getFileNamesInFolder = async (folderOptions) => {
  const files = await getFilesInFolder(folderOptions)
  return files.map(e => e.name)
}


/**
 * Get the files in the parent folder and all the children folders (ofType is optional)  
 * @param {{withFolderId:String,ofType:number}} folderOptions
 * @returns {Promise<Array.<{FILE_META_FOR_FOLDER_SEARCH}>>} array of file objects where each object has the properties 
 * specified by the constant `FILE_META_FOR_FOLDER_SEARCH` 
 * @example getFilesRecursively({withFolderId:"someId", ofType:mimeType.SPREADSHEET})
 */
const getFilesRecursively = async (folderOptions) => {
  let result = []
  const {ofType} = folderOptions
  const {withFolderId} = folderOptions
  const folderType = mimeType.getType(mimeType.FOLDER)
  const allTypes = {withFolderId, ofType: undefined}
  const files = await getFilesInFolder(allTypes)
  for (const entry of files) {
    if (entry.mimeType === folderType) {
      const subFolderFiles = await getFilesRecursively({withFolderId: entry.id, ofType})
      result = result.concat(subFolderFiles)
    } else {
      if ((ofType === undefined) || (entry.mimeType === mimeType.getType(ofType))) {
        result.push(entry)
      }
    }
  }
  return result
}


/**
 * Private helper function to look up the mimetype string for the passed enum and construct and "and" clause that
 * can be used in the API search query. The FILE enum isn't a type the API understands
 * but we use it to mean any type of file but NOT a folder.   
* @param {number} type
 * @returns {string} the additional clause to limit the search for the specified type. 
 * For example if mimeType.SPREADSHEET was passed in, then the clause  
 * will be returned.
 * @example getMimeTypeClause(mimeType.SPREADSHEET) will return `and mimeType = application/vnd.google-apps.spreadsheet`
 */
const getMimeTypeClause = (type) => {
  if (type === undefined) {
    return ''
  }

  if (type === mimeType.FILE) {
    return `and mimeType != '${mimeType.getType(mimeType.FOLDER)}'`
  }
  return `and mimeType = '${mimeType.getType(type)}'`
}


module.exports = {
  autoInit,
  init,
  getFile,
  getFiles,
  getFileId,
  listFiles,
  getFilesInFolder,
  getFileNamesInFolder,
  getFilesRecursively,
  mimeType,
}