Spreadsheets
Read a Google Sheet, write catalogs into one, and work with A1 ranges without writing the parsing
yourself. TowerSheets handles the reading and writing; you supply the transport, so the
same code runs against the Google API, a service account, a cache, or a fixture in a test.
A1 Ranges
The range helpers are standalone and need no client at all — useful whenever you are building ranges by hand.
import {
buildA1Range,
parseA1RangeToCoordinates,
columnIndexToLabel,
columnLabelToIndex,
sheetNameFromRange,
a1RangeCellCount,
splitA1Range
} from 'thetowersdk/sheets'
// Coordinates are zero-based; the A1 string is not.
buildA1Range({ startRow: 0, startCol: 0, endRow: 9, endCol: 2 }, 'My Sheet')
// "'My Sheet'!A1:C10" — the tab name is quoted for you
parseA1RangeToCoordinates('A1:C10')
// { startRow: 0, startCol: 0, endRow: 9, endCol: 2 }
columnIndexToLabel(27) // 'AB'
columnLabelToIndex('AB') // 27
sheetNameFromRange("'My Sheet'!A1:C10") // 'My Sheet'
a1RangeCellCount('A1:C10') // 30Splitting A Large Range
The Sheets API rejects a request whose range is too large. splitA1Range cuts one range
into row-wise chunks of at most the cell count you give it, and every chunk is still a rectangle, so
the pieces reassemble by concatenation.
splitA1Range("'My Sheet'!A1:C10", 20)
// [ "'My Sheet'!A1:C6", "'My Sheet'!A7:C10" ]
// Read a whole tab in pieces and join them back together.
const rows = []
for (const chunk of splitA1Range(fullRange, 50_000)) {
rows.push(...(await sheets.readValues(chunk)))
}Connect A Sheet
A transport is any object with a readValues method, plus writeValues if you
want to write. That is the whole interface — the client never imports a Google library, so nothing about
your auth is assumed.
import { TowerSheets } from 'thetowersdk/sheets'
const sheets = new TowerSheets({
spreadsheetId: '1YwZtKP6B4WYhRba5T6APJ1YxKNdfnIGQnprgnxmO7zc',
transport: {
async readValues({ spreadsheetId, range, valueRenderOption }) {
const url =
`https://sheets.googleapis.com/v4/spreadsheets/${spreadsheetId}` +
`/values/${encodeURIComponent(range)}?valueRenderOption=${valueRenderOption}`
const response = await fetch(url, {
headers: { authorization: `Bearer ${accessToken}` }
})
const payload = await response.json()
return payload.values ?? []
}
},
// Sheets listed here refuse writes. Include any community workbook you read.
protectSpreadsheets: ['1YwZtKP6B4WYhRba5T6APJ1YxKNdfnIGQnprgnxmO7zc']
})
console.log(sheets.isReadOnly) // true, because that id is protectedConnecting With A Google Service Account
The transport above needs a token. For a tool that runs unattended — a bot, a sync job, a build step — the identity to use is a service account: a robot with its own email address, which reaches exactly the sheets you share with it and nothing else. No OAuth screen and no user to keep signed in.
- In the Google Cloud console, create or pick a project.
- Enable the Google Sheets API for it.
- IAM & Admin → Service Accounts → Create. Access is granted per sheet by sharing, so it needs no project roles at all.
- Keys → Add key → Create new key → JSON. It downloads once. Treat it as a password and keep it out of version control.
- Copy the account's email — it looks like
something@project-id.iam.gserviceaccount.com. - Share the spreadsheet with that email. Viewer is enough to read. Editor is only needed if you write.
Then mint a token the way you would for any Google API and hand it to the transport. The SDK never
imports a Google library, so this stays entirely your choice — google-auth-library is the usual one.
import { GoogleAuth } from 'google-auth-library'
import { TowerSheets } from 'thetowersdk/sheets'
const auth = new GoogleAuth({
keyFile: process.env.GOOGLE_APPLICATION_CREDENTIALS,
scopes: ['https://www.googleapis.com/auth/spreadsheets.readonly']
})
const client = await auth.getClient()
const sheets = new TowerSheets({
spreadsheetId: process.env.SHEET_ID,
transport: {
async readValues({ spreadsheetId, range, valueRenderOption }) {
const url =
`https://sheets.googleapis.com/v4/spreadsheets/${spreadsheetId}` +
`/values/${encodeURIComponent(range)}?valueRenderOption=${valueRenderOption}`
const { data } = await client.request({ url })
return data.values ?? []
}
}
})An unshared sheet reads as an empty range, not an error. That is the single most common way this looks broken: the call succeeds, the rows come back empty, and nothing says why. If a read returns nothing, check the sharing before you check anything else.
Read Values
readValues gives you what the API returned. readGrid gives you a
rectangle of exactly the size you asked for, so rows[3][7] is the cell you meant even when
the sheet's last columns are blank.
// Ragged: trailing empty cells are simply absent.
const raw = await sheets.readValues("'Labs'!A1:H50")
// Rectangular: always 50 rows of 8, padded where the sheet is empty.
const grid = await sheets.readGrid("'Labs'!A1:H50")
console.log(grid.length, grid[0].length) // 50 8Read Formulas
readFormulas returns what was typed into each cell alongside a count of how many cells
hold a value and how many hold a formula of their own — which is how you tell a column of typed formulas
from a single formula spilling across a range.
const result = await sheets.readFormulas("'Costs'!B2:B200")
console.log(result.formulas[0]) // '=A2*1.15' — what was typed
console.log(result.cellsWithValues) // 199
console.log(result.cellsWithFormulas) // 1
console.log(result.likelySpilled) // true — one formula filling the range
console.log(result.notes)Write A Catalog Into A Sheet
writeValues takes rows. Pass 'USER_ENTERED' when the strings are
formulas you want the sheet to evaluate, and 'RAW' when they are literal values.
import { LAB_CATALOG } from 'thetowersdk/data'
const lab = LAB_CATALOG.find((entry) => entry.name === 'Attack Speed')
const rows = [
['Level', 'Coins', 'Research time'],
...lab.levels.map((level) => [level.level, level.cost, level.duration])
]
await sheets.writeValues("'Attack Speed'!A1", rows, 'RAW')Writing formulas instead of values keeps the sheet live for whoever opens it — they can change an input and watch the totals move.
const withFormulas = lab.levels.map((level, index) => {
const row = index + 2
return [level.level, level.cost, `=B${row}*(1-$F$1)`]
})
await sheets.writeValues("'Attack Speed'!A2", withFormulas, 'USER_ENTERED')Test Without A Network
Because the transport is yours, a fixture is a transport too — so the code that reads your live sheet is the code your tests exercise.
const fixture = new TowerSheets({
spreadsheetId: 'test',
transport: {
async readValues() {
return [
['Level', 'Coins'],
[1, 30],
[2, 60]
]
}
}
})
const grid = await fixture.readGrid('A1:B3')