taktik-simple-spreadsheet-reader
Version:
Simple reader for public google spreadsheet
428 lines (394 loc) • 12.4 kB
text/typescript
import { Request, HttpClient, newHttpClient, Response, } from 'typescript-http-client'
interface IRGBColors {
red: number
green: number
blue: number
}
interface ISheetsProperties {
properties: {
autoRecalc: string
defaultFormat: {
backgroundColor: IRGBColors
backgroundColorStyle: IRGBColors
padding: { bottom: number; top: number; right: number; left: number }
textFormat: {
bold: boolean
fontFamily: string
fontSize: number
foregroundColor?: IRGBColors
foregroundColorStyle: { rgbColor?: IRGBColors }
static: boolean
strikethrough: boolean
underline: boolean
}
verticalAlignment: string
wrapStrategy: string
}
locale: string
spreadsheetTheme: {
primaryFontFamily: string
themeColors: Array<{
colorType: string
color: { rgbColor?: IRGBColors }
}>
}
timeZone: string
title: string
}
sheets: Array<{
properties: {
gridProperties: { rowCount: number; columnCount: number }
index: number
sheetId: number
sheetType: string
title: string
}
}>
spreadsheetId: string
spreadsheetUrl: string
}
interface ISheetValue {
majorDimension: string
range: string
values?: Array<Array<string>>
}
type ICell = { cell: string; value: string }
type IParsedCells = Array<ICell>
export type SpredsheedCell = {
cell: string
value: string
rows: string
coll: string
}
interface ISheetsData {
parsedCells?: IParsedCells
cellsList?: Array<SpredsheedCell>
maxRow?: number
maxColl?: string
}
/**
* A simple reader for a Google spreadsheet publish on web.
*/
export class SpreadsheetReader {
private readonly apiKey: string
private sheetsProperties?: ISheetsProperties
private _currentPage = 0
private sheetsData: ISheetsData[] = []
private readonly spreadsheetsId?: string
private httpClient: HttpClient
private _xmlError?: string
/**
* gets the current page, indexed at 0
*/
get currentPage(): number {
return this._currentPage
}
/**
* sets the current page, indexed at 0
*/
set currentPage(page: number) {
if (page >= 0 && page < this.numberOfPages) {
this._currentPage = page
} else {
throw Error(`The new page value should be included in [0;${this.numberOfPages}[`)
}
}
/**
* get the total number of pages the sheet has
*/
get numberOfPages(): number {
if (this.sheetsData.length > 0) {
return this.sheetsData.length
}
throw Error('No data, call loadSpreadsheetData first')
}
/**
* XML string of the error message
*/
get xmlError(): string | undefined {
return this._xmlError
}
/**
* get parsed cells
*/
get parsedCells(): IParsedCells {
const parsedCells = this.sheetsData[this.currentPage].parsedCells
if (parsedCells) {
return parsedCells
}
throw Error('No data, call loadSpreadsheetData first')
}
/**
* List od cells loaded from google spreadsheet
*/
get cellsList(): Array<SpredsheedCell> {
const cellsList = this.sheetsData[this.currentPage].cellsList
if (cellsList) {
return cellsList
}
throw Error('No data, call loadSpreadsheetData first')
}
/**
* get the number of raw used in the spreadsheet
*/
get maxRow(): number {
const maxRow = this.sheetsData[this.currentPage].maxRow
if (maxRow) {
return maxRow
}
throw Error('No data, call loadSpreadsheetData first')
}
/**
* get the number of column used in the spreadsheet.
*/
get maxColl(): string {
const maxColl = this.sheetsData[this.currentPage].maxColl
if (maxColl) {
return maxColl
}
throw Error('No data, call loadSpreadsheetData first')
}
constructor(spreadsheetsUrlOrId: string, apiKey: string) {
this.apiKey = apiKey
this.httpClient = newHttpClient()
try {
const url = new URL(spreadsheetsUrlOrId)
const parsed = /spreadsheets\/\w\/(.*)\//.exec(url.pathname)
if (parsed) {
this.spreadsheetsId = parsed[1]
}
} catch (e) {
this.spreadsheetsId = spreadsheetsUrlOrId
}
}
private processSpreadsheet(parsedCells: IParsedCells): ISheetsData {
const cellsList = parsedCells.map((elem) => {
const parcedCell = /([A-Z]+)([0-9]+)/.exec(elem.cell)
if (parcedCell === null) throw Error('Error in spredsheet format')
const [cellId, coll, rows] = parcedCell
return { rows, coll, cellId, ...elem }
})
const maxRow = cellsList.reduce((highestRow, nextValue) => {
const currentRow = Number(nextValue.rows)
if (currentRow > highestRow) {
return currentRow
}
return highestRow
}, 0)
const maxColl = cellsList.reduce((highestColl, { coll }) => {
// parseInt(coll, 36) parses the letters as a number, which can then be compared to each other to define which is the "highest" column letter
if (parseInt(coll, 36) > parseInt(highestColl, 36)) {
return coll
}
return highestColl
}, 'A')
return { cellsList, parsedCells, maxRow, maxColl }
}
private static getColumnLettersFromIndex(index: number): string {
// After the letter Z (index >= 26), the letter go back again from AA, AB, AC, ...
if (index >= 26) {
const firstLetterIndex = Math.floor(index / 26) - 1
const secondLetterIndex = index % 26
return `${this.getColumnLettersFromIndex(firstLetterIndex)}${this.getColumnLettersFromIndex(
secondLetterIndex
)}`
}
return String.fromCharCode(index + 65)
}
/*
* Function to parse the array we receive from google API into an array of objects containing the cell name alongside the cell value
* [["cellValue1", "cellValue2"]] => [{cell: "A1", value: "cellValue1"}, {cell: "B2", value: "cellValue2"}]
* This function is made to process results from a request using majorDimension=ROWS (default value for majorDimension on the get/value request)
* If the request is made with another value for majorDimension, this function will break
*/
private parseSheetValues(sheetValues: Array<Array<string>>): ISheetsData {
const parsedValues: IParsedCells = sheetValues.flatMap((row, rowIndex) =>
row.map((cellValue, columnIndex) => ({
cell: `${SpreadsheetReader.getColumnLettersFromIndex(columnIndex)}${rowIndex + 1}`,
value: cellValue,
}))
)
return this.processSpreadsheet(parsedValues)
}
/**
* Load spreadsheet cells values
*/
async loadSpreadsheetData(): Promise<void> {
try {
const sheetPropertiesUrl = `https://sheets.googleapis.com/v4/spreadsheets/${this.spreadsheetsId}?key=${this.apiKey}`
const sheetPropertiesRequest = new Request(sheetPropertiesUrl, {
method: 'GET',
contentType: 'application/json',
})
// We are interested in the sheetsProperties.sheets to know the number of sheets and their names as it is
// necessary for the next request on /v4/spreadsheets/{sheetId}/values/{sheetName}
this.sheetsProperties = await this.httpClient.execute<ISheetsProperties>(
sheetPropertiesRequest
)
if (this.sheetsProperties) {
const sheets = await Promise.all(
this.sheetsProperties.sheets.map(async ({ properties: { title } }) => {
// The function parseSheetValues is made to parse the format majorDimension=ROWS, if that value changes, the function will break.
// https://developers.google.com/sheets/api/reference/rest/v4/spreadsheets.values/get
const valuesUrl = `https://sheets.googleapis.com/v4/spreadsheets/${this.spreadsheetsId}/values/${title}?majorDimension=ROWS&key=${this.apiKey}`
const valuesRequest = new Request(valuesUrl, {
method: 'GET',
contentType: 'application/json',
})
const sheet = await this.httpClient.execute<ISheetValue>(valuesRequest)
// If sheet.value doesnt exists it means the sheet is empty, we should not display it.
// We can't know before we fetch the sheets data, so the promise will resolve to undefined and we filter
// that out after.
if (!sheet.values) {
return undefined
}
return this.parseSheetValues(sheet.values)
})
)
this.sheetsData = sheets.filter((sheet): sheet is ISheetsData => sheet !== undefined)
}
} catch (error) {
this._xmlError = error instanceof Response
? error.body
: error instanceof Error
? error.message
: error
throw Error('Unable to load spreadsheets. For more info see xmlError attribute')
}
}
/**
* get value of a cell
* @param cellId
* @param page
*/
getCellValue(cellId: string, page = 0): string | undefined {
this.currentPage = page
const matchingCell = this.parsedCells.find(({ cell }) => cell === cellId)
if (matchingCell) {
return matchingCell.value
}
}
/**
* Compute Node elements of the table.
* In case of errors the node will contains the error message.
*
* *classes*
* - ssr-table: class of the root elements of the table
* - ssr-cell-head: class of header cells
* - ssr-cell-data: class of cells contains data
*
* *id*
* - All data Element have id="ssr-${cellID}"
*
* *results HTML*
*
* ```html
* <table class="ssr-table">
<thead>
<tr>
<td class="ssr-cell-head"></td>
<td class="ssr-cell-head">A</td>
<td class="ssr-cell-head">B</td>
</tr>
</thead>
<tbody>
<tr>
<td class="ssr-cell-head">1</td>
<td cell-id="A1" id="ssr-A1" class="ssr-cell-data">text</td>
<td cell-id="B1" id="ssr-B1" class="ssr-cell-data">value</td>
</tr>
<tr>
<td class="ssr-cell-head">2</td>
<td cell-id="A2" id="ssr-A2" class="ssr-cell-data"></td>
<td cell-id="B2" id="ssr-B2" class="ssr-cell-data">other</td>
</tr>
</tbody>
</table>
```
*
* <table class="ssr-table">
<thead>
<tr>
<td class="ssr-cell-head"></td>
<td class="ssr-cell-head">A</td>
<td class="ssr-cell-head">B</td>
</tr>
</thead>
<tbody>
<tr>
<td class="ssr-cell-head">1</td>
<td cell-id="A1" id="ssr-A1" class="ssr-cell-data">text</td>
<td cell-id="B1" id="ssr-B1" class="ssr-cell-data">value</td>
</tr>
<tr>
<td class="ssr-cell-head">2</td>
<td cell-id="A2" id="ssr-A2" class="ssr-cell-data"></td>
<td cell-id="B2" id="ssr-B2" class="ssr-cell-data">other</td>
</tr>
</tbody>
</table>
*/
getTable(): Node {
if (this._xmlError) {
const template = document.createElement('template')
template.innerHTML = this._xmlError.trim()
if (template.content && template.content.firstChild) return template.content.firstChild
throw Error('Unknow Error')
}
const table = this.generateTable(this.maxRow, this.maxColl)
this.cellsList.forEach((cell) => {
const cellContain = document.createTextNode(cell.value || '')
const cellElem = table.querySelector(`#ssr-${cell.cell}`)
cellElem?.append(cellContain)
})
return table
}
private static *lettersGenerator(maxLetters: string): Generator<string> {
let currentLetters = ''
let index = 0
while (maxLetters !== currentLetters) {
currentLetters = SpreadsheetReader.getColumnLettersFromIndex(index)
yield currentLetters
index++
}
}
private static *numberGenerator(maxLines = 100): Generator<number> {
for (let i = 1; i <= maxLines; i++) {
yield i
}
}
private createHeadCell(cellContaint: string | undefined): HTMLTableDataCellElement {
const cell = document.createElement('td')
cell.classList.add('ssr-cell-head')
cell.appendChild(document.createTextNode(cellContaint || ''))
return cell
}
private generateTable(maxRow: number, maxCell: string): HTMLTableElement {
const table = document.createElement('table')
table.classList.add('ssr-table')
const tableHead = document.createElement('thead')
const rowHead = document.createElement('tr')
rowHead.appendChild(this.createHeadCell(''))
Array.from(SpreadsheetReader.lettersGenerator(maxCell)).forEach((collId) => {
rowHead.appendChild(this.createHeadCell(collId))
})
tableHead.appendChild(rowHead)
table.appendChild(tableHead)
const tableBody = document.createElement('tbody')
Array.from(SpreadsheetReader.numberGenerator(maxRow)).forEach((rowId) => {
const row = document.createElement('tr')
row.appendChild(this.createHeadCell(`${rowId}`))
Array.from(SpreadsheetReader.lettersGenerator(maxCell)).forEach((collId) => {
const cell = document.createElement('td')
const cellId = collId + rowId
cell.id = `ssr-${cellId}`
cell.setAttribute('cell-id', cellId)
cell.classList.add('ssr-cell-data')
row.appendChild(cell)
})
tableBody.appendChild(row)
})
table.appendChild(tableBody)
return table
}
}