├── docs ├── .nojekyll ├── _sidebar.md ├── guides │ ├── exports.md │ ├── migration-guide.md │ └── authentication.md ├── index.html ├── classes │ ├── google-spreadsheet-row.md │ ├── google-spreadsheet-cell.md │ ├── google-spreadsheet.md │ └── google-spreadsheet-worksheet.md └── README.md ├── .node-version ├── .github ├── FUNDING.yml └── workflows │ └── ci.yml ├── .vscode ├── extensions.json └── settings.json ├── .eslintignore ├── .husky └── pre-commit ├── src ├── test │ ├── globalSetup.ts │ ├── auth │ │ └── docs-and-auth.ts │ ├── utils.test.ts │ ├── auth.test.ts │ ├── cells.test.ts │ ├── manage.test.ts │ └── rows.test.ts ├── lib │ ├── types │ │ ├── util-types.ts │ │ ├── auth-types.ts │ │ ├── drive-types.ts │ │ └── sheets-types.ts │ ├── toolkit.ts │ ├── GoogleSpreadsheetCellErrorValue.ts │ ├── utils.ts │ ├── GoogleSpreadsheetRow.ts │ ├── GoogleSpreadsheetCell.ts │ └── GoogleSpreadsheet.ts └── index.ts ├── tea.yaml ├── .gitignore ├── .release-it.json ├── vitest.config.ts ├── .changeset ├── config.json └── README.md ├── tsconfig.json ├── tsup.config.ts ├── .env.schema ├── LICENSE ├── .eslintrc.cjs ├── package.json └── README.md /docs/.nojekyll: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.node-version: -------------------------------------------------------------------------------- 1 | 24.5.0 -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: [theoephraim] 2 | custom: ['https://buymeacoffee.com/theo.dmno'] 3 | -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "recommendations": [ 3 | "dbaeumer.vscode-eslint" 4 | ] 5 | } -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | .eslintrc.cjs 2 | tsup.config.ts 3 | vitest.config.ts 4 | dist 5 | examples 6 | ignore -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | . "$(dirname -- "$0")/_/husky.sh" 3 | 4 | pnpm run lint 5 | -------------------------------------------------------------------------------- /src/test/globalSetup.ts: -------------------------------------------------------------------------------- 1 | import { load } from 'varlock'; 2 | 3 | export default async function setup() { 4 | await load(); 5 | } 6 | -------------------------------------------------------------------------------- /tea.yaml: -------------------------------------------------------------------------------- 1 | # https://tea.xyz/what-is-this-file 2 | --- 3 | version: 1.0.0 4 | codeOwners: 5 | - '0xeB88F212459a3a91C88414A57A4e2d62c1cE3E2B' 6 | quorum: 1 7 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | src/ignore/ 3 | .DS_Store 4 | examples/ 5 | TODO 6 | dist 7 | ignore 8 | 9 | .env.local 10 | .env.*.local 11 | env.d.ts 12 | -------------------------------------------------------------------------------- /.release-it.json: -------------------------------------------------------------------------------- 1 | { 2 | "git": { 3 | "commitMessage": "chore: release v${version}", 4 | "requireCleanWorkingDir": false 5 | }, 6 | "github": { 7 | "release": true 8 | } 9 | } -------------------------------------------------------------------------------- /src/lib/types/util-types.ts: -------------------------------------------------------------------------------- 1 | // utility types 2 | export type MakeOptional = Omit & 3 | Partial>; 4 | 5 | export type RecursivePartial = { 6 | [P in keyof T]?: RecursivePartial; 7 | }; 8 | 9 | -------------------------------------------------------------------------------- /vitest.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'vitest/config'; 2 | 3 | export default defineConfig({ 4 | test: { 5 | globalSetup: 'src/test/globalSetup.ts', 6 | include: ['src/**/*.test.ts'], 7 | hookTimeout: 15000, 8 | fileParallelism: false, 9 | }, 10 | }) -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "editor.formatOnSave": true, 3 | "[typescript]": { 4 | "editor.defaultFormatter": "dbaeumer.vscode-eslint" 5 | }, 6 | "[javascript]": { 7 | "editor.defaultFormatter": "dbaeumer.vscode-eslint" 8 | }, 9 | "editor.rulers": [ 10 | 120 11 | ], 12 | "eslint.packageManager": "pnpm" 13 | } -------------------------------------------------------------------------------- /.changeset/config.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://unpkg.com/@changesets/config@3.1.1/schema.json", 3 | "changelog": "@changesets/cli/changelog", 4 | "commit": false, 5 | "fixed": [ ], 6 | "linked": [ ], 7 | "access": "public", 8 | "baseBranch": "main", 9 | "updateInternalDependencies": "patch", 10 | "ignore": [ ] 11 | } 12 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | export { GoogleSpreadsheet } from './lib/GoogleSpreadsheet'; 2 | export { GoogleSpreadsheetWorksheet } from './lib/GoogleSpreadsheetWorksheet'; 3 | export { GoogleSpreadsheetRow } from './lib/GoogleSpreadsheetRow'; 4 | export { GoogleSpreadsheetCell } from './lib/GoogleSpreadsheetCell'; 5 | export { GoogleSpreadsheetCellErrorValue } from './lib/GoogleSpreadsheetCellErrorValue'; 6 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ESNext", 4 | "module": "ESNext", 5 | "moduleResolution": "bundler", 6 | "sourceMap": true, 7 | "outDir": "dist", 8 | "declaration": true, 9 | "strict": true, 10 | "noEmitOnError": true, 11 | "lib": [ 12 | "esnext" 13 | ], 14 | "esModuleInterop": true, 15 | "resolveJsonModule": true 16 | }, 17 | "include": [ 18 | "src/**/*.ts", 19 | "examples/**/*.ts", 20 | "env.d.ts" 21 | ] 22 | } -------------------------------------------------------------------------------- /docs/_sidebar.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | * **Guides** 4 | * [Overview](/) 5 | * [Authentication](guides/authentication) 6 | * [Exporting Data](guides/exports) 7 | * [Version Upgrade Guides](guides/migration-guide) 8 | * **Full Class Reference** 9 | * [GoogleSpreadsheet](classes/google-spreadsheet) 10 | * [GoogleSpreadsheetWorksheet](classes/google-spreadsheet-worksheet) 11 | * [GoogleSpreadsheetCell](classes/google-spreadsheet-cell) 12 | * [GoogleSpreadsheetRow](classes/google-spreadsheet-row) -------------------------------------------------------------------------------- /src/lib/types/auth-types.ts: -------------------------------------------------------------------------------- 1 | /** single type to handle all valid auth types */ 2 | export type GoogleApiAuth = 3 | // this simple interface should cover all google-auth-library auth methods 4 | | { getRequestHeaders: () => Promise } 5 | // used to pass in an API key only 6 | | { apiKey: string } 7 | // used to pass in a raw token 8 | | { token: string }; 9 | 10 | export enum AUTH_MODES { 11 | GOOGLE_AUTH_CLIENT = 'google_auth', 12 | RAW_ACCESS_TOKEN = 'raw_access_token', 13 | API_KEY = 'api_key' 14 | } 15 | -------------------------------------------------------------------------------- /.changeset/README.md: -------------------------------------------------------------------------------- 1 | # Changesets 2 | 3 | Hello and welcome! This folder has been automatically generated by `@changesets/cli`, a build tool that works 4 | with multi-package repos, or single-package repos to help you version and publish your code. You can 5 | find the full documentation for it [in our repository](https://github.com/changesets/changesets) 6 | 7 | We have a quick list of common questions to get you started engaging with this project in 8 | [our documentation](https://github.com/changesets/changesets/blob/main/docs/common-questions.md) 9 | -------------------------------------------------------------------------------- /tsup.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'tsup'; 2 | 3 | export default defineConfig({ 4 | entry: [ // Entry point(s) 5 | 'src/index.ts', 6 | ], 7 | 8 | dts: { 9 | resolve: true, 10 | }, 11 | 12 | sourcemap: true, // Generate sourcemaps 13 | treeshake: true, // Remove unused code 14 | 15 | clean: true, // Clean output directory before building 16 | outDir: 'dist', // Output directory 17 | 18 | format: ['cjs', 'esm'], // Output format(s) 19 | 20 | splitting: false, 21 | keepNames: true, // stops build from prefixing our class names with `_` in some cases 22 | 23 | platform: 'node', 24 | target: 'node20', 25 | }); 26 | -------------------------------------------------------------------------------- /src/lib/toolkit.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable import/no-extraneous-dependencies */ 2 | 3 | // re-export just what we need from es-toolkit (prev lodash) these will be bundled into the final result 4 | // but this lets us have a single import, which is nicer to use 5 | 6 | export { 7 | compact, 8 | each, 9 | filter, 10 | find, 11 | flatten, 12 | get, 13 | groupBy, 14 | isArray, 15 | isBoolean, 16 | isEqual, 17 | isFinite, 18 | isInteger, 19 | isNil, 20 | isNumber, 21 | isObject, 22 | isString, 23 | keyBy, 24 | keys, 25 | map, 26 | omit, 27 | pickBy, 28 | set, 29 | some, 30 | sortBy, 31 | times, 32 | unset, 33 | values, 34 | } from 'es-toolkit/compat'; 35 | -------------------------------------------------------------------------------- /.env.schema: -------------------------------------------------------------------------------- 1 | # This env file uses @env-spec - see https://varlock.dev/env-spec for more info 2 | # 3 | # @defaultRequired=false @defaultSensitive=false 4 | # @generateTypes(lang=ts, path=env.d.ts) 5 | # ---------- 6 | 7 | # API key for our testing "app" 8 | # @sensitive @required 9 | GOOGLE_API_KEY= 10 | 11 | # Email of service account used for tests 12 | # @sensitive @required @type=email 13 | GOOGLE_SERVICE_ACCOUNT_EMAIL= 14 | 15 | # API key for service account used for tests 16 | # @sensitive @required 17 | GOOGLE_SERVICE_ACCOUNT_KEY= 18 | 19 | # Flag set to true when running in CI 20 | # @type=boolean 21 | CI= 22 | 23 | # Delay in ms to use when running tests 24 | # @type=number 25 | TEST_DELAY=500 -------------------------------------------------------------------------------- /src/test/auth/docs-and-auth.ts: -------------------------------------------------------------------------------- 1 | import { JWT } from 'google-auth-library'; 2 | import { ENV } from 'varlock/env'; 3 | 4 | export const DOC_IDS = { 5 | public: '1LG6vqg6ezQpIXr-SIDDWQAc9mLNSXasboDR7MUbLvZw', 6 | publicReadOnly: '1Gf1RL2FUjQpE6nJ4ywuX7hpZFqQ8oLE2yMAgzF7VsF0', 7 | private: '148tpVrZgcc-ReSMRXiQaqf9hstgT8HTzyPeKx6f399Y', 8 | privateReadOnly: '1d9McHkpKu-1R3WxPT7B-bhNPnBzijMp2zI_knjwnw4s', 9 | }; 10 | 11 | export const testServiceAccountAuth = new JWT({ 12 | email: ENV.GOOGLE_SERVICE_ACCOUNT_EMAIL, 13 | key: ENV.GOOGLE_SERVICE_ACCOUNT_KEY, 14 | scopes: [ 15 | 'https://www.googleapis.com/auth/spreadsheets', 16 | 'https://www.googleapis.com/auth/drive.file', 17 | ], 18 | }); 19 | -------------------------------------------------------------------------------- /src/lib/GoogleSpreadsheetCellErrorValue.ts: -------------------------------------------------------------------------------- 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 | } 26 | -------------------------------------------------------------------------------- /src/lib/types/drive-types.ts: -------------------------------------------------------------------------------- 1 | 2 | export type PermissionRoles = 3 | 'owner' | 4 | 'writer' | 5 | 'commenter' | 6 | 'reader'; 7 | // these roles also exist but are not needed for our use case 8 | // 'organizer' | 'fileOrganizer' 9 | 10 | export type PublicPermissionRoles = Exclude; 11 | 12 | // this shape is set by what we request... 13 | type PublicPermissionListEntry = { 14 | id: 'anyoneWithLink' 15 | type: 'anyone', 16 | role: PublicPermissionRoles, 17 | }; 18 | type UserOrGroupPermissionListEntry = { 19 | id: string; 20 | displayName: string; 21 | type: 'user' | 'group'; 22 | photoLink?: string; 23 | emailAddress: string; 24 | role: PermissionRoles; 25 | deleted: boolean; 26 | }; 27 | type DomainPermissionListEntry = { 28 | id: string; 29 | displayName: string; 30 | type: 'domain'; 31 | domain: string; 32 | role: PublicPermissionRoles; 33 | photoLink?: string; 34 | }; 35 | 36 | export type PermissionsList = ( 37 | PublicPermissionListEntry | 38 | UserOrGroupPermissionListEntry | 39 | DomainPermissionListEntry 40 | )[]; 41 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2013-2024 Theo Ephraim 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /src/lib/utils.ts: -------------------------------------------------------------------------------- 1 | import * as _ from './toolkit'; 2 | 3 | export function getFieldMask(obj: Record) { 4 | let fromGrid = ''; 5 | const fromRoot = Object.keys(obj).filter((key) => key !== 'gridProperties').join(','); 6 | 7 | if (obj.gridProperties) { 8 | fromGrid = Object.keys(obj.gridProperties).map((key) => `gridProperties.${key}`).join(','); 9 | if (fromGrid.length && fromRoot.length) { 10 | fromGrid = `${fromGrid},`; 11 | } 12 | } 13 | return fromGrid + fromRoot; 14 | } 15 | 16 | export function columnToLetter(column: number) { 17 | let temp; 18 | let letter = ''; 19 | let col = column; 20 | while (col > 0) { 21 | temp = (col - 1) % 26; 22 | letter = String.fromCharCode(temp + 65) + letter; 23 | col = (col - temp - 1) / 26; 24 | } 25 | return letter; 26 | } 27 | 28 | export function letterToColumn(letter: string) { 29 | let column = 0; 30 | const { length } = letter; 31 | for (let i = 0; i < length; i++) { 32 | column += (letter.charCodeAt(i) - 64) * 26 ** (length - i - 1); 33 | } 34 | return column; 35 | } 36 | 37 | export function checkForDuplicateHeaders(headers: string[]) { 38 | // check for duplicate headers 39 | const checkForDupes = _.groupBy(headers); // { c1: ['c1'], c2: ['c2', 'c2' ]} 40 | _.each(checkForDupes, (grouped, header) => { 41 | if (!header) return; // empty columns are skipped, so multiple is ok 42 | if (grouped.length > 1) { 43 | throw new Error(`Duplicate header detected: "${header}". Please make sure all non-empty headers are unique`); 44 | } 45 | }); 46 | } 47 | 48 | -------------------------------------------------------------------------------- /src/test/utils.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, it } from 'vitest'; 2 | 3 | import { getFieldMask } from '../lib/utils'; 4 | 5 | describe('utils', () => { 6 | describe('getFieldMask', () => { 7 | const cases = [ 8 | { 9 | expectedMask: 'tabColor', 10 | fromObj: { 11 | tabColor: { 12 | red: 0, 13 | green: 1, 14 | blue: 2, 15 | }, 16 | }, 17 | }, 18 | { 19 | expectedMask: 'hidden,tabColor', 20 | fromObj: { 21 | hidden: false, 22 | tabColor: { 23 | red: 0, 24 | green: 1, 25 | blue: 2, 26 | }, 27 | }, 28 | }, 29 | { 30 | expectedMask: 'hidden,tabColor', 31 | fromObj: { 32 | hidden: false, 33 | gridProperties: {}, 34 | tabColor: { 35 | red: 0, 36 | green: 1, 37 | blue: 2, 38 | }, 39 | }, 40 | }, 41 | { 42 | expectedMask: 'gridProperties.colCount,hidden,tabColor', 43 | fromObj: { 44 | hidden: false, 45 | gridProperties: { 46 | colCount: 78, 47 | }, 48 | tabColor: { 49 | red: 0, 50 | green: 1, 51 | blue: 2, 52 | }, 53 | }, 54 | }, 55 | { 56 | expectedMask: 'gridProperties.colCount,gridProperties.rowCount,hidden,tabColor', 57 | fromObj: { 58 | hidden: false, 59 | gridProperties: { 60 | colCount: 78, 61 | rowCount: 14, 62 | }, 63 | tabColor: { 64 | red: 0, 65 | green: 1, 66 | blue: 2, 67 | }, 68 | }, 69 | }, 70 | { 71 | expectedMask: 'gridProperties.colCount,gridProperties.rowCount', 72 | fromObj: { 73 | gridProperties: { 74 | colCount: 78, 75 | rowCount: 14, 76 | }, 77 | }, 78 | }, 79 | ]; 80 | 81 | cases.forEach((c) => { 82 | it(c.expectedMask, () => { 83 | expect(getFieldMask(c.fromObj)).toBe(c.expectedMask); 84 | }); 85 | }); 86 | }); 87 | }); 88 | -------------------------------------------------------------------------------- /.eslintrc.cjs: -------------------------------------------------------------------------------- 1 | // https://eslint.org/docs/user-guide/configuring 2 | module.exports = { 3 | root: true, 4 | parserOptions: { 5 | sourceType: 'module', 6 | ecmaVersion: 2018, 7 | project: './tsconfig.json', 8 | }, 9 | env: { 10 | es6: true, 11 | node: true, 12 | }, 13 | extends: [ 14 | 'airbnb-base', 15 | 'airbnb-typescript/base', 16 | ], 17 | plugins: [ 18 | 'no-floating-promise', 19 | ], 20 | // add your custom rules here 21 | rules: { 22 | 'no-underscore-dangle': 0, 23 | 'no-plusplus': 0, // i++ OK :D 24 | 'class-methods-use-this': 0, 25 | 'radix': 0, 26 | 'prefer-destructuring': 0, 27 | 'no-param-reassign': 0, // sometimes it's just much easier 28 | '@typescript-eslint/lines-between-class-members': 0, // grouping related one-liners can be nice 29 | 'no-continue': 0, 30 | // override airbnb - breaks old version of node - https://github.com/eslint/eslint/issues/7749 31 | '@typescript-eslint/comma-dangle': ['error', { 32 | arrays: 'always-multiline', 33 | objects: 'always-multiline', 34 | imports: 'always-multiline', 35 | exports: 'always-multiline', 36 | functions: 'never', // this breaks 37 | }], 38 | 'no-multiple-empty-lines': 0, // sometimes helpful to break up sections of code 39 | 'import/prefer-default-export': 0, 40 | 'import/no-cycle': 0, 41 | 'grouped-accessor-pairs': 0, 42 | "@typescript-eslint/naming-convention": 0, 43 | 44 | "@typescript-eslint/no-unused-vars": [ 45 | "error", 46 | { 47 | argsIgnorePattern: "^_", 48 | varsIgnorePattern: "^_", 49 | }, 50 | ], 51 | 52 | 'max-len': ['error', 120, 2, { // bumped to 120, otherwise same as airbnb's rule but ignoring comments 53 | ignoreUrls: true, 54 | ignoreComments: true, 55 | ignoreRegExpLiterals: true, 56 | ignoreStrings: true, 57 | ignoreTemplateLiterals: true, 58 | }], 59 | 60 | }, 61 | overrides: [ 62 | { // extra rules for tests 63 | files: 'test/*', 64 | rules: { 65 | 'no-await-in-loop': 0, 66 | } 67 | }, 68 | { // relaxed rules for examples 69 | files: 'examples/*', 70 | rules: { 71 | 'no-console': 0, 72 | 'no-unused-vars': 0, 73 | }, 74 | }, 75 | ], 76 | }; 77 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | # This workflow will do a clean installation of node dependencies, cache/restore them, build the source code and run tests across different versions of node 2 | # For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-nodejs 3 | 4 | name: CI 5 | 6 | on: 7 | push: 8 | branches: ["main"] 9 | pull_request: 10 | branches: ["main"] 11 | workflow_dispatch: # allow manual reruns 12 | 13 | jobs: 14 | ci: 15 | runs-on: ubuntu-latest 16 | 17 | # strategy: 18 | # matrix: 19 | # node-version: [20.x] # [14.x, 16.x, 18.x] 20 | # # See supported Node.js release schedule at https://nodejs.org/en/about/releases/ 21 | 22 | steps: 23 | - name: Checkout 24 | uses: actions/checkout@v4 25 | 26 | - name: Install Node.js 27 | uses: actions/setup-node@v4 28 | with: 29 | node-version: 20.x 30 | 31 | - uses: pnpm/action-setup@v4 32 | name: Install pnpm 33 | with: 34 | version: 9 35 | run_install: false 36 | 37 | - name: Get pnpm store directory 38 | shell: bash 39 | run: | 40 | echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV 41 | 42 | - uses: actions/cache@v4 43 | name: Setup pnpm cache 44 | with: 45 | path: ${{ env.STORE_PATH }} 46 | key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }} 47 | restore-keys: | 48 | ${{ runner.os }}-pnpm-store- 49 | 50 | - name: Install dependencies 51 | run: pnpm install 52 | 53 | - name: Lint 54 | run: pnpm run lint --format junit -o reports/junit/js-lint-results.xml 55 | 56 | - name: Test 57 | run: pnpm run test 58 | env: 59 | CI: 1 60 | NODE_ENV: ci 61 | JEST_JUNIT_OUTPUT: "reports/junit/js-test-results.xml" 62 | TEST_DELAY: 1000 63 | GOOGLE_API_KEY: ${{ secrets.GOOGLE_API_KEY }} 64 | GOOGLE_SERVICE_ACCOUNT_EMAIL: ${{ secrets.GOOGLE_SERVICE_ACCOUNT_EMAIL }} 65 | GOOGLE_SERVICE_ACCOUNT_KEY: ${{ secrets.GOOGLE_SERVICE_ACCOUNT_KEY }} 66 | 67 | - uses: actions/upload-artifact@v4 68 | with: 69 | name: reports 70 | path: reports/junit 71 | 72 | - uses: actions/upload-artifact@v4 73 | with: 74 | name: coverage 75 | path: coverage 76 | 77 | 78 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "google-spreadsheet", 3 | "version": "5.0.2", 4 | "description": "Google Sheets API -- simple interface to read/write data and manage sheets", 5 | "keywords": [ 6 | "google spreadsheets", 7 | "google sheets", 8 | "google", 9 | "spreadsheet", 10 | "spreadsheets", 11 | "sheets", 12 | "gdata", 13 | "api", 14 | "googleapis", 15 | "drive", 16 | "google docs", 17 | "google drive" 18 | ], 19 | "homepage": "https://theoephraim.github.io/node-google-spreadsheet", 20 | "repository": { 21 | "type": "git", 22 | "url": "git://github.com/theoephraim/node-google-spreadsheet.git" 23 | }, 24 | "license": "MIT", 25 | "author": "Theo Ephraim (https://theoephraim.com)", 26 | "type": "module", 27 | "exports": { 28 | ".": { 29 | "types": "./dist/index.d.ts", 30 | "import": "./dist/index.js", 31 | "require": "./dist/index.cjs" 32 | } 33 | }, 34 | "main": "./dist/index.cjs", 35 | "module": "./dist/index.js", 36 | "types": "./dist/index.d.ts", 37 | "files": [ 38 | "dist", 39 | "src/index.ts", 40 | "src/lib" 41 | ], 42 | "scripts": { 43 | "build": "tsup", 44 | "dev": "tsup --watch", 45 | "docs:preview": "docsify serve docs", 46 | "lint": "eslint ./ --ext .ts", 47 | "lint:fix": "pnpm run lint --fix", 48 | "nodev": "node -v", 49 | "readme:copy": "echo \"\n\n_Welcome to the docs site for_\n\" | cat - README.md > docs/README.md", 50 | "test": "vitest", 51 | "test:ci": "vitest run" 52 | }, 53 | "dependencies": { 54 | "es-toolkit": "^1.39.8", 55 | "ky": "^1.8.2" 56 | }, 57 | "devDependencies": { 58 | "@changesets/cli": "^2.29.5", 59 | "@types/node": "^24.2.0", 60 | "@typescript-eslint/eslint-plugin": "^5.59.7", 61 | "@typescript-eslint/parser": "^5.59.7", 62 | "auto-changelog": "^2.4.0", 63 | "docsify-cli": "^4.4.4", 64 | "eslint": "^8.41.0", 65 | "eslint-config-airbnb-base": "^15.0.0", 66 | "eslint-config-airbnb-typescript": "^17.0.0", 67 | "eslint-plugin-import": "^2.27.5", 68 | "eslint-plugin-no-floating-promise": "^1.0.2", 69 | "google-auth-library": "^10.2.1", 70 | "tsup": "^8.5.0", 71 | "typescript": "^5.5.4", 72 | "varlock": "^0.0.7", 73 | "vitest": "^3.2.4" 74 | }, 75 | "peerDependencies": { 76 | "google-auth-library": ">=8.8.0" 77 | }, 78 | "peerDependenciesMeta": { 79 | "google-auth-library": { 80 | "optional": true 81 | } 82 | }, 83 | "volta": { 84 | "node": "20.17.0" 85 | } 86 | } 87 | -------------------------------------------------------------------------------- /docs/guides/exports.md: -------------------------------------------------------------------------------- 1 | # Exporting Data 2 | 3 | The Google sheets UI lets you export your document in various formats by navigating to the `File > Download` menu. 4 | 5 | Some of these formats export the entire document, while others are only a single sheet (the sheet you are currently viewing). 6 | 7 | These are the available formats: 8 | 9 | File Type|File Extension|Contents|Method 10 | ---|---|---|--- 11 | Web Page | zip > html+css | document | [`doc.downloadAsHTML()`](classes/google-spreadsheet?id=fn-downloadAsHTML) 12 | Microsoft Excel | xlsx | document | [`doc.downloadAsXLSX()`](classes/google-spreadsheet?id=fn-downloadAsXLSX) 13 | OpenDocument | ods | document | [`doc.downloadAsODS()`](classes/google-spreadsheet?id=fn-downloadAsODS) 14 | Comma Separated Values | csv | worksheet | [`sheet.downloadAsCSV()`](classes/google-spreadsheet-worksheet?id=fn-downloadAsCSV) 15 | Tab Separated Values | tsv | worksheet | [`sheet.downloadAsTSV()`](classes/google-spreadsheet-worksheet?id=fn-downloadAsTSV) 16 | PDF | pdf | worksheet | [`sheet.downloadAsPDF()`](/classes/google-spreadsheet-worksheet?id=fn-downloadAsPDF) 17 | 18 | 19 | All of these methods by default fetch an ArrayBuffer, but can be passed an optional parameter to return a stream instead. 20 | 21 | ## ArrayBuffer mode (default) 22 | 23 | This means you are dealing with the entire document at once. Usually you'd want to write this to a file, for example: 24 | 25 | ```javascript 26 | const doc = new GoogleSpreadsheet('', auth); 27 | 28 | const xlsxBuffer = await doc.downloadAsXLSX(); 29 | await fs.writeFile('./my-export.xlsx', Buffer.from(xlsxBuffer)); 30 | ``` 31 | 32 | ## Stream mode 33 | 34 | Dealing with [streams](https://developer.mozilla.org/en-US/docs/Web/API/Streams_API) means you are dealing with a stream of data rather than the entire file at once. This can be useful to do things like upload the file to somewhere else without saving it locally first, or to handle a large CSV when you want to do something else with each entry. 35 | 36 | This example doesn't get into the details of using streams, but this simple example should at least get you started: 37 | 38 | ```javascript 39 | import { Readable } from 'node:stream'; 40 | 41 | // ... 42 | const doc = new GoogleSpreadsheet('', auth); 43 | 44 | const csvStream = await doc.downloadAsCSV(true); // this `true` arg toggles to stream mode 45 | const writableStream = fs.createWriteStream('./my-export-stream.csv'); 46 | writableStream.on('finish', () => { 47 | console.log('done'); 48 | }); 49 | writableStream.on('error', (err) => { 50 | console.log(err); 51 | }); 52 | 53 | // convert the ReadableStream (web response) to a normal Node.js stream 54 | // and pipe to the fs writable stream 55 | Readable.fromWeb(csvStream).pipe(writableStream); 56 | ``` 57 | 58 | -------------------------------------------------------------------------------- /docs/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | google-spreadsheet - Google Spreadsheets API -- simple interface to read/write data and manage sheets 7 | 8 | 9 | 11 | 12 | 13 | 73 | 74 | 75 | 76 |
77 | 86 | 87 | 88 | 89 | 90 | 91 | 92 | -------------------------------------------------------------------------------- /docs/guides/migration-guide.md: -------------------------------------------------------------------------------- 1 | # Breaking Changes Upgrade Guide 2 | 3 | Some helpful info about how to deal with breaking changes 4 | 5 | ## V3 -> V4 6 | 7 | ### Auth 8 | 9 | Authentication methods have been decoupled from the library itself, and now instead you can rely on using the `google-auth-library` directly. 10 | 11 | In practice, initialization looks slightly different but doesn't change too much. 12 | 13 | #### Using a service account 14 | ```javascript 15 | import { JWT } from 'google-auth-library'; 16 | import creds from './service-account-creds-file.json'; 17 | 18 | const serviceAccountJWT = new JWT({ 19 | email: creds.client_email, 20 | key: creds.private_key, 21 | scopes: [ 22 | 'https://www.googleapis.com/auth/spreadsheets', 23 | 'https://www.googleapis.com/auth/drive.file', 24 | ], 25 | }); 26 | 27 | const doc = new GoogleSpreadsheet('YOUR-DOC-ID', serviceAccountJWT); 28 | ``` 29 | 30 | 31 | 32 | 33 | ### Row-based API 34 | 35 | In order to work better with TypeScript, the api has changed from using dynamic getters/setters to a more explicit get/set functions. 36 | 37 | While the getter/setter method used previously was _slightly_ more convenient, the new way allows us to specify the type/shape of the row data, and will help avoid any naming collisions with properties and functions on the `GoogleSpreadsheetRow` class. 38 | 39 | Before: 40 | ```javascript 41 | console.log(row.first_name); 42 | row.email = 'theo@example.com'; 43 | Object.assign(row, { first_name: 'Theo', email: 'theo@example.com' }) 44 | ``` 45 | 46 | After: 47 | ```javascript 48 | console.log(row.get('first_name')); 49 | row.set('email', 'theo@example.com'); 50 | row.assign({ first_name: 'Theo', email: 'theo@example.com' }); 51 | ``` 52 | 53 | #### Using with TypeScript 54 | 55 | You can now (optionally) specify the shape of the data that will be returned in rows. 56 | 57 | ```ts 58 | type UserRow = { first_name: string; email: string }; 59 | 60 | const userRows = await sheet.getRows(); 61 | const name = userRows[0].get('first_name'); // key checked to exist, value is typed 62 | 63 | // type errors 64 | userRows[0].get('bad_key'); // key does not exist! 65 | userRows[0].set('first_name', 123); // type of value is wrong 66 | ``` 67 | 68 | ### Row deletion / clearing 69 | 70 | Previously when a row was deleted, the row numbers of other loaded rows become out of sync. Now a cache of rows is stored 71 | on the Worksheet object, and when a row is deleted, all other rows in the cache have their row numbers updated if necessary. 72 | 73 | This will hopefully make row-deletion much more usable and match expectations. 74 | 75 | Calling `sheet.clearRows()` will also clear row values in the cache. 76 | 77 | ### Cell Errors 78 | 79 | If cells are in an error state,`cell.formulaError` has been renamed to `cell.errorValue` to match google's API. 80 | 81 | The error class was also renamed from `GoogleSpreadsheetFormulaError` to `GoogleSpreadsheetCellErrorValue` 82 | 83 | -------------------------------------------------------------------------------- /src/test/auth.test.ts: -------------------------------------------------------------------------------- 1 | import { 2 | describe, expect, it, afterEach, 3 | } from 'vitest'; 4 | import { setTimeout as delay } from 'timers/promises'; 5 | import { ENV } from 'varlock/env'; 6 | 7 | import { GoogleSpreadsheet, GoogleSpreadsheetWorksheet, GoogleSpreadsheetCell } from '..'; 8 | 9 | import { DOC_IDS, testServiceAccountAuth } from './auth/docs-and-auth'; 10 | import { GoogleApiAuth } from '../lib/types/auth-types'; 11 | 12 | function checkDocAccess( 13 | docType: keyof typeof DOC_IDS, 14 | auth: GoogleApiAuth, 15 | spec: { 16 | canRead?: boolean, 17 | canWrite?: boolean, 18 | readError?: string, 19 | writeError?: string, 20 | } 21 | ) { 22 | const doc = new GoogleSpreadsheet(DOC_IDS[docType], auth); 23 | let sheet: GoogleSpreadsheetWorksheet; 24 | 25 | describe(`Doc type = ${docType}`, () => { 26 | if (spec.canRead) { 27 | it('reading info should succeed', async () => { 28 | await doc.loadInfo(); 29 | expect(doc.title).toBeTruthy(); 30 | sheet = doc.sheetsByIndex[0]; 31 | }); 32 | it('reading row data should succeed', async () => { 33 | const rows = await sheet.getRows(); 34 | expect(rows).toBeInstanceOf(Array); 35 | }); 36 | it('reading cell data should succeed', async () => { 37 | await sheet.loadCells(['A1:A2', 'B2:B3']); 38 | expect(sheet.getCell(0, 0)).toBeInstanceOf(GoogleSpreadsheetCell); 39 | }); 40 | } else { 41 | it('reading info should fail', async () => { 42 | await expect(doc.loadInfo()).rejects.toThrow(spec.readError); 43 | }); 44 | } 45 | 46 | if (spec.canWrite) { 47 | it('writing should succeed', async () => { 48 | if (!sheet) return; 49 | await sheet.addRow([1, 2, 3]); 50 | }); 51 | } else { 52 | it('writing should fail', async () => { 53 | if (!sheet) return; 54 | await expect(sheet.addRow([1, 2, 3])).rejects.toThrow(spec.writeError); 55 | }); 56 | } 57 | }); 58 | } 59 | 60 | describe('Authentication', () => { 61 | // hitting rate limits when running tests on ci - so we add a short delay 62 | if (ENV.TEST_DELAY) afterEach(async () => delay(ENV.TEST_DELAY)); 63 | 64 | const apiKeyAuth = { apiKey: process.env.GOOGLE_API_KEY! }; 65 | 66 | describe('api key', () => { 67 | checkDocAccess('private', apiKeyAuth, { 68 | canRead: false, 69 | canWrite: false, 70 | readError: '[403]', 71 | }); 72 | checkDocAccess('public', apiKeyAuth, { 73 | canRead: true, 74 | canWrite: false, // requires auth to write 75 | writeError: '[401]', 76 | }); 77 | 78 | checkDocAccess('publicReadOnly', apiKeyAuth, { 79 | canRead: true, 80 | canWrite: false, 81 | writeError: '[401]', 82 | }); 83 | }); 84 | 85 | describe('service account', () => { 86 | checkDocAccess('private', testServiceAccountAuth, { 87 | canRead: true, 88 | canWrite: true, 89 | }); 90 | checkDocAccess('public', testServiceAccountAuth, { 91 | canRead: true, 92 | canWrite: true, 93 | }); 94 | checkDocAccess('publicReadOnly', testServiceAccountAuth, { 95 | canRead: true, 96 | canWrite: false, 97 | writeError: '[403]', 98 | }); 99 | checkDocAccess('privateReadOnly', testServiceAccountAuth, { 100 | canRead: true, 101 | canWrite: false, 102 | writeError: '[403]', 103 | }); 104 | }); 105 | 106 | // describe('oauth') 107 | }); 108 | -------------------------------------------------------------------------------- /src/lib/GoogleSpreadsheetRow.ts: -------------------------------------------------------------------------------- 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 = Record> { 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(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: Partial) { 54 | // eslint-disable-next-line no-restricted-syntax, guard-for-in 55 | for (const key in obj) this.set(key, obj[key] as any); 56 | } 57 | 58 | /** return raw object of row data */ 59 | toObject() { 60 | const o: Partial = {}; 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.put(`values/${encodeURIComponent(this.a1Range)}`, { 74 | searchParams: { 75 | valueInputOption: options?.raw ? 'RAW' : 'USER_ENTERED', 76 | includeValuesInResponse: true, 77 | }, 78 | json: { 79 | range: this.a1Range, 80 | majorDimension: 'ROWS', 81 | values: [this._rawData], 82 | }, 83 | }); 84 | const data = await response.json(); 85 | this._rawData = data.updatedData.values[0]; 86 | } 87 | 88 | /** delete this row */ 89 | async delete() { 90 | if (this._deleted) throw new Error('This row has been deleted - call getRows again before making updates.'); 91 | 92 | const result = await this._worksheet._makeSingleUpdateRequest('deleteRange', { 93 | range: { 94 | sheetId: this._worksheet.sheetId, 95 | startRowIndex: this._rowNumber - 1, // this format is zero indexed, because of course... 96 | endRowIndex: this._rowNumber, 97 | }, 98 | shiftDimension: 'ROWS', 99 | }); 100 | this._deleted = true; 101 | this._worksheet._shiftRowCache(this.rowNumber); 102 | 103 | return result; 104 | } 105 | 106 | /** 107 | * @internal 108 | * Used internally to clear row data after calling sheet.clearRows 109 | * Should not be called directly. 110 | */ 111 | _clearRowData() { 112 | for (let i = 0; i < this._rawData.length; i++) { 113 | this._rawData[i] = ''; 114 | } 115 | } 116 | } 117 | -------------------------------------------------------------------------------- /docs/classes/google-spreadsheet-row.md: -------------------------------------------------------------------------------- 1 | _Class Reference_ 2 | 3 | # GoogleSpreadsheetRow 4 | 5 | > **This class represents an individual row in a spreadsheet, plus header row info** 6 |
7 | Provides methods to read/write the values, save updates, and delete the row 8 | 9 | 10 | **Disclaimer** - Google's previous v3 API had "row-based" interactions built in, but it is no longer supported in their current v4 version. This module tries to recreate this interface as much as possible, but it's not perfect, because google no long supports it natively. 11 | 12 | ## Initialization 13 | 14 | You do not initialize rows directly. Instead you can load rows from a sheet. For example, with a sheet that looks like: 15 | 16 | name|email 17 | ---|--- 18 | Larry Page|larry@google.com 19 | Sergey Brin|sergey@google.com 20 | 21 | ```javascript 22 | const doc = new GoogleSpreadsheet('', auth); 23 | await doc.loadInfo(); // loads sheets 24 | const sheet = doc.sheetsByIndex[0]; // the first sheet 25 | 26 | const rows = await sheet.getRows(); 27 | console.log(rows.length); // 2 28 | console.log(rows[0].get('name')); // 'Larry Page' 29 | console.log(rows[0].get('email')); // 'larry@google.com' 30 | 31 | // make updates 32 | rows[1].set('email', 'sergey@abc.xyz'); 33 | await rows[1].save(); // save changes 34 | 35 | // add new row, returns a GoogleSpreadsheetRow object 36 | const sundar = await sheet.addRow({ name: 'Sundar Pichai', email: 'sundar@abc.xyz' }); 37 | ``` 38 | 39 | ## Properties 40 | 41 | ### Row Location 42 | Google uses both row/column indices and A1-style notation, available as **read-only** props: 43 | 44 | Property|Type|Description 45 | ---|---|--- 46 | `rowNumber`|Number
_int >= 1_|A1 row number in the sheet of this row 47 | `a1Range`|String|Full A1 range of this row, including the sheet name
_Ex: "sheet1!A5:D5"_ 48 | 49 | ### Row Values 50 | 51 | Property keys are determined by the header row of the sheet, and each row will have a property getter/setter available for each column. For example, for a sheet that looks like: 52 | 53 | name|email|lastContacted|status 54 | ---|---|---|--- 55 | Larry Page|larry@google.com|2020-01-02|active 56 | ... 57 | 58 | Each row would have props of `name`, `email`, `lastContacted`, `status` 59 | 60 | You can update these values by simply setting values for those props. 61 | 62 | #### Formulas 63 | 64 | The row-based interface is designed to much simpler than using cells. It therefore only returns values, and you cannot access the underlying formula, formatting info, or notes - which you can using the cells-based interface. 65 | 66 | That said, you can set a formula in a property and after saving the row, it will return the value the formula resolved to. However if you were to make other updates and NOT re-set the formula into the cell, the cell will lose the formula and will be overwritten with the value. 67 | 68 | ```javascript 69 | const row = await doc.addRow({ col1: '=A1' }); 70 | console.log(row.get('col1')); // logs '=A1', the formula has not been actually resolved yet 71 | await row.save(); // cell will now contain the value from cell A1 72 | ``` 73 | 74 | !> Be careful - it is not recommended to use formulas with the row based interface if you are planning on ever updating row values. If you are only inserting rows and reading data, you should be ok. 75 | 76 | 77 | ## Methods 78 | 79 | #### `get(key)` :id=fn-get 80 | > Get value of specific cell using header key 81 | 82 | Param|Type|Required|Description 83 | ---|---|---|--- 84 | `key`|String|-|header value 85 | 86 | #### `set(key, value)` :id=fn-set 87 | > Set value of specific cell using header key 88 | 89 | Param|Type|Required|Description 90 | ---|---|---|--- 91 | `key`|String|-|header value 92 | `value`|String|-|new value 93 | 94 | #### `assign(valuesObject)` (async) :id=fn-assign 95 | > Assign multiple values in the row at once 96 | 97 | Similar to `Object.assign()` 98 | 99 | Param|Type|Required|Description 100 | ---|---|---|--- 101 | `valuesObject`|Object|-|key-value object of data to set 102 | 103 | 104 | #### `save(options)` (async) :id=fn-save 105 | > Save any updates made to row values 106 | 107 | Param|Type|Required|Description 108 | ---|---|---|--- 109 | `options`|Object|-|Options object 110 | `options.raw`|Boolean|-|Store raw values instead of converting as if typed into the sheets UI
_see [ValueInputOption](https://developers.google.com/sheets/api/reference/rest/v4/ValueInputOption)_ 111 | 112 | - ✨ **Side effects** - updates are saved and everything re-fetched from google 113 | 114 | #### `delete()` (async) :id=fn-delete 115 | > Delete this row 116 | 117 | - ✨ **Side effects** - Row is removed from the sheet. Later rows that have been loaded have their `rowNumber` shifted accordingly. 118 | 119 | _also available as `row.del()`_ 120 | 121 | 122 | 123 | #### `toObject()` :id=fn-toObject 124 | > Get plain javascript object of row data 125 | 126 | -------------------------------------------------------------------------------- /docs/classes/google-spreadsheet-cell.md: -------------------------------------------------------------------------------- 1 | _Class Reference_ 2 | 3 | # GoogleSpreadsheetCell 4 | 5 | > **This class represents an individual cell in a spreadsheet - [Cells](https://developers.google.com/sheets/api/reference/rest/v4/spreadsheets/cells)** 6 |
7 | Provides methods to read/write the value, formatted value, formula, and cell formatting 8 | 9 | ## Initialization 10 | 11 | You do not initialize cells directly. Instead you can load a range of cells in a worksheet and access them by their A1 style address (ex: "B5") or row/column indices. For example: 12 | 13 | ```javascript 14 | const doc = new GoogleSpreadsheet('', auth); 15 | await doc.loadInfo(); // loads sheets 16 | const sheet = doc.sheetsByIndex[0]; // the first sheet 17 | await sheet.loadCells('A1:D5'); 18 | const cellA1 = sheet.getCell(0, 0); 19 | const cellC3 = sheet.getCellByA1('C3'); 20 | ``` 21 | 22 | ## Saving updates 23 | 24 | Changes are made locally by just setting props of the cell, but you must save your changes back to google. Usually you will make changes to multiple cells and then call `sheet.saveUpdatedCells()` to save all unsaved changes at once. 25 | 26 | Certain properties affect others and new values need to be saved back to google in order to be able to read again. For example, when setting a formula in a cell, we cannot read the value until we save back to google since we do not want to try to recreate the formula logic, and we may not have all the data required even if we could. 27 | 28 | ```javascript 29 | // continuing from above example ^^ 30 | cellA1.note = 'This is cell A1'; 31 | cellA1.value = 123.45; 32 | cellA1.textFormat = { bold: true }; 33 | cellC3.formula = '=A1'; 34 | console.log(cellC3.value); // this will throw an error 35 | await sheet.saveUpdatedCells(); // saves both cells in one API call 36 | console.log(cellC3.value); // 123.45 37 | ``` 38 | 39 | ## Properties 40 | 41 | ### Cell Location 42 | Google uses both row/column indices and A1-style notation, available as **read-only** props: 43 | 44 | Property|Type|Description 45 | ---|---|--- 46 | `rowIndex`|Number
_int >= 0_|Row in the sheet this cell is in
_first row is 0_ 47 | `columnIndex`|Number
_int >= 0_|Column in the sheet this cell is in 48 | `a1Row`|Number
_int >= 1_|Row number used in A1 addresses
_This matches what you see in the UI_ 49 | `a1Column`|String|Column letter used in the sheet
_starts at A, goes up to Z, then AA..._ 50 | `a1Address`|String|Full A1 address of the cell
_for example "B5"_ 51 | 52 | ### Cell Value(s) 53 | 54 | A cell can contain several layers of information. For example, the cell can contain a formula, which resolves to a value, which is displayed with some formatting applied, plus an additional note. The following props expose this info while simplifiying the inner workings a bit. 55 | 56 | Property|Type|Writeable|Description 57 | ---|---|---|--- 58 | `value`|*|✅|This is the _value_ in the cell. If there is a formula in the cell, this will be the value the formula resolves to 59 | `valueType`|String|-|The type of the value contained in the cell, using google's terminology
_One of `boolValue`, `stringValue`, `numberValue`, `errorValue`_ 60 | `boolValue`|boolean|✅|the value as a boolean, if the cell contains a boolean 61 | `stringValue`|string|✅|the value as a string, if the cell contains a string 62 | `numberValue`|string|✅|the value as a number, if the cell contains a number 63 | `formattedValue`|*|-|The value in the cell with [formatting rules](https://developers.google.com/sheets/api/reference/rest/v4/spreadsheets/cells#numberformat) applied
_Ex: value is `123.456`, formattedValue is `$123.46`_ 64 | `formula`|String|✅|The formula in the cell (if there is one) 65 | `error`|Error|-|An error with some details if the formula is invalid 66 | `note`|String|✅|The note attached to the cell 67 | `hyperlink`|String
_url_|-|URL of the cell's link if it has a`=HYPERLINK` formula
_ex: `=HYPERLINK("http://google.com", "google")`_ 68 | 69 | ### Cell Formatting 70 | 71 | Formatting related info is returned by google as two nested [CellFormat](https://developers.google.com/sheets/api/reference/rest/v4/spreadsheets/cells#CellFormat) objects. These are available as **read-only** props: 72 | 73 | Property|Type|Description 74 | ---|---|--- 75 | `userEnteredFormat`|Object
[CellFormat](https://developers.google.com/sheets/api/reference/rest/v4/spreadsheets/cells#CellFormat)|The format the user entered for the cell 76 | `effectiveFormat`|Object
[CellFormat](https://developers.google.com/sheets/api/reference/rest/v4/spreadsheets/cells#CellFormat)|the "effective format" being used by the cell
_This includes the results of applying any conditional formatting and, if the cell contains a formula, the computed number format. If the effective format is the default format, effective format will not be written._ 77 | 78 | However, to make reading and updating format easier, this class provides the follow **read/write** properties that reach into the `userEnteredFormat`. There is also a `clearAllFormatting()` method that will clear all format settings. 79 | 80 | Property|Type|Description 81 | ---|---|--- 82 | `numberFormat`|Object
[NumberFormat](https://developers.google.com/sheets/api/reference/rest/v4/spreadsheets/cells#NumberFormat)|A format describing how number values should be represented to the user. 83 | `backgroundColor`|Object
[Color](https://developers.google.com/sheets/api/reference/rest/v4/spreadsheets/other#Color)|The background color of the cell. 84 | `borders`|Object
[Borders](https://developers.google.com/sheets/api/reference/rest/v4/spreadsheets/cells#Borders)|The borders of the cell. 85 | `padding`|Object
[Padding](https://developers.google.com/sheets/api/reference/rest/v4/spreadsheets/cells#Padding)|The padding of the cell. 86 | `horizontalAlignment`|String (enum)
[HorizonalAlign](https://developers.google.com/sheets/api/reference/rest/v4/spreadsheets/other#HorizontalAlign)|The horizontal alignment of the value in the cell. 87 | `verticalAlignment`|String (enum)
[VerticalAlign](https://developers.google.com/sheets/api/reference/rest/v4/spreadsheets/cells#VerticalAlign)|The vertical alignment of the value in the cell. 88 | `wrapStrategy`|String (enum)
[WrapStrategy](https://developers.google.com/sheets/api/reference/rest/v4/spreadsheets/cells#WrapStrategy)|The wrap strategy for the value in the cell. 89 | `textDirection`|String (enum)
[TextDirection](https://developers.google.com/sheets/api/reference/rest/v4/spreadsheets/cells#TextDirection)|The direction of the text in the cell. 90 | `textFormat`|Object
[TextFormat](https://developers.google.com/sheets/api/reference/rest/v4/spreadsheets/other#TextFormat)|The format of the text in the cell (unless overridden by a format run). 91 | `hyperlinkDisplayType`|String
[HyperlinkDisplayType](https://developers.google.com/sheets/api/reference/rest/v4/spreadsheets/cells#HyperlinkDisplayType)|How a hyperlink, if it exists, should be displayed in the cell. 92 | `textRotation`|Object
[TextRotation](https://developers.google.com/sheets/api/reference/rest/v4/spreadsheets/cells#TextRotation)|The rotation applied to text in a cell 93 | 94 | 95 | ## Methods 96 | 97 | #### `clearAllFormatting()` :id=fn-clearAllFormatting 98 | > Reset all cell formatting to default/nothing 99 | 100 | **This is still only a local change which must still be saved** 101 | 102 | - ✨ **Side effects** - all user entered format settings are cleared (locally) 103 | 104 | 105 | #### `discardUnsavedChanges()` :id=fn-discardUnsavedChanges 106 | > Discard all unsaved changes - includes value, notes, and formatting 107 | 108 | - ✨ **Side effects** - cell will no longer be considered "dirty" and unsaved changes are discarded 109 | 110 | #### `save()` (async) :id=fn-save 111 | > Save this individual cell 112 | 113 | - ✨ **Side effects** - updates are saved and everything re-fetched from google 114 | 115 | ?> Usually makes more sense to use `sheet.saveUpdatedCells()` to save many cell updates at once 116 | 117 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # google-spreadsheet 2 | 3 | > The most popular [Google Sheets API](https://developers.google.com/sheets/api/guides/concepts) wrapper for javascript / typescript 4 | 5 | wizard pixel art 9 | varlock banner 13 | 14 | [![NPM version](https://img.shields.io/npm/v/google-spreadsheet)](https://www.npmjs.com/package/google-spreadsheet) 15 | [![CI status](https://github.com/theoephraim/node-google-spreadsheet/actions/workflows/ci.yml/badge.svg)](https://github.com/theoephraim/node-google-spreadsheet/actions/workflows/ci.yml) 16 | [![Known Vulnerabilities](https://snyk.io/test/github/theoephraim/node-google-spreadsheet/badge.svg?targetFile=package.json)](https://snyk.io/test/github/theoephraim/node-google-spreadsheet?targetFile=package.json) 17 | [![NPM](https://img.shields.io/npm/dw/google-spreadsheet)](https://www.npmtrends.com/google-spreadsheet) 18 | 19 | - multiple auth options (via [google-auth-library](https://www.npmjs.com/package/google-auth-library)) - service account, OAuth, API key, ADC, etc 20 | - cell-based API - read, write, bulk-updates, formatting 21 | - row-based API - read, update, delete (based on the old v3 row-based calls) 22 | - managing worksheets - add, remove, resize, update properties (ex: title), duplicate to same or other document 23 | - managing docs - create new doc, delete doc, basic sharing/permissions 24 | - export - download sheet/docs in various formats 25 | 26 | **Docs site -** 27 | Full docs available at [https://theoephraim.github.io/node-google-spreadsheet](https://theoephraim.github.io/node-google-spreadsheet) 28 | 29 | --- 30 | 31 | > 🌈 **Installation** - `pnpm i google-spreadsheet`
(or `npm i google-spreadsheet --save` or `yarn add google-spreadsheet`) 32 | 33 | ## Examples 34 | 35 | _The following examples are meant to give you an idea of just some of the things you can do_ 36 | 37 | > **IMPORTANT NOTE** - To keep the examples concise, I'm calling await [at the top level](https://v8.dev/features/top-level-await) which is not allowed in some older versions of node. If you need to call await in a script at the root level and your environment does not support it, you must instead wrap it in an async function like so: 38 | 39 | ```javascript 40 | (async function () { 41 | await someAsyncFunction(); 42 | })(); 43 | ``` 44 | 45 | ### The Basics 46 | 47 | ```js 48 | import { GoogleSpreadsheet } from 'google-spreadsheet'; 49 | import { JWT } from 'google-auth-library'; 50 | 51 | // Initialize auth - see https://theoephraim.github.io/node-google-spreadsheet/#/guides/authentication 52 | const serviceAccountAuth = new JWT({ 53 | // env var values here are copied from service account credentials generated by google 54 | // see "Authentication" section in docs for more info 55 | email: process.env.GOOGLE_SERVICE_ACCOUNT_EMAIL, 56 | key: process.env.GOOGLE_PRIVATE_KEY, 57 | scopes: ['https://www.googleapis.com/auth/spreadsheets'], 58 | }); 59 | 60 | const doc = new GoogleSpreadsheet('', serviceAccountAuth); 61 | 62 | await doc.loadInfo(); // loads document properties and worksheets 63 | console.log(doc.title); 64 | await doc.updateProperties({ title: 'renamed doc' }); 65 | 66 | const sheet = doc.sheetsByIndex[0]; // or use `doc.sheetsById[id]` or `doc.sheetsByTitle[title]` 67 | console.log(sheet.title); 68 | console.log(sheet.rowCount); 69 | 70 | // adding / removing sheets 71 | const newSheet = await doc.addSheet({ title: 'another sheet' }); 72 | await newSheet.delete(); 73 | ``` 74 | 75 | More info: 76 | 77 | - [GoogleSpreadsheet](https://theoephraim.github.io/node-google-spreadsheet/#/classes/google-spreadsheet) 78 | - [GoogleSpreadsheetWorksheet](https://theoephraim.github.io/node-google-spreadsheet/#/classes/google-spreadsheet-worksheet) 79 | - [Authentication](https://theoephraim.github.io/node-google-spreadsheet/#/guides/authentication) 80 | 81 | ### Working with rows 82 | 83 | ```js 84 | // if creating a new sheet, you can set the header row 85 | const sheet = await doc.addSheet({ headerValues: ['name', 'email'] }); 86 | 87 | // append rows 88 | const larryRow = await sheet.addRow({ name: 'Larry Page', email: 'larry@google.com' }); 89 | const moreRows = await sheet.addRows([ 90 | { name: 'Sergey Brin', email: 'sergey@google.com' }, 91 | { name: 'Eric Schmidt', email: 'eric@google.com' }, 92 | ]); 93 | 94 | // read rows 95 | const rows = await sheet.getRows(); // can pass in { limit, offset } 96 | 97 | // read/write row values 98 | console.log(rows[0].get('name')); // 'Larry Page' 99 | rows[1].set('email', 'sergey@abc.xyz'); // update a value 100 | rows[2].assign({ name: 'Sundar Pichai', email: 'sundar@google.com' }); // set multiple values 101 | await rows[2].save(); // save updates on a row 102 | await rows[2].delete(); // delete a row 103 | ``` 104 | 105 | Row methods support explicit TypeScript types for shape of the data 106 | 107 | ```typescript 108 | type UsersRowData = { 109 | name: string; 110 | email: string; 111 | type?: 'admin' | 'user'; 112 | }; 113 | const userRows = await sheet.getRows(); 114 | 115 | userRows[0].get('name'); // <- TS is happy, knows it will be a string 116 | userRows[0].get('badColumn'); // <- will throw a type error 117 | ``` 118 | 119 | More info: 120 | 121 | - [GoogleSpreadsheetWorksheet > Working With Rows](https://theoephraim.github.io/node-google-spreadsheet/#/classes/google-spreadsheet-worksheet#working-with-rows) 122 | - [GoogleSpreadsheetRow](https://theoephraim.github.io/node-google-spreadsheet/#/classes/google-spreadsheet-row) 123 | 124 | ### Working with cells 125 | 126 | ```js 127 | await sheet.loadCells('A1:E10'); // loads range of cells into local cache - DOES NOT RETURN THE CELLS 128 | console.log(sheet.cellStats); // total cells, loaded, how many non-empty 129 | const a1 = sheet.getCell(0, 0); // access cells using a zero-based index 130 | const c6 = sheet.getCellByA1('C6'); // or A1 style notation 131 | // access everything about the cell 132 | console.log(a1.value); 133 | console.log(a1.formula); 134 | console.log(a1.formattedValue); 135 | // update the cell contents and formatting 136 | a1.value = 123.456; 137 | c6.formula = '=A1'; 138 | a1.textFormat = { bold: true }; 139 | c6.note = 'This is a note!'; 140 | await sheet.saveUpdatedCells(); // save all updates in one call 141 | ``` 142 | 143 | More info: 144 | 145 | - [GoogleSpreadsheetWorksheet > Working With Cells](https://theoephraim.github.io/node-google-spreadsheet/#/classes/google-spreadsheet-worksheet#working-with-cells) 146 | - [GoogleSpreadsheetCell](https://theoephraim.github.io/node-google-spreadsheet/#/classes/google-spreadsheet-cell) 147 | 148 | ### Managing docs and sharing 149 | 150 | ```js 151 | const auth = new JWT({ 152 | email: process.env.GOOGLE_SERVICE_ACCOUNT_EMAIL, 153 | key: process.env.GOOGLE_PRIVATE_KEY, 154 | scopes: [ 155 | 'https://www.googleapis.com/auth/spreadsheets', 156 | // note that sharing-related calls require the google drive scope 157 | 'https://www.googleapis.com/auth/drive.file', 158 | ], 159 | }); 160 | 161 | // create a new doc 162 | const newDoc = await GoogleSpreadsheet.createNewSpreadsheetDocument(auth, { title: 'new fancy doc' }); 163 | 164 | // share with specific users, domains, or make public 165 | await newDoc.share('someone.else@example.com'); 166 | await newDoc.share('mycorp.com'); 167 | await newDoc.setPublicAccessLevel('reader'); 168 | 169 | // delete doc 170 | await newDoc.delete(); 171 | ``` 172 | 173 | ## Why? 174 | 175 | > **This module provides an intuitive wrapper around Google's API to simplify common interactions** 176 | 177 | While Google's v4 sheets API is much easier to use than v3 was, the official [googleapis npm module](https://www.npmjs.com/package/googleapis) is a giant autogenerated meta-tool that handles _every Google product_. The module and the API itself are awkward and the docs are pretty terrible, at least to get started. 178 | 179 | **In what situation should you use Google's API directly?**
180 | This module makes trade-offs for simplicity of the interface. 181 | Google's API provides a mechanism to make many requests in parallel, so if speed and efficiency are extremely important to your use case, you may want to use their API directly. There are also many lesser-used features of their API that are not implemented here yet. 182 | 183 | ## Support & Contributions 184 | 185 | This module was written and is actively maintained by [Theo Ephraim](https://theoephraim.com). 186 | 187 | **Are you actively using this module for a commercial project? Want to help support it?**
188 | [Buy Theo a beer](https://paypal.me/theoephraim) 189 | 190 | ### Sponsors 191 | 192 | None yet - get in touch! 193 | 194 | ### Contributing 195 | 196 | Contributions are welcome, but please follow the existing conventions, use the linter, add relevant tests, and add relevant documentation. 197 | 198 | The docs site is generated using [docsify](https://docsify.js.org). To preview and run locally so you can make edits, run `npm run docs:preview` and head to http://localhost:3000 199 | The content lives in markdown files in the docs folder. 200 | 201 | ## License 202 | 203 | This project is released under the MIT license. Previously it was using the "Unlicense". TLDR do whatever you want with it. 204 | -------------------------------------------------------------------------------- /docs/README.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | _Welcome to the docs site for_ 4 | 5 | # google-spreadsheet 6 | 7 | > The most popular [Google Sheets API](https://developers.google.com/sheets/api/guides/concepts) wrapper for javascript / typescript 8 | 9 | wizard pixel art 13 | varlock banner 17 | 18 | [![NPM version](https://img.shields.io/npm/v/google-spreadsheet)](https://www.npmjs.com/package/google-spreadsheet) 19 | [![CI status](https://github.com/theoephraim/node-google-spreadsheet/actions/workflows/ci.yml/badge.svg)](https://github.com/theoephraim/node-google-spreadsheet/actions/workflows/ci.yml) 20 | [![Known Vulnerabilities](https://snyk.io/test/github/theoephraim/node-google-spreadsheet/badge.svg?targetFile=package.json)](https://snyk.io/test/github/theoephraim/node-google-spreadsheet?targetFile=package.json) 21 | [![NPM](https://img.shields.io/npm/dw/google-spreadsheet)](https://www.npmtrends.com/google-spreadsheet) 22 | 23 | - multiple auth options (via [google-auth-library](https://www.npmjs.com/package/google-auth-library)) - service account, OAuth, API key, ADC, etc 24 | - cell-based API - read, write, bulk-updates, formatting 25 | - row-based API - read, update, delete (based on the old v3 row-based calls) 26 | - managing worksheets - add, remove, resize, update properties (ex: title), duplicate to same or other document 27 | - managing docs - create new doc, delete doc, basic sharing/permissions 28 | - export - download sheet/docs in various formats 29 | 30 | **Docs site -** 31 | Full docs available at [https://theoephraim.github.io/node-google-spreadsheet](https://theoephraim.github.io/node-google-spreadsheet) 32 | 33 | --- 34 | 35 | > 🌈 **Installation** - `pnpm i google-spreadsheet`
(or `npm i google-spreadsheet --save` or `yarn add google-spreadsheet`) 36 | 37 | ## Examples 38 | 39 | _The following examples are meant to give you an idea of just some of the things you can do_ 40 | 41 | > **IMPORTANT NOTE** - To keep the examples concise, I'm calling await [at the top level](https://v8.dev/features/top-level-await) which is not allowed in some older versions of node. If you need to call await in a script at the root level and your environment does not support it, you must instead wrap it in an async function like so: 42 | 43 | ```javascript 44 | (async function () { 45 | await someAsyncFunction(); 46 | })(); 47 | ``` 48 | 49 | ### The Basics 50 | 51 | ```js 52 | import { GoogleSpreadsheet } from 'google-spreadsheet'; 53 | import { JWT } from 'google-auth-library'; 54 | 55 | // Initialize auth - see https://theoephraim.github.io/node-google-spreadsheet/#/guides/authentication 56 | const serviceAccountAuth = new JWT({ 57 | // env var values here are copied from service account credentials generated by google 58 | // see "Authentication" section in docs for more info 59 | email: process.env.GOOGLE_SERVICE_ACCOUNT_EMAIL, 60 | key: process.env.GOOGLE_PRIVATE_KEY, 61 | scopes: ['https://www.googleapis.com/auth/spreadsheets'], 62 | }); 63 | 64 | const doc = new GoogleSpreadsheet('', serviceAccountAuth); 65 | 66 | await doc.loadInfo(); // loads document properties and worksheets 67 | console.log(doc.title); 68 | await doc.updateProperties({ title: 'renamed doc' }); 69 | 70 | const sheet = doc.sheetsByIndex[0]; // or use `doc.sheetsById[id]` or `doc.sheetsByTitle[title]` 71 | console.log(sheet.title); 72 | console.log(sheet.rowCount); 73 | 74 | // adding / removing sheets 75 | const newSheet = await doc.addSheet({ title: 'another sheet' }); 76 | await newSheet.delete(); 77 | ``` 78 | 79 | More info: 80 | 81 | - [GoogleSpreadsheet](https://theoephraim.github.io/node-google-spreadsheet/#/classes/google-spreadsheet) 82 | - [GoogleSpreadsheetWorksheet](https://theoephraim.github.io/node-google-spreadsheet/#/classes/google-spreadsheet-worksheet) 83 | - [Authentication](https://theoephraim.github.io/node-google-spreadsheet/#/guides/authentication) 84 | 85 | ### Working with rows 86 | 87 | ```js 88 | // if creating a new sheet, you can set the header row 89 | const sheet = await doc.addSheet({ headerValues: ['name', 'email'] }); 90 | 91 | // append rows 92 | const larryRow = await sheet.addRow({ name: 'Larry Page', email: 'larry@google.com' }); 93 | const moreRows = await sheet.addRows([ 94 | { name: 'Sergey Brin', email: 'sergey@google.com' }, 95 | { name: 'Eric Schmidt', email: 'eric@google.com' }, 96 | ]); 97 | 98 | // read rows 99 | const rows = await sheet.getRows(); // can pass in { limit, offset } 100 | 101 | // read/write row values 102 | console.log(rows[0].get('name')); // 'Larry Page' 103 | rows[1].set('email', 'sergey@abc.xyz'); // update a value 104 | rows[2].assign({ name: 'Sundar Pichai', email: 'sundar@google.com' }); // set multiple values 105 | await rows[2].save(); // save updates on a row 106 | await rows[2].delete(); // delete a row 107 | ``` 108 | 109 | Row methods support explicit TypeScript types for shape of the data 110 | 111 | ```typescript 112 | type UsersRowData = { 113 | name: string; 114 | email: string; 115 | type?: 'admin' | 'user'; 116 | }; 117 | const userRows = await sheet.getRows(); 118 | 119 | userRows[0].get('name'); // <- TS is happy, knows it will be a string 120 | userRows[0].get('badColumn'); // <- will throw a type error 121 | ``` 122 | 123 | More info: 124 | 125 | - [GoogleSpreadsheetWorksheet > Working With Rows](https://theoephraim.github.io/node-google-spreadsheet/#/classes/google-spreadsheet-worksheet#working-with-rows) 126 | - [GoogleSpreadsheetRow](https://theoephraim.github.io/node-google-spreadsheet/#/classes/google-spreadsheet-row) 127 | 128 | ### Working with cells 129 | 130 | ```js 131 | await sheet.loadCells('A1:E10'); // loads range of cells into local cache - DOES NOT RETURN THE CELLS 132 | console.log(sheet.cellStats); // total cells, loaded, how many non-empty 133 | const a1 = sheet.getCell(0, 0); // access cells using a zero-based index 134 | const c6 = sheet.getCellByA1('C6'); // or A1 style notation 135 | // access everything about the cell 136 | console.log(a1.value); 137 | console.log(a1.formula); 138 | console.log(a1.formattedValue); 139 | // update the cell contents and formatting 140 | a1.value = 123.456; 141 | c6.formula = '=A1'; 142 | a1.textFormat = { bold: true }; 143 | c6.note = 'This is a note!'; 144 | await sheet.saveUpdatedCells(); // save all updates in one call 145 | ``` 146 | 147 | More info: 148 | 149 | - [GoogleSpreadsheetWorksheet > Working With Cells](https://theoephraim.github.io/node-google-spreadsheet/#/classes/google-spreadsheet-worksheet#working-with-cells) 150 | - [GoogleSpreadsheetCell](https://theoephraim.github.io/node-google-spreadsheet/#/classes/google-spreadsheet-cell) 151 | 152 | ### Managing docs and sharing 153 | 154 | ```js 155 | const auth = new JWT({ 156 | email: process.env.GOOGLE_SERVICE_ACCOUNT_EMAIL, 157 | key: process.env.GOOGLE_PRIVATE_KEY, 158 | scopes: [ 159 | 'https://www.googleapis.com/auth/spreadsheets', 160 | // note that sharing-related calls require the google drive scope 161 | 'https://www.googleapis.com/auth/drive.file', 162 | ], 163 | }); 164 | 165 | // create a new doc 166 | const newDoc = await GoogleSpreadsheet.createNewSpreadsheetDocument(auth, { title: 'new fancy doc' }); 167 | 168 | // share with specific users, domains, or make public 169 | await newDoc.share('someone.else@example.com'); 170 | await newDoc.share('mycorp.com'); 171 | await newDoc.setPublicAccessLevel('reader'); 172 | 173 | // delete doc 174 | await newDoc.delete(); 175 | ``` 176 | 177 | ## Why? 178 | 179 | > **This module provides an intuitive wrapper around Google's API to simplify common interactions** 180 | 181 | While Google's v4 sheets API is much easier to use than v3 was, the official [googleapis npm module](https://www.npmjs.com/package/googleapis) is a giant autogenerated meta-tool that handles _every Google product_. The module and the API itself are awkward and the docs are pretty terrible, at least to get started. 182 | 183 | **In what situation should you use Google's API directly?**
184 | This module makes trade-offs for simplicity of the interface. 185 | Google's API provides a mechanism to make many requests in parallel, so if speed and efficiency are extremely important to your use case, you may want to use their API directly. There are also many lesser-used features of their API that are not implemented here yet. 186 | 187 | ## Support & Contributions 188 | 189 | This module was written and is actively maintained by [Theo Ephraim](https://theoephraim.com). 190 | 191 | **Are you actively using this module for a commercial project? Want to help support it?**
192 | [Buy Theo a beer](https://paypal.me/theoephraim) 193 | 194 | ### Sponsors 195 | 196 | None yet - get in touch! 197 | 198 | ### Contributing 199 | 200 | Contributions are welcome, but please follow the existing conventions, use the linter, add relevant tests, and add relevant documentation. 201 | 202 | The docs site is generated using [docsify](https://docsify.js.org). To preview and run locally so you can make edits, run `npm run docs:preview` and head to http://localhost:3000 203 | The content lives in markdown files in the docs folder. 204 | 205 | ## License 206 | 207 | This project is released under the MIT license. Previously it was using the "Unlicense". TLDR do whatever you want with it. 208 | -------------------------------------------------------------------------------- /src/test/cells.test.ts: -------------------------------------------------------------------------------- 1 | import { 2 | describe, expect, it, beforeAll, beforeEach, afterAll, afterEach, 3 | } from 'vitest'; 4 | import { setTimeout as delay } from 'timers/promises'; 5 | import { ENV } from 'varlock/env'; 6 | import * as _ from '../lib/toolkit'; 7 | 8 | import { 9 | GoogleSpreadsheet, GoogleSpreadsheetWorksheet, GoogleSpreadsheetCell, GoogleSpreadsheetCellErrorValue, 10 | } from '..'; 11 | 12 | import { DOC_IDS, testServiceAccountAuth } from './auth/docs-and-auth'; 13 | 14 | const doc = new GoogleSpreadsheet(DOC_IDS.private, testServiceAccountAuth); 15 | 16 | let sheet: GoogleSpreadsheetWorksheet; 17 | 18 | const NUM_ROWS = 10; 19 | const NUM_COLS = 10; 20 | const TOTAL_CELLS = NUM_ROWS * NUM_COLS; 21 | 22 | describe('Cell-based operations', () => { 23 | beforeAll(async () => { 24 | sheet = await doc.addSheet({ 25 | gridProperties: { 26 | rowCount: NUM_ROWS, 27 | columnCount: NUM_COLS, 28 | }, 29 | headerValues: ['col1', 'col2', 'col3'], 30 | }); 31 | }); 32 | afterAll(async () => { 33 | await sheet.delete(); 34 | }); 35 | // hitting rate limits when running tests on ci - so we add a short delay 36 | if (ENV.TEST_DELAY) afterEach(async () => delay(ENV.TEST_DELAY)); 37 | 38 | describe('loading cells', () => { 39 | afterEach(() => { 40 | sheet.resetLocalCache(true); 41 | }); 42 | 43 | it('fetches all cells if no range given', async () => { 44 | await sheet.loadCells(); 45 | expect(sheet.cellStats).toEqual({ 46 | nonEmpty: 3, 47 | loaded: TOTAL_CELLS, 48 | total: TOTAL_CELLS, 49 | }); 50 | }); 51 | 52 | it('can fetch a specific A1 range by passing a string', async () => { 53 | await sheet.loadCells('B1:D3'); 54 | expect(sheet.cellStats).toMatchObject({ 55 | nonEmpty: 2, 56 | loaded: 9, 57 | }); 58 | }); 59 | 60 | it('can load multiple ranges', async () => { 61 | await sheet.loadCells(['A1:A3', 'C1:C3']); 62 | expect(sheet.cellStats).toMatchObject({ 63 | nonEmpty: 2, 64 | loaded: 6, 65 | }); 66 | }); 67 | 68 | it('can load multiple ranges (mix of A1 and object style)', async () => { 69 | await sheet.loadCells([ 70 | 'A1:A3', 71 | { 72 | startRowIndex: 0, endRowIndex: 3, startColumnIndex: 2, endColumnIndex: 5, 73 | }, 74 | ]); 75 | expect(sheet.cellStats).toMatchObject({ 76 | nonEmpty: 2, 77 | loaded: 12, 78 | }); 79 | }); 80 | 81 | it('can fetch a range that overlaps the sheet but goes out of bounds', async () => { 82 | await sheet.loadCells('A10:B11'); 83 | expect(sheet.cellStats).toMatchObject({ loaded: 2 }); 84 | }); 85 | 86 | it('can fetch a range using a GridRange style object', async () => { 87 | // start is inclusive, end is exclusive 88 | await sheet.loadCells({ 89 | startRowIndex: 0, 90 | endRowIndex: 3, 91 | startColumnIndex: 2, 92 | endColumnIndex: 5, 93 | }); 94 | expect(sheet.cellStats).toMatchObject({ 95 | nonEmpty: 1, 96 | loaded: 9, 97 | }); 98 | }); 99 | 100 | it('should throw if a cell is not loaded yet', async () => { 101 | expect(() => { sheet.getCell(0, 0); }).toThrow(); 102 | expect(() => { sheet.getCellByA1('A1'); }).toThrow(); 103 | }); 104 | 105 | it('can load a cell multiple times (this was a bug)', async () => { 106 | await sheet.loadCells('J10'); 107 | expect(sheet.getCellByA1('J10').value).toBeNull(); 108 | await sheet.loadCells('J10'); 109 | expect(sheet.getCellByA1('J10').value).toBeNull(); 110 | }); 111 | 112 | describe('invalid filters', () => { 113 | _.each({ 114 | 'invalid A1 range': 'NOT-A-RANGE', 115 | 'A1 range out of bounds': 'A20:B21', 116 | 'gridrange sheetId mismatch': { sheetId: '0' }, 117 | 'gridrange range out of bounds': { startRowIndex: 20 }, 118 | 'not a string or object': 5, 119 | }, (badFilter, description) => { 120 | it(`throws for ${description}`, async () => { 121 | await expect(sheet.loadCells(badFilter as any)).rejects.toThrow(); 122 | }); 123 | }); 124 | }); 125 | }); 126 | 127 | describe('basic cell functionality', () => { 128 | let c1: GoogleSpreadsheetCell; 129 | let c2: GoogleSpreadsheetCell; 130 | let c3: GoogleSpreadsheetCell; 131 | beforeEach(async () => { 132 | sheet.resetLocalCache(true); 133 | await sheet.loadCells('A1:C1'); 134 | c1 = sheet.getCell(0, 0); 135 | c2 = sheet.getCell(0, 1); 136 | c3 = sheet.getCell(0, 2); 137 | }); 138 | 139 | it('can select a cell by A1 address or row/col index', async () => { 140 | // c2 is `sheet.getCell(0, 1);` 141 | expect(c2.rowIndex).toBe(0); 142 | expect(c2.columnIndex).toBe(1); 143 | expect(c2.a1Address).toBe('B1'); 144 | expect(c2).toEqual(sheet.getCellByA1('B1')); 145 | }); 146 | 147 | it('can update cells and save them', async () => { 148 | c1.value = 1.2345; 149 | c2.value = 2.3456; 150 | c3.formula = '=A1 + B1'; 151 | await sheet.saveUpdatedCells(); 152 | expect(c3.value).toBe(c1.value + c2.value); 153 | }); 154 | 155 | it('can save a single cell using cell.save()', async () => { 156 | c1.value = 9.8765; 157 | await c1.save(); 158 | }); 159 | 160 | it('can set cell value formatting', async () => { 161 | c3.numberFormat = { type: 'NUMBER', pattern: '#.00' }; 162 | await sheet.saveUpdatedCells(); 163 | if (!_.isNumber(c1.value) || !_.isNumber(c2.value) || !_.isNumber(c3.value)) { 164 | throw new Error('expected cell values to be numeric'); 165 | } 166 | expect(c3.numberValue).toBe(c1.value + c2.value); 167 | expect(c3.formattedValue!).toBe(c3.value.toFixed(2)); 168 | expect(c3.formula).toBe('=A1 + B1'); 169 | }); 170 | 171 | it('can update a cells note', async () => { 172 | c1.note = 'This is a note!'; 173 | await sheet.saveUpdatedCells(); 174 | sheet.resetLocalCache(true); 175 | await sheet.loadCells('A1'); 176 | expect(sheet.getCell(0, 0).note).toBe(c1.note); 177 | }); 178 | 179 | it('can update multiple cell properties at once', async () => { 180 | c1.note = null; 181 | c1.value = 567.89; 182 | c1.textFormat = { bold: true }; 183 | await sheet.saveUpdatedCells(); 184 | }); 185 | 186 | it('can clear cell value using null, undefined, empty string', async () => { 187 | _.each([c1, c2, c3], (cell) => { cell.value = 'something'; }); 188 | await sheet.saveUpdatedCells(); 189 | c1.value = null; 190 | c2.value = undefined; 191 | c3.value = ''; 192 | await sheet.saveUpdatedCells(); 193 | _.each([c1, c2, c3], (cell) => { expect(cell.value).toBeNull(); }); 194 | }); 195 | 196 | it('cannot set a cell value to an object', async () => { 197 | expect(() => { (c1.value as any) = { foo: 1 }; }).toThrow(); 198 | }); 199 | 200 | describe('calling saveCells directly', () => { 201 | it('can save an array of cells', async () => { 202 | _.each([c1, c2, c3], (cell) => { cell.value = 'calling saveCells'; }); 203 | await sheet.saveCells([c1, c2, c3]); 204 | }); 205 | 206 | it('can save a mix of dirty and non-dirty', async () => { 207 | c2.value = 'saveCells again'; 208 | await sheet.saveCells([c1, c2, c3]); 209 | }); 210 | 211 | it('will throw an error if no cells are dirty', async () => { 212 | await expect(sheet.saveCells([c1, c2, c3])).rejects.toThrow(); 213 | }); 214 | }); 215 | 216 | describe('cell formulas', () => { 217 | it('can update a cell with a formula via .value', async () => { 218 | c1.value = '=2'; 219 | await sheet.saveUpdatedCells(); 220 | expect(c1.value).toBe(2); 221 | expect(c1.formula).toBe('=2'); 222 | }); 223 | 224 | it('can update a cell with a formula via .formula', async () => { 225 | c1.formula = '=1'; 226 | await sheet.saveUpdatedCells(); 227 | expect(c1.value).toBe(1); 228 | expect(c1.formula).toBe('=1'); 229 | }); 230 | 231 | it('can only set .formula with a string starting with "="', async () => { 232 | expect(() => { c1.formula = '123'; }).toThrow(); 233 | }); 234 | 235 | it('cannot set a formula to a non-string', async () => { 236 | expect(() => { (c1.formula as any) = 123; }).toThrow(); 237 | }); 238 | 239 | it('handles formula errors correctly', async () => { 240 | c1.formula = '=NOTAFORMULA'; 241 | await sheet.saveUpdatedCells(); 242 | expect(c1.value).toBeInstanceOf(GoogleSpreadsheetCellErrorValue); 243 | expect(c1.value).toEqual(c1.errorValue); 244 | }); 245 | }); 246 | 247 | describe('value type handling', () => { 248 | _.each({ 249 | string: { value: 'string', valueType: 'stringValue' }, 250 | number: { value: 123.45, valueType: 'numberValue' }, 251 | boolean: { value: true, valueType: 'boolValue' }, 252 | 'formula number': { value: '=123', valueType: 'numberValue' }, 253 | 'formula boolean': { value: '=TRUE', valueType: 'boolValue' }, 254 | 'formula string': { value: '="ASDF"', valueType: 'stringValue' }, 255 | 'formula error': { value: '=BADFFORMULA', valueType: 'errorValue' }, 256 | }, (spec, type) => { 257 | it(`can set a value with type - ${type}`, async () => { 258 | c1.value = spec.value; 259 | await sheet.saveUpdatedCells(); 260 | expect(c1.valueType).toBe(spec.valueType); 261 | }); 262 | }); 263 | }); 264 | }); 265 | 266 | describe('read-only (API key) access', () => { 267 | it('cannot load cells using object style range', async () => { 268 | const doc2 = new GoogleSpreadsheet(DOC_IDS.public, { apiKey: process.env.GOOGLE_API_KEY! }); 269 | await doc2.loadInfo(); 270 | const sheet2 = doc2.sheetsByIndex[0]; 271 | await expect( 272 | sheet2.loadCells({ startRowIndex: 0, startColumnIndex: 2 }) 273 | ).rejects.toThrow('read-only access'); 274 | }); 275 | }); 276 | 277 | describe.todo('cell formatting', () => { 278 | // TODO: add tests! 279 | // - set the background color twice, conflicts b/w backgroundColor and backgroundColorStyle 280 | }); 281 | }); 282 | -------------------------------------------------------------------------------- /docs/classes/google-spreadsheet.md: -------------------------------------------------------------------------------- 1 | _Class Reference_ 2 | 3 | # GoogleSpreadsheet 4 | 5 | > **This class represents an entire google spreadsheet document** 6 |
7 | 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. 8 | 9 | ## Initialization 10 | 11 | ### Existing documents 12 | #### `new GoogleSpreadsheet(id, auth)` :id=fn-newGoogleSpreadsheet 13 | > Work with an existing document 14 | 15 | > You'll need the document ID, which you can find in your browser's URL when you navigate to the document.
16 | > For example: `https://docs.google.com/spreadsheets/d/THIS-IS-THE-DOCUMENT-ID/edit#gid=123456789` 17 | 18 | Param|Type|Required|Description 19 | ---|---|---|--- 20 | `spreadsheetId` | String | ✅ | Document ID 21 | `auth` | `GoogleAuth` \|
`JWT` \|
`OAuth2Client` \|
`{ apiKey: string }` \|
`{ token: string }` | ✅ | Authentication to use
See [Authentication](guides/authentication) for more info 22 | 23 | 24 | ### Creating a new document 25 | 26 | In cases where you need to create a new document and then work with it, a static method is provided: 27 | 28 | #### `GoogleSpreadsheet.createNewSpreadsheetDocument(auth, properties)` (async) :id=fn-createNewSpreadsheetDocument 29 | > Create a new google spreadsheet document 30 | 31 | In case you do need to create a new document, a static method is provided. 32 | 33 | Note that as this will create the document owned by the auth method you are using (which is often a service account), it may not be accessible to _your_ google account. If you need to share with yourself or others, see the [sharing methods below](#sharing-permissions) 34 | 35 | 36 | Param|Type|Required|Description 37 | ---|---|---|--- 38 | `auth`|Auth|✅|Auth object to use when creating the document
_See [Authentication](guides/authentication) for more info_ 39 | `properties`|Object|-|Properties to use when creating the new doc
_See [basic document properties](#basic-document-properties) for more details_ 40 | 41 | 42 | 43 | - ↩️ **Returns** - Promise<[GoogleSpreadsheet](classes/google-spreadsheet)> with auth set, id and info loaded 44 | - 🚨 **Warning** - The document will be owned by the authenticated user, which depending on the auth you are using, could be a service account. In this case the sheet may not be accessible to you personally 45 | - ✨ **Side effects** - all info (including `spreadsheetId`) and sheets loaded as if you called [`loadInfo()`](#fn-loadInfo) 46 | 47 | ```javascript 48 | // see Authentication for more info on auth and how to create jwt 49 | const doc = await GoogleSpreadsheet.createNewSpreadsheetDocument(jwt, { title: 'This is a new doc' }); 50 | console.log(doc.spreadsheetId); 51 | const sheet1 = doc.sheetsByIndex[0]; 52 | ``` 53 | 54 | ## Properties 55 | 56 | ### Basic Document Properties 57 | 58 | Basic properties about the document are loaded only after you call call `doc.loadInfo()` and are kept up to date as further interactions are made with the API. These properties are not editable directly. Instead to update them, use the `doc.updateProperties()` method 59 | 60 | See [official google docs](https://developers.google.com/sheets/api/reference/rest/v4/spreadsheets#spreadsheetproperties) for more details. 61 | 62 | Property|Type|Description 63 | ---|---|--- 64 | `spreadsheetId`|String|Document ID
_set during initialization, not editable_ 65 | `title`|String|Document title 66 | `locale`|String|Document locale/language
_ISO code - ex: "en", "en\_US"_ 67 | `timeZone`|String|Document timezone
_CLDR format - ex: "America/New\_York", "GMT-07:00"_ 68 | `autoRecalc`|String
_enum_|See [RecalculationInterval](https://developers.google.com/sheets/api/reference/rest/v4/spreadsheets#RecalculationInterval) 69 | `defaultFormat`|Object|See [CellFormat](https://developers.google.com/sheets/api/reference/rest/v4/spreadsheets/cells#CellFormat) 70 | `spreadsheetTheme`|Object|See [SpreadsheetTheme](https://developers.google.com/sheets/api/reference/rest/v4/spreadsheets#SpreadsheetTheme) 71 | `iterativeCalculationSettings`|Object|See [IterativeCalculationSettings](https://developers.google.com/sheets/api/reference/rest/v4/spreadsheets#iterativecalculationsettings) 72 | 73 | ### Worksheets 74 | 75 | The child worksheets (each an instance of [`GoogleSpreadsheetWorksheet`](classes/google-spreadsheet-worksheet)) in the document are also loaded once `loadInfo()` is called and can be accessed using these read-only properties of the document. 76 | 77 | Property|Type|Description 78 | ---|---|--- 79 | `sheetsById`| `{ [sheetId: number]: GoogleSpreadsheetWorksheet }` | Child worksheets, keyed by their `sheetId` 80 | `sheetsByTitle`| `{ [title: string]: GoogleSpreadsheetWorksheet }` | Child worksheets keyed by their `title`
_⚠️ beware of title conflicts_ 81 | `sheetsByIndex`| `GoogleSpreadsheetWorksheet[]` |Array of sheets, ordered by their index
_this is the order they appear in the Google sheets UI_ 82 | `sheetCount`| `number` |Count of child worksheets
_same as `doc.sheetsByIndex.length`_ 83 | 84 | 85 | ## Methods 86 | 87 | ### Basic info 88 | 89 | #### `loadInfo()` (async) :id=fn-loadInfo 90 | > Load basic document props and child sheets 91 | 92 | - ✨ **Side Effects -** props are populated, sheets are populated 93 | 94 | #### `updateProperties(props)` (async) :id=fn-updateProperties 95 | > Update basic document properties 96 | 97 | Param|Type|Required|Description 98 | ---|---|---|--- 99 | `props`|Object|-|properties to update
See [basic document properties](#basic-document-properties) above for props documentation. 100 | 101 | 102 | - ✨ **Side Effects -** props are updated 103 | 104 | 105 | #### `resetLocalCache()` :id=fn-resetLocalCache 106 | > Clear local cache of properties and sheets 107 | 108 | You must call `loadInfo()` again to re-load the properties and sheets 109 | 110 | - ✨ **Side Effects -** basic props and sheets are gone 111 | 112 | 113 | ### Managing Sheets 114 | 115 | #### `addSheet(props)` (async) :id=fn-addSheet 116 | > Add a new worksheet to the document 117 | 118 | Param|Type|Required|Description 119 | ---|---|---|--- 120 | `props`|Object|-|Object of all sheet properties 121 | `props.sheetId`|Number
_positive int_|-|Sheet ID, cannot be chagned after setting
_easiest to just let google handle it_ 122 | `props.headerValues`|[String]|-|Sets the contents of the first row, to be used in row-based interactions 123 | `props.headerRowIndex`|Number|-|Set custom header row index (1-indexed)
_defaults to 1 (first)_ 124 | `props.[more]`|...|-|_See [GoogleSpreadsheetWorksheet](classes/google-spreadsheet-worksheet#basic-document-properties) for more props_ 125 | 126 | 127 | - ↩️ **Returns** - [GoogleSpreadsheetWorksheet](classes/google-spreadsheet-worksheet) (in a promise) 128 | - ✨ **Side effects** - new sheet is now avilable via sheet getters (`doc.sheetsByIndex`, `doc.sheetsById`, `doc.sheetsByTitle`) 129 | 130 | _Also available as `addWorksheet()`_ 131 | 132 | 133 | #### `deleteSheet(sheetId)` (async) :id=fn-deleteSheet 134 | > Delete a worksheet from the document 135 | 136 | Param|Type|Required|Description 137 | ---|---|---|--- 138 | `sheetId`|String|✅|ID of the sheet to remove 139 | 140 | - ✨ **Side effects** - sheet is removed and no longer avaialable via sheet getters (`doc.sheetsByIndex`, `doc.sheetsById`, `doc.sheetsByTitle`) 141 | 142 | ?> **TIP** - Usually easier to use GoogleSpreadsheetWorksheet instance method `delete()` 143 | 144 | 145 | ### Named Ranges 146 | 147 | #### `addNamedRange(name, range, rangeId)` (async) :id=fn-addNamedRange 148 | > Add a new named range to the document 149 | 150 | Param|Type|Required|Description 151 | ---|---|---|--- 152 | `name`|String|✅|Name of the range
_used in formulas to refer to it_ 153 | `range`|String or Object|✅|A1 range or [GridRange](https://developers.google.com/sheets/api/reference/rest/v4/spreadsheets/other#gridrange) object 154 | `rangeId`|String|-|ID to use
_autogenerated by google if empty_ 155 | 156 | #### `deleteNamedRange(rangeId)` (async) :id=fn-deleteNamedRange 157 | > Delete a named range from the document 158 | 159 | Param|Type|Required|Description 160 | ---|---|---|--- 161 | `rangeId`|String|✅|ID of the range to remove 162 | 163 | 164 | 165 | ### Exports 166 | 167 | See [Exports guide](guides/exports) for more info. 168 | 169 | #### `downloadAsHTML(returnStreamInsteadOfBuffer)` (async) :id=fn-downloadAsHTML 170 | > Export entire document in HTML format (zip file) 171 | 172 | Param|Type|Required|Description 173 | ---|---|---|--- 174 | `returnStreamInsteadOfBuffer`|Boolean|-|Set to true to return a stream instead of a Buffer
_See [Exports guide](guides/exports) for more details_ 175 | 176 | - ↩️ **Returns** - Buffer (or stream) containing HTML data (in a zip file) 177 | 178 | 179 | #### `downloadAsXLSX(returnStreamInsteadOfBuffer)` (async) :id=fn-downloadAsXLSX 180 | > Export entire document in XLSX (excel) format 181 | 182 | Param|Type|Required|Description 183 | ---|---|---|--- 184 | `returnStreamInsteadOfBuffer`|Boolean|-|Set to true to return a stream instead of a Buffer
_See [Exports guide](guides/exports) for more details_ 185 | 186 | - ↩️ **Returns** - Buffer (or stream) containing XLSX data 187 | 188 | 189 | #### `downloadAsODS(returnStreamInsteadOfBuffer)` (async) :id=fn-downloadAsODS 190 | > Export entire document in ODS (Open Document Format) format 191 | 192 | Param|Type|Required|Description 193 | ---|---|---|--- 194 | `returnStreamInsteadOfBuffer`|Boolean|-|Set to true to return a stream instead of a Buffer
_See [Exports guide](guides/exports) for more details_ 195 | 196 | - ↩️ **Returns** - Buffer (or stream) containing ODS data 197 | 198 | ### Deletion 199 | #### `delete()` (async) :id=fn-delete 200 | > delete the document 201 | 202 | NOTE - requires drive scopes 203 | 204 | 205 | ### Sharing / Permissions 206 | 207 | NOTE - to deal with permissions, you must include Drive API scope(s) when setting up auth 208 | - `https://www.googleapis.com/auth/drive` 209 | - `https://www.googleapis.com/auth/drive.readonly` 210 | - `https://www.googleapis.com/auth/drive.file` 211 | 212 | #### `listPermissions()` (async) :id=fn-listPermissions 213 | > list all permissions entries for doc 214 | 215 | - ↩️ **Returns** - `Promise` 216 | 217 | ```js 218 | const permissions = await doc.listPermissions(); 219 | ``` 220 | 221 | #### `setPublicAccessLevel(role)` (async) :id=fn-setPublicAccessLevel 222 | > list all permissions entries for doc 223 | 224 | Param|Type|Required|Description 225 | ---|---|---|--- 226 | `role`|`false` or `'writer'` or `'commenter'` or `'reader'`|✅| 227 | 228 | Possible roles: 229 | - `false` - revoke all public access 230 | - `'writer'` - anyone* with the link can edit the document 231 | - `'commenter'` - anyone* with the link can comment on the document 232 | - `'reader'` - anyone with the link can read the document 233 | 234 | > * - users will still need to be logged in, even though not explicitly granted any access 235 | 236 | 237 | 238 | #### `share(emailAddressOrDomain, options?)` (async) :id=fn-share 239 | > list all permissions entries for doc 240 | 241 | Param|Type|Required|Description 242 | ---|---|---|--- 243 | `emailAddressOrDomain`|string|✅|email or domain to share 244 | `options`|object|-| 245 | `options.role`|string|-|set to role 246 | `options.isGroup`|boolean|-|set to true if sharing with an email that refers to a group 247 | `options.emailMessage`|false or string|-|leave empty to send default message
set to a string to include special messsage in email
set to false to disable email notificaiton entirely 248 | 249 | 250 | Possible roles: 251 | - `owner` - transfers ownership. Only valid for single users (not groups or domains) 252 | - `writer` - allows writing, commenting, reading 253 | - `commenter` - allows reading and commenting 254 | - `reader` - allows reading only 255 | -------------------------------------------------------------------------------- /docs/guides/authentication.md: -------------------------------------------------------------------------------- 1 | # Authentication 2 | 3 | This module relies heavily on the [google-auth-library](https://github.com/googleapis/google-auth-library-nodejs) module to handle most authentication needs. 4 | 5 | > _For those already familiar..._
6 | > Follow [their instructions](https://github.com/googleapis/google-auth-library-nodejs#ways-to-authenticate) 7 | > and pass a `JWT` | `OAuth2Client` | `GoogleAuth` object as the second arg when initializing your `GoogleSpreadsheet`. 8 | 9 | Now as with the rest of Google's docs, it's a bit hard to just jump in and get started quickly, so hopefully this guide will get you going for most common scenarios... 10 | 11 | You have several options for how you want to connect, but most projects connecting from a server/backend should use a service account. 12 | 13 | - [Service Account (via JWT)](#service-account) - connects as a specific "bot" user generated by google for your application 14 | - [OAuth 2](#oauth) - connect on behalf of a specific user using OAuth 15 | - [Application Default Credentials](#adc) - auto-detect credentials, useful if running your app in google cloud 16 | - [API-key](#api-key) - only identifies your application for metering, provides read-only access to public docs 17 | - [Other (self-managed token)](#token) - set a token that you are managing some other way (not recommended) 18 | 19 | Some of google-auth-library's more exotic auth methods are likely supported as well, as long as you pass in a GoogleAuth object that has a `getRequestHeaders()` method. 20 | 21 | ## Setting up your "Application" 22 | 23 | Regardless of your needs and the auth method you use, before you can do anything, you'll need to set up a new "Google Cloud Project" 24 | 25 | **👉 BUT FIRST -- Set up your google project & enable the sheets API 👈** 26 | 1. Go to the [Google Developers Console](https://console.developers.google.com/) 27 | 2. Select your project or create a new one (and then select it) 28 | 3. Enable the Sheets API for your project 29 | - In the sidebar on the left, select **Enabled APIs & Services** 30 | - Click the blue "Enable APIs and Services" button in the top bar 31 | - Search for "sheets" 32 | - Click on "Google Sheets API" 33 | - click the blue "Enable" button 34 | 4. (Optional) Enable the "Google Drive API" for your project - if you want to manage document permissions 35 | - same as above, but search for "drive" and enable the "Google Drive API" 36 | 37 | 38 | ## Auth Scopes 39 | 40 | Aside from enabling the correct API(s) for your _project_, your specific auth method must be initialized with the correct scope(s). 41 | 42 | (API key access does not need any scopes selected - as it is read only) 43 | 44 | In most cases, I'd suggest using the following for your list of scopes: 45 | `['https://www.googleapis.com/auth/spreadsheets', 'https://www.googleapis.com/auth/drive.file']` 46 | 47 | But if you need more control, this is the full list of relevant scopes you might want to use: 48 | - `https://www.googleapis.com/auth/spreadsheets` - read/write access to all Sheets docs 49 | - `https://www.googleapis.com/auth/spreadsheets.readonly` - read-only access to all Sheets docs 50 | - `https://www.googleapis.com/auth/drive` - read/write access to all Google Drive files 51 | - `https://www.googleapis.com/auth/drive.readonly` - read-only access to all Google Drive files 52 | - `https://www.googleapis.com/auth/drive.file` - read/write access to only the specific Google Drive files used with this "app" 53 | 54 | 55 | 56 | ## Authentication Methods 57 | 58 | ### 🤖 Service Account (recommended) :id=service-account 59 | **Connect as a bot user that belongs to your app** 60 | 61 | This is a 2-legged oauth method and designed to be "an account that belongs to your application instead of to an individual end user". 62 | Use this for an app that needs to access a set of documents that you have full access to, or can at least be shared with your service account. 63 | ([read more](https://developers.google.com/identity/protocols/OAuth2ServiceAccount)) 64 | 65 | You may also grant your service account ["domain-wide delegation"](https://developers.google.com/identity/protocols/oauth2/service-account#delegatingauthority) which enables it to impersonate any user within your org. This can be helpful if you need to connect on behalf of users _only within your organization_. 66 | 67 | __Setup Instructions__ 68 | 69 | 1. Follow steps above to set up project and enable sheets API 70 | 2. Create a service account for your project 71 | - In the sidebar on the left, select **APIs & Services > Credentials** 72 | - Click blue "+ CREATE CREDENTIALS" and select "Service account" option 73 | - Enter name, description, click "CREATE" 74 | - You can skip permissions, click "CONTINUE" 75 | - Click "CREATE AND CONTINUE" button 76 | - Step 2 and 3 are optional, you can skip it. 77 | - Click "DONE" button 78 | 3. Edit your new service account 79 | - In the bottom of the page, you can see the "Service Account" tab with your new Service Account 80 | - In column actions, click the pencil button to edit 81 | - Select the "Keys" tab 82 | - Click "ADD KEY" then "Create new key" 83 | - Select the "JSON" key type option 84 | - Click "Create" button 85 | - your JSON key file is generated and downloaded to your machine (__it is the only copy!__) 86 | - click "CLOSE" 87 | - note your service account's email address (also available in the JSON key file) 88 | 4. Share the doc (or docs) with your service account using the email noted above 89 | 90 | !> Be careful - never check your API keys / secrets into version control (git) 91 | 92 | You can now use this file in your project to authenticate as your service account. If you have a config setup involving environment variables, you only need to worry about the `client_email` and `private_key` from the JSON file. For example: 93 | 94 | ```javascript 95 | import { JWT } from 'google-auth-library' 96 | 97 | import creds from './config/myapp-1dd646d7c2af.json'; // the file saved above 98 | 99 | const SCOPES = [ 100 | 'https://www.googleapis.com/auth/spreadsheets', 101 | 'https://www.googleapis.com/auth/drive.file', 102 | ]; 103 | 104 | const jwt = new JWT({ 105 | email: creds.client_email, 106 | key: creds.private_key, 107 | scopes: SCOPES, 108 | }); 109 | const doc = new GoogleSpreadsheet('', jwt); 110 | ``` 111 | 112 | Or preferably load the info from env vars, for example: 113 | ```javascript 114 | const jwtFromEnv = new JWT({ 115 | email: process.env.GOOGLE_SERVICE_ACCOUNT_EMAIL, 116 | key: process.env.GOOGLE_PRIVATE_KEY, 117 | scopes: SCOPES, 118 | }); 119 | ``` 120 | 121 | And here is an example using impersonation - NOTE: your service account must have ["domain-wide delegation" enabled](https://developers.google.com/identity/protocols/oauth2/service-account#delegatingauthority). 122 | 123 | ```javascript 124 | const jwtWithImpersonation = new JWT({ 125 | email: process.env.GOOGLE_SERVICE_ACCOUNT_EMAIL, 126 | key: process.env.GOOGLE_PRIVATE_KEY, 127 | subject: 'user.to.impersonate@mycompany.com', 128 | scopes: SCOPES, 129 | }); 130 | ``` 131 | 132 | **SPECIAL NOTE FOR HEROKU USERS (AND SIMILAR PLATFORMS)** 133 | 134 | Depending on the platform you use, sometimes setting/retreiving env vars that include line breaks can get tricky as they get encoded weirdly while saving these values in their system. 135 | 136 | Here's one way to get it to work (using heroku cli): 137 | 1. Save the private key to a new text file 138 | 2. Replace `\n` with actual line breaks 139 | 3. Replace `\u003d` with `=` 140 | 4. run `heroku config:add GOOGLE_PRIVATE_KEY="$(cat yourfile.txt)"` in your terminal 141 | 142 | And in some other scenarios, replacing newlines in your code can be helpful, for example: 143 | ```js 144 | const jwtFromEnv = new JWT({ 145 | email: process.env.GOOGLE_SERVICE_ACCOUNT_EMAIL, 146 | key: process.env.GOOGLE_PRIVATE_KEY.replace(/\\n/g, "\n"), 147 | scopes: SCOPES, 148 | }); 149 | ``` 150 | 151 | ### 👨‍💻 OAuth 2.0 :id=oauth 152 | **connect on behalf of a user with an Oauth token** 153 | 154 | Use [OAuth2Client](https://github.com/googleapis/google-auth-library-nodejs#oauth2) to authenticate. 155 | 156 | Handling Oauth and how it works is out of scope of this project - but the info you need can be found [here](https://developers.google.com/identity/protocols/oauth2). 157 | 158 | Nevertheless, here is a rough outline of what to do with a few tips: 159 | 1. Follow steps above to set up project and enable sheets API 160 | 2. Create OAuth 2.0 credentials for your project (**these are not the same as the service account credentials described above**) 161 | - Navigate to the [credentials section of the google developer console](https://console.cloud.google.com/apis/credentials) 162 | - Click blue "+ CREATE CREDENTIALS" and select "Oauth Client ID" option 163 | - Select your application type and set up authorized domains / callback URIs 164 | - Record your client ID and secret 165 | - You will need to go through an Oauth Consent screen verification process to use these credentials for a production app with many users 166 | 3. For each user you want to connect on behalf of, you must get them to authorize your app which involves asking their permissions by redirecting them to a google-hosted URL 167 | - generate the oauth consent page url and redirect the user to it 168 | - there are many tools, [google provided](https://github.com/googleapis/google-api-nodejs-client#oauth2-client) and [more](https://www.npmjs.com/package/simple-oauth2) [generic](https://www.npmjs.com/package/hellojs) or you can even generate the URL yourself 169 | - make sure you use the credentials generated above 170 | - make sure you include the [appropriate scopes](https://developers.google.com/identity/protocols/oauth2/scopes#sheets) for your application 171 | - the callback URL (if successful) will include a short lived authorization code 172 | - you can then exchange this code for the user's oauth tokens which include: 173 | - an access token (that expires) which can be used to make API requests on behalf of the user, limited to the scopes requested and approved 174 | - a refresh token (that does not expire) which can be used to generate new access tokens 175 | - save these tokens somewhere secure like a database (ideally you should encrypt these before saving!) 176 | 4. Initialize an OAuth2Client with your apps oauth credentials and the user's tokens, and pass the client to your GoogleSpreadsheet object 177 | 178 | 179 | ```javascript 180 | const { OAuth2Client } = require('google-auth-library'); 181 | 182 | // Initialize the OAuth2Client with your app's oauth credentials 183 | const oauthClient = new OAuth2Client({ 184 | clientId: process.env.GOOGLE_OAUTH_CLIENT_ID, 185 | clientSecret: process.env.GOOGLE_OAUTH_CLIENT_SECRET, 186 | }); 187 | 188 | // Pre-configure the client with credentials you have stored in e.g. your database 189 | // NOTE - `refresh_token` is required, `access_token` and `expiry_date` are optional 190 | // (the refresh token is used to generate a missing/expired access token) 191 | const { accessToken, refreshToken, expiryDate } = await fetchUserGoogleCredsFromDatabase(); 192 | oauthClient.credentials.access_token = accessToken; 193 | oauthClient.credentials.refresh_token = refreshToken; 194 | oauthClient.credentials.expiry_date = expiryDate; // Unix epoch milliseconds 195 | 196 | // Listen in whenever a new access token is obtained, as you might want to store the new token in your database 197 | // Note that the refresh_token never changes (unless it's revoked, in which case your end-user will 198 | // need to go through the full authentication flow again), so storing the new access_token is optional 199 | oauthClient.on('tokens', (credentials) => { 200 | console.log(credentials.access_token); 201 | console.log(credentials.scope); 202 | console.log(credentials.expiry_date); 203 | console.log(credentials.token_type); // will always be 'Bearer' 204 | }) 205 | 206 | const doc = new GoogleSpreadsheet('', oauthClient); 207 | ``` 208 | 209 | 210 | 211 | ### 🪄 Application Default Credentials :id=adc 212 | **Auto-detect credentials in google infrastructure** 213 | 214 | Use [GoogleAuth](https://github.com/googleapis/google-auth-library-nodejs#application-default-credentials) to authenticate. 215 | 216 | 217 | ```js 218 | const { GoogleAuth } = require('google-auth-library'); 219 | 220 | const adcAuth = new GoogleAuth({ 221 | scopes: ['https://www.googleapis.com/auth/spreadsheets', 'https://www.googleapis.com/auth/drive.file'], 222 | }); 223 | 224 | const doc = new GoogleSpreadsheet('', adcAuth); 225 | ``` 226 | 227 | 228 | ### 🔑 API Key :id=api-key 229 | **read-only access of public docs** 230 | 231 | Google requires this so they can at least meter your usage of their API. 232 | 233 | !> Allowing only read-only access using an API key is a limitation of Google's API (see [issuetracker](https://issuetracker.google.com/issues/36755576#comment3)) 234 | 235 | __Setup Instructions__ 236 | 1. Follow steps above to set up project and enable sheets API 237 | 2. Create an API key for your project 238 | - Navigate to the [credentials section of the google developer console](https://console.cloud.google.com/apis/credentials) 239 | - Click blue "+ CREATE CREDENTIALS" and select "API key" option 240 | - Copy the API key 241 | 3. OPTIONAL - click "Restrict key" on popup to set up restrictions 242 | - Click "API restrictions" > Restrict Key" 243 | - Check the "Google Sheets API" checkbox 244 | - Click "Save" 245 | 246 | !> Be careful - never check your API keys / secrets into version control (git) 247 | 248 | ```javascript 249 | const doc = new GoogleSpreadsheet('', { apiKey: process.env.GOOGLE_API_KEY }); 250 | ``` 251 | 252 | ### 🗝️ Raw Token :id=token 253 | **self managed token** 254 | 255 | If for some reason you are not using google-auth-library and you are already managing auth tokens some other way, you can just pass that in directly. 256 | 257 | ```javascript 258 | const doc = new GoogleSpreadsheet('', { token: someTokenYouAreManaging }); 259 | ``` 260 | 261 | -------------------------------------------------------------------------------- /src/lib/GoogleSpreadsheetCell.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable max-classes-per-file */ 2 | import * as _ from './toolkit'; 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(param: T): Readonly { 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(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 | } 308 | -------------------------------------------------------------------------------- /src/test/manage.test.ts: -------------------------------------------------------------------------------- 1 | import { 2 | describe, expect, it, beforeAll, afterAll, afterEach, 3 | } from 'vitest'; 4 | import { setTimeout as delay } from 'timers/promises'; 5 | import { ENV } from 'varlock/env'; 6 | import * as _ from '../lib/toolkit'; 7 | 8 | import { GoogleSpreadsheet, GoogleSpreadsheetWorksheet } from '..'; 9 | 10 | import { DOC_IDS, testServiceAccountAuth } from './auth/docs-and-auth'; 11 | 12 | const doc = new GoogleSpreadsheet(DOC_IDS.private, testServiceAccountAuth); 13 | 14 | // TODO: reorganize some of this? 15 | 16 | describe('Managing doc info and sheets', () => { 17 | describe('creation and deletion', () => { 18 | let spreadsheetId: string; 19 | const title = `new sheet - ${+new Date()}`; 20 | it('can create a new document', async () => { 21 | const newDoc = await GoogleSpreadsheet.createNewSpreadsheetDocument(testServiceAccountAuth, { title }); 22 | expect(newDoc.title).toEqual(title); 23 | spreadsheetId = newDoc.spreadsheetId; 24 | }); 25 | it('confirm the document exists', async () => { 26 | const newDoc = new GoogleSpreadsheet(spreadsheetId, testServiceAccountAuth); 27 | await newDoc.loadInfo(); 28 | expect(newDoc.title).toEqual(title); 29 | }); 30 | it('can delete the document', async () => { 31 | const newDoc = new GoogleSpreadsheet(spreadsheetId, testServiceAccountAuth); 32 | await newDoc.delete(); 33 | }); 34 | it('deleting the document twice fails', async () => { 35 | const newDoc = new GoogleSpreadsheet(spreadsheetId, testServiceAccountAuth); 36 | await expect(newDoc.delete()).rejects.toThrow('404'); 37 | }); 38 | }); 39 | 40 | // beforeAll(async () => { 41 | // // TODO: do something to trigger auth refresh? 42 | // }); 43 | 44 | // hitting rate limits when running tests on ci - so we add a short delay 45 | if (ENV.TEST_DELAY) afterEach(async () => delay(ENV.TEST_DELAY)); 46 | 47 | // uncomment temporarily to clear out all the sheets in the test doc 48 | // it.only('clear out all the existing sheets', async () => { 49 | // await doc.loadInfo(); 50 | // // delete all sheets after the first 51 | // for (const sheet of doc.sheetsByIndex.slice(1)) await sheet.delete(); 52 | // }); 53 | 54 | describe('accessing and updating document properties', () => { 55 | it('accessing properties throws an error if info not fetched yet', async () => { 56 | expect(() => doc.title).toThrow(); 57 | }); 58 | 59 | it('can load the doc info', async () => { 60 | await doc.loadInfo(); 61 | }); 62 | 63 | it('should include the document title', async () => { 64 | expect(doc.title).toBeTruthy(); 65 | }); 66 | 67 | it('should include worksheet info and instantiate them', async () => { 68 | expect(doc.sheetsByIndex.length > 0).toBeTruthy(); 69 | expect(doc.sheetsByIndex[0]).toBeInstanceOf(GoogleSpreadsheetWorksheet); 70 | const sheet = doc.sheetsByIndex[0]; 71 | expect(sheet.title).toBeTruthy(); 72 | expect(sheet.rowCount > 0).toBeTruthy(); 73 | expect(sheet.columnCount > 0).toBeTruthy(); 74 | }); 75 | 76 | it('can find a sheet by title', async () => { 77 | expect(_.values(doc.sheetsByIndex).length > 0).toBeTruthy(); 78 | const sheet = doc.sheetsByIndex[0]; 79 | expect(doc.sheetsByTitle[sheet.title]).toEqual(sheet); 80 | }); 81 | 82 | it('throws an error if updating title directly', async () => { 83 | expect(() => { (doc as any).title = 'new title'; }).toThrow(); 84 | }); 85 | 86 | it('can update the title using updateProperties', async () => { 87 | const oldTitle = doc.title; 88 | const newTitle = `node-google-spreadsheet test - private (updated @ ${+new Date()})`; 89 | await doc.updateProperties({ title: newTitle }); 90 | expect(doc.title).toBe(newTitle); 91 | 92 | // make sure the update actually stuck 93 | doc.resetLocalCache(); 94 | await doc.loadInfo(); 95 | expect(doc.title).toBe(newTitle); 96 | 97 | // set the title back 98 | await doc.updateProperties({ title: oldTitle }); 99 | }); 100 | 101 | // TODO: check ability to update other properties? 102 | }); 103 | 104 | describe('adding and updating sheets', () => { 105 | const newSheetTitle = `Test sheet ${+new Date()}`; 106 | let sheet: GoogleSpreadsheetWorksheet; 107 | 108 | afterAll(async () => { 109 | if (sheet) await sheet.delete(); 110 | }); 111 | 112 | it('can add a sheet', async () => { 113 | const numSheets = doc.sheetCount; 114 | sheet = await doc.addSheet({ 115 | title: newSheetTitle, 116 | gridProperties: { 117 | rowCount: 7, 118 | columnCount: 11, 119 | }, 120 | headerValues: ['col1', 'col2', 'col3', 'col4', 'col5'], 121 | }); 122 | expect(doc.sheetCount).toBe(numSheets + 1); 123 | 124 | expect(sheet.title).toBe(newSheetTitle); 125 | }); 126 | 127 | it('check the sheet is actually there', async () => { 128 | doc.resetLocalCache(); 129 | await doc.loadInfo(); // re-fetch 130 | const newSheet = doc.sheetsByIndex.pop(); 131 | if (!newSheet) throw new Error('Expected to find new sheet'); 132 | expect(newSheet.title).toBe(sheet.title); 133 | expect(newSheet.rowCount).toBe(sheet.rowCount); 134 | expect(newSheet.columnCount).toBe(sheet.columnCount); 135 | }); 136 | 137 | it('check the headers', async () => { 138 | await sheet.loadHeaderRow(); 139 | expect(sheet.headerValues.length).toBe(5); 140 | expect(sheet.headerValues[0]).toBe('col1'); 141 | expect(sheet.headerValues[4]).toBe('col5'); 142 | }); 143 | 144 | it('clears the rest of the header row when setting headers', async () => { 145 | await sheet.setHeaderRow(['newcol1', 'newcol2']); 146 | expect(sheet.headerValues.length).toBe(2); 147 | }); 148 | }); 149 | 150 | describe('updating sheet properties', () => { 151 | let sheet: GoogleSpreadsheetWorksheet; 152 | 153 | beforeAll(async () => { 154 | sheet = await doc.addSheet({ title: `Spécial CнArs - ${+new Date()}` }); 155 | }); 156 | afterAll(async () => { 157 | await sheet.delete(); 158 | }); 159 | 160 | it('throws an error if updating title directly', async () => { 161 | expect(() => { sheet.title = 'new title'; }).toThrow(); 162 | }); 163 | 164 | it('can update the title using updateProperties', async () => { 165 | const newTitle = `${sheet.title} updated @ ${+new Date()}`; 166 | await sheet.updateProperties({ title: newTitle }); 167 | expect(sheet.title).toBe(newTitle); 168 | 169 | // make sure the update actually stuck 170 | sheet.resetLocalCache(); 171 | await doc.loadInfo(); 172 | expect(sheet.title).toBe(newTitle); 173 | }); 174 | 175 | it('can resize a sheet', async () => { 176 | // cannot update directly 177 | expect(() => { (sheet as any).rowCount = 77; }).toThrow(); 178 | await sheet.resize({ rowCount: 77, columnCount: 44 }); 179 | expect(sheet.rowCount).toBe(77); 180 | sheet.resetLocalCache(); 181 | await doc.loadInfo(); 182 | expect(sheet.rowCount).toBe(77); 183 | }); 184 | 185 | it('can clear sheet data', async () => { 186 | await sheet.setHeaderRow(['some', 'data', 'to', 'clear']); 187 | await sheet.loadCells(); 188 | expect(sheet.cellStats.nonEmpty).toBe(4); 189 | await sheet.clear(); 190 | await sheet.loadCells(); 191 | expect(sheet.cellStats.nonEmpty).toBe(0); 192 | }); 193 | }); 194 | 195 | describe('data validation rules', () => { 196 | let sheet: GoogleSpreadsheetWorksheet; 197 | 198 | beforeAll(async () => { 199 | sheet = await doc.addSheet({ title: `validation rules test ${+new Date()}` }); 200 | }); 201 | afterAll(async () => { 202 | await sheet.delete(); 203 | }); 204 | 205 | 206 | it('can set data validation', async () => { 207 | // add a dropdown; ref: https://stackoverflow.com/a/43442775/3068233 208 | await sheet.setDataValidation( 209 | { 210 | startRowIndex: 2, 211 | endRowIndex: 100, 212 | startColumnIndex: 3, 213 | endColumnIndex: 4, 214 | }, 215 | { 216 | condition: { 217 | type: 'ONE_OF_LIST', 218 | values: [ 219 | { 220 | userEnteredValue: 'YES', 221 | }, 222 | { 223 | userEnteredValue: 'NO', 224 | }, 225 | { 226 | userEnteredValue: 'MAYBE', 227 | }, 228 | ], 229 | }, 230 | showCustomUi: true, 231 | strict: true, 232 | } 233 | ); 234 | }); 235 | 236 | it('can clear a data validation', async () => { 237 | await sheet.setDataValidation( 238 | { 239 | startRowIndex: 2, 240 | endRowIndex: 100, 241 | startColumnIndex: 3, 242 | endColumnIndex: 4, 243 | }, 244 | false 245 | ); 246 | }); 247 | }); 248 | 249 | describe('deleting a sheet', () => { 250 | let sheet: GoogleSpreadsheetWorksheet; 251 | let numSheets: number; 252 | 253 | it('can remove a sheet', async () => { 254 | await doc.loadInfo(); 255 | numSheets = doc.sheetsByIndex.length; 256 | 257 | sheet = await doc.addSheet({ 258 | title: `please delete me ${+new Date()}`, 259 | }); 260 | expect(doc.sheetsByIndex.length).toBe(numSheets + 1); 261 | 262 | await sheet.delete(); 263 | expect(doc.sheetsByIndex.length).toBe(numSheets); 264 | }); 265 | 266 | it('check the sheet is really gone', async () => { 267 | doc.resetLocalCache(); 268 | await doc.loadInfo(); 269 | expect(doc.sheetsByIndex.length).toBe(numSheets); 270 | }); 271 | }); 272 | 273 | describe('duplicating a sheet within the same document', () => { 274 | let sheet: GoogleSpreadsheetWorksheet; 275 | let duplicateSheet: GoogleSpreadsheetWorksheet; 276 | beforeAll(async () => { 277 | sheet = await doc.addSheet({ 278 | title: `Sheet to duplicate ${+new Date()}`, 279 | headerValues: ['duplicate', 'this', 'sheet'], 280 | }); 281 | }); 282 | afterAll(async () => { 283 | await sheet.delete(); 284 | await duplicateSheet.delete(); 285 | }); 286 | 287 | it('can duplicate the sheet within the same doc', async () => { 288 | const existingSheetIndex = sheet.index; 289 | 290 | const newTitle = `duplicated ${+new Date()}`; 291 | duplicateSheet = await sheet.duplicate({ 292 | title: newTitle, 293 | }); 294 | 295 | expect(duplicateSheet.title).toEqual(newTitle); 296 | expect(doc.sheetsByIndex[0]).toEqual(duplicateSheet); 297 | 298 | expect(sheet.index).toEqual(existingSheetIndex + 1); 299 | }); 300 | }); 301 | 302 | describe('copying a sheet to another document', () => { 303 | let sheet: GoogleSpreadsheetWorksheet; 304 | 305 | beforeAll(async () => { 306 | sheet = await doc.addSheet({ 307 | title: `Sheet to copy ${+new Date()}`, 308 | headerValues: ['copy', 'this', 'sheet'], 309 | }); 310 | }); 311 | afterAll(async () => { 312 | await sheet.delete(); 313 | }); 314 | 315 | it('should fail without proper permissions', async () => { 316 | const newDocId = DOC_IDS.privateReadOnly; 317 | await expect(sheet.copyToSpreadsheet(newDocId)).rejects.toThrow('403'); 318 | }); 319 | 320 | it('can copy the sheet to another doc', async () => { 321 | await sheet.copyToSpreadsheet(DOC_IDS.public); 322 | 323 | const publicDoc = new GoogleSpreadsheet(DOC_IDS.public, testServiceAccountAuth); 324 | await publicDoc.loadInfo(); 325 | // check title and content (header row) 326 | const copiedSheet = publicDoc.sheetsByIndex.splice(-1)[0]; 327 | expect(copiedSheet.title).toBe(`Copy of ${sheet.title}`); 328 | await copiedSheet.loadHeaderRow(); 329 | expect(copiedSheet.headerValues).toEqual(sheet.headerValues); 330 | await copiedSheet.delete(); 331 | }); 332 | }); 333 | 334 | describe('creating a new document', () => { 335 | let newDoc: GoogleSpreadsheet; 336 | 337 | afterAll(async () => { 338 | await newDoc.delete(); 339 | }); 340 | 341 | it('should fail without auth', async () => { 342 | // @ts-ignore 343 | await expect(GoogleSpreadsheet.createNewSpreadsheetDocument()).rejects.toThrow(); 344 | }); 345 | it('should create a new sheet', async () => { 346 | const newTitle = `New doc ${+new Date()}`; 347 | newDoc = await GoogleSpreadsheet.createNewSpreadsheetDocument(testServiceAccountAuth, { title: newTitle }); 348 | expect(newDoc.title).toEqual(newTitle); 349 | expect(newDoc.sheetsByIndex.length > 0).toBeTruthy(); 350 | expect(newDoc.sheetsByIndex[0]).toBeInstanceOf(GoogleSpreadsheetWorksheet); 351 | }); 352 | }); 353 | 354 | describe('insertDimension - inserting columns/rows into a sheet', () => { 355 | let sheet: GoogleSpreadsheetWorksheet; 356 | 357 | beforeAll(async () => { 358 | sheet = await doc.addSheet({ 359 | title: `Insert dimension test ${+new Date()}`, 360 | headerValues: ['a', 'b'], 361 | }); 362 | await sheet.addRows([ 363 | { a: 'a1', b: 'b1' }, 364 | { a: 'a2', b: 'b2' }, 365 | ]); 366 | }); 367 | 368 | afterAll(async () => { 369 | await sheet.delete(); 370 | }); 371 | 372 | // TODO: add error checking tests 373 | 374 | it('Should insert a new empty rows at index', async () => { 375 | // should insert 2 rows in between the first and second row of data (first row is header) 376 | await sheet.insertDimension('ROWS', { startIndex: 2, endIndex: 4 }); 377 | 378 | // read rows and check it did what we expected 379 | const rows = await sheet.getRows<{ 380 | a: string, 381 | b: string, 382 | }>(); 383 | // header row 384 | expect(rows[0].get('a')).toEqual('a1'); 385 | expect(rows[0].get('b')).toEqual('b1'); 386 | expect(rows[1].get('a')).toBeUndefined(); 387 | expect(rows[1].get('b')).toBeUndefined(); 388 | expect(rows[2].get('a')).toBeUndefined(); 389 | expect(rows[2].get('b')).toBeUndefined(); 390 | expect(rows[3].get('a')).toEqual('a2'); 391 | expect(rows[3].get('b')).toEqual('b2'); 392 | }); 393 | }); 394 | }); 395 | -------------------------------------------------------------------------------- /src/test/rows.test.ts: -------------------------------------------------------------------------------- 1 | import { 2 | describe, expect, it, beforeAll, afterAll, afterEach, 3 | } from 'vitest'; 4 | import { setTimeout as delay } from 'timers/promises'; 5 | import { ENV } from 'varlock/env'; 6 | import * as _ from '../lib/toolkit'; 7 | 8 | import { GoogleSpreadsheet, GoogleSpreadsheetWorksheet, GoogleSpreadsheetRow } from '..'; 9 | 10 | import { DOC_IDS, testServiceAccountAuth } from './auth/docs-and-auth'; 11 | 12 | const doc = new GoogleSpreadsheet(DOC_IDS.private, testServiceAccountAuth); 13 | 14 | let sheet: GoogleSpreadsheetWorksheet; 15 | 16 | // having some issues caused by blank headers, so we add one here 17 | const HEADERS = ['numbers', 'letters', '', 'col1', 'col2', 'col3']; 18 | const INITIAL_ROW_COUNT = 15; 19 | const INITIAL_DATA = [ 20 | ['0', 'A'], 21 | ['1', 'B'], 22 | ['2', 'C'], 23 | ['3', 'D'], 24 | ['4', 'E'], 25 | ]; 26 | 27 | describe('Row-based operations', () => { 28 | beforeAll(async () => { 29 | sheet = await doc.addSheet({ 30 | headerValues: HEADERS, 31 | title: `Spécial CнArs ${+new Date()}`, // some urls have sheet title in them 32 | gridProperties: { rowCount: INITIAL_ROW_COUNT }, 33 | }); 34 | await sheet.addRows(INITIAL_DATA); 35 | }); 36 | afterAll(async () => { 37 | await sheet.delete(); 38 | }); 39 | // hitting rate limits when running tests on ci - so we add a short delay 40 | if (ENV.TEST_DELAY) afterEach(async () => delay(ENV.TEST_DELAY)); 41 | 42 | describe('fetching rows', () => { 43 | let rows: GoogleSpreadsheetRow[]; 44 | it('can fetch multiple rows', async () => { 45 | rows = await sheet.getRows(); 46 | expect(rows.length).toEqual(INITIAL_DATA.length); 47 | }); 48 | 49 | it('a row has properties with keys from the headers', () => { 50 | expect(rows[0].get('numbers')).toEqual(INITIAL_DATA[0][0]); 51 | expect(rows[0].get('letters')).toEqual(INITIAL_DATA[0][1]); 52 | }); 53 | 54 | it('supports `offset` option', async () => { 55 | rows = await sheet.getRows({ offset: 2 }); 56 | expect(rows.length).toEqual(3); 57 | expect(rows[0].get('numbers')).toEqual(INITIAL_DATA[2][0]); 58 | }); 59 | 60 | it('supports `limit` option', async () => { 61 | rows = await sheet.getRows({ limit: 3 }); 62 | expect(rows.length).toEqual(3); 63 | expect(rows[0].get('numbers')).toEqual(INITIAL_DATA[0][0]); 64 | }); 65 | 66 | it('supports combined `limit` and `offset`', async () => { 67 | rows = await sheet.getRows({ offset: 2, limit: 2 }); 68 | expect(rows.length).toEqual(2); 69 | expect(rows[0].get('numbers')).toEqual(INITIAL_DATA[2][0]); 70 | }); 71 | 72 | it('it will fetch the same row content when the header is not populated', async () => { 73 | sheet.resetLocalCache(true); // forget the header values 74 | expect(() => sheet.headerValues).toThrowError('Header values are not yet loaded'); 75 | const rowsWithoutPrefetchHeaders = await sheet.getRows(); 76 | 77 | expect(sheet.headerValues).toBeDefined(); 78 | const rowsWithFetchedHeaders = await sheet.getRows(); 79 | 80 | expect(rowsWithoutPrefetchHeaders).toEqual(rowsWithFetchedHeaders); 81 | }); 82 | }); 83 | 84 | describe('adding rows', () => { 85 | let rows: GoogleSpreadsheetRow[]; 86 | let row: GoogleSpreadsheetRow; 87 | it('can add a row with an array of values', async () => { 88 | const newRowData = ['5', 'F']; 89 | row = await sheet.addRow(newRowData); 90 | expect(row.get('numbers')).toEqual(newRowData[0]); 91 | expect(row.get('letters')).toEqual(newRowData[1]); 92 | expect(row.get('dates')).toEqual(newRowData[2]); 93 | }); 94 | 95 | it('persisted the row', async () => { 96 | rows = await sheet.getRows(); 97 | expect(rows.length).toEqual(INITIAL_DATA.length + 1); 98 | const newRowIndex = INITIAL_DATA.length; 99 | expect(rows[newRowIndex].get('numbers')).toEqual(row.get('numbers')); 100 | expect(rows[newRowIndex].get('letters')).toEqual(row.get('letters')); 101 | expect(rows[newRowIndex].get('dates')).toEqual(row.get('dates')); 102 | }); 103 | 104 | it('can add a row with keyed object data', async () => { 105 | const newRowData = { 106 | numbers: '6', 107 | letters: 'G', 108 | }; 109 | row = await sheet.addRow(newRowData); 110 | expect(row.get('numbers')).toEqual(newRowData.numbers); 111 | expect(row.get('letters')).toEqual(newRowData.letters); 112 | }); 113 | 114 | it('can add multiple rows', async () => { 115 | const newRows = await sheet.addRows([ 116 | { numbers: '7', letters: 'H' }, 117 | ['8', 'I'], 118 | ]); 119 | expect(newRows[0].get('numbers')).toEqual('7'); 120 | expect(newRows[1].get('numbers')).toEqual('8'); 121 | }); 122 | 123 | it('can add rows with options.insert', async () => { 124 | // we should still have some empty rows left for this test to be valid 125 | rows = await sheet.getRows(); 126 | expect(rows.length).toBeLessThan(INITIAL_ROW_COUNT); 127 | const oldRowCount = sheet.rowCount; 128 | await sheet.addRows([ 129 | { numbers: '101', letters: 'XX' }, 130 | ], { insert: true }); 131 | expect(sheet.rowCount).toEqual(oldRowCount + 1); 132 | }); 133 | 134 | it('will update sheet.rowCount if new rows are added (while not in insert mode)', async () => { 135 | const oldRowCount = sheet.rowCount; 136 | const dataForMoreRowsThanFit = _.times(INITIAL_ROW_COUNT, () => ({ 137 | numbers: '999', letters: 'ZZZ', 138 | })); 139 | const newRows = await sheet.addRows(dataForMoreRowsThanFit); 140 | const updatedRowCount = sheet.rowCount; 141 | await doc.loadInfo(); // actually reload to make sure the logic is correct 142 | expect(sheet.rowCount).toEqual(updatedRowCount); 143 | expect(sheet.rowCount).toBeGreaterThan(oldRowCount); 144 | expect(newRows[newRows.length - 1].rowNumber).toEqual(sheet.rowCount); 145 | }); 146 | 147 | it('can add rows with options.raw', async () => { 148 | const rawValue = 'true'; 149 | const regularRow = await sheet.addRow({ col1: rawValue }); 150 | const rawRow = await sheet.addRow({ col1: rawValue }, { raw: true }); 151 | 152 | expect(regularRow.get('col1')).toEqual('TRUE'); // internally its treating as a boolean 153 | expect(rawRow.get('col1')).toEqual(rawValue); 154 | }); 155 | }); 156 | 157 | describe('deleting rows', () => { 158 | let rows: GoogleSpreadsheetRow[]; 159 | let row: GoogleSpreadsheetRow; 160 | it('can delete a row', async () => { 161 | rows = await sheet.getRows(); 162 | 163 | const numRows = rows.length; 164 | 165 | // delete the row at index 1 (which has "1" in numbers col) 166 | row = rows[1]; 167 | await row.delete(); 168 | 169 | // make sure we have 1 less row 170 | rows = await sheet.getRows(); 171 | expect(rows.length).toEqual(numRows - 1); 172 | 173 | // make sure we deleted the correct row 174 | expect(rows[0].get('numbers')).toEqual('0'); 175 | expect(rows[1].get('numbers')).toEqual('2'); 176 | }); 177 | 178 | it('cannot delete a row twice', async () => { 179 | await expect(row.delete()).rejects.toThrow(); 180 | }); 181 | 182 | it('cannot update a deleted row', async () => { 183 | row.set('col1', 'new value'); 184 | await expect(row.save()).rejects.toThrow(); 185 | }); 186 | }); 187 | 188 | describe('updating rows', () => { 189 | let rows: GoogleSpreadsheetRow[]; 190 | let row: GoogleSpreadsheetRow; 191 | it('can update a row', async () => { 192 | rows = await sheet.getRows(); 193 | row = rows[0]; 194 | 195 | row.set('numbers', '999'); 196 | row.set('letters', 'Z'); 197 | await row.save(); 198 | expect(row.get('numbers')).toBe('999'); 199 | expect(row.get('letters')).toBe('Z'); 200 | }); 201 | 202 | it('persisted the row update', async () => { 203 | rows = await sheet.getRows(); 204 | expect(rows[0].get('numbers')).toEqual(row.get('numbers')); 205 | expect(rows[0].get('letters')).toEqual(row.get('letters')); 206 | }); 207 | 208 | it('can write a formula', async () => { 209 | row.set('col1', 1); 210 | row.set('col2', 2); 211 | row.set('col3', '=D2+E2'); // col1 is column C 212 | await row.save(); 213 | expect(row.get('col1')).toEqual('1'); // it converts to strings 214 | expect(row.get('col2')).toEqual('2'); 215 | expect(row.get('col3')).toEqual('3'); // it evaluates the formula and formats as a string 216 | }); 217 | 218 | describe('encoding and odd characters', () => { 219 | _.each( 220 | { 221 | 'new lines': 'new\n\nlines\n', 222 | 'special chars': '∑πécial <> chårs = !\t', 223 | }, 224 | (value, description) => { 225 | it(`supports ${description}`, async () => { 226 | row.set('col1', value); 227 | await row.save(); 228 | 229 | rows = await sheet.getRows(); 230 | expect(rows[0].get('col1')).toEqual(value); 231 | }); 232 | } 233 | ); 234 | }); 235 | }); 236 | 237 | // TODO: Move to cells.test.js because mergeCells and unmergeCells are really cell operations 238 | // but they were implemented using the existing data we have here in the rows tests 239 | // so we'll leave them here for now 240 | describe('merge and unmerge operations', () => { 241 | beforeAll(async () => { 242 | await sheet.loadCells('A1:H2'); 243 | }); 244 | 245 | const range = { 246 | startColumnIndex: 0, 247 | endColumnIndex: 2, 248 | }; 249 | 250 | it('merges all cells', async () => { 251 | await sheet.mergeCells({ 252 | startRowIndex: 2, 253 | endRowIndex: 4, 254 | ...range, 255 | }); 256 | const mergedRows = await sheet.getRows(); 257 | expect(mergedRows[1].get('numbers')).toBe('2'); 258 | expect(mergedRows[1].get('letters')).toBe(undefined); 259 | expect(mergedRows[2].get('numbers')).toBe(undefined); 260 | expect(mergedRows[2].get('letters')).toBe(undefined); 261 | }); 262 | 263 | it('merges all cells in column direction', async () => { 264 | await sheet.mergeCells({ 265 | startRowIndex: 4, 266 | endRowIndex: 6, 267 | ...range, 268 | }, 'MERGE_COLUMNS'); 269 | const mergedRows = await sheet.getRows(); 270 | expect(mergedRows[3].get('numbers')).toBe('4'); 271 | expect(mergedRows[3].get('letters')).toBe('E'); 272 | expect(mergedRows[4].get('numbers')).toBe(undefined); 273 | expect(mergedRows[4].get('letters')).toBe(undefined); 274 | }); 275 | 276 | it('merges all cells in row direction', async () => { 277 | await sheet.mergeCells({ 278 | startRowIndex: 6, 279 | endRowIndex: 8, 280 | ...range, 281 | }, 'MERGE_ROWS'); 282 | const mergedRows = await sheet.getRows(); 283 | expect(mergedRows[5].get('numbers')).toBe('6'); 284 | expect(mergedRows[5].get('letters')).toBe(undefined); 285 | expect(mergedRows[6].get('numbers')).toBe('7'); 286 | expect(mergedRows[6].get('letters')).toBe(undefined); 287 | }); 288 | 289 | it('unmerges cells', async () => { 290 | await sheet.mergeCells({ 291 | startRowIndex: 8, 292 | endRowIndex: 9, 293 | ...range, 294 | }); 295 | const mergedRows = await sheet.getRows(); 296 | expect(mergedRows[7].get('numbers')).toBe('8'); 297 | expect(mergedRows[7].get('letters')).toBe(undefined); 298 | mergedRows[7].set('letters', 'Z'); 299 | await mergedRows[7].save(); 300 | expect(mergedRows[7].get('numbers')).toBe('8'); 301 | expect(mergedRows[7].get('letters')).toBe(undefined); 302 | await sheet.unmergeCells({ 303 | startRowIndex: 8, 304 | endRowIndex: 9, 305 | ...range, 306 | }); 307 | mergedRows[7].set('letters', 'Z'); 308 | await mergedRows[7].save(); 309 | expect(mergedRows[7].get('numbers')).toBe('8'); 310 | expect(mergedRows[7].get('letters')).toBe('Z'); 311 | }); 312 | }); 313 | 314 | describe('header validation and cleanup', () => { 315 | let rows: GoogleSpreadsheetRow[]; 316 | beforeAll(async () => { 317 | sheet.loadCells('A1:E1'); 318 | }); 319 | 320 | it('clears the entire header row when setting new values', async () => { 321 | await sheet.setHeaderRow(['col1', 'col2', 'col3', 'col4', 'col5', 'col6', 'col7', 'col8']); 322 | await sheet.setHeaderRow(['new1', 'new2']); 323 | sheet.resetLocalCache(true); 324 | await sheet.loadHeaderRow(); 325 | expect(sheet.headerValues.length).toBe(2); 326 | }); 327 | 328 | it('allows empty headers', async () => { 329 | await sheet.setHeaderRow(['', 'col1', '', 'col2']); 330 | rows = await sheet.getRows(); 331 | const rowProps = _.keys(rows[0].toObject()); 332 | expect(rowProps).not.toContain(''); 333 | expect(rowProps).toContain('col1'); 334 | }); 335 | 336 | it('trims each header', async () => { 337 | await sheet.setHeaderRow([' col1 ', ' something with spaces ']); 338 | rows = await sheet.getRows(); 339 | expect(rows[0].toObject()).toHaveProperty('col1'); 340 | expect(rows[0].toObject()).toHaveProperty(['something with spaces']); 341 | }); 342 | 343 | it('throws an error if setting duplicate headers', async () => { 344 | await expect(sheet.setHeaderRow(['col1', 'col1'])).rejects.toThrow(); 345 | }); 346 | it('throws an error if setting empty headers', async () => { 347 | await expect(sheet.setHeaderRow([])).rejects.toThrow(); 348 | }); 349 | it('throws an error if setting empty headers after trimming', async () => { 350 | await expect(sheet.setHeaderRow([' '])).rejects.toThrow(); 351 | }); 352 | 353 | it('throws an error if duplicate headers already exist', async () => { 354 | await sheet.loadCells('A1:C1'); 355 | sheet.getCellByA1('A1').value = 'col1'; 356 | sheet.getCellByA1('B1').value = 'col1'; 357 | sheet.getCellByA1('C1').value = 'col2'; 358 | await sheet.saveUpdatedCells(); 359 | sheet.resetLocalCache(true); // forget the header values 360 | await expect(sheet.getRows()).rejects.toThrow(); 361 | }); 362 | 363 | it('throws if headers are all blank', async () => { 364 | await sheet.loadCells('A1:C1'); 365 | sheet.getCellByA1('A1').value = ''; 366 | sheet.getCellByA1('B1').value = ''; 367 | sheet.getCellByA1('C1').value = ''; 368 | await sheet.saveUpdatedCells(); 369 | sheet.resetLocalCache(true); // forget the header values 370 | await expect(sheet.getRows()).rejects.toThrow(); 371 | }); 372 | 373 | it('throws if headers are all blank after trimming spaces', async () => { 374 | await sheet.loadCells('A1:C1'); 375 | sheet.getCellByA1('A1').value = ''; 376 | sheet.getCellByA1('B1').value = ' '; 377 | sheet.getCellByA1('C1').value = ''; 378 | await sheet.saveUpdatedCells(); 379 | sheet.resetLocalCache(true); // forget the header values 380 | await expect(sheet.getRows()).rejects.toThrow(); 381 | }); 382 | }); 383 | 384 | describe('custom header row index', () => { 385 | const CUSTOM_HEADER_ROW_INDEX = 3; 386 | let newSheet: GoogleSpreadsheetWorksheet; 387 | 388 | afterAll(async () => { 389 | await newSheet.delete(); 390 | }); 391 | 392 | it('can set custom header row index while adding a sheet', async () => { 393 | newSheet = await doc.addSheet({ 394 | headerValues: ['a', 'b', 'c'], 395 | headerRowIndex: CUSTOM_HEADER_ROW_INDEX, 396 | title: `custom header index sheet ${+new Date()}`, 397 | gridProperties: { rowCount: INITIAL_ROW_COUNT }, 398 | }); 399 | await newSheet.loadCells(); 400 | const aHeaderCell = newSheet.getCell(CUSTOM_HEADER_ROW_INDEX - 1, 0); 401 | expect(aHeaderCell.value).toEqual('a'); 402 | }); 403 | 404 | it('can load existing header row from custom index', async () => { 405 | newSheet.resetLocalCache(true); 406 | 407 | // first row is empty so this should fail 408 | await expect(newSheet.getRows()).rejects.toThrow(); 409 | 410 | // load header row from custom index 411 | await newSheet.loadHeaderRow(CUSTOM_HEADER_ROW_INDEX); 412 | expect(newSheet.headerValues[0]).toEqual('a'); 413 | 414 | await newSheet.addRows([ 415 | { a: 'a1', b: 'b1' }, 416 | { a: 'a2', b: 'b2' }, 417 | ]); 418 | 419 | const rows = await newSheet.getRows(); 420 | expect(rows[0].get('a')).toEqual('a1'); 421 | 422 | await newSheet.loadCells(); 423 | // now verify header and data are in the right place, using the cell-based methods 424 | const aDataCell = newSheet.getCell(CUSTOM_HEADER_ROW_INDEX, 0); 425 | expect(aDataCell.value).toEqual('a1'); 426 | }); 427 | 428 | it('can clear rows properly when custom header index is used', async () => { 429 | await newSheet.clearRows(); 430 | 431 | await newSheet.loadCells(); 432 | // now verify header is still there and data is cleared 433 | const aHeaderCell = newSheet.getCell(CUSTOM_HEADER_ROW_INDEX - 1, 0); 434 | expect(aHeaderCell.value).toEqual('a'); 435 | const aDataCell = newSheet.getCell(CUSTOM_HEADER_ROW_INDEX, 0); 436 | expect(aDataCell.value).toEqual(null); 437 | }); 438 | }); 439 | }); 440 | -------------------------------------------------------------------------------- /docs/classes/google-spreadsheet-worksheet.md: -------------------------------------------------------------------------------- 1 | _Class Reference_ 2 | 3 | # GoogleSpreadsheetWorksheet 4 | 5 | > **This class represents an individual worksheet/sheet in a spreadsheet doc - [Sheets](https://developers.google.com/sheets/api/reference/rest/v4/spreadsheets/sheets)** 6 |
7 | Provides methods to interact with sheet metadata and acts as the gateway to interacting the data it contains 8 | 9 | ?> Google's v4 api refers to these as "**sheets**" but we prefer their v3 api terminology of "**worksheets**" as the distinction from "spreadsheets" is more clear. 10 | 11 | ## Initialization 12 | 13 | You do not initialize worksheets directly. Instead you can load the sheets from a doc. For example: 14 | 15 | ```javascript 16 | const doc = new GoogleSpreadsheet('', auth); 17 | await doc.loadInfo(); // loads sheets and other document metadata 18 | 19 | const firstSheet = doc.sheetsByIndex[0]; // in the order they appear on the sheets UI 20 | const sheet123 = doc.sheetsById[123]; // accessible via ID if you already know it 21 | 22 | const newSheet = await doc.addSheet(); // adds a new sheet 23 | ``` 24 | 25 | ## Properties 26 | 27 | ### Basic Sheet Properties 28 | 29 | Basic properties about the sheet are available once the sheet is loaded from the `doc.loadInfo()` call. Much of this information is refreshed during various API interactions. These properties are not editable directly. Instead to update them, use the `sheet.updateProperties()` method 30 | 31 | See [official google docs](https://developers.google.com/sheets/api/reference/rest/v4/spreadsheets/sheets#sheetproperties) for more details. 32 | 33 | Property|Type|Description 34 | ---|---|--- 35 | `sheetId`|String|Sheet ID
_set during creation, not editable_ 36 | `title`|String|The name of the sheet 37 | `index`|Number
_int >= 0_|The index of the sheet within the spreadsheet 38 | `sheetType`|String (enum)
[SheetType](https://developers.google.com/sheets/api/reference/rest/v4/spreadsheets/sheets#SheetType)|The type of sheet
_set during creation, not editable_ 39 | `gridProperties`|Object
[GridProperties](https://developers.google.com/sheets/api/reference/rest/v4/spreadsheets/sheets#GridProperties)|Additional properties of the sheet if this sheet is a grid 40 | `hidden`|Boolean|True if the sheet is hidden in the UI, false if it's visible 41 | `tabColor`|Object
[Color](https://developers.google.com/sheets/api/reference/rest/v4/spreadsheets/other#Color)|The color of the tab in the UI 42 | `rightToLeft`|Boolean|True if the sheet is an RTL sheet instead of an LTR sheet 43 | 44 | ?> Use [`sheet.updateProperties()`](#fn-updateProperties) to update these props 45 | 46 | 47 | ### Sheet Dimensions & Stats 48 | 49 | Property|Type|Description 50 | ---|---|--- 51 | `rowCount`|Number
_int > 1_|Number of rows in the sheet 52 | `columnCount`|Number
_int > 1_|Number of columns in the sheet 53 | `cellStats`|Object|Stats about cells in the sheet 54 | `cellStats.total`|Number
_int >= 0_|Total number of cells in the sheet
_should equal rowCount * columnCount_ 55 | `cellStats.loaded`|Number
_int >= 0_|Number of cells that are loaded locally 56 | `cellStats.nonEmpty`|Number
_int >= 0_|Number of loaded cells that are not empty 57 | 58 | ?> Use [`sheet.resize()`](#fn-resize) to update the sheet dimensions 59 | 60 | 61 | ## Methods 62 | 63 | ### Working With Rows 64 | 65 | The row-based interface is provided as a simplified way to deal with sheets that are being used like a database (first row is column headers). In some situations it is much simpler to use, but it comes with many limitations, so beware. 66 | 67 | Also note that the row-based API and cell-based API are isolated from each other, meaning when you load a set of rows, the corresponding cells are not loaded as well. You usually want to use one or the other. 68 | 69 | #### `loadHeaderRow(headerRowIndex)` (async) :id=fn-loadHeaderRow 70 | > Loads the header row (usually first) of the sheet 71 | 72 | Usually this is called automatically when loading rows via `getRows()` if the header row has not yet been loaded. However you should call this explicitly if you want to load a header row that is not the first row of the sheet. 73 | 74 | Param|Type|Required|Description 75 | ---|---|---|--- 76 | `headerRowIndex`|Number
_int >= 1_|-|Optionally set custom header row index, if headers are not in first row
NOTE - not zero-indexed, 1 = first 77 | 78 | - ✨ **Side effects** - `sheet.headerValues` is populated 79 | 80 | #### `setHeaderRow(headerValues, headerRowIndex)` (async) :id=fn-setHeaderRow 81 | > Set the header row (usually first) of the sheet 82 | 83 | Param|Type|Required|Description 84 | ---|---|---|--- 85 | `headerValues`|[String]|✅|Array of strings to set as cell values in first row 86 | `headerRowIndex`|Number
_int >= 1_|-|Optionally set custom header row index, if headers are not in first row
NOTE - not zero-indexed, 1 = first 87 | 88 | - ✨ **Side effects** - header row of the sheet is filled, `sheet.headerValues` is populated 89 | 90 | #### `addRow(rowValues, options)` (async) :id=fn-addRow 91 | > Append a new row to the sheet 92 | 93 | Param|Type|Required|Description 94 | ---|---|---|--- 95 | `rowValues`
_option 1_|Object|✅|Object of cell values, keys are based on the header row
_ex: `{ col1: 'val1', col2: 'val2', ... }`_ 96 | `rowValues`
_option 2_|Array|✅|Array of cell values in order from first column onwards
_ex: `['val1', 'val2', ...]`_ 97 | `options`|Object|-|Options object 98 | `options.raw`|Boolean|-|Store raw values instead of converting as if typed into the sheets UI
_see [ValueInputOption](https://developers.google.com/sheets/api/reference/rest/v4/ValueInputOption)_ 99 | `options.insert`|Boolean|-|Insert new rows instead of overwriting empty rows and only adding if necessary
_see [InsertDataOption](https://developers.google.com/sheets/api/reference/rest/v4/spreadsheets.values/append#InsertDataOption)_ 100 | 101 | 102 | - ↩️ **Returns** - [GoogleSpreadsheetRow](classes/google-spreadsheet-row) (in a promise) 103 | - ✨ **Side effects** - row is added to the sheet 104 | 105 | 106 | #### `addRows(arrayOfRowValues, options)` (async) :id=fn-addRows 107 | > Append multiple new rows to the sheet at once 108 | 109 | Param|Type|Required|Description 110 | ---|---|---|--- 111 | `arrayOfRowValues`|Array|✅|Array of rows values to append to the sheet
_see [`sheet.addRow()`](#fn-addRow) above for more info_ 112 | `options`|Object|-|Inserting options
_see [`sheet.addRow()`](#fn-addRow) above for more info_ 113 | 114 | 115 | - ↩️ **Returns** - [[GoogleSpreadsheetRow](classes/google-spreadsheet-row)] (in a promise) 116 | - ✨ **Side effects** - rows are added to the sheet 117 | 118 | 119 | #### `getRows(options)` (async) :id=fn-getRows 120 | > Fetch rows from the sheet 121 | 122 | Param|Type|Required|Description 123 | ---|---|---|--- 124 | `options`|Object|-|Options object 125 | `options.offset`|Number
_int >= 0_|-|How many rows to skip from the top 126 | `options.limit`|Number
_int >= 1_|-|Max number of rows to fetch 127 | 128 | - ↩️ **Returns** - [[GoogleSpreadsheetRow](classes/google-spreadsheet-row)] (in a promise) 129 | 130 | !> The older version of this module allowed you to filter and order the rows as you fetched them, but this is no longer supported by google 131 | 132 | 133 | #### `clearRows(options)` (async) :id=fn-clearRows 134 | > Clear rows in the sheet 135 | 136 | By default, this will clear all rows and leave the header (and anything above it) intact, but you can pass in start and/or end to limit which rows are cleared. 137 | 138 | Param|Type|Required|Description 139 | ---|---|---|--- 140 | `options`|Object|-|Options object 141 | `options.start`|Number
_int >= 1_|-|A1 style row number of first row to clear
_defaults to first non-header row_ 142 | `options.end`|Number
_int >= 1_|-|A1 style row number of last row to clear
_defaults to last row_ 143 | 144 | - ✨ **Side effects** - rows in the sheet are emptied, loaded GoogleSpreadsheetRows in the cache have the data cleared 145 | 146 | 147 | ### Working With Cells 148 | 149 | The cell-based interface lets you load and update individual cells in a sheeet, including things like the formula and formatting within those cells. It is more feature rich, but tends to be more awkward to use for many simple use cases. 150 | 151 | #### `loadCells(filters)` (async) :id=fn-loadCells 152 | > Fetch cells from google 153 | 154 | !> This method does not return the cells it loads, instead they are kept in a local cache managed by the sheet. See methods below (`getCell` and `getCellByA1`) to access them. 155 | 156 | You can filter the cells you want to fetch in several ways. See [Data Filters](https://developers.google.com/sheets/api/reference/rest/v4/DataFilter) for more info. Strings are treated as A1 ranges, objects are detected to be a [GridRange](https://developers.google.com/sheets/api/reference/rest/v4/spreadsheets/other#GridRange) with sheetId not required. 157 | 158 | ```javascript 159 | await sheet.loadCells(); // no filter - will load ALL cells in the sheet 160 | await sheet.loadCells('B2:D5'); // A1 range 161 | await sheet.loadCells({ // GridRange object 162 | startRowIndex: 5, endRowIndex: 100, startColumnIndex:0, endColumnIndex: 200 163 | }); 164 | await sheet.loadCells({ startRowIndex: 50 }); // not all props required 165 | await sheet.loadCells(['B2:D5', 'B50:D55']); // can pass an array of filters 166 | ``` 167 | 168 | !> If using an API key (read-only access), only A1 ranges are supported 169 | 170 | Param|Type|Required|Description 171 | ---|---|---|--- 172 | `filters`|*|-|Can be a single filter or array of filters 173 | 174 | - ✨ **Side effects** - cells are loaded into local cache, `cellStats` is updated 175 | 176 | 177 | #### `getCell(rowIndex, columnIndex)` :id=fn-getCell 178 | > retrieve a cell from the cache based on zero-indexed row/column 179 | 180 | Param|Type|Required|Description 181 | ---|---|---|--- 182 | `rowIndex`|Number
_int >= 0_|✅|Row of the cell 183 | `columnIndex`|Number
_int >= 0_|✅|Column of the cell to retrieve 184 | 185 | - ↩️ **Returns** - [GoogleSpreadsheetCell](classes/google-spreadsheet-cell) 186 | 187 | 188 | #### `getCellByA1(a1Address)` :id=fn-getCellByA1 189 | > retrieve a cell from the cache based on A1 address 190 | 191 | Param|Type|Required|Description 192 | ---|---|---|--- 193 | `a1Address`|String|✅|Address of the cell
_ex: "B5"_ 194 | 195 | - ↩️ **Returns** - [GoogleSpreadsheetCell](classes/google-spreadsheet-cell) 196 | 197 | 198 | #### `saveUpdatedCells()` (async) :id=fn-saveUpdatedCells 199 | > saves all cells in the sheet that have unsaved changes 200 | 201 | !> NOTE - this method will only save changes made using the cell-based methods described here, not the row-based ones described above 202 | 203 | - ✨ **Side effects** - cells are saved, data refreshed from google 204 | 205 | #### `saveCells(cells)` (async) :id=fn-saveCells 206 | > saves specific cells 207 | 208 | Param|Type|Required|Description 209 | ---|---|---|--- 210 | `cells`|[[GoogleSpreadsheetCell](classes/google-spreadsheet-cell)]|✅|Array of cells to save 211 | 212 | - 🚨 **Warning** - At least one cell must have something to save 213 | - ✨ **Side effects** - cells are saved, data refreshed from google 214 | 215 | ?> Usually easier to just use `sheet.saveUpdatedCells` 216 | 217 | 218 | #### `resetLocalCache(dataOnly)` :id=fn-resetLocalCache 219 | > Reset local cache of properties and cell data 220 | 221 | Param|Type|Required|Description 222 | ---|---|---|--- 223 | `dataOnly`|Boolean|-|If true, only affects data, not properties 224 | 225 | - ✨ **Side effects** - cache is emptied so props and cells must be re-fetched 226 | 227 | #### `mergeCells(range, mergeType)` (async) :id=fn-mergeCells 228 | > merge cells together 229 | 230 | Param|Type|Required|Description 231 | ---|---|---|--- 232 | `range`|Object
[GridRange](https://developers.google.com/sheets/api/reference/rest/v4/spreadsheets/other#GridRange)|✅|Range of cells to merge, sheetId not required! 233 | `mergeType`|String (enum)
[MergeType](https://developers.google.com/sheets/api/reference/rest/v4/spreadsheets/request#MergeType)|-|_defaults to `MERGE_ALL`_ 234 | 235 | - 🚨 **Warning** - Reading values from merged cells other than the top-left one will show a null value 236 | 237 | #### `unmergeCells(range)` (async) :id=fn-unmergeCells 238 | > split merged cells 239 | 240 | Param|Type|Required|Description 241 | ---|---|---|--- 242 | `range`|Object
[GridRange](https://developers.google.com/sheets/api/reference/rest/v4/spreadsheets/other#GridRange)]|✅|Range of cells to unmerge, sheetId not required! 243 | 244 | ### Updating Sheet Properties 245 | 246 | #### `updateProperties(props)` (async) :id=fn-updateProperties 247 | > Update basic sheet properties 248 | 249 | For example: `await sheet.updateProperties({ title: 'New sheet title' });`
250 | See [basic sheet properties](#basic-sheet-properties) above for props documentation. 251 | 252 | - ✨ **Side Effects -** props are updated 253 | 254 | #### `resize(props)` (async) :id=fn-resize 255 | > Update grid properties / dimensions 256 | 257 | Just a shorcut for `(props) => sheet.updateProperties({ gridProperties: props })`
258 | Example: `await sheet.resize({ rowCount: 1000, columnCount: 20 });` 259 | 260 | - ✨ **Side Effects -** grid properties / dimensions are updated 261 | 262 | _also available as `sheet.updateGridProperties()`_ 263 | 264 | #### `updateDimensionProperties(columnsOrRows, props, bounds)` (async) :id=fn-updateDimensionProperties 265 | > Update sheet "dimension properties" 266 | 267 | Param|Type|Required|Description 268 | ---|---|---|--- 269 | `columnsOrRows`|String (enum)
_"COLUMNS" or "ROWS"_|✅|Which dimension 270 | `props`|Object
[DimensionProperties](https://developers.google.com/sheets/api/reference/rest/v4/spreadsheets/sheets#DimensionProperties)|✅|properties to update 271 | `bounds`|Object|-| 272 | `bounds.startIndex`|Number
_int >= 0_|-|Start row/column 273 | `bounds.endIndex`|Number
_int >= 0_|-|End row/column 274 | 275 | - ✨ **Side effects** - sheet is updated 276 | 277 | #### `insertDimension(columnsOrRows, range, inheritFromBefore)` (async) :id=fn-insertDimension 278 | 279 | > Update sheet "dimension properties" 280 | 281 | | Param | Type | Required | Description | 282 | | --- | --- | --- | --- | 283 | | `columnsOrRows` | String (enum)
_"COLUMNS" or "ROWS"_ | ✅ | Which dimension | 284 | | `range` | Object | ✅ | 285 | | `range.startIndex` | Number
_int >= 0_ | ✅ | Start row/column (inclusive) | 286 | | `range.endIndex` | Number
_int >= 1_ | ✅ | End row/column (exclusive), must be greater than startIndex | 287 | | `inheritFromBefore` | Boolean | - | If true, tells the API to give the new columns or rows the same properties as the prior row or column

_defaults to true, unless inserting in first row/column_ | 288 | 289 | - ✨ **Side effects** - new row(s) or column(s) are inserted into the sheet 290 | - 🚨 **Warning** - Does not update cached rows/cells, so be sure to reload rows/cells before trying to make any updates to sheet contents 291 | 292 | ### Other 293 | 294 | #### `clear(a1Range)` (async) :id=fn-clear 295 | > Clear data/cells in the sheet 296 | 297 | Defaults to clearing the entire sheet, or pass in a specific a1 range 298 | 299 | | Param | Type | Required | Description | 300 | | --- | --- | --- | --- | 301 | | `a1Range` | String (A1 range) | - | Optional specific range within the sheet to clear | 302 | 303 | - ✨ **Side Effects -** clears the sheet (entire sheet or specified range), resets local cache 304 | 305 | #### `delete()` (async) :id=fn-delete 306 | > Delete this sheet 307 | 308 | - ✨ **Side Effects -** sheet is deleted and removed from `doc.sheetsById`, `doc.sheetsByIndex`, `doc.sheetsById` 309 | 310 | _also available as `sheet.del()`_ 311 | 312 | #### `duplicate(options)` (async) :id=fn-duplicate 313 | > Duplicate this sheet within this document 314 | 315 | |Param|Type|Required|Description 316 | |---|---|---|--- 317 | | `options` | Object | - | 318 | | `options.title` | String | - | Name/title for new sheet, must be unique within the document
_defaults to something like "Copy of [sheet.title]" if not provided_ | 319 | | `options.index` | Number
_int >= 0_ | - | Where to insert the new sheet (zero-indexed)
_defaults to 0 (first)_ | 320 | | `options.id` | Number
_int >= 1_ | - | unique ID to use for new sheet
_defaults to new unique id generated by google_ | 321 | 322 | - ↩️ **Returns** - [GoogleSpreadsheetRow](classes/google-spreadsheet-row) (in a promise) 323 | - ✨ **Side Effects -** new sheet is creted, sheets in parent doc are updated (`sheetsByIndex`, `sheetsByTitle`, `sheetsById`) 324 | 325 | #### `copyToSpreadsheet(destinationSpreadsheetId)` (async) :id=fn-copyToSpreadsheet 326 | > Copy this sheet to a different document 327 | 328 | Param|Type|Required|Description 329 | ---|---|---|--- 330 | `destinationSpreadsheetId`|String|✅|ID of another spreadsheet document 331 | 332 | - ✨ **Side Effects -** sheet is copied to the other doc 333 | 334 | ?> The authentication method being used must have write access to the destination document as well 335 | 336 | 337 | #### `setDataValidation(range, rule)` (async) :id=fn-setDataValidation 338 | > Sets a data validation rule to every cell in the range 339 | 340 | Param|Type|Required|Description 341 | ---|---|---|--- 342 | `range`|Object
[GridRange](https://developers.google.com/sheets/api/reference/rest/v4/spreadsheets/other#GridRange)|✅|Range of cells to apply the rule to, sheetId not required! 343 | `rule`|Object
[DataValidationRule](https://developers.google.com/sheets/api/reference/rest/v4/spreadsheets/cells#DataValidationRule)
or `false`|✅|Object describing the validation rule
Or `false` to unset the rule 344 | 345 | 346 | ### Exports 347 | 348 | See [Exports guide](guides/exports) for more info. 349 | 350 | #### `downloadAsCSV(returnStreamInsteadOfBuffer)` (async) :id=fn-downloadAsCSV 351 | > Export worksheet in CSV format 352 | 353 | Param|Type|Required|Description 354 | ---|---|---|--- 355 | `returnStreamInsteadOfBuffer`|Boolean|-|Set to true to return a stream instead of a Buffer
_See [Exports guide](guides/exports) for more details_ 356 | 357 | - ↩️ **Returns** - Buffer (or stream) containing CSV data 358 | 359 | 360 | #### `downloadAsTSV(returnStreamInsteadOfBuffer)` (async) :id=fn-downloadAsTSV 361 | > Export worksheet in TSV format 362 | 363 | Param|Type|Required|Description 364 | ---|---|---|--- 365 | `returnStreamInsteadOfBuffer`|Boolean|-|Set to true to return a stream instead of a Buffer
_See [Exports guide](guides/exports) for more details_ 366 | 367 | - ↩️ **Returns** - Buffer (or stream) containing TSV data 368 | 369 | 370 | #### `downloadAsPDF(returnStreamInsteadOfBuffer)` (async) :id=fn-downloadAsPDF 371 | > Export worksheet in PDF format 372 | 373 | Param|Type|Required|Description 374 | ---|---|---|--- 375 | `returnStreamInsteadOfBuffer`|Boolean|-|Set to true to return a stream instead of a Buffer
_See [Exports guide](guides/exports) for more details_ 376 | 377 | - ↩️ **Returns** - Buffer (or stream) containing PDF data 378 | 379 | 380 | -------------------------------------------------------------------------------- /src/lib/types/sheets-types.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-unused-vars */ 2 | 3 | import { MakeOptional } from './util-types'; 4 | 5 | // some basic types which are just aliases, but they make the code a bit clearer 6 | export type Integer = number; 7 | 8 | export type SpreadsheetId = string; 9 | export type WorksheetId = number; 10 | export type DataSourceId = string; 11 | 12 | export type WorksheetIndex = number; 13 | export type RowOrColumnIndex = number; 14 | export type RowIndex = number; 15 | export type ColumnIndex = number; 16 | export type A1Address = string; 17 | export type ColumnAddress = string; 18 | export type A1Range = string; 19 | 20 | export type NamedRangeId = string; 21 | 22 | 23 | 24 | /** 25 | * ISO language code 26 | * @example en 27 | * @example en_US 28 | * */ 29 | export type LocaleCode = string; 30 | /** 31 | * timezone code, if not recognized, may be a custom time zone such as `GMT-07:00` 32 | * @example America/New_York 33 | * */ 34 | export type Timezone = string; 35 | 36 | // ENUMS /////////////////////////////////////////////////////////////////////////////////////////////////// 37 | 38 | /** @see https://developers.google.com/sheets/api/reference/rest/v4/spreadsheets/sheets#SheetType */ 39 | export type WorksheetType = 40 | /** The sheet is a grid. */ 41 | 'GRID' | 42 | /** The sheet has no grid and instead has an object like a chart or image. */ 43 | 'OBJECT' | 44 | /** The sheet connects with an external DataSource and shows the preview of data. */ 45 | 'DATA_SOURCE'; 46 | 47 | export type WorksheetDimension = 'ROWS' | 'COLUMNS'; 48 | 49 | export type HyperlinkDisplayType = 'LINKED' | 'PLAIN_TEXT'; 50 | 51 | /** @see https://developers.google.com/sheets/api/reference/rest/v4/spreadsheets/cells#numberformattype */ 52 | export type NumberFormatType = 53 | /** Text formatting, e.g 1000.12 */ 54 | 'TEXT' | 55 | /** Number formatting, e.g, 1,000.12 */ 56 | 'NUMBER' | 57 | /** Percent formatting, e.g 10.12% */ 58 | 'PERCENT' | 59 | /** Currency formatting, e.g $1,000.12 */ 60 | 'CURRENCY' | 61 | /** Date formatting, e.g 9/26/2008 */ 62 | 'DATE' | 63 | /** Time formatting, e.g 3:59:00 PM */ 64 | 'TIME' | 65 | /** Date+Time formatting, e.g 9/26/08 15:59:00 */ 66 | 'DATE_TIME' | 67 | /** Scientific number formatting, e.g 1.01E+03 */ 68 | 'SCIENTIFIC'; 69 | 70 | /** @see https://developers.google.com/sheets/api/reference/rest/v4/spreadsheets/other#errortype */ 71 | export type CellValueErrorType = 72 | /** Corresponds to the #ERROR! error */ 73 | 'ERROR' | 74 | /** Corresponds to the #NULL! error. */ 75 | 'NULL_VALUE' | 76 | /** Corresponds to the #DIV/0 error. */ 77 | 'DIVIDE_BY_ZERO' | 78 | /** Corresponds to the #VALUE! error. */ 79 | 'VALUE' | 80 | /** Corresponds to the #REF! error. */ 81 | 'REF' | 82 | /** Corresponds to the #NAME? error. */ 83 | 'NAME' | 84 | /** Corresponds to the #NUM! error. */ 85 | 'NUM' | 86 | /** Corresponds to the #N/A error. */ 87 | 'N_A' | 88 | /** Corresponds to the Loading... state. */ 89 | 'LOADING'; 90 | 91 | /** @see https://developers.google.com/sheets/api/reference/rest/v4/spreadsheets/other#horizontalalign */ 92 | export type HorizontalAlign = 'LEFT' | 'CENTER' | 'RIGHT'; 93 | 94 | /** @see https://developers.google.com/sheets/api/reference/rest/v4/spreadsheets/cells#verticalalign */ 95 | export type VerticalAlign = 'TOP' | 'MIDDLE' | 'BOTTOM'; 96 | 97 | /** @see https://developers.google.com/sheets/api/reference/rest/v4/spreadsheets/cells#textdirection */ 98 | export type TextDirection = 'LEFT_TO_RIGHT' | 'RIGHT_TO_LEFT'; 99 | 100 | /** @see https://developers.google.com/sheets/api/reference/rest/v4/spreadsheets/cells#wrapstrategy */ 101 | export type WrapStrategy = 'OVERFLOW_CELL' | 'LEGACY_WRAP' | 'CLIP' | 'WRAP'; 102 | 103 | /** @see https://developers.google.com/sheets/api/reference/rest/v4/spreadsheets/other#themecolortype */ 104 | export type ThemeColorType = 'TEXT' | 'BACKGROUND' | 'ACCENT1' | 'ACCENT2' | 'ACCENT3' | 'ACCENT4' | 'ACCENT5' | 'ACCENT6' | 'LINK'; 105 | 106 | /** @see https://developers.google.com/sheets/api/reference/rest/v4/spreadsheets#recalculationinterval */ 107 | export type RecalculationInterval = 'ON_CHANGE' | 'MINUTE' | 'HOUR'; 108 | 109 | /** @see https://developers.google.com/sheets/api/reference/rest/v4/spreadsheets.developerMetadata#developermetadatavisibility */ 110 | export type DeveloperMetadataVisibility = 111 | /** Document-visible metadata is accessible from any developer project with access to the document. */ 112 | | 'DOCUMENT' 113 | /** Project-visible metadata is only visible to and accessible by the developer project that created the metadata. */ 114 | | 'PROJECT'; 115 | 116 | /** @see https://developers.google.com/sheets/api/reference/rest/v4/spreadsheets.developerMetadata#developermetadatalocationtype */ 117 | export type DeveloperMetadataLocationType = 'ROW' | 'COLUMN' | 'SHEET' | 'SPREADSHEET'; 118 | 119 | 120 | 121 | // formatting types 122 | export type TextFormat = { 123 | foregroundColor?: Color; 124 | foregroundColorStyle?: ColorStyle; 125 | fontFamily?: string; 126 | fontSize?: number; 127 | bold?: boolean; 128 | italic?: boolean; 129 | strikethrough?: boolean; 130 | underline?: boolean; 131 | }; 132 | 133 | /** @see https://developers.google.com/sheets/api/reference/rest/v4/spreadsheets/cells#Style */ 134 | export type CellBorderLineStyle = 'NONE' | 'DOTTED' | 'DASHED' | 'SOLID' | 'SOLID_MEDIUM' | 'SOLID_THICK' | 'DOUBLE'; 135 | 136 | /** @see https://developers.google.com/sheets/api/reference/rest/v4/spreadsheets/cells#Border */ 137 | export type CellBorder = { 138 | style: CellBorderLineStyle; 139 | width: number; 140 | color: Color; 141 | colorStyle: ColorStyle; 142 | }; 143 | 144 | /** @see https://developers.google.com/sheets/api/reference/rest/v4/spreadsheets/cells#Borders */ 145 | export type CellBorders = { 146 | top: CellBorder; 147 | bottom: CellBorder; 148 | left: CellBorder; 149 | right: CellBorder; 150 | }; 151 | 152 | /** @see https://developers.google.com/sheets/api/reference/rest/v4/spreadsheets/cells#Padding */ 153 | export type CellPadding = { 154 | top: number; 155 | bottom: number; 156 | left: number; 157 | right: number; 158 | }; 159 | 160 | export type TextRotation = { 161 | angle: number; 162 | vertical: boolean; 163 | }; 164 | 165 | export type ThemeColorPair = { 166 | color: ColorStyle; 167 | colorType: ThemeColorType; 168 | }; 169 | 170 | export type SpreadsheetTheme = { 171 | primaryFontFamily: string; 172 | themeColors: ThemeColorPair[]; 173 | }; 174 | 175 | // --------------------------------- 176 | 177 | export type PaginationOptions = { 178 | limit: number; 179 | offset: number; 180 | }; 181 | 182 | /** 183 | * @see https://developers.google.com/sheets/api/reference/rest/v4/spreadsheets#iterativecalculationsettings 184 | */ 185 | export type IterativeCalculationSetting = { 186 | maxIterations: number; 187 | convergenceThreshold: number; 188 | }; 189 | 190 | 191 | export type DimensionRangeIndexes = { 192 | startIndex: RowOrColumnIndex; 193 | endIndex: RowOrColumnIndex; 194 | }; 195 | 196 | 197 | /** @see https://developers.google.com/sheets/api/reference/rest/v4/spreadsheets.developerMetadata#DeveloperMetadata.DeveloperMetadataLocation */ 198 | export interface DeveloperMetadataLocation { 199 | sheetId: number; 200 | spreadsheet: boolean; 201 | dimensionRange: DimensionRange; 202 | locationType: DeveloperMetadataLocationType; 203 | } 204 | 205 | /** @see https://developers.google.com/sheets/api/reference/rest/v4/spreadsheets.developerMetadata#DeveloperMetadata.DeveloperMetadataLocation */ 206 | export interface DeveloperMetadata { 207 | metadataId: number; 208 | metadataKey: string; 209 | metadataValue: string; 210 | location: DeveloperMetadataLocation; 211 | visibility: DeveloperMetadataVisibility; 212 | } 213 | 214 | export interface WorksheetDimensionProperties { 215 | pixelSize: number; 216 | hiddenByUser: boolean; 217 | hiddenByFilter: boolean; 218 | 219 | /** 220 | * @see https://developers.google.com/sheets/api/reference/rest/v4/spreadsheets.developerMetadata#DeveloperMetadata 221 | */ 222 | developerMetadata: DeveloperMetadata[]; 223 | } 224 | 225 | /** @see https://developers.google.com/sheets/api/reference/rest/v4/spreadsheets/other#DataSourceColumnReference */ 226 | type DataSourceColumnReference = { 227 | name: string; 228 | }; 229 | /** @see https://developers.google.com/sheets/api/reference/rest/v4/spreadsheets/other#DataSourceColumn */ 230 | type DataSourceColumn = { 231 | reference: DataSourceColumnReference, 232 | formula: string 233 | }; 234 | /** @see https://developers.google.com/sheets/api/reference/rest/v4/spreadsheets/other#DataExecutionState */ 235 | type DataExecutionState = 236 | /** The data execution has not started. */ 237 | 'NOT_STARTED' | 238 | /** The data execution has started and is running. */ 239 | 'RUNNING' | 240 | /** The data execution has completed successfully. */ 241 | 'SUCCEEDED' | 242 | /** The data execution has completed with errors. */ 243 | 'FAILED'; 244 | 245 | /** @see https://developers.google.com/sheets/api/reference/rest/v4/spreadsheets/other#DataExecutionState */ 246 | type DataExecutionErrorCode = 247 | /** Default value, do not use. */ 248 | 'DATA_EXECUTION_ERROR_CODE_UNSPECIFIED' | 249 | /** The data execution timed out. */ 250 | 'TIMED_OUT' | 251 | /** The data execution returns more rows than the limit. */ 252 | 'TOO_MANY_ROWS' | 253 | /** The data execution returns more columns than the limit. */ 254 | 'TOO_MANY_COLUMNS' | 255 | /** The data execution returns more cells than the limit. */ 256 | 'TOO_MANY_CELLS' | 257 | /** Error is received from the backend data execution engine (e.g. BigQuery). Check errorMessage for details. */ 258 | 'ENGINE' | 259 | /** One or some of the provided data source parameters are invalid. */ 260 | 'PARAMETER_INVALID' | 261 | /** The data execution returns an unsupported data type. */ 262 | 'UNSUPPORTED_DATA_TYPE' | 263 | /** The data execution returns duplicate column names or aliases. */ 264 | 'DUPLICATE_COLUMN_NAMES' | 265 | /** The data execution is interrupted. Please refresh later. */ 266 | 'INTERRUPTED' | 267 | /** The data execution is currently in progress, can not be refreshed until it completes. */ 268 | 'CONCURRENT_QUERY' | 269 | /** Other errors. */ 270 | 'OTHER' | 271 | /** The data execution returns values that exceed the maximum characters allowed in a single cell. */ 272 | 'TOO_MANY_CHARS_PER_CELL' | 273 | /** The database referenced by the data source is not found. */ 274 | 'DATA_NOT_FOUND' | 275 | /** The user does not have access to the database referenced by the data source. */ 276 | 'PERMISSION_DENIED' | 277 | /** The data execution returns columns with missing aliases. */ 278 | 'MISSING_COLUMN_ALIAS' | 279 | /** The data source object does not exist. */ 280 | 'OBJECT_NOT_FOUND' | 281 | /** The data source object is currently in error state. To force refresh, set force in RefreshDataSourceRequest . */ 282 | 'OBJECT_IN_ERROR_STATE' | 283 | /** The data source object specification is invalid. */ 284 | 'OBJECT_SPEC_INVALID'; 285 | 286 | /** @see https://developers.google.com/sheets/api/reference/rest/v4/spreadsheets/other#DataExecutionStatus */ 287 | type DataExecutionStatus = { 288 | 'state': DataExecutionState, 289 | 'errorCode': DataExecutionErrorCode, 290 | 'errorMessage': string, 291 | 'lastRefreshTime': string 292 | }; 293 | 294 | /** @see https://developers.google.com/sheets/api/reference/rest/v4/spreadsheets/sheets#DataSourceSheetProperties */ 295 | export type DataSourceSheetProperties = { 296 | 'dataSourceId': DataSourceId 297 | 'columns': DataSourceColumn[], 298 | 'dataExecutionStatus': DataExecutionStatus, 299 | }; 300 | 301 | 302 | // Spreadsheet types ///////////////////// 303 | 304 | /** @see https://developers.google.com/sheets/api/reference/rest/v4/spreadsheets#SpreadsheetProperties */ 305 | export type SpreadsheetProperties = { 306 | /** title of the spreadsheet */ 307 | title: string, 308 | /** locale of the spreadsheet (note - not all locales are supported) */ 309 | locale: LocaleCode, 310 | /** amount of time to wait before volatile functions are recalculated */ 311 | autoRecalc: RecalculationInterval, 312 | /** timezone of the sheet */ 313 | timeZone: Timezone; 314 | 315 | // TODO 316 | defaultFormat: any 317 | iterativeCalculationSettings: any, 318 | spreadsheetTheme: any 319 | }; 320 | 321 | 322 | 323 | /** @see https://developers.google.com/sheets/api/reference/rest/v4/spreadsheets/sheets#SheetProperties */ 324 | export type WorksheetProperties = { 325 | 'sheetId': WorksheetId, 326 | 'title': string, 327 | 'index': WorksheetIndex, 328 | 'sheetType': WorksheetType, 329 | 'gridProperties': WorksheetGridProperties, 330 | 'hidden': boolean, 331 | 'tabColor': Color, 332 | 'tabColorStyle': ColorStyle, 333 | 'rightToLeft': boolean, 334 | 'dataSourceSheetProperties': DataSourceSheetProperties 335 | }; 336 | export type WorksheetPropertiesPartial = { 337 | 338 | }; 339 | 340 | // Spreadsheet Cell types /////////////////// 341 | /** @see https://developers.google.com/sheets/api/reference/rest/v4/spreadsheets/cells#CellFormat */ 342 | export type CellFormat = { 343 | /** format describing how number values should be represented to the user */ 344 | numberFormat: NumberFormat, 345 | /** @deprecated use backgroundColorStyle */ 346 | backgroundColor: Color, 347 | backgroundColorStyle: ColorStyle, 348 | borders: CellBorders, 349 | padding: CellPadding, 350 | horizontalAlignment: HorizontalAlign, 351 | verticalAlignment: VerticalAlign, 352 | wrapStrategy: WrapStrategy, 353 | textDirection: TextDirection, 354 | textFormat: TextFormat 355 | hyperlinkDisplayType: HyperlinkDisplayType, 356 | textRotation: TextRotation, 357 | }; 358 | 359 | 360 | /** @see https://developers.google.com/sheets/api/reference/rest/v4/spreadsheets/cells#numberformat */ 361 | export type NumberFormat = { 362 | type: NumberFormatType; 363 | /** 364 | * pattern string used for formatting 365 | * @see https://developers.google.com/sheets/api/reference/rest/v4/spreadsheets/cells#numberformat 366 | * */ 367 | pattern: string; 368 | }; 369 | 370 | /** @see https://developers.google.com/sheets/api/reference/rest/v4/spreadsheets/sheets#GridProperties */ 371 | export type WorksheetGridProperties = { 372 | rowCount: number; 373 | columnCount: number; 374 | frozenRowCount?: number; 375 | frozenColumnCount?: number; 376 | hideGridlines?: boolean; 377 | rowGroupControlAfter?: boolean; 378 | columnGroupControlAfter?: boolean; 379 | }; 380 | 381 | // 382 | 383 | /** 384 | * 385 | * @see https://developers.google.com/sheets/api/reference/rest/v4/DimensionRange 386 | */ 387 | export type DimensionRange = { 388 | sheetId: WorksheetId, 389 | dimension: WorksheetDimension, 390 | startIndex?: Integer, 391 | endIndex?: Integer, 392 | }; 393 | export type DimensionRangeWithoutWorksheetId = Omit; 394 | 395 | /** 396 | * object describing a range in a sheet 397 | * see https://developers.google.com/sheets/api/reference/rest/v4/spreadsheets/other#GridRange 398 | * */ 399 | export type GridRange = { 400 | /** The sheet this range is on */ 401 | sheetId: WorksheetId, 402 | /** The start row (inclusive) of the range, or not set if unbounded. */ 403 | startRowIndex?: Integer, 404 | /** The end row (exclusive) of the range, or not set if unbounded. */ 405 | endRowIndex?: Integer, 406 | /** The start column (inclusive) of the range, or not set if unbounded. */ 407 | startColumnIndex?: Integer, 408 | /** The end column (exclusive) of the range, or not set if unbounded. */ 409 | endColumnIndex?: Integer 410 | }; 411 | export type GridRangeWithoutWorksheetId = Omit; 412 | export type GridRangeWithOptionalWorksheetId = MakeOptional; 413 | export type DataFilter = A1Range | GridRange; 414 | export type DataFilterWithoutWorksheetId = A1Range | GridRangeWithoutWorksheetId; 415 | 416 | 417 | /** @see https://developers.google.com/sheets/api/reference/rest/v4/spreadsheets/other#colorstyle */ 418 | export type ColorStyle = { rgbColor: Color } | { themeColor: ThemeColorType }; 419 | /** @see https://developers.google.com/sheets/api/reference/rest/v4/spreadsheets/other#Color */ 420 | export type Color = { 421 | red: number, 422 | green: number, 423 | blue: number, 424 | /** docs say alpha is not generally supported? */ 425 | alpha?: number 426 | }; 427 | 428 | 429 | /** @see https://developers.google.com/sheets/api/reference/rest/v4/ValueRenderOption */ 430 | type ValueRenderOption = 431 | /** Values will be calculated & formatted in the reply according to the cell's formatting. Formatting is based on the spreadsheet's locale, not the requesting user's locale. For example, if A1 is 1.23 and A2 is =A1 and formatted as currency, then A2 would return "$1.23". */ 432 | 'FORMATTED_VALUE' | 433 | /** Values will be calculated, but not formatted in the reply. For example, if A1 is 1.23 and A2 is =A1 and formatted as currency, then A2 would return the number 1.23. */ 434 | 'UNFORMATTED_VALUE' | 435 | /** Values will not be calculated. The reply will include the formulas. For example, if A1 is 1.23 and A2 is =A1 and formatted as currency, then A2 would return "=A1". */ 436 | 'FORMULA'; 437 | 438 | /** @see https://developers.google.com/sheets/api/reference/rest/v4/DateTimeRenderOption */ 439 | type DateTimeRenderOption = 440 | /** Instructs date, time, datetime, and duration fields to be output as doubles in "serial number" format, as popularized by Lotus 1-2-3. The whole number portion of the value (left of the decimal) counts the days since December 30th 1899. The fractional portion (right of the decimal) counts the time as a fraction of the day. For example, January 1st 1900 at noon would be 2.5, 2 because it's 2 days after December 30th 1899, and .5 because noon is half a day. February 1st 1900 at 3pm would be 33.625. This correctly treats the year 1900 as not a leap year. */ 441 | 'SERIAL_NUMBER' | 442 | /** Instructs date, time, datetime, and duration fields to be output as strings in their given number format (which depends on the spreadsheet locale). */ 443 | 'FORMATTED_STRING'; 444 | 445 | /** @see https://developers.google.com/sheets/api/reference/rest/v4/spreadsheets.values/get#query-parameters */ 446 | export type GetValuesRequestOptions = { 447 | majorDimension?: WorksheetDimension, 448 | valueRenderOption?: ValueRenderOption 449 | }; 450 | 451 | 452 | 453 | 454 | /** 455 | * Info about an error in a cell 456 | * @see https://developers.google.com/sheets/api/reference/rest/v4/spreadsheets/other#errortype 457 | */ 458 | export type ErrorValue = { 459 | type: CellValueErrorType, 460 | message: string 461 | }; 462 | 463 | /** @see https://developers.google.com/sheets/api/reference/rest/v4/spreadsheets/other#ExtendedValue */ 464 | export type ExtendedValue = 465 | { numberValue: number } | 466 | { stringValue: string } | 467 | { boolValue: boolean } | 468 | { formulaValue: string } | 469 | { errorValue: ErrorValue }; 470 | export type CellValueType = 'boolValue' | 'stringValue' | 'numberValue' | 'errorValue'; 471 | 472 | //------------------------------------ 473 | 474 | /** @see https://developers.google.com/sheets/api/reference/rest/v4/spreadsheets/cells */ 475 | export type CellData = { 476 | /** The value the user entered in the cell. e.g., 1234, 'Hello', or =NOW() Note: Dates, Times and DateTimes are represented as doubles in serial number format. */ 477 | userEnteredValue: ExtendedValue, 478 | /** The effective value of the cell. For cells with formulas, this is the calculated value. For cells with literals, this is the same as the userEnteredValue. This field is read-only. */ 479 | effectiveValue: ExtendedValue, 480 | /** The formatted value of the cell. This is the value as it's shown to the user. This field is read-only. */ 481 | formattedValue: string, 482 | 483 | /** The format the user entered for the cell. */ 484 | userEnteredFormat: CellFormat, 485 | /** The effective format being used by the cell. This includes the results of applying any conditional formatting and, if the cell contains a formula, the computed number format. If the effective format is the default format, effective format will not be written. This field is read-only. */ 486 | effectiveFormat: CellFormat, 487 | /** hyperlink in the cell if any */ 488 | hyperlink?: string, 489 | /** note on the cell */ 490 | note?: string, 491 | // textFormatRuns: [ 492 | // { 493 | // object (TextFormatRun) 494 | // } 495 | // ], 496 | // dataValidation: { 497 | // object (DataValidationRule) 498 | // }, 499 | // pivotTable: { 500 | // object (PivotTable) 501 | // }, 502 | // dataSourceTable: { 503 | // object (DataSourceTable) 504 | // }, 505 | // dataSourceFormula: { 506 | // object (DataSourceFormula) 507 | // } 508 | }; 509 | 510 | /** shape of the cell data sent back when fetching the sheet */ 511 | export type CellDataRange = { 512 | startRow?: RowIndex, 513 | startColumn?: ColumnIndex, 514 | // TODO: fix these types 515 | rowMetadata: any[], 516 | columnMetadata: any[], 517 | rowData: { 518 | values: any[] 519 | }[] 520 | }; 521 | 522 | export type AddRowOptions = { 523 | 524 | /** set to true to use raw mode rather than user entered */ 525 | raw?: boolean, 526 | /** set to true to insert new rows in the sheet while adding this data */ 527 | insert?: boolean, 528 | }; 529 | 530 | /** 531 | * @see https://developers.google.com/sheets/api/reference/rest/v4/spreadsheets/other#ConditionType 532 | */ 533 | export type ConditionType = 534 | | 'NUMBER_GREATER' 535 | | 'NUMBER_GREATER_THAN_EQ' 536 | | 'NUMBER_LESS' 537 | | 'NUMBER_LESS_THAN_EQ' 538 | | 'NUMBER_EQ' 539 | | 'NUMBER_NOT_EQ' 540 | | 'NUMBER_BETWEEN' 541 | | 'NUMBER_NOT_BETWEEN' 542 | | 'TEXT_CONTAINS' 543 | | 'TEXT_NOT_CONTAINS' 544 | | 'TEXT_STARTS_WITH' 545 | | 'TEXT_ENDS_WITH' 546 | | 'TEXT_EQ' 547 | | 'TEXT_IS_EMAIL' 548 | | 'TEXT_IS_URL' 549 | | 'DATE_EQ' 550 | | 'DATE_BEFORE' 551 | | 'DATE_AFTER' 552 | | 'DATE_ON_OR_BEFORE' 553 | | 'DATE_ON_OR_AFTER' 554 | | 'DATE_BETWEEN' 555 | | 'DATE_NOT_BETWEEN' 556 | | 'DATE_IS_VALID' 557 | | 'ONE_OF_RANGE' 558 | | 'ONE_OF_LIST' 559 | | 'BLANK' 560 | | 'NOT_BLANK' 561 | | 'CUSTOM_FORMULA' 562 | | 'BOOLEAN' 563 | | 'TEXT_NOT_EQ' 564 | | 'DATE_NOT_EQ' 565 | | 'FILTER_EXPRESSION'; 566 | 567 | /** 568 | * @see https://developers.google.com/sheets/api/reference/rest/v4/spreadsheets/other#relativedate 569 | */ 570 | export type RelativeDate = 571 | | 'PAST_YEAR' 572 | | 'PAST_MONTH' 573 | | 'PAST_WEEK' 574 | | 'YESTERDAY' 575 | | 'TODAY' 576 | | 'TOMORROW'; 577 | 578 | /** 579 | * @see https://developers.google.com/sheets/api/reference/rest/v4/spreadsheets/other#ConditionValue 580 | */ 581 | export type ConditionValue = 582 | | { relativeDate: RelativeDate, userEnteredValue?: undefined } 583 | | { relativeDate?: undefined, userEnteredValue: string }; 584 | 585 | /** 586 | * @see https://developers.google.com/sheets/api/reference/rest/v4/spreadsheets/other#BooleanCondition 587 | */ 588 | export type BooleanCondition = { 589 | /** The type of condition. */ 590 | type: ConditionType; 591 | /** 592 | * The values of the condition. 593 | * The number of supported values depends on the condition type. Some support zero values, others one or two values, and ConditionType.ONE_OF_LIST supports an arbitrary number of values. 594 | */ 595 | values: ConditionValue[]; 596 | }; 597 | 598 | /** 599 | * @see https://developers.google.com/sheets/api/reference/rest/v4/spreadsheets/cells#DataValidationRule 600 | * 601 | * example: 602 | * - https://stackoverflow.com/a/43442775/3068233 603 | */ 604 | export type DataValidationRule = { 605 | /** The condition that data in the cell must match. */ 606 | condition: BooleanCondition; 607 | /** A message to show the user when adding data to the cell. */ 608 | inputMessage?: string; 609 | /** True if invalid data should be rejected. */ 610 | strict: boolean; 611 | /** True if the UI should be customized based on the kind of condition. If true, "List" conditions will show a dropdown. */ 612 | showCustomUi: boolean; 613 | }; 614 | -------------------------------------------------------------------------------- /src/lib/GoogleSpreadsheet.ts: -------------------------------------------------------------------------------- 1 | import ky, { HTTPError, KyInstance } from 'ky'; // eslint-disable-line import/no-extraneous-dependencies 2 | import * as _ from './toolkit'; 3 | import { GoogleSpreadsheetWorksheet } from './GoogleSpreadsheetWorksheet'; 4 | import { getFieldMask } from './utils'; 5 | import { 6 | DataFilter, GridRange, NamedRangeId, SpreadsheetId, SpreadsheetProperties, WorksheetId, WorksheetProperties, 7 | } from './types/sheets-types'; 8 | import { PermissionRoles, PermissionsList, PublicPermissionRoles } from './types/drive-types'; 9 | import { RecursivePartial } from './types/util-types'; 10 | import { AUTH_MODES, GoogleApiAuth } from './types/auth-types'; 11 | 12 | 13 | const SHEETS_API_BASE_URL = 'https://sheets.googleapis.com/v4/spreadsheets'; 14 | const DRIVE_API_BASE_URL = 'https://www.googleapis.com/drive/v3/files'; 15 | 16 | const EXPORT_CONFIG: Record = { 17 | html: {}, 18 | zip: {}, 19 | xlsx: {}, 20 | ods: {}, 21 | csv: { singleWorksheet: true }, 22 | tsv: { singleWorksheet: true }, 23 | pdf: { singleWorksheet: true }, 24 | }; 25 | type ExportFileTypes = keyof typeof EXPORT_CONFIG; 26 | 27 | 28 | 29 | 30 | function getAuthMode(auth: GoogleApiAuth) { 31 | if ('getRequestHeaders' in auth) return AUTH_MODES.GOOGLE_AUTH_CLIENT; 32 | if ('token' in auth && auth.token) return AUTH_MODES.RAW_ACCESS_TOKEN; 33 | // google-auth-library now has an empty `apiKey` property 34 | if ('apiKey' in auth && auth.apiKey) return AUTH_MODES.API_KEY; 35 | throw new Error('Invalid auth'); 36 | } 37 | 38 | async function getRequestAuthConfig(auth: GoogleApiAuth): Promise<{ 39 | headers?: Record; 40 | searchParams?: Record 41 | }> { 42 | // google-auth-libary methods all can call this method to get the right headers 43 | // JWT | OAuth2Client | GoogleAuth | Impersonate | AuthClient 44 | if ('getRequestHeaders' in auth) { 45 | const headers = await auth.getRequestHeaders(); 46 | 47 | // google-auth-library v10 uses a Headers object rather than a plain object 48 | if ('entries' in headers) { 49 | return { headers: Object.fromEntries(headers.entries()) }; 50 | } if (_.isObject(headers)) { 51 | return { headers: headers as Record }; 52 | } 53 | throw new Error('unexpected headers returned from getRequestHeaders'); 54 | } 55 | 56 | // API key only access passes through the api key as a query param 57 | // (note this can only provide read-only access) 58 | if ('apiKey' in auth && auth.apiKey) { 59 | return { searchParams: { key: auth.apiKey } }; 60 | } 61 | 62 | // RAW ACCESS TOKEN 63 | if ('token' in auth && auth.token) { 64 | return { headers: { Authorization: `Bearer ${auth.token}` } }; 65 | } 66 | 67 | throw new Error('Invalid auth'); 68 | } 69 | 70 | /** 71 | * Google Sheets document 72 | * 73 | * @description 74 | * **This class represents an entire google spreadsheet document** 75 | * 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 76 | * 77 | */ 78 | export class GoogleSpreadsheet { 79 | readonly spreadsheetId: string; 80 | 81 | public auth: GoogleApiAuth; 82 | get authMode() { 83 | return getAuthMode(this.auth); 84 | } 85 | 86 | private _rawSheets: any; 87 | private _rawProperties = null as SpreadsheetProperties | null; 88 | private _spreadsheetUrl = null as string | null; 89 | private _deleted = false; 90 | 91 | /** 92 | * Sheets API [ky](https://github.com/sindresorhus/ky?tab=readme-ov-file#kycreatedefaultoptions) instance 93 | * authentication is automatically attached 94 | * can be used if unsupported sheets calls need to be made 95 | * @see https://developers.google.com/sheets/api/reference/rest 96 | * */ 97 | readonly sheetsApi: KyInstance; 98 | 99 | /** 100 | * Drive API [ky](https://github.com/sindresorhus/ky?tab=readme-ov-file#kycreatedefaultoptions) instance 101 | * authentication automatically attached 102 | * can be used if unsupported drive calls need to be made 103 | * @topic permissions 104 | * @see https://developers.google.com/drive/api/v3/reference 105 | * */ 106 | readonly driveApi: KyInstance; 107 | 108 | 109 | /** 110 | * initialize new GoogleSpreadsheet 111 | * @category Initialization 112 | * */ 113 | constructor( 114 | /** id of google spreadsheet doc */ 115 | spreadsheetId: SpreadsheetId, 116 | /** authentication to use with Google Sheets API */ 117 | auth: GoogleApiAuth 118 | ) { 119 | this.spreadsheetId = spreadsheetId; 120 | this.auth = auth; 121 | 122 | this._rawSheets = {}; 123 | this._spreadsheetUrl = null; 124 | 125 | // create a ky instance with sheet root URL and hooks to handle auth 126 | this.sheetsApi = ky.create({ 127 | prefixUrl: `${SHEETS_API_BASE_URL}/${spreadsheetId}`, 128 | hooks: { 129 | beforeRequest: [(r) => this._setAuthRequestHook(r)], 130 | beforeError: [(e) => this._errorHook(e)], 131 | }, 132 | }); 133 | this.driveApi = ky.create({ 134 | prefixUrl: `${DRIVE_API_BASE_URL}/${spreadsheetId}`, 135 | hooks: { 136 | beforeRequest: [(r) => this._setAuthRequestHook(r)], 137 | beforeError: [(e) => this._errorHook(e)], 138 | }, 139 | }); 140 | } 141 | 142 | 143 | // INTERNAL UTILITY FUNCTIONS //////////////////////////////////////////////////////////////////// 144 | 145 | /** @internal */ 146 | async _setAuthRequestHook(req: Request) { 147 | const authConfig = await getRequestAuthConfig(this.auth); 148 | if (authConfig.headers) { 149 | Object.entries(authConfig.headers).forEach(([key, val]) => { 150 | req.headers.set(key, String(val)); 151 | }); 152 | } 153 | 154 | if (authConfig.searchParams) { 155 | const url = new URL(req.url); 156 | Object.entries(authConfig.searchParams).forEach(([key, val]) => { 157 | url.searchParams.set(key, String(val)); 158 | }); 159 | // cannot change the URL with ky, so have to return a new request 160 | return new Request(url, req); 161 | } 162 | 163 | return req; 164 | } 165 | 166 | /** @internal */ 167 | async _errorHook(error: HTTPError) { 168 | const { response } = error; 169 | const errorDataText = await response?.text(); 170 | let errorData; 171 | try { 172 | errorData = JSON.parse(errorDataText); 173 | } catch (e) { 174 | // console.log('parsing json failed', errorDataText); 175 | } 176 | 177 | if (errorData) { 178 | // usually the error has a code and message, but occasionally not 179 | if (!errorData.error) return error; 180 | 181 | const { code, message } = errorData.error; 182 | error.message = `Google API error - [${code}] ${message}`; 183 | return error; 184 | } 185 | 186 | if (_.get(error, 'response.status') === 403) { 187 | if ('apiKey' in this.auth) { 188 | 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)'); 189 | } 190 | } 191 | return error; 192 | } 193 | 194 | /** @internal */ 195 | async _makeSingleUpdateRequest(requestType: string, requestParams: any) { 196 | const response = await this.sheetsApi.post(':batchUpdate', { 197 | json: { 198 | requests: [{ [requestType]: requestParams }], 199 | includeSpreadsheetInResponse: true, 200 | // responseRanges: [string] 201 | // responseIncludeGridData: true 202 | }, 203 | }); 204 | const data = await response.json(); 205 | 206 | this._updateRawProperties(data.updatedSpreadsheet.properties); 207 | _.each(data.updatedSpreadsheet.sheets, (s: any) => this._updateOrCreateSheet(s)); 208 | // console.log('API RESPONSE', response.data.replies[0][requestType]); 209 | return data.replies[0][requestType]; 210 | } 211 | 212 | // TODO: review these types 213 | // currently only used in batching cell updates 214 | /** @internal */ 215 | async _makeBatchUpdateRequest(requests: any[], responseRanges?: string | string[]) { 216 | // this is used for updating batches of cells 217 | const response = await this.sheetsApi.post(':batchUpdate', { 218 | json: { 219 | requests, 220 | includeSpreadsheetInResponse: true, 221 | ...responseRanges && { 222 | responseIncludeGridData: true, 223 | ...responseRanges !== '*' && { responseRanges }, 224 | }, 225 | }, 226 | }); 227 | 228 | const data = await response.json(); 229 | this._updateRawProperties(data.updatedSpreadsheet.properties); 230 | _.each(data.updatedSpreadsheet.sheets, (s: any) => this._updateOrCreateSheet(s)); 231 | } 232 | 233 | /** @internal */ 234 | _ensureInfoLoaded() { 235 | if (!this._rawProperties) throw new Error('You must call `doc.loadInfo()` before accessing this property'); 236 | } 237 | 238 | /** @internal */ 239 | _updateRawProperties(newProperties: SpreadsheetProperties) { this._rawProperties = newProperties; } 240 | 241 | /** @internal */ 242 | _updateOrCreateSheet(sheetInfo: { properties: WorksheetProperties, data: any }) { 243 | const { properties, data } = sheetInfo; 244 | const { sheetId } = properties; 245 | if (!this._rawSheets[sheetId]) { 246 | this._rawSheets[sheetId] = new GoogleSpreadsheetWorksheet(this, properties, data); 247 | } else { 248 | this._rawSheets[sheetId].updateRawData(properties, data); 249 | } 250 | } 251 | 252 | // BASIC PROPS ////////////////////////////////////////////////////////////////////////////// 253 | _getProp(param: keyof SpreadsheetProperties) { 254 | this._ensureInfoLoaded(); 255 | // ideally ensureInfoLoaded would assert that _rawProperties is in fact loaded 256 | // but this is not currently possible in TS - see https://github.com/microsoft/TypeScript/issues/49709 257 | return this._rawProperties![param]; 258 | } 259 | 260 | get title(): SpreadsheetProperties['title'] { return this._getProp('title'); } 261 | get locale(): SpreadsheetProperties['locale'] { return this._getProp('locale'); } 262 | get timeZone(): SpreadsheetProperties['timeZone'] { return this._getProp('timeZone'); } 263 | get autoRecalc(): SpreadsheetProperties['autoRecalc'] { return this._getProp('autoRecalc'); } 264 | get defaultFormat(): SpreadsheetProperties['defaultFormat'] { return this._getProp('defaultFormat'); } 265 | get spreadsheetTheme(): SpreadsheetProperties['spreadsheetTheme'] { return this._getProp('spreadsheetTheme'); } 266 | get iterativeCalculationSettings(): SpreadsheetProperties['iterativeCalculationSettings'] { return this._getProp('iterativeCalculationSettings'); } 267 | 268 | /** 269 | * update spreadsheet properties 270 | * @see https://developers.google.com/sheets/api/reference/rest/v4/spreadsheets#SpreadsheetProperties 271 | * */ 272 | async updateProperties(properties: Partial) { 273 | await this._makeSingleUpdateRequest('updateSpreadsheetProperties', { 274 | properties, 275 | fields: getFieldMask(properties), 276 | }); 277 | } 278 | 279 | // BASIC INFO //////////////////////////////////////////////////////////////////////////////////// 280 | async loadInfo(includeCells = false) { 281 | const response = await this.sheetsApi.get('', { 282 | searchParams: { 283 | ...includeCells && { includeGridData: true }, 284 | }, 285 | }); 286 | const data = await response.json(); 287 | this._spreadsheetUrl = data.spreadsheetUrl; 288 | this._rawProperties = data.properties; 289 | data.sheets?.forEach((s: any) => this._updateOrCreateSheet(s)); 290 | } 291 | 292 | resetLocalCache() { 293 | this._rawProperties = null; 294 | this._rawSheets = {}; 295 | } 296 | 297 | // WORKSHEETS //////////////////////////////////////////////////////////////////////////////////// 298 | get sheetCount() { 299 | this._ensureInfoLoaded(); 300 | return _.values(this._rawSheets).length; 301 | } 302 | 303 | get sheetsById(): Record { 304 | this._ensureInfoLoaded(); 305 | return this._rawSheets; 306 | } 307 | 308 | get sheetsByIndex(): GoogleSpreadsheetWorksheet[] { 309 | this._ensureInfoLoaded(); 310 | return _.sortBy(this._rawSheets, 'index'); 311 | } 312 | 313 | get sheetsByTitle(): Record { 314 | this._ensureInfoLoaded(); 315 | return _.keyBy(this._rawSheets, 'title'); 316 | } 317 | 318 | /** 319 | * Add new worksheet to document 320 | * @see https://developers.google.com/sheets/api/reference/rest/v4/spreadsheets/request#AddSheetRequest 321 | * */ 322 | async addSheet( 323 | properties: Partial< 324 | RecursivePartial 325 | & { 326 | headerValues: string[], 327 | headerRowIndex: number 328 | } 329 | > = {} 330 | ) { 331 | const response = await this._makeSingleUpdateRequest('addSheet', { 332 | properties: _.omit(properties, 'headerValues', 'headerRowIndex'), 333 | }); 334 | // _makeSingleUpdateRequest already adds the sheet 335 | const newSheetId = response.properties.sheetId; 336 | const newSheet = this.sheetsById[newSheetId]; 337 | 338 | if (properties.headerValues) { 339 | await newSheet.setHeaderRow(properties.headerValues, properties.headerRowIndex); 340 | } 341 | 342 | return newSheet; 343 | } 344 | 345 | /** 346 | * delete a worksheet 347 | * @see https://developers.google.com/sheets/api/reference/rest/v4/spreadsheets/request#DeleteSheetRequest 348 | * */ 349 | async deleteSheet(sheetId: WorksheetId) { 350 | await this._makeSingleUpdateRequest('deleteSheet', { sheetId }); 351 | delete this._rawSheets[sheetId]; 352 | } 353 | 354 | // NAMED RANGES ////////////////////////////////////////////////////////////////////////////////// 355 | 356 | /** 357 | * create a new named range 358 | * @see https://developers.google.com/sheets/api/reference/rest/v4/spreadsheets/request#AddNamedRangeRequest 359 | */ 360 | async addNamedRange( 361 | /** name of new named range */ 362 | name: string, 363 | /** GridRange object describing range */ 364 | range: GridRange, 365 | /** id for named range (optional) */ 366 | namedRangeId?: string 367 | ) { 368 | // TODO: add named range to local cache 369 | return this._makeSingleUpdateRequest('addNamedRange', { 370 | name, 371 | namedRangeId, 372 | range, 373 | }); 374 | } 375 | 376 | /** 377 | * delete a named range 378 | * @see https://developers.google.com/sheets/api/reference/rest/v4/spreadsheets/request#DeleteNamedRangeRequest 379 | * */ 380 | async deleteNamedRange( 381 | /** id of named range to delete */ 382 | namedRangeId: NamedRangeId 383 | ) { 384 | // TODO: remove named range from local cache 385 | return this._makeSingleUpdateRequest('deleteNamedRange', { namedRangeId }); 386 | } 387 | 388 | // LOADING CELLS ///////////////////////////////////////////////////////////////////////////////// 389 | 390 | /** fetch cell data into local cache */ 391 | async loadCells( 392 | /** 393 | * single filter or array of filters 394 | * strings are treated as A1 ranges, objects are treated as GridRange objects 395 | * pass nothing to fetch all cells 396 | * */ 397 | filters?: DataFilter | DataFilter[] 398 | ) { 399 | // TODO: make it support DeveloperMetadataLookup objects 400 | 401 | 402 | 403 | // TODO: switch to this mode if using a read-only auth token? 404 | const readOnlyMode = this.authMode === AUTH_MODES.API_KEY; 405 | 406 | const filtersArray = _.isArray(filters) ? filters : [filters]; 407 | const dataFilters = _.map(filtersArray, (filter) => { 408 | if (_.isString(filter)) { 409 | return readOnlyMode ? filter : { a1Range: filter }; 410 | } 411 | if (_.isObject(filter)) { 412 | if (readOnlyMode) { 413 | throw new Error('Only A1 ranges are supported when fetching cells with read-only access (using only an API key)'); 414 | } 415 | // TODO: make this support Developer Metadata filters 416 | return { gridRange: filter }; 417 | } 418 | throw new Error('Each filter must be an A1 range string or a gridrange object'); 419 | }); 420 | 421 | let result; 422 | // when using an API key only, we must use the regular get endpoint 423 | // because :getByDataFilter requires higher access 424 | if (this.authMode === AUTH_MODES.API_KEY) { 425 | const params = new URLSearchParams(); 426 | params.append('includeGridData', 'true'); 427 | dataFilters.forEach((singleFilter) => { 428 | if (!_.isString(singleFilter)) { 429 | throw new Error('Only A1 ranges are supported when fetching cells with read-only access (using only an API key)'); 430 | } 431 | params.append('ranges', singleFilter); 432 | }); 433 | result = await this.sheetsApi.get('', { 434 | searchParams: params, 435 | }); 436 | // otherwise we use the getByDataFilter endpoint because it is more flexible 437 | } else { 438 | result = await this.sheetsApi.post(':getByDataFilter', { 439 | json: { 440 | includeGridData: true, 441 | dataFilters, 442 | }, 443 | }); 444 | } 445 | 446 | const data = await result?.json(); 447 | _.each(data.sheets, (sheet: any) => { this._updateOrCreateSheet(sheet); }); 448 | } 449 | 450 | // EXPORTING ///////////////////////////////////////////////////////////// 451 | 452 | /** 453 | * export/download helper, not meant to be called directly (use downloadAsX methods on spreadsheet and worksheet instead) 454 | * @internal 455 | */ 456 | async _downloadAs( 457 | fileType: ExportFileTypes, 458 | worksheetId: WorksheetId | undefined, 459 | returnStreamInsteadOfBuffer?: boolean 460 | ) { 461 | // see https://stackoverflow.com/questions/11619805/using-the-google-drive-api-to-download-a-spreadsheet-in-csv-format/51235960#51235960 462 | 463 | if (!EXPORT_CONFIG[fileType]) throw new Error(`unsupported export fileType - ${fileType}`); 464 | if (EXPORT_CONFIG[fileType].singleWorksheet) { 465 | if (worksheetId === undefined) throw new Error(`Must specify worksheetId when exporting as ${fileType}`); 466 | } else if (worksheetId) throw new Error(`Cannot specify worksheetId when exporting as ${fileType}`); 467 | 468 | // google UI shows "html" but passes through "zip" 469 | if (fileType === 'html') fileType = 'zip'; 470 | 471 | if (!this._spreadsheetUrl) throw new Error('Cannot export sheet that is not fully loaded'); 472 | 473 | const exportUrl = this._spreadsheetUrl.replace('edit', 'export'); 474 | const response = await this.sheetsApi.get(exportUrl, { 475 | prefixUrl: '', // unset baseUrl since we're not hitting the normal sheets API 476 | searchParams: { 477 | id: this.spreadsheetId, 478 | format: fileType, 479 | // worksheetId can be 0 480 | ...worksheetId !== undefined && { gid: worksheetId }, 481 | }, 482 | }); 483 | if (returnStreamInsteadOfBuffer) { 484 | return response.body; 485 | } 486 | return response.arrayBuffer(); 487 | } 488 | 489 | /** 490 | * exports entire document as html file (zipped) 491 | * @topic export 492 | * */ 493 | async downloadAsZippedHTML(): Promise; 494 | async downloadAsZippedHTML(returnStreamInsteadOfBuffer: false): Promise; 495 | async downloadAsZippedHTML(returnStreamInsteadOfBuffer: true): Promise; 496 | async downloadAsZippedHTML(returnStreamInsteadOfBuffer?: boolean) { 497 | return this._downloadAs('html', undefined, returnStreamInsteadOfBuffer); 498 | } 499 | 500 | /** 501 | * @deprecated 502 | * use `doc.downloadAsZippedHTML()` instead 503 | * */ 504 | async downloadAsHTML(returnStreamInsteadOfBuffer?: boolean) { 505 | return this._downloadAs('html', undefined, returnStreamInsteadOfBuffer); 506 | } 507 | 508 | /** 509 | * exports entire document as xlsx spreadsheet (Microsoft Office Excel) 510 | * @topic export 511 | * */ 512 | async downloadAsXLSX(): Promise; 513 | async downloadAsXLSX(returnStreamInsteadOfBuffer: false): Promise; 514 | async downloadAsXLSX(returnStreamInsteadOfBuffer: true): Promise; 515 | async downloadAsXLSX(returnStreamInsteadOfBuffer = false) { 516 | return this._downloadAs('xlsx', undefined, returnStreamInsteadOfBuffer); 517 | } 518 | /** 519 | * exports entire document as ods spreadsheet (Open Office) 520 | * @topic export 521 | */ 522 | async downloadAsODS(): Promise; 523 | async downloadAsODS(returnStreamInsteadOfBuffer: false): Promise; 524 | async downloadAsODS(returnStreamInsteadOfBuffer: true): Promise; 525 | async downloadAsODS(returnStreamInsteadOfBuffer = false) { 526 | return this._downloadAs('ods', undefined, returnStreamInsteadOfBuffer); 527 | } 528 | 529 | 530 | async delete() { 531 | await this.driveApi.delete(''); 532 | this._deleted = true; 533 | // endpoint returns nothing when successful 534 | } 535 | 536 | // PERMISSIONS /////////////////////////////////////////////////////////////////////////////////// 537 | 538 | /** 539 | * list all permissions entries for doc 540 | */ 541 | async listPermissions(): Promise { 542 | const listReq = await this.driveApi.get('permissions', { 543 | searchParams: { 544 | fields: 'permissions(id,type,emailAddress,domain,role,displayName,photoLink,deleted)', 545 | }, 546 | }); 547 | const data = await listReq.json<{ permissions: PermissionsList }>(); 548 | return data.permissions; 549 | } 550 | 551 | async setPublicAccessLevel(role: PublicPermissionRoles | false) { 552 | const permissions = await this.listPermissions(); 553 | const existingPublicPermission = _.find(permissions, (p) => p.type === 'anyone'); 554 | 555 | if (role === false) { 556 | if (!existingPublicPermission) { 557 | // doc is already not public... could throw an error or just do nothing 558 | return; 559 | } 560 | await this.driveApi.delete(`permissions/${existingPublicPermission.id}`); 561 | } else { 562 | const _shareReq = await this.driveApi.post('permissions', { 563 | json: { 564 | role: role || 'viewer', 565 | type: 'anyone', 566 | }, 567 | }); 568 | } 569 | } 570 | 571 | /** share document to email or domain */ 572 | async share(emailAddressOrDomain: string, opts?: { 573 | /** set role level, defaults to owner */ 574 | role?: PermissionRoles, 575 | 576 | /** set to true if email is for a group */ 577 | isGroup?: boolean, 578 | 579 | /** set to string to include a custom message, set to false to skip sending a notification altogether */ 580 | emailMessage?: string | false, 581 | 582 | // moveToNewOwnersRoot?: string, 583 | // /** send a notification email (default = true) */ 584 | // sendNotificationEmail?: boolean, 585 | // /** support My Drives and shared drives (default = false) */ 586 | // supportsAllDrives?: boolean, 587 | 588 | // /** Issue the request as a domain administrator */ 589 | // useDomainAdminAccess?: boolean, 590 | }) { 591 | let emailAddress: string | undefined; 592 | let domain: string | undefined; 593 | if (emailAddressOrDomain.includes('@')) { 594 | emailAddress = emailAddressOrDomain; 595 | } else { 596 | domain = emailAddressOrDomain; 597 | } 598 | 599 | 600 | const shareReq = await this.driveApi.post('permissions', { 601 | searchParams: { 602 | ...opts?.emailMessage === false && { sendNotificationEmail: false }, 603 | ..._.isString(opts?.emailMessage) && { emailMessage: opts?.emailMessage }, 604 | ...opts?.role === 'owner' && { transferOwnership: true }, 605 | }, 606 | json: { 607 | role: opts?.role || 'writer', 608 | ...emailAddress && { 609 | type: opts?.isGroup ? 'group' : 'user', 610 | emailAddress, 611 | }, 612 | ...domain && { 613 | type: 'domain', 614 | domain, 615 | }, 616 | }, 617 | }); 618 | 619 | return shareReq.json(); 620 | } 621 | 622 | // 623 | // CREATE NEW DOC //////////////////////////////////////////////////////////////////////////////// 624 | static async createNewSpreadsheetDocument(auth: GoogleApiAuth, properties?: Partial) { 625 | // see updateProperties for more info about available properties 626 | 627 | if (getAuthMode(auth) === AUTH_MODES.API_KEY) { 628 | throw new Error('Cannot use api key only to create a new spreadsheet - it is only usable for read-only access of public docs'); 629 | } 630 | 631 | // TODO: handle injecting default credentials if running on google infra 632 | 633 | const authConfig = await getRequestAuthConfig(auth); 634 | 635 | const response = await ky.post(SHEETS_API_BASE_URL, { 636 | ...authConfig, // has the auth header 637 | json: { 638 | properties, 639 | }, 640 | }); 641 | 642 | const data = await response.json(); 643 | const newSpreadsheet = new GoogleSpreadsheet(data.spreadsheetId, auth); 644 | 645 | // TODO ideally these things aren't public, might want to refactor anyway 646 | newSpreadsheet._spreadsheetUrl = data.spreadsheetUrl; 647 | newSpreadsheet._rawProperties = data.properties; 648 | _.each(data.sheets, (s: any) => newSpreadsheet._updateOrCreateSheet(s)); 649 | 650 | return newSpreadsheet; 651 | } 652 | } 653 | --------------------------------------------------------------------------------