mirror of https://github.com/ghostfolio/ghostfolio
You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
387 lines
14 KiB
387 lines
14 KiB
const _ = require('lodash');
|
|
const { JWT } = require('google-auth-library');
|
|
const Axios = require('axios');
|
|
|
|
const GoogleSpreadsheetWorksheet = require('./GoogleSpreadsheetWorksheet');
|
|
const { getFieldMask } = require('./utils');
|
|
|
|
const GOOGLE_AUTH_SCOPES = [
|
|
'https://www.googleapis.com/auth/spreadsheets',
|
|
|
|
// the list from the sheets v4 auth for spreadsheets.get
|
|
// 'https://www.googleapis.com/auth/drive',
|
|
// 'https://www.googleapis.com/auth/drive.readonly',
|
|
// 'https://www.googleapis.com/auth/drive.file',
|
|
// 'https://www.googleapis.com/auth/spreadsheets',
|
|
// 'https://www.googleapis.com/auth/spreadsheets.readonly',
|
|
];
|
|
|
|
const AUTH_MODES = {
|
|
JWT: 'JWT',
|
|
API_KEY: 'API_KEY',
|
|
RAW_ACCESS_TOKEN: 'RAW_ACCESS_TOKEN',
|
|
OAUTH: 'OAUTH',
|
|
};
|
|
|
|
class GoogleSpreadsheet {
|
|
constructor(sheetId) {
|
|
this.spreadsheetId = sheetId;
|
|
this.authMode = null;
|
|
this._rawSheets = {};
|
|
this._rawProperties = null;
|
|
|
|
// create an axios instance with sheet root URL and interceptors to handle auth
|
|
this.axios = Axios.create({
|
|
baseURL: `https://sheets.googleapis.com/v4/spreadsheets/${sheetId || ''}`,
|
|
// send arrays in params with duplicate keys - ie `?thing=1&thing=2` vs `?thing[]=1...`
|
|
// solution taken from https://github.com/axios/axios/issues/604
|
|
paramsSerializer(params) {
|
|
let options = '';
|
|
_.keys(params).forEach((key) => {
|
|
const isParamTypeObject = typeof params[key] === 'object';
|
|
const isParamTypeArray = isParamTypeObject && (params[key].length >= 0);
|
|
if (!isParamTypeObject) options += `${key}=${encodeURIComponent(params[key])}&`;
|
|
if (isParamTypeObject && isParamTypeArray) {
|
|
_.each(params[key], (val) => {
|
|
options += `${key}=${encodeURIComponent(val)}&`;
|
|
});
|
|
}
|
|
});
|
|
return options ? options.slice(0, -1) : options;
|
|
},
|
|
});
|
|
// have to use bind here or the functions dont have access to `this` :(
|
|
this.axios.interceptors.request.use(this._setAxiosRequestAuth.bind(this));
|
|
this.axios.interceptors.response.use(
|
|
this._handleAxiosResponse.bind(this),
|
|
this._handleAxiosErrors.bind(this)
|
|
);
|
|
|
|
return this;
|
|
}
|
|
|
|
// CREATE NEW DOC ////////////////////////////////////////////////////////////////////////////////
|
|
async createNewSpreadsheetDocument(properties) {
|
|
// see updateProperties for more info about available properties
|
|
|
|
if (this.spreadsheetId) {
|
|
throw new Error('Only call `createNewSpreadsheetDocument()` on a GoogleSpreadsheet object that has no spreadsheetId set');
|
|
}
|
|
const response = await this.axios.post(this.url, {
|
|
properties,
|
|
});
|
|
this.spreadsheetId = response.data.spreadsheetId;
|
|
this.axios.defaults.baseURL += this.spreadsheetId;
|
|
|
|
this._rawProperties = response.data.properties;
|
|
_.each(response.data.sheets, (s) => this._updateOrCreateSheet(s));
|
|
}
|
|
|
|
// AUTH RELATED FUNCTIONS ////////////////////////////////////////////////////////////////////////
|
|
async useApiKey(key) {
|
|
this.authMode = AUTH_MODES.API_KEY;
|
|
this.apiKey = key;
|
|
}
|
|
|
|
// token must be created and managed (refreshed) elsewhere
|
|
async useRawAccessToken(token) {
|
|
this.authMode = AUTH_MODES.RAW_ACCESS_TOKEN;
|
|
this.accessToken = token;
|
|
}
|
|
|
|
async useOAuth2Client(oAuth2Client) {
|
|
this.authMode = AUTH_MODES.OAUTH;
|
|
this.oAuth2Client = oAuth2Client;
|
|
}
|
|
|
|
// creds should be an object obtained by loading the json file google gives you
|
|
// impersonateAs is an email of any user in the G Suite domain
|
|
// (only works if service account has domain-wide delegation enabled)
|
|
async useServiceAccountAuth(creds, impersonateAs = null) {
|
|
this.jwtClient = new JWT({
|
|
email: creds.client_email,
|
|
key: creds.private_key,
|
|
scopes: GOOGLE_AUTH_SCOPES,
|
|
subject: impersonateAs,
|
|
});
|
|
await this.renewJwtAuth();
|
|
}
|
|
|
|
async renewJwtAuth() {
|
|
this.authMode = AUTH_MODES.JWT;
|
|
await this.jwtClient.authorize();
|
|
/*
|
|
returned token looks like
|
|
{
|
|
access_token: 'secret-token...',
|
|
token_type: 'Bearer',
|
|
expiry_date: 1576005020000,
|
|
id_token: undefined,
|
|
refresh_token: 'jwt-placeholder'
|
|
}
|
|
*/
|
|
}
|
|
|
|
// TODO: provide mechanism to share single JWT auth between docs?
|
|
|
|
// INTERNAL UTILITY FUNCTIONS ////////////////////////////////////////////////////////////////////
|
|
async _setAxiosRequestAuth(config) {
|
|
// TODO: check auth mode, if valid, renew if expired, etc
|
|
if (this.authMode === AUTH_MODES.JWT) {
|
|
if (!this.jwtClient) throw new Error('JWT auth is not set up properly');
|
|
// this seems to do the right thing and only renew the token if expired
|
|
await this.jwtClient.authorize();
|
|
config.headers.Authorization = `Bearer ${this.jwtClient.credentials.access_token}`;
|
|
} else if (this.authMode === AUTH_MODES.RAW_ACCESS_TOKEN) {
|
|
if (!this.accessToken) throw new Error('Invalid access token');
|
|
config.headers.Authorization = `Bearer ${this.accessToken}`;
|
|
} else if (this.authMode === AUTH_MODES.API_KEY) {
|
|
if (!this.apiKey) throw new Error('Please set API key');
|
|
config.params = config.params || {};
|
|
config.params.key = this.apiKey;
|
|
} else if (this.authMode === AUTH_MODES.OAUTH) {
|
|
const credentials = await this.oAuth2Client.getAccessToken();
|
|
config.headers.Authorization = `Bearer ${credentials.token}`;
|
|
} else {
|
|
throw new Error('You must initialize some kind of auth before making any requests');
|
|
}
|
|
return config;
|
|
}
|
|
|
|
async _handleAxiosResponse(response) { return response; }
|
|
async _handleAxiosErrors(error) {
|
|
// console.log(error);
|
|
if (error.response && error.response.data) {
|
|
// usually the error has a code and message, but occasionally not
|
|
if (!error.response.data.error) throw error;
|
|
|
|
const { code, message } = error.response.data.error;
|
|
error.message = `Google API error - [${code}] ${message}`;
|
|
throw error;
|
|
}
|
|
|
|
if (_.get(error, 'response.status') === 403) {
|
|
if (this.authMode === AUTH_MODES.API_KEY) {
|
|
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)');
|
|
}
|
|
}
|
|
throw error;
|
|
}
|
|
|
|
async _makeSingleUpdateRequest(requestType, requestParams) {
|
|
const response = await this.axios.post(':batchUpdate', {
|
|
requests: [{ [requestType]: requestParams }],
|
|
includeSpreadsheetInResponse: true,
|
|
// responseRanges: [string]
|
|
// responseIncludeGridData: true
|
|
});
|
|
|
|
this._updateRawProperties(response.data.updatedSpreadsheet.properties);
|
|
_.each(response.data.updatedSpreadsheet.sheets, (s) => this._updateOrCreateSheet(s));
|
|
// console.log('API RESPONSE', response.data.replies[0][requestType]);
|
|
return response.data.replies[0][requestType];
|
|
}
|
|
|
|
async _makeBatchUpdateRequest(requests, responseRanges) {
|
|
// this is used for updating batches of cells
|
|
const response = await this.axios.post(':batchUpdate', {
|
|
requests,
|
|
includeSpreadsheetInResponse: true,
|
|
...responseRanges && {
|
|
responseIncludeGridData: true,
|
|
...responseRanges !== '*' && { responseRanges },
|
|
},
|
|
});
|
|
|
|
this._updateRawProperties(response.data.updatedSpreadsheet.properties);
|
|
_.each(response.data.updatedSpreadsheet.sheets, (s) => this._updateOrCreateSheet(s));
|
|
}
|
|
|
|
_ensureInfoLoaded() {
|
|
if (!this._rawProperties) throw new Error('You must call `doc.loadInfo()` before accessing this property');
|
|
}
|
|
|
|
_updateRawProperties(newProperties) { this._rawProperties = newProperties; }
|
|
|
|
_updateOrCreateSheet({ properties, data }) {
|
|
const { sheetId } = properties;
|
|
if (!this._rawSheets[sheetId]) {
|
|
this._rawSheets[sheetId] = new GoogleSpreadsheetWorksheet(this, { properties, data });
|
|
} else {
|
|
this._rawSheets[sheetId]._rawProperties = properties;
|
|
this._rawSheets[sheetId]._fillCellData(data);
|
|
}
|
|
}
|
|
|
|
// BASIC PROPS //////////////////////////////////////////////////////////////////////////////
|
|
_getProp(param) {
|
|
this._ensureInfoLoaded();
|
|
return this._rawProperties[param];
|
|
}
|
|
_setProp(param, newVal) { // eslint-disable-line no-unused-vars
|
|
throw new Error('Do not update directly - use `updateProperties()`');
|
|
}
|
|
|
|
get title() { return this._getProp('title'); }
|
|
get locale() { return this._getProp('locale'); }
|
|
get timeZone() { return this._getProp('timeZone'); }
|
|
get autoRecalc() { return this._getProp('autoRecalc'); }
|
|
get defaultFormat() { return this._getProp('defaultFormat'); }
|
|
get spreadsheetTheme() { return this._getProp('spreadsheetTheme'); }
|
|
get iterativeCalculationSettings() { return this._getProp('iterativeCalculationSettings'); }
|
|
|
|
set title(newVal) { this._setProp('title', newVal); }
|
|
set locale(newVal) { this._setProp('locale', newVal); }
|
|
set timeZone(newVal) { this._setProp('timeZone', newVal); }
|
|
set autoRecalc(newVal) { this._setProp('autoRecalc', newVal); }
|
|
set defaultFormat(newVal) { this._setProp('defaultFormat', newVal); }
|
|
set spreadsheetTheme(newVal) { this._setProp('spreadsheetTheme', newVal); }
|
|
set iterativeCalculationSettings(newVal) { this._setProp('iterativeCalculationSettings', newVal); }
|
|
|
|
async updateProperties(properties) {
|
|
// updateSpreadsheetProperties
|
|
// https://developers.google.com/sheets/api/reference/rest/v4/spreadsheets#SpreadsheetProperties
|
|
|
|
/*
|
|
title (string) - title of the spreadsheet
|
|
locale (string) - ISO code
|
|
autoRecalc (enum) - ON_CHANGE|MINUTE|HOUR
|
|
timeZone (string) - timezone code
|
|
iterativeCalculationSettings (object) - see https://developers.google.com/sheets/api/reference/rest/v4/spreadsheets#IterativeCalculationSettings
|
|
*/
|
|
|
|
await this._makeSingleUpdateRequest('updateSpreadsheetProperties', {
|
|
properties,
|
|
fields: getFieldMask(properties),
|
|
});
|
|
}
|
|
|
|
// BASIC INFO ////////////////////////////////////////////////////////////////////////////////////
|
|
async loadInfo(includeCells) {
|
|
const response = await this.axios.get('/', {
|
|
params: {
|
|
...includeCells && { includeGridData: true },
|
|
},
|
|
});
|
|
this._rawProperties = response.data.properties;
|
|
_.each(response.data.sheets, (s) => this._updateOrCreateSheet(s));
|
|
}
|
|
async getInfo() { return this.loadInfo(); } // alias to mimic old version
|
|
|
|
resetLocalCache() {
|
|
this._rawProperties = null;
|
|
this._rawSheets = {};
|
|
}
|
|
|
|
// WORKSHEETS ////////////////////////////////////////////////////////////////////////////////////
|
|
get sheetCount() {
|
|
this._ensureInfoLoaded();
|
|
return _.values(this._rawSheets).length;
|
|
}
|
|
|
|
get sheetsById() {
|
|
this._ensureInfoLoaded();
|
|
return this._rawSheets;
|
|
}
|
|
|
|
get sheetsByIndex() {
|
|
this._ensureInfoLoaded();
|
|
return _.sortBy(this._rawSheets, 'index');
|
|
}
|
|
|
|
get sheetsByTitle() {
|
|
this._ensureInfoLoaded();
|
|
return _.keyBy(this._rawSheets, 'title');
|
|
}
|
|
|
|
async addSheet(properties = {}) {
|
|
// Request type = `addSheet`
|
|
// https://developers.google.com/sheets/api/reference/rest/v4/spreadsheets/request#AddSheetRequest
|
|
|
|
const response = await this._makeSingleUpdateRequest('addSheet', {
|
|
properties: _.omit(properties, 'headers', 'headerValues', 'headerRowIndex'),
|
|
});
|
|
// _makeSingleUpdateRequest already adds the sheet
|
|
const newSheetId = response.properties.sheetId;
|
|
const newSheet = this.sheetsById[newSheetId];
|
|
|
|
// allow it to work with `.headers` but `.headerValues` is the real prop
|
|
const headers = properties.headerValues || properties.headers;
|
|
if (headers) {
|
|
await newSheet.setHeaderRow(headers, properties.headerRowIndex);
|
|
}
|
|
|
|
return newSheet;
|
|
}
|
|
async addWorksheet(properties) { return this.addSheet(properties); } // alias to mimic old version
|
|
|
|
async deleteSheet(sheetId) {
|
|
// Request type = `deleteSheet`
|
|
// https://developers.google.com/sheets/api/reference/rest/v4/spreadsheets/request#DeleteSheetRequest
|
|
await this._makeSingleUpdateRequest('deleteSheet', { sheetId });
|
|
delete this._rawSheets[sheetId];
|
|
}
|
|
|
|
// NAMED RANGES //////////////////////////////////////////////////////////////////////////////////
|
|
async addNamedRange(name, range, namedRangeId) {
|
|
// namedRangeId is optional
|
|
return this._makeSingleUpdateRequest('addNamedRange', {
|
|
name,
|
|
range,
|
|
namedRangeId,
|
|
});
|
|
}
|
|
|
|
async deleteNamedRange(namedRangeId) {
|
|
return this._makeSingleUpdateRequest('deleteNamedRange', { namedRangeId });
|
|
}
|
|
|
|
// LOADING CELLS /////////////////////////////////////////////////////////////////////////////////
|
|
async loadCells(filters) {
|
|
// you can pass in a single filter or an array of filters
|
|
// strings are treated as a1 ranges
|
|
// objects are treated as GridRange objects
|
|
// TODO: make it support DeveloperMetadataLookup objects
|
|
|
|
// 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)');
|
|
}
|
|
// TODO: make this support Developer Metadata filters
|
|
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) {
|
|
result = await this.axios.get('/', {
|
|
params: {
|
|
includeGridData: true,
|
|
ranges: dataFilters,
|
|
},
|
|
});
|
|
// otherwise we use the getByDataFilter endpoint because it is more flexible
|
|
} else {
|
|
result = await this.axios.post(':getByDataFilter', {
|
|
includeGridData: true,
|
|
dataFilters,
|
|
});
|
|
}
|
|
|
|
const { sheets } = result.data;
|
|
_.each(sheets, (sheet) => { this._updateOrCreateSheet(sheet); });
|
|
}
|
|
}
|
|
|
|
module.exports = GoogleSpreadsheet;
|
|
|