google-spreadsheet 3.3.0 → 4.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,307 @@
1
+ /* eslint-disable max-classes-per-file */
2
+ import * as _ from 'lodash-es';
3
+
4
+ import { columnToLetter } from './utils';
5
+
6
+ import { GoogleSpreadsheetWorksheet } from './GoogleSpreadsheetWorksheet';
7
+ import { GoogleSpreadsheetCellErrorValue } from './GoogleSpreadsheetCellErrorValue';
8
+
9
+ import {
10
+ CellData,
11
+ CellFormat, CellValueType, ColumnIndex, RowIndex,
12
+ } from './types/sheets-types';
13
+
14
+ export class GoogleSpreadsheetCell {
15
+ private _rawData?: CellData;
16
+ private _draftData: any = {};
17
+ private _error?: GoogleSpreadsheetCellErrorValue;
18
+
19
+ constructor(
20
+ readonly _sheet: GoogleSpreadsheetWorksheet,
21
+ private _rowIndex: RowIndex,
22
+ private _columnIndex: ColumnIndex,
23
+ rawCellData: CellData
24
+ ) {
25
+ this._updateRawData(rawCellData);
26
+ this._rawData = rawCellData; // so TS does not complain
27
+ }
28
+
29
+ // TODO: figure out how to deal with empty rawData
30
+ // newData can be undefined/null if the cell is totally empty and unformatted
31
+ /**
32
+ * update cell using raw CellData coming back from sheets API
33
+ * @internal
34
+ */
35
+ _updateRawData(newData: CellData) {
36
+ this._rawData = newData;
37
+ this._draftData = {};
38
+ if (this._rawData?.effectiveValue && 'errorValue' in this._rawData.effectiveValue) {
39
+ this._error = new GoogleSpreadsheetCellErrorValue(this._rawData.effectiveValue.errorValue);
40
+ } else {
41
+ this._error = undefined;
42
+ }
43
+ }
44
+
45
+ // CELL LOCATION/ADDRESS /////////////////////////////////////////////////////////////////////////
46
+ get rowIndex() { return this._rowIndex; }
47
+ get columnIndex() { return this._columnIndex; }
48
+ get a1Column() { return columnToLetter(this._columnIndex + 1); }
49
+ get a1Row() { return this._rowIndex + 1; } // a1 row numbers start at 1 instead of 0
50
+ get a1Address() { return `${this.a1Column}${this.a1Row}`; }
51
+
52
+ // CELL CONTENTS - VALUE/FORMULA/NOTES ///////////////////////////////////////////////////////////
53
+ get value(): number | boolean | string | null | GoogleSpreadsheetCellErrorValue {
54
+ // const typeKey = _.keys(this._rawData.effectiveValue)[0];
55
+ if (this._draftData.value !== undefined) throw new Error('Value has been changed');
56
+ if (this._error) return this._error;
57
+ if (!this._rawData?.effectiveValue) return null;
58
+ return _.values(this._rawData.effectiveValue)[0];
59
+ }
60
+
61
+
62
+ set value(newValue: number | boolean | Date | string | null | undefined | GoogleSpreadsheetCellErrorValue) {
63
+ // had to include the GoogleSpreadsheetCellErrorValue in the type to make TS happy
64
+ if (newValue instanceof GoogleSpreadsheetCellErrorValue) {
65
+ throw new Error("You can't manually set a value to an error");
66
+ }
67
+
68
+ if (_.isBoolean(newValue)) {
69
+ this._draftData.valueType = 'boolValue';
70
+ } else if (_.isString(newValue)) {
71
+ if (newValue.substring(0, 1) === '=') this._draftData.valueType = 'formulaValue';
72
+ else this._draftData.valueType = 'stringValue';
73
+ } else if (_.isFinite(newValue)) {
74
+ this._draftData.valueType = 'numberValue';
75
+ } else if (_.isNil(newValue)) {
76
+ // null or undefined
77
+ this._draftData.valueType = 'stringValue';
78
+ newValue = '';
79
+ } else {
80
+ throw new Error('Set value to boolean, string, or number');
81
+ }
82
+ this._draftData.value = newValue;
83
+ }
84
+
85
+ get valueType(): CellValueType | null {
86
+ // an error only happens with a formula (as far as I know)
87
+ if (this._error) return 'errorValue';
88
+ if (!this._rawData?.effectiveValue) return null;
89
+ return _.keys(this._rawData.effectiveValue)[0] as CellValueType;
90
+ }
91
+
92
+ /** The formatted value of the cell - this is the value as it's shown to the user */
93
+ get formattedValue(): string | null { return this._rawData?.formattedValue || null; }
94
+
95
+ get formula() { return _.get(this._rawData, 'userEnteredValue.formulaValue', null); }
96
+ set formula(newValue: string | null) {
97
+ if (!newValue) throw new Error('To clear a formula, set `cell.value = null`');
98
+ if (newValue.substring(0, 1) !== '=') throw new Error('formula must begin with "="');
99
+ this.value = newValue; // use existing value setter
100
+ }
101
+ /**
102
+ * @deprecated use `cell.errorValue` instead
103
+ */
104
+ get formulaError() { return this._error; }
105
+ /**
106
+ * error contained in the cell, which can happen with a bad formula (maybe some other weird cases?)
107
+ */
108
+ get errorValue() { return this._error; }
109
+
110
+ get numberValue(): number | undefined {
111
+ if (this.valueType !== 'numberValue') return undefined;
112
+ return this.value as number;
113
+ }
114
+ set numberValue(val: number | undefined) {
115
+ this.value = val;
116
+ }
117
+
118
+ get boolValue(): boolean | undefined {
119
+ if (this.valueType !== 'boolValue') return undefined;
120
+ return this.value as boolean;
121
+ }
122
+ set boolValue(val: boolean | undefined) {
123
+ this.value = val;
124
+ }
125
+
126
+ get stringValue(): string | undefined {
127
+ if (this.valueType !== 'stringValue') return undefined;
128
+ return this.value as string;
129
+ }
130
+ set stringValue(val: string | undefined) {
131
+ if (val?.startsWith('=')) {
132
+ throw new Error('Use cell.formula to set formula values');
133
+ }
134
+ this.value = val;
135
+ }
136
+
137
+ /**
138
+ * Hyperlink contained within the cell.
139
+ *
140
+ * To modify, do not set directly. Instead set cell.formula, for example `cell.formula = \'=HYPERLINK("http://google.com", "Google")\'`
141
+ */
142
+ get hyperlink() {
143
+ if (this._draftData.value) throw new Error('Save cell to be able to read hyperlink');
144
+ return this._rawData?.hyperlink;
145
+ }
146
+
147
+ /** a note attached to the cell */
148
+ get note(): string {
149
+ return this._draftData.note !== undefined ? this._draftData.note : this._rawData?.note;
150
+ }
151
+ set note(newVal: string | null | undefined | false) {
152
+ if (newVal === null || newVal === undefined || newVal === false) newVal = '';
153
+ if (!_.isString(newVal)) throw new Error('Note must be a string');
154
+ if (newVal === this._rawData?.note) delete this._draftData.note;
155
+ else this._draftData.note = newVal;
156
+ }
157
+
158
+ // CELL FORMATTING ///////////////////////////////////////////////////////////////////////////////
159
+ get userEnteredFormat() { return Object.freeze(this._rawData?.userEnteredFormat); }
160
+ get effectiveFormat() { return Object.freeze(this._rawData?.effectiveFormat); }
161
+
162
+ private _getFormatParam<T extends keyof CellFormat>(param: T): Readonly<CellFormat[T]> {
163
+ // we freeze the object so users don't change nested props accidentally
164
+ // TODO: figure out something that would throw an error if you try to update it?
165
+ if (_.get(this._draftData, `userEnteredFormat.${param}`)) {
166
+ throw new Error('User format is unsaved - save the cell to be able to read it again');
167
+ }
168
+ // TODO: figure out how to deal with possible empty rawData
169
+ // if (!this._rawData?.userEnteredFormat?.[param]) {
170
+ // return undefined;
171
+ // }
172
+ return Object.freeze(this._rawData!.userEnteredFormat[param]);
173
+ }
174
+
175
+ private _setFormatParam<T extends keyof CellFormat>(param: T, newVal: CellFormat[T]) {
176
+ if (_.isEqual(newVal, _.get(this._rawData, `userEnteredFormat.${param}`))) {
177
+ _.unset(this._draftData, `userEnteredFormat.${param}`);
178
+ } else {
179
+ _.set(this._draftData, `userEnteredFormat.${param}`, newVal);
180
+ this._draftData.clearFormat = false;
181
+ }
182
+ }
183
+
184
+ // format getters
185
+ get numberFormat() { return this._getFormatParam('numberFormat'); }
186
+ get backgroundColor() { return this._getFormatParam('backgroundColor'); }
187
+ get backgroundColorStyle() { return this._getFormatParam('backgroundColorStyle'); }
188
+ get borders() { return this._getFormatParam('borders'); }
189
+ get padding() { return this._getFormatParam('padding'); }
190
+ get horizontalAlignment() { return this._getFormatParam('horizontalAlignment'); }
191
+ get verticalAlignment() { return this._getFormatParam('verticalAlignment'); }
192
+ get wrapStrategy() { return this._getFormatParam('wrapStrategy'); }
193
+ get textDirection() { return this._getFormatParam('textDirection'); }
194
+ get textFormat() { return this._getFormatParam('textFormat'); }
195
+ get hyperlinkDisplayType() { return this._getFormatParam('hyperlinkDisplayType'); }
196
+ get textRotation() { return this._getFormatParam('textRotation'); }
197
+
198
+ // format setters
199
+ set numberFormat(newVal: CellFormat['numberFormat']) { this._setFormatParam('numberFormat', newVal); }
200
+ set backgroundColor(newVal: CellFormat['backgroundColor']) { this._setFormatParam('backgroundColor', newVal); }
201
+ set backgroundColorStyle(newVal: CellFormat['backgroundColorStyle']) { this._setFormatParam('backgroundColorStyle', newVal); }
202
+ set borders(newVal: CellFormat['borders']) { this._setFormatParam('borders', newVal); }
203
+ set padding(newVal: CellFormat['padding']) { this._setFormatParam('padding', newVal); }
204
+ set horizontalAlignment(newVal: CellFormat['horizontalAlignment']) { this._setFormatParam('horizontalAlignment', newVal); }
205
+ set verticalAlignment(newVal: CellFormat['verticalAlignment']) { this._setFormatParam('verticalAlignment', newVal); }
206
+ set wrapStrategy(newVal: CellFormat['wrapStrategy']) { this._setFormatParam('wrapStrategy', newVal); }
207
+ set textDirection(newVal: CellFormat['textDirection']) { this._setFormatParam('textDirection', newVal); }
208
+ set textFormat(newVal: CellFormat['textFormat']) { this._setFormatParam('textFormat', newVal); }
209
+ set hyperlinkDisplayType(newVal: CellFormat['hyperlinkDisplayType']) { this._setFormatParam('hyperlinkDisplayType', newVal); }
210
+ set textRotation(newVal: CellFormat['textRotation']) { this._setFormatParam('textRotation', newVal); }
211
+
212
+ clearAllFormatting() {
213
+ // need to track this separately since by setting/unsetting things, we may end up with
214
+ // this._draftData.userEnteredFormat as an empty object, but not an intent to clear it
215
+ this._draftData.clearFormat = true;
216
+ delete this._draftData.userEnteredFormat;
217
+ }
218
+
219
+ // SAVING + UTILS ////////////////////////////////////////////////////////////////////////////////
220
+
221
+ // returns true if there are any updates that have not been saved yet
222
+ get _isDirty() {
223
+ // have to be careful about checking undefined rather than falsy
224
+ // in case a new value is empty string or 0 or false
225
+ if (this._draftData.note !== undefined) return true;
226
+ if (_.keys(this._draftData.userEnteredFormat).length) return true;
227
+ if (this._draftData.clearFormat) return true;
228
+ if (this._draftData.value !== undefined) return true;
229
+ return false;
230
+ }
231
+
232
+ discardUnsavedChanges() {
233
+ this._draftData = {};
234
+ }
235
+
236
+ /**
237
+ * saves updates for single cell
238
+ * usually it's better to make changes and call sheet.saveUpdatedCells
239
+ * */
240
+ async save() {
241
+ await this._sheet.saveCells([this]);
242
+ }
243
+
244
+ /**
245
+ * used by worksheet when saving cells
246
+ * returns an individual batchUpdate request to update the cell
247
+ * @internal
248
+ */
249
+ _getUpdateRequest() {
250
+ // this logic should match the _isDirty logic above
251
+ // but we need it broken up to build the request below
252
+ const isValueUpdated = this._draftData.value !== undefined;
253
+ const isNoteUpdated = this._draftData.note !== undefined;
254
+ const isFormatUpdated = !!_.keys(this._draftData.userEnteredFormat || {}).length;
255
+ const isFormatCleared = this._draftData.clearFormat;
256
+
257
+ // if no updates, we return null, which we can filter out later before sending requests
258
+ if (!_.some([isValueUpdated, isNoteUpdated, isFormatUpdated, isFormatCleared])) {
259
+ return null;
260
+ }
261
+
262
+ // build up the formatting object, which has some quirks...
263
+ const format = {
264
+ // have to pass the whole object or it will clear existing properties
265
+ ...this._rawData?.userEnteredFormat,
266
+ ...this._draftData.userEnteredFormat,
267
+ };
268
+ // if background color already set, cell has backgroundColor and backgroundColorStyle
269
+ // but backgroundColorStyle takes precendence so we must remove to set the color
270
+ // see https://developers.google.com/sheets/api/reference/rest/v4/spreadsheets/cells#CellFormat
271
+ if (_.get(this._draftData, 'userEnteredFormat.backgroundColor')) {
272
+ delete (format.backgroundColorStyle);
273
+ }
274
+
275
+ return {
276
+ updateCells: {
277
+ rows: [{
278
+ values: [{
279
+ ...isValueUpdated && {
280
+ userEnteredValue: { [this._draftData.valueType]: this._draftData.value },
281
+ },
282
+ ...isNoteUpdated && {
283
+ note: this._draftData.note,
284
+ },
285
+ ...isFormatUpdated && {
286
+ userEnteredFormat: format,
287
+ },
288
+ ...isFormatCleared && {
289
+ userEnteredFormat: {},
290
+ },
291
+ }],
292
+ }],
293
+ // turns into a string of which fields to update ex "note,userEnteredFormat"
294
+ fields: _.keys(_.pickBy({
295
+ userEnteredValue: isValueUpdated,
296
+ note: isNoteUpdated,
297
+ userEnteredFormat: isFormatUpdated || isFormatCleared,
298
+ })).join(','),
299
+ start: {
300
+ sheetId: this._sheet.sheetId,
301
+ rowIndex: this.rowIndex,
302
+ columnIndex: this.columnIndex,
303
+ },
304
+ },
305
+ };
306
+ }
307
+ }
@@ -0,0 +1,25 @@
1
+ import { CellValueErrorType, ErrorValue } from './types/sheets-types';
2
+
3
+ /**
4
+ * Cell error
5
+ *
6
+ * not a js "error" that gets thrown, but a value that holds an error code and message for a cell
7
+ * it's useful to use a class so we can check `instanceof`
8
+
9
+ * @see https://developers.google.com/sheets/api/reference/rest/v4/spreadsheets/other#ErrorType
10
+ */
11
+ export class GoogleSpreadsheetCellErrorValue {
12
+ /**
13
+ * type of the error
14
+ * @see https://developers.google.com/sheets/api/reference/rest/v4/spreadsheets/other#ErrorType
15
+ * */
16
+ readonly type: CellValueErrorType;
17
+
18
+ /** A message with more information about the error (in the spreadsheet's locale) */
19
+ readonly message: string;
20
+
21
+ constructor(rawError: ErrorValue) {
22
+ this.type = rawError.type;
23
+ this.message = rawError.message;
24
+ }
25
+ }
@@ -0,0 +1,117 @@
1
+ import { GoogleSpreadsheetWorksheet } from './GoogleSpreadsheetWorksheet';
2
+ import { columnToLetter } from './utils';
3
+
4
+
5
+ // TODO: add type for possible row values (currently any)
6
+
7
+ export class GoogleSpreadsheetRow<T extends Record<string, any> = Record<string, any>> {
8
+ constructor(
9
+ /** parent GoogleSpreadsheetWorksheet instance */
10
+ readonly _worksheet: GoogleSpreadsheetWorksheet,
11
+ /** the A1 row (1-indexed) */
12
+ private _rowNumber: number,
13
+ /** raw underlying data for row */
14
+ private _rawData: any[]
15
+ ) {
16
+
17
+ }
18
+
19
+ private _deleted = false;
20
+ get deleted() { return this._deleted; }
21
+
22
+ /** row number (matches A1 notation, ie first row is 1) */
23
+ get rowNumber() { return this._rowNumber; }
24
+ /**
25
+ * @internal
26
+ * Used internally to update row numbers after deleting rows.
27
+ * Should not be called directly.
28
+ */
29
+ _updateRowNumber(newRowNumber: number) {
30
+ this._rowNumber = newRowNumber;
31
+ }
32
+ get a1Range() {
33
+ return [
34
+ this._worksheet.a1SheetName,
35
+ '!',
36
+ `A${this._rowNumber}`,
37
+ ':',
38
+ `${columnToLetter(this._worksheet.headerValues.length)}${this._rowNumber}`,
39
+ ].join('');
40
+ }
41
+
42
+ /** get row's value of specific cell (by header key) */
43
+ get(key: keyof T) {
44
+ const index = this._worksheet.headerValues.indexOf(key as string);
45
+ return this._rawData[index];
46
+ }
47
+ /** set row's value of specific cell (by header key) */
48
+ set<K extends keyof T>(key: K, val: T[K]) {
49
+ const index = this._worksheet.headerValues.indexOf(key as string);
50
+ this._rawData[index] = val;
51
+ }
52
+ /** set multiple values in the row at once from an object */
53
+ assign(obj: T) {
54
+ // eslint-disable-next-line no-restricted-syntax, guard-for-in
55
+ for (const key in obj) this.set(key, obj[key]);
56
+ }
57
+
58
+ /** return raw object of row data */
59
+ toObject() {
60
+ const o: Partial<T> = {};
61
+ for (let i = 0; i < this._worksheet.headerValues.length; i++) {
62
+ const key: keyof T = this._worksheet.headerValues[i];
63
+ if (!key) continue;
64
+ o[key] = this._rawData[i];
65
+ }
66
+ return o;
67
+ }
68
+
69
+ /** save row values */
70
+ async save(options?: { raw?: boolean }) {
71
+ if (this._deleted) throw new Error('This row has been deleted - call getRows again before making updates.');
72
+
73
+ const response = await this._worksheet._spreadsheet.sheetsApi.request({
74
+ method: 'put',
75
+ url: `/values/${encodeURIComponent(this.a1Range)}`,
76
+ params: {
77
+ valueInputOption: options?.raw ? 'RAW' : 'USER_ENTERED',
78
+ includeValuesInResponse: true,
79
+ },
80
+ data: {
81
+ range: this.a1Range,
82
+ majorDimension: 'ROWS',
83
+ values: [this._rawData],
84
+ },
85
+ });
86
+ this._rawData = response.data.updatedData.values[0];
87
+ }
88
+
89
+ /** delete this row */
90
+ async delete() {
91
+ if (this._deleted) throw new Error('This row has been deleted - call getRows again before making updates.');
92
+
93
+ const result = await this._worksheet._makeSingleUpdateRequest('deleteRange', {
94
+ range: {
95
+ sheetId: this._worksheet.sheetId,
96
+ startRowIndex: this._rowNumber - 1, // this format is zero indexed, because of course...
97
+ endRowIndex: this._rowNumber,
98
+ },
99
+ shiftDimension: 'ROWS',
100
+ });
101
+ this._deleted = true;
102
+ this._worksheet._shiftRowCache(this.rowNumber);
103
+
104
+ return result;
105
+ }
106
+
107
+ /**
108
+ * @internal
109
+ * Used internally to clear row data after calling sheet.clearRows
110
+ * Should not be called directly.
111
+ */
112
+ _clearRowData() {
113
+ for (let i = 0; i < this._rawData.length; i++) {
114
+ this._rawData[i] = '';
115
+ }
116
+ }
117
+ }