@noloco/google-spreadsheet
Version:
Google Sheets API -- simple interface to read/write data and manage sheets
352 lines (316 loc) • 13.9 kB
text/typescript
/* eslint-disable max-classes-per-file */
import * as _ from './lodash';
import { columnToLetter } from './utils';
import { GoogleSpreadsheetWorksheet } from './GoogleSpreadsheetWorksheet';
import { GoogleSpreadsheetCellErrorValue } from './GoogleSpreadsheetCellErrorValue';
import {
CellData,
CellFormat,
CellValueType,
ColumnIndex,
DeveloperMetadataId,
DeveloperMetadataKey,
DeveloperMetadataValue,
DeveloperMetadataVisibility,
RowIndex,
} from './types/sheets-types';
export class GoogleSpreadsheetCell {
private _rawData?: CellData;
private _draftData: any = {};
private _error?: GoogleSpreadsheetCellErrorValue;
constructor(
readonly _sheet: GoogleSpreadsheetWorksheet,
private _rowIndex: RowIndex,
private _columnIndex: ColumnIndex,
rawCellData: CellData
) {
this._updateRawData(rawCellData);
this._rawData = rawCellData; // so TS does not complain
}
// TODO: figure out how to deal with empty rawData
// newData can be undefined/null if the cell is totally empty and unformatted
/**
* update cell using raw CellData coming back from sheets API
* @internal
*/
_updateRawData(newData: CellData) {
this._rawData = newData;
this._draftData = {};
if (this._rawData?.effectiveValue && 'errorValue' in this._rawData.effectiveValue) {
this._error = new GoogleSpreadsheetCellErrorValue(this._rawData.effectiveValue.errorValue);
} else {
this._error = undefined;
}
}
// CELL LOCATION/ADDRESS /////////////////////////////////////////////////////////////////////////
get rowIndex() { return this._rowIndex; }
get columnIndex() { return this._columnIndex; }
get a1Column() { return columnToLetter(this._columnIndex + 1); }
get a1Row() { return this._rowIndex + 1; } // a1 row numbers start at 1 instead of 0
get a1Address() { return `${this.a1Column}${this.a1Row}`; }
// CELL CONTENTS - VALUE/FORMULA/NOTES ///////////////////////////////////////////////////////////
get value(): number | boolean | string | null | GoogleSpreadsheetCellErrorValue {
// const typeKey = _.keys(this._rawData.effectiveValue)[0];
if (this._draftData.value !== undefined) throw new Error('Value has been changed');
if (this._error) return this._error;
if (!this._rawData?.effectiveValue) return null;
return _.values(this._rawData.effectiveValue)[0];
}
set value(newValue: number | boolean | Date | string | null | undefined | GoogleSpreadsheetCellErrorValue) {
// had to include the GoogleSpreadsheetCellErrorValue in the type to make TS happy
if (newValue instanceof GoogleSpreadsheetCellErrorValue) {
throw new Error("You can't manually set a value to an error");
}
if (_.isBoolean(newValue)) {
this._draftData.valueType = 'boolValue';
} else if (_.isString(newValue)) {
if (newValue.substring(0, 1) === '=') this._draftData.valueType = 'formulaValue';
else this._draftData.valueType = 'stringValue';
} else if (_.isFinite(newValue)) {
this._draftData.valueType = 'numberValue';
} else if (_.isNil(newValue)) {
// null or undefined
this._draftData.valueType = 'stringValue';
newValue = '';
} else {
throw new Error('Set value to boolean, string, or number');
}
this._draftData.value = newValue;
}
get valueType(): CellValueType | null {
// an error only happens with a formula (as far as I know)
if (this._error) return 'errorValue';
if (!this._rawData?.effectiveValue) return null;
return _.keys(this._rawData.effectiveValue)[0] as CellValueType;
}
/** The formatted value of the cell - this is the value as it's shown to the user */
get formattedValue(): string | null { return this._rawData?.formattedValue || null; }
get formula() { return _.get(this._rawData, 'userEnteredValue.formulaValue', null); }
set formula(newValue: string | null) {
if (!newValue) throw new Error('To clear a formula, set `cell.value = null`');
if (newValue.substring(0, 1) !== '=') throw new Error('formula must begin with "="');
this.value = newValue; // use existing value setter
}
/**
* @deprecated use `cell.errorValue` instead
*/
get formulaError() { return this._error; }
/**
* error contained in the cell, which can happen with a bad formula (maybe some other weird cases?)
*/
get errorValue() { return this._error; }
get numberValue(): number | undefined {
if (this.valueType !== 'numberValue') return undefined;
return this.value as number;
}
set numberValue(val: number | undefined) {
this.value = val;
}
get boolValue(): boolean | undefined {
if (this.valueType !== 'boolValue') return undefined;
return this.value as boolean;
}
set boolValue(val: boolean | undefined) {
this.value = val;
}
get stringValue(): string | undefined {
if (this.valueType !== 'stringValue') return undefined;
return this.value as string;
}
set stringValue(val: string | undefined) {
if (val?.startsWith('=')) {
throw new Error('Use cell.formula to set formula values');
}
this.value = val;
}
/**
* Hyperlink contained within the cell.
*
* To modify, do not set directly. Instead set cell.formula, for example `cell.formula = \'=HYPERLINK("http://google.com", "Google")\'`
*/
get hyperlink() {
if (this._draftData.value) throw new Error('Save cell to be able to read hyperlink');
return this._rawData?.hyperlink;
}
/** a note attached to the cell */
get note(): string {
return this._draftData.note !== undefined ? this._draftData.note : this._rawData?.note;
}
set note(newVal: string | null | undefined | false) {
if (newVal === null || newVal === undefined || newVal === false) newVal = '';
if (!_.isString(newVal)) throw new Error('Note must be a string');
if (newVal === this._rawData?.note) delete this._draftData.note;
else this._draftData.note = newVal;
}
// CELL FORMATTING ///////////////////////////////////////////////////////////////////////////////
get userEnteredFormat() { return Object.freeze(this._rawData?.userEnteredFormat); }
get effectiveFormat() { return Object.freeze(this._rawData?.effectiveFormat); }
get dataValidation() { return Object.freeze(this._rawData?.dataValidation); }
private _getFormatParam<T extends keyof CellFormat>(param: T): Readonly<CellFormat[T]> {
// we freeze the object so users don't change nested props accidentally
// TODO: figure out something that would throw an error if you try to update it?
if (_.get(this._draftData, `userEnteredFormat.${param}`)) {
throw new Error('User format is unsaved - save the cell to be able to read it again');
}
// TODO: figure out how to deal with possible empty rawData
// if (!this._rawData?.userEnteredFormat?.[param]) {
// return undefined;
// }
return Object.freeze(this._rawData!.userEnteredFormat[param]);
}
private _setFormatParam<T extends keyof CellFormat>(param: T, newVal: CellFormat[T]) {
if (_.isEqual(newVal, _.get(this._rawData, `userEnteredFormat.${param}`))) {
_.unset(this._draftData, `userEnteredFormat.${param}`);
} else {
_.set(this._draftData, `userEnteredFormat.${param}`, newVal);
this._draftData.clearFormat = false;
}
}
// format getters
get numberFormat() { return this._getFormatParam('numberFormat'); }
get backgroundColor() { return this._getFormatParam('backgroundColor'); }
get backgroundColorStyle() { return this._getFormatParam('backgroundColorStyle'); }
get borders() { return this._getFormatParam('borders'); }
get padding() { return this._getFormatParam('padding'); }
get horizontalAlignment() { return this._getFormatParam('horizontalAlignment'); }
get verticalAlignment() { return this._getFormatParam('verticalAlignment'); }
get wrapStrategy() { return this._getFormatParam('wrapStrategy'); }
get textDirection() { return this._getFormatParam('textDirection'); }
get textFormat() { return this._getFormatParam('textFormat'); }
get hyperlinkDisplayType() { return this._getFormatParam('hyperlinkDisplayType'); }
get textRotation() { return this._getFormatParam('textRotation'); }
// format setters
set numberFormat(newVal: CellFormat['numberFormat']) { this._setFormatParam('numberFormat', newVal); }
set backgroundColor(newVal: CellFormat['backgroundColor']) { this._setFormatParam('backgroundColor', newVal); }
set backgroundColorStyle(newVal: CellFormat['backgroundColorStyle']) { this._setFormatParam('backgroundColorStyle', newVal); }
set borders(newVal: CellFormat['borders']) { this._setFormatParam('borders', newVal); }
set padding(newVal: CellFormat['padding']) { this._setFormatParam('padding', newVal); }
set horizontalAlignment(newVal: CellFormat['horizontalAlignment']) { this._setFormatParam('horizontalAlignment', newVal); }
set verticalAlignment(newVal: CellFormat['verticalAlignment']) { this._setFormatParam('verticalAlignment', newVal); }
set wrapStrategy(newVal: CellFormat['wrapStrategy']) { this._setFormatParam('wrapStrategy', newVal); }
set textDirection(newVal: CellFormat['textDirection']) { this._setFormatParam('textDirection', newVal); }
set textFormat(newVal: CellFormat['textFormat']) { this._setFormatParam('textFormat', newVal); }
set hyperlinkDisplayType(newVal: CellFormat['hyperlinkDisplayType']) { this._setFormatParam('hyperlinkDisplayType', newVal); }
set textRotation(newVal: CellFormat['textRotation']) { this._setFormatParam('textRotation', newVal); }
clearAllFormatting() {
// need to track this separately since by setting/unsetting things, we may end up with
// this._draftData.userEnteredFormat as an empty object, but not an intent to clear it
this._draftData.clearFormat = true;
delete this._draftData.userEnteredFormat;
}
// SAVING + UTILS ////////////////////////////////////////////////////////////////////////////////
// returns true if there are any updates that have not been saved yet
get _isDirty() {
// have to be careful about checking undefined rather than falsy
// in case a new value is empty string or 0 or false
if (this._draftData.note !== undefined) return true;
if (_.keys(this._draftData.userEnteredFormat).length) return true;
if (this._draftData.clearFormat) return true;
if (this._draftData.value !== undefined) return true;
return false;
}
discardUnsavedChanges() {
this._draftData = {};
}
/**
* saves updates for single cell
* usually it's better to make changes and call sheet.saveUpdatedCells
* */
async save() {
await this._sheet.saveCells([this]);
}
/**
* Add developer metadata to the cell
*/
async createDeveloperMetadata(
metadataKey: DeveloperMetadataKey,
metadataValue: DeveloperMetadataValue,
visibility?: DeveloperMetadataVisibility,
metadataId?: DeveloperMetadataId
) {
return this._sheet._spreadsheet.createRangeDeveloperMetadata(
metadataKey,
metadataValue,
{
dimension: 'COLUMNS',
sheetId: this._sheet.sheetId,
startIndex: this._columnIndex,
endIndex: this._columnIndex + 1,
},
visibility,
metadataId
);
}
/**
* Get developer metadata from the cell
*/
async getDeveloperMetadata() {
return this._sheet._spreadsheet.getDeveloperMetadataByGridRange(
{
sheetId: this._sheet.sheetId,
startColumnIndex: this._columnIndex,
endColumnIndex: this._columnIndex + 1,
}
);
}
/**
* used by worksheet when saving cells
* returns an individual batchUpdate request to update the cell
* @internal
*/
_getUpdateRequest() {
// this logic should match the _isDirty logic above
// but we need it broken up to build the request below
const isValueUpdated = this._draftData.value !== undefined;
const isNoteUpdated = this._draftData.note !== undefined;
const isFormatUpdated = !!_.keys(this._draftData.userEnteredFormat || {}).length;
const isFormatCleared = this._draftData.clearFormat;
// if no updates, we return null, which we can filter out later before sending requests
if (!_.some([isValueUpdated, isNoteUpdated, isFormatUpdated, isFormatCleared])) {
return null;
}
// build up the formatting object, which has some quirks...
const format = {
// have to pass the whole object or it will clear existing properties
...this._rawData?.userEnteredFormat,
...this._draftData.userEnteredFormat,
};
// if background color already set, cell has backgroundColor and backgroundColorStyle
// but backgroundColorStyle takes precendence so we must remove to set the color
// see https://developers.google.com/sheets/api/reference/rest/v4/spreadsheets/cells#CellFormat
if (_.get(this._draftData, 'userEnteredFormat.backgroundColor')) {
delete (format.backgroundColorStyle);
}
return {
updateCells: {
rows: [{
values: [{
...isValueUpdated && {
userEnteredValue: { [this._draftData.valueType]: this._draftData.value },
},
...isNoteUpdated && {
note: this._draftData.note,
},
...isFormatUpdated && {
userEnteredFormat: format,
},
...isFormatCleared && {
userEnteredFormat: {},
},
}],
}],
// turns into a string of which fields to update ex "note,userEnteredFormat"
fields: _.keys(_.pickBy({
userEnteredValue: isValueUpdated,
note: isNoteUpdated,
userEnteredFormat: isFormatUpdated || isFormatCleared,
})).join(','),
start: {
sheetId: this._sheet.sheetId,
rowIndex: this.rowIndex,
columnIndex: this.columnIndex,
},
},
};
}
}