UNPKG

google-spreadsheet

Version:

Google Sheets API -- simple interface to read/write data and manage sheets

700 lines (607 loc) 24.6 kB
import ky, { HTTPError, KyInstance, RetryOptions } from 'ky'; // eslint-disable-line import/no-extraneous-dependencies import * as _ from './toolkit'; import { GoogleSpreadsheetWorksheet } from './GoogleSpreadsheetWorksheet'; import { getFieldMask } from './utils'; import { DataFilter, DataFilterObject, DeveloperMetadata, GridRange, NamedRangeId, ProtectedRange, SpreadsheetId, SpreadsheetProperties, WorksheetId, WorksheetProperties, } from './types/sheets-types'; import { PermissionRoles, PermissionsList, PublicPermissionRoles } from './types/drive-types'; import { RecursivePartial } from './types/util-types'; import { AUTH_MODES, GoogleApiAuth } from './types/auth-types'; const SHEETS_API_BASE_URL = 'https://sheets.googleapis.com/v4/spreadsheets'; const DRIVE_API_BASE_URL = 'https://www.googleapis.com/drive/v3/files'; const EXPORT_CONFIG: Record<string, { singleWorksheet?: boolean }> = { html: {}, zip: {}, xlsx: {}, ods: {}, csv: { singleWorksheet: true }, tsv: { singleWorksheet: true }, pdf: { singleWorksheet: true }, }; type ExportFileTypes = keyof typeof EXPORT_CONFIG; function getAuthMode(auth: GoogleApiAuth) { if ('getRequestHeaders' in auth) return AUTH_MODES.GOOGLE_AUTH_CLIENT; if ('token' in auth && auth.token) return AUTH_MODES.RAW_ACCESS_TOKEN; // google-auth-library now has an empty `apiKey` property if ('apiKey' in auth && auth.apiKey) return AUTH_MODES.API_KEY; throw new Error('Invalid auth'); } async function getRequestAuthConfig(auth: GoogleApiAuth): Promise<{ headers?: Record<string, string>; searchParams?: Record<string, string> }> { // google-auth-libary methods all can call this method to get the right headers // JWT | OAuth2Client | GoogleAuth | Impersonate | AuthClient if ('getRequestHeaders' in auth) { const headers = await auth.getRequestHeaders(); // google-auth-library v10 uses a Headers object rather than a plain object if ('entries' in headers) { return { headers: Object.fromEntries(headers.entries()) }; } if (_.isObject(headers)) { return { headers: headers as Record<string, string> }; } throw new Error('unexpected headers returned from getRequestHeaders'); } // API key only access passes through the api key as a query param // (note this can only provide read-only access) if ('apiKey' in auth && auth.apiKey) { return { searchParams: { key: auth.apiKey } }; } // RAW ACCESS TOKEN if ('token' in auth && auth.token) { return { headers: { Authorization: `Bearer ${auth.token}` } }; } throw new Error('Invalid auth'); } /** * Google Sheets document * * @description * **This class represents an entire google spreadsheet document** * Provides methods to interact with document metadata/settings, formatting, manage sheets, and acts as the main gateway to interacting with sheets and data that the document contains.q * */ export class GoogleSpreadsheet { readonly spreadsheetId: string; public auth: GoogleApiAuth; get authMode() { return getAuthMode(this.auth); } private _rawSheets: any; private _rawProperties = null as SpreadsheetProperties | null; private _spreadsheetUrl = null as string | null; private _deleted = false; /** * Sheets API [ky](https://github.com/sindresorhus/ky?tab=readme-ov-file#kycreatedefaultoptions) instance * authentication is automatically attached * can be used if unsupported sheets calls need to be made * @see https://developers.google.com/sheets/api/reference/rest * */ readonly sheetsApi: KyInstance; /** * Drive API [ky](https://github.com/sindresorhus/ky?tab=readme-ov-file#kycreatedefaultoptions) instance * authentication automatically attached * can be used if unsupported drive calls need to be made * @topic permissions * @see https://developers.google.com/drive/api/v3/reference * */ readonly driveApi: KyInstance; /** * initialize new GoogleSpreadsheet * @category Initialization * */ constructor( /** id of Google spreadsheet doc */ spreadsheetId: SpreadsheetId, /** authentication to use with Google Sheets API */ auth: GoogleApiAuth, /** Additional options */ options?: { /** * customize retry behavior -- * see the [ky documentation](https://github.com/sindresorhus/ky#retry) for details of the available options and defaults. * */ retryConfig?: RetryOptions | number } ) { const { retryConfig } = options || {}; this.spreadsheetId = spreadsheetId; this.auth = auth; this._rawSheets = {}; this._spreadsheetUrl = null; // create a ky instance with sheet root URL and hooks to handle auth this.sheetsApi = ky.create({ prefix: `${SHEETS_API_BASE_URL}/${spreadsheetId}`, timeout: 180_000, hooks: { beforeRequest: [ ({ request }) => this._setAuthRequestHook(request), ], beforeError: [ ({ error }) => this._errorHook(error), ], }, retry: retryConfig, }); this.driveApi = ky.create({ prefix: `${DRIVE_API_BASE_URL}/${spreadsheetId}`, hooks: { beforeRequest: [({ request }) => this._setAuthRequestHook(request)], beforeError: [({ error }) => this._errorHook(error)], }, retry: retryConfig, }); } // INTERNAL UTILITY FUNCTIONS //////////////////////////////////////////////////////////////////// /** @internal */ async _setAuthRequestHook(req: Request): Promise<Request> { const authConfig = await getRequestAuthConfig(this.auth); if (authConfig.headers) { Object.entries(authConfig.headers).forEach(([key, val]) => { req.headers.set(key, String(val)); }); } if (authConfig.searchParams) { const url = new URL(req.url); Object.entries(authConfig.searchParams).forEach(([key, val]) => { url.searchParams.set(key, String(val)); }); // cannot change the URL with ky, so have to return a new request return new Request(url, req); } return req; } /** @internal */ async _errorHook(error: Error) { if (!(error instanceof HTTPError)) return error; // ky pre-parses the response body into error.data (the response body is already consumed) const errorData = typeof error.data === 'string' ? (() => { try { return JSON.parse(error.data as string); } catch { return undefined; } })() : error.data; if (errorData?.error) { const { code, message } = errorData.error; error.message = `Google API error - [${code}] ${message}`; return error; } if (error.response?.status === 403) { if ('apiKey' in this.auth) { throw new Error('Sheet is private. Use authentication or make public. (see https://github.com/theoephraim/node-google-spreadsheet#a-note-on-authentication for details)'); } } return error; } /** @internal */ async _makeSingleUpdateRequest(requestType: string, requestParams: any) { const response = await this.sheetsApi.post(':batchUpdate', { json: { requests: [{ [requestType]: requestParams }], includeSpreadsheetInResponse: true, // responseRanges: [string] // responseIncludeGridData: true }, }); const data = await response.json<any>(); this._updateRawProperties(data.updatedSpreadsheet.properties); _.each(data.updatedSpreadsheet.sheets, (s: any) => this._updateOrCreateSheet(s)); // console.log('API RESPONSE', response.data.replies[0][requestType]); return data.replies[0][requestType]; } // TODO: review these types // currently only used in batching cell updates /** @internal */ async _makeBatchUpdateRequest(requests: any[], responseRanges?: string | string[]) { // this is used for updating batches of cells const response = await this.sheetsApi.post(':batchUpdate', { json: { requests, includeSpreadsheetInResponse: true, ...responseRanges && { responseIncludeGridData: true, ...responseRanges !== '*' && { responseRanges }, }, }, }); const data = await response.json<any>(); this._updateRawProperties(data.updatedSpreadsheet.properties); _.each(data.updatedSpreadsheet.sheets, (s: any) => this._updateOrCreateSheet(s)); } /** @internal */ _ensureInfoLoaded() { if (!this._rawProperties) throw new Error('You must call `doc.loadInfo()` before accessing this property'); } /** @internal */ _updateRawProperties(newProperties: SpreadsheetProperties) { this._rawProperties = newProperties; } /** @internal */ _updateOrCreateSheet(sheetInfo: { properties: WorksheetProperties, data: any, protectedRanges?: ProtectedRange[] }) { const { properties, data, protectedRanges } = sheetInfo; const { sheetId } = properties; if (!this._rawSheets[sheetId]) { this._rawSheets[sheetId] = new GoogleSpreadsheetWorksheet(this, properties, data, protectedRanges); } else { this._rawSheets[sheetId].updateRawData(properties, data, protectedRanges); } } // BASIC PROPS ////////////////////////////////////////////////////////////////////////////// _getProp(param: keyof SpreadsheetProperties) { this._ensureInfoLoaded(); // ideally ensureInfoLoaded would assert that _rawProperties is in fact loaded // but this is not currently possible in TS - see https://github.com/microsoft/TypeScript/issues/49709 return this._rawProperties![param]; } get title(): SpreadsheetProperties['title'] { return this._getProp('title'); } get locale(): SpreadsheetProperties['locale'] { return this._getProp('locale'); } get timeZone(): SpreadsheetProperties['timeZone'] { return this._getProp('timeZone'); } get autoRecalc(): SpreadsheetProperties['autoRecalc'] { return this._getProp('autoRecalc'); } get defaultFormat(): SpreadsheetProperties['defaultFormat'] { return this._getProp('defaultFormat'); } get spreadsheetTheme(): SpreadsheetProperties['spreadsheetTheme'] { return this._getProp('spreadsheetTheme'); } get iterativeCalculationSettings(): SpreadsheetProperties['iterativeCalculationSettings'] { return this._getProp('iterativeCalculationSettings'); } /** * update spreadsheet properties * @see https://developers.google.com/sheets/api/reference/rest/v4/spreadsheets#SpreadsheetProperties * */ async updateProperties(properties: Partial<SpreadsheetProperties>) { await this._makeSingleUpdateRequest('updateSpreadsheetProperties', { properties, fields: getFieldMask(properties), }); } // BASIC INFO //////////////////////////////////////////////////////////////////////////////////// async loadInfo(includeCells = false) { const response = await this.sheetsApi.get('', { searchParams: { ...includeCells && { includeGridData: true }, }, }); const data = await response.json<any>(); this._spreadsheetUrl = data.spreadsheetUrl; this._rawProperties = data.properties; data.sheets?.forEach((s: any) => this._updateOrCreateSheet(s)); } resetLocalCache() { this._rawProperties = null; this._rawSheets = {}; } // WORKSHEETS //////////////////////////////////////////////////////////////////////////////////// get sheetCount() { this._ensureInfoLoaded(); return _.values(this._rawSheets).length; } get sheetsById(): Record<WorksheetId, GoogleSpreadsheetWorksheet> { this._ensureInfoLoaded(); return this._rawSheets; } get sheetsByIndex(): GoogleSpreadsheetWorksheet[] { this._ensureInfoLoaded(); return _.sortBy(this._rawSheets, 'index'); } get sheetsByTitle(): Record<string, GoogleSpreadsheetWorksheet> { this._ensureInfoLoaded(); return _.keyBy(this._rawSheets, 'title'); } /** * Add new worksheet to document * @see https://developers.google.com/sheets/api/reference/rest/v4/spreadsheets/request#AddSheetRequest * */ async addSheet( properties: Partial< RecursivePartial<WorksheetProperties> & { headerValues: string[], headerRowIndex: number } > = {} ) { const response = await this._makeSingleUpdateRequest('addSheet', { properties: _.omit(properties, 'headerValues', 'headerRowIndex'), }); // _makeSingleUpdateRequest already adds the sheet const newSheetId = response.properties.sheetId; const newSheet = this.sheetsById[newSheetId]; if (properties.headerValues) { await newSheet.setHeaderRow(properties.headerValues, properties.headerRowIndex); } return newSheet; } /** * delete a worksheet * @see https://developers.google.com/sheets/api/reference/rest/v4/spreadsheets/request#DeleteSheetRequest * */ async deleteSheet(sheetId: WorksheetId) { await this._makeSingleUpdateRequest('deleteSheet', { sheetId }); delete this._rawSheets[sheetId]; } // NAMED RANGES ////////////////////////////////////////////////////////////////////////////////// /** * create a new named range * @see https://developers.google.com/sheets/api/reference/rest/v4/spreadsheets/request#AddNamedRangeRequest */ async addNamedRange( /** name of new named range */ name: string, /** GridRange object describing range */ range: GridRange, /** id for named range (optional) */ namedRangeId?: string ) { // TODO: add named range to local cache return this._makeSingleUpdateRequest('addNamedRange', { namedRange: { name, namedRangeId, range, }, }); } /** * delete a named range * @see https://developers.google.com/sheets/api/reference/rest/v4/spreadsheets/request#DeleteNamedRangeRequest * */ async deleteNamedRange( /** id of named range to delete */ namedRangeId: NamedRangeId ) { // TODO: remove named range from local cache return this._makeSingleUpdateRequest('deleteNamedRange', { namedRangeId }); } // LOADING CELLS ///////////////////////////////////////////////////////////////////////////////// /** fetch cell data into local cache */ async loadCells( /** * single filter or array of filters * strings are treated as A1 ranges, objects are treated as GridRange objects, * objects with a `developerMetadataLookup` key are treated as DeveloperMetadataLookup filters * pass nothing to fetch all cells * */ filters?: DataFilter | DataFilter[] ) { // TODO: switch to this mode if using a read-only auth token? const readOnlyMode = this.authMode === AUTH_MODES.API_KEY; const filtersArray = _.isArray(filters) ? filters : [filters]; const dataFilters = _.map(filtersArray, (filter) => { if (_.isString(filter)) { return readOnlyMode ? filter : { a1Range: filter }; } if (_.isObject(filter)) { if (readOnlyMode) { throw new Error('Only A1 ranges are supported when fetching cells with read-only access (using only an API key)'); } if ('developerMetadataLookup' in filter) { return { developerMetadataLookup: filter.developerMetadataLookup }; } return { gridRange: filter }; } throw new Error('Each filter must be an A1 range string or a gridrange object'); }); let result; // when using an API key only, we must use the regular get endpoint // because :getByDataFilter requires higher access if (this.authMode === AUTH_MODES.API_KEY) { const params = new URLSearchParams(); params.append('includeGridData', 'true'); dataFilters.forEach((singleFilter) => { if (!_.isString(singleFilter)) { throw new Error('Only A1 ranges are supported when fetching cells with read-only access (using only an API key)'); } params.append('ranges', singleFilter); }); result = await this.sheetsApi.get('', { searchParams: params, }); // otherwise we use the getByDataFilter endpoint because it is more flexible } else { result = await this.sheetsApi.post(':getByDataFilter', { json: { includeGridData: true, dataFilters, }, }); } const data = await result?.json<any>(); _.each(data.sheets, (sheet: any) => { this._updateOrCreateSheet(sheet); }); } // EXPORTING ///////////////////////////////////////////////////////////// /** * export/download helper, not meant to be called directly (use downloadAsX methods on spreadsheet and worksheet instead) * @internal */ async _downloadAs( fileType: ExportFileTypes, worksheetId: WorksheetId | undefined, returnStreamInsteadOfBuffer?: boolean ) { // see https://stackoverflow.com/questions/11619805/using-the-google-drive-api-to-download-a-spreadsheet-in-csv-format/51235960#51235960 if (!EXPORT_CONFIG[fileType]) throw new Error(`unsupported export fileType - ${fileType}`); if (EXPORT_CONFIG[fileType].singleWorksheet) { if (worksheetId === undefined) throw new Error(`Must specify worksheetId when exporting as ${fileType}`); } else if (worksheetId) throw new Error(`Cannot specify worksheetId when exporting as ${fileType}`); // google UI shows "html" but passes through "zip" if (fileType === 'html') fileType = 'zip'; if (!this._spreadsheetUrl) throw new Error('Cannot export sheet that is not fully loaded'); const exportUrl = this._spreadsheetUrl.replace('edit', 'export'); const response = await this.sheetsApi.get(exportUrl, { prefix: '', // unset baseUrl since we're not hitting the normal sheets API searchParams: { id: this.spreadsheetId, format: fileType, // worksheetId can be 0 ...worksheetId !== undefined && { gid: worksheetId }, }, }); if (returnStreamInsteadOfBuffer) { return response.body; } return response.arrayBuffer(); } /** * exports entire document as html file (zipped) * @topic export * */ async downloadAsZippedHTML(): Promise<ArrayBuffer>; async downloadAsZippedHTML(returnStreamInsteadOfBuffer: false): Promise<ArrayBuffer>; async downloadAsZippedHTML(returnStreamInsteadOfBuffer: true): Promise<ReadableStream>; async downloadAsZippedHTML(returnStreamInsteadOfBuffer?: boolean) { return this._downloadAs('html', undefined, returnStreamInsteadOfBuffer); } /** * @deprecated * use `doc.downloadAsZippedHTML()` instead * */ async downloadAsHTML(returnStreamInsteadOfBuffer?: boolean) { return this._downloadAs('html', undefined, returnStreamInsteadOfBuffer); } /** * exports entire document as xlsx spreadsheet (Microsoft Office Excel) * @topic export * */ async downloadAsXLSX(): Promise<ArrayBuffer>; async downloadAsXLSX(returnStreamInsteadOfBuffer: false): Promise<ArrayBuffer>; async downloadAsXLSX(returnStreamInsteadOfBuffer: true): Promise<ReadableStream>; async downloadAsXLSX(returnStreamInsteadOfBuffer = false) { return this._downloadAs('xlsx', undefined, returnStreamInsteadOfBuffer); } /** * exports entire document as ods spreadsheet (Open Office) * @topic export */ async downloadAsODS(): Promise<ArrayBuffer>; async downloadAsODS(returnStreamInsteadOfBuffer: false): Promise<ArrayBuffer>; async downloadAsODS(returnStreamInsteadOfBuffer: true): Promise<ReadableStream>; async downloadAsODS(returnStreamInsteadOfBuffer = false) { return this._downloadAs('ods', undefined, returnStreamInsteadOfBuffer); } async delete() { await this.driveApi.delete(''); this._deleted = true; // endpoint returns nothing when successful } // PERMISSIONS /////////////////////////////////////////////////////////////////////////////////// /** * list all permissions entries for doc */ async listPermissions(): Promise<PermissionsList> { const listReq = await this.driveApi.get('permissions', { searchParams: { fields: 'permissions(id,type,emailAddress,domain,role,displayName,photoLink,deleted)', }, }); const data = await listReq.json<{ permissions: PermissionsList }>(); return data.permissions; } async setPublicAccessLevel(role: PublicPermissionRoles | false) { const permissions = await this.listPermissions(); const existingPublicPermission = _.find(permissions, (p) => p.type === 'anyone'); if (role === false) { if (!existingPublicPermission) { // doc is already not public... could throw an error or just do nothing return; } await this.driveApi.delete(`permissions/${existingPublicPermission.id}`); } else { const _shareReq = await this.driveApi.post('permissions', { json: { role: role || 'viewer', type: 'anyone', }, }); } } /** share document to email or domain */ async share(emailAddressOrDomain: string, opts?: { /** set role level, defaults to owner */ role?: PermissionRoles, /** set to true if email is for a group */ isGroup?: boolean, /** set to string to include a custom message, set to false to skip sending a notification altogether */ emailMessage?: string | false, // moveToNewOwnersRoot?: string, // /** send a notification email (default = true) */ // sendNotificationEmail?: boolean, // /** support My Drives and shared drives (default = false) */ // supportsAllDrives?: boolean, // /** Issue the request as a domain administrator */ // useDomainAdminAccess?: boolean, }) { let emailAddress: string | undefined; let domain: string | undefined; if (emailAddressOrDomain.includes('@')) { emailAddress = emailAddressOrDomain; } else { domain = emailAddressOrDomain; } const shareReq = await this.driveApi.post('permissions', { searchParams: { ...opts?.emailMessage === false && { sendNotificationEmail: false }, ..._.isString(opts?.emailMessage) && { emailMessage: opts?.emailMessage }, ...opts?.role === 'owner' && { transferOwnership: true }, }, json: { role: opts?.role || 'writer', ...emailAddress && { type: opts?.isGroup ? 'group' : 'user', emailAddress, }, ...domain && { type: 'domain', domain, }, }, }); return shareReq.json(); } /** * delete a permission by its ID * @see https://developers.google.com/drive/api/v3/reference/permissions/delete */ async deletePermission(permissionId: string) { await this.driveApi.delete(`permissions/${permissionId}`); } // DEVELOPER METADATA /////////////////////////////////////////////////////////////////////////// /** * search for developer metadata entries matching the given filters * @see https://developers.google.com/sheets/api/reference/rest/v4/spreadsheets.developerMetadata/search */ async searchDeveloperMetadata( /** array of DataFilter objects to match against */ filters: DataFilterObject[] ): Promise<DeveloperMetadata[]> { const response = await this.sheetsApi.post('developerMetadata:search', { json: { dataFilters: filters }, }); const data = await response.json<any>(); if (!data.matchedDeveloperMetadata) return []; return data.matchedDeveloperMetadata.map((m: any) => m.developerMetadata); } // // CREATE NEW DOC //////////////////////////////////////////////////////////////////////////////// static async createNewSpreadsheetDocument(auth: GoogleApiAuth, properties?: Partial<SpreadsheetProperties>) { // see updateProperties for more info about available properties if (getAuthMode(auth) === AUTH_MODES.API_KEY) { throw new Error('Cannot use api key only to create a new spreadsheet - it is only usable for read-only access of public docs'); } // TODO: handle injecting default credentials if running on google infra const authConfig = await getRequestAuthConfig(auth); const response = await ky.post(SHEETS_API_BASE_URL, { ...authConfig, // has the auth header json: { properties, }, }); const data = await response.json<any>(); const newSpreadsheet = new GoogleSpreadsheet(data.spreadsheetId, auth); // TODO ideally these things aren't public, might want to refactor anyway newSpreadsheet._spreadsheetUrl = data.spreadsheetUrl; newSpreadsheet._rawProperties = data.properties; _.each(data.sheets, (s: any) => newSpreadsheet._updateOrCreateSheet(s)); return newSpreadsheet; } }