google-spreadsheet
Version:
Google Sheets API -- simple interface to read/write data and manage sheets
700 lines (607 loc) • 24.6 kB
text/typescript
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;
}
}