Adds GSheet read&write API

This commit is contained in:
forceoranj
2021-10-23 03:34:19 +02:00
parent 3398f4f42d
commit 2616b109d7
17 changed files with 881 additions and 17754 deletions

25
src/gsheets/envies.ts Normal file
View File

@@ -0,0 +1,25 @@
import { Request, Response, NextFunction } from "express"
import { getList, setList } from "./utils"
import { Envie } from "../services/envies"
export const getEnvieList = async (
_request: Request,
response: Response,
_next: NextFunction
): Promise<void> => {
const list = await getList<Envie>("Envies d'aider", new Envie())
if (list) {
response.status(200).json(list)
}
}
export const setEnvieList = async (
request: Request,
response: Response,
_next: NextFunction
): Promise<void> => {
const success = await setList<Envie>("Envies d'aider", request.body)
if (success) {
response.status(200).json()
}
}

View File

@@ -8,7 +8,7 @@ export const getJeuxJavList = async (
response: Response,
_next: NextFunction
): Promise<void> => {
const list = await getList<JeuxJav>("Jeux JAV")
const list = await getList<JeuxJav>("Jeux JAV", new JeuxJav())
if (list) {
response.status(200).json(list)
}
@@ -19,7 +19,7 @@ export const getJeuxJavData = async (
response: Response,
_next: NextFunction
): Promise<void> => {
const list = await getList<JeuxJav>("Jeux JAV")
const list = await getList<JeuxJav>("Jeux JAV", new JeuxJav())
const data = _.find(list, { id: 56 })
if (data) {
response.status(200).json(data)

View File

@@ -1,92 +1,312 @@
import path from "path"
import fs from "fs"
import readline from "readline"
import _ from "lodash"
import { google } from "googleapis"
import config from "../config"
import { promises as fs } from "fs"
import { GoogleSpreadsheet, GoogleSpreadsheetWorksheet } from "google-spreadsheet"
const SCOPES = ["https://www.googleapis.com/auth/spreadsheets"]
const TOKEN_PATH = path.resolve(process.cwd(), "access/token.json")
const CRED_PATH = path.resolve(process.cwd(), "access/gsheets.json")
export const getList = async <T>(sheetName: string): Promise<T[] | undefined> => {
const auth = await authorize(JSON.parse(fs.readFileSync(CRED_PATH, "utf8")))
const sheets = google.sheets({ version: "v4", auth })
const r = await sheets.spreadsheets.values.get({
spreadsheetId: config.GOOGLE_SHEET_ID,
range: `${sheetName}!A1:Z`,
// eslint-disable-next-line @typescript-eslint/ban-types
export async function getList<Element extends object>(
sheetName: string,
specimen: Element
): Promise<Element[]> {
type StringifiedElement = Record<keyof Element, string>
const sheet = await getGSheet(sheetName)
// Load sheet into an array of objects
const rows = (await sheet.getRows()) as StringifiedElement[]
const elements: Element[] = []
if (!rows[0]) {
// TODO: Report format error to database maintainers
return []
}
const types = _.pick(rows[0], Object.keys(specimen)) as Record<keyof Element, string>
rows.shift()
rows.forEach((row) => {
const stringifiedElement = _.pick(row, Object.keys(specimen)) as Record<
keyof Element,
string
>
const element = parseElement<Element>(stringifiedElement, types, specimen)
if (element !== undefined) {
elements.push(element)
}
})
if (_.isArray(r?.data?.values)) {
const rows = r.data.values as string[][]
const keys: string[] = rows[0]
rows.shift()
const list: T[] = _.map(
rows,
(row) =>
_.reduce(
row,
(game: any, val: any, collumn: number) => {
game[keys[collumn]] = val
return game
},
{}
) as T
)
return list
}
return undefined
return elements
}
async function authorize(cred: any) {
const {
client_secret: clientSecret,
client_id: clientId,
redirect_uris: redirectUris,
} = cred.web
const oAuth2Client = new google.auth.OAuth2(clientId, clientSecret, redirectUris[0])
// eslint-disable-next-line @typescript-eslint/ban-types
export async function setList<Element extends object>(
sheetName: string,
elements: Element[]
): Promise<true | undefined> {
const sheet = await getGSheet(sheetName)
if (fs.existsSync(TOKEN_PATH)) {
oAuth2Client.setCredentials(JSON.parse(fs.readFileSync(TOKEN_PATH, "utf8")))
return oAuth2Client
// Load sheet into an array of objects
const rows = await sheet.getRows()
if (!rows[0]) {
return undefined
}
const types = _.pick(rows[0], Object.keys(elements[0] || {})) as Record<keyof Element, string>
// Update received rows
let rowid = 1
// eslint-disable-next-line no-restricted-syntax
for (const element of elements) {
const row = rows[rowid]
const stringifiedRow = stringifyElement(element, types)
if (stringifiedRow === undefined) {
return undefined
}
if (!row) {
// eslint-disable-next-line no-await-in-loop
await sheet.addRow(stringifiedRow)
} else {
const keys = Object.keys(stringifiedRow)
const sameCells = _.every(
keys,
(key: keyof Element) => row[key as string] === stringifiedRow[key]
)
if (!sameCells) {
keys.forEach((key) => {
row[key] = stringifiedRow[key as keyof Element]
})
// eslint-disable-next-line no-await-in-loop
await row.save()
}
}
rowid += 1
}
return getNewToken<typeof oAuth2Client>(oAuth2Client)
// Delete all following rows
for (let rowToDelete = sheet.rowCount - 1; rowToDelete >= rowid; rowToDelete -= 1) {
if (rows[rowToDelete]) {
// eslint-disable-next-line no-await-in-loop
await rows[rowToDelete].delete()
}
}
return true
}
async function getNewToken<T = any>(oAuth2Client: any): Promise<T> {
const authUrl = oAuth2Client.generateAuthUrl({
access_type: "offline",
scope: SCOPES,
})
console.log("Authorize this app by visiting this url:", authUrl)
const code = await readlineAsync("Enter the code from that page here: ")
const token = await new Promise((resolve, reject) => {
oAuth2Client.getToken(code, (err: any, _token: any) =>
err ? reject(err) : resolve(_token)
)
})
oAuth2Client.setCredentials(token)
// Store the token to disk for later program executions
fs.writeFileSync(TOKEN_PATH, JSON.stringify(token))
console.log("Token stored to", TOKEN_PATH)
return oAuth2Client
async function getGSheet(sheetName: string): Promise<GoogleSpreadsheetWorksheet> {
const doc = new GoogleSpreadsheet("1pMMKcYx6NXLOqNn6pLHJTPMTOLRYZmSNg2QQcAu7-Pw")
const creds = await fs.readFile(CRED_PATH)
// Authentication
await doc.useServiceAccountAuth(JSON.parse(creds.toString()))
await doc.loadInfo()
return doc.sheetsByTitle[sheetName]
}
async function readlineAsync(question: string) {
const rl = readline.createInterface({
input: process.stdin,
output: process.stdout,
})
// eslint-disable-next-line @typescript-eslint/ban-types
function parseElement<Element extends object>(
rawElement: Record<keyof Element, string>,
types: Record<keyof Element, string>,
specimen: Element
): Element | undefined {
const fullElement = _.reduce(
types,
(element: any, type: string, prop: string) => {
if (element === undefined) {
return undefined
}
const rawProp: string = rawElement[prop as keyof Element]
switch (type) {
case "string":
element[prop] = rawProp
break
return new Promise((resolve) => {
rl.question(question, (answer) => {
rl.close()
resolve(answer)
})
})
case "number":
element[prop] = +rawProp
break
case "boolean":
element[prop] = rawProp !== "0" && rawProp !== ""
break
case "date":
// eslint-disable-next-line no-case-declarations
const matchDate = rawProp.match(/^([0-9]+)\/([0-9]+)\/([0-9]+)$/)
if (matchDate) {
element[prop] = new Date(+matchDate[3], +matchDate[2] - 1, +matchDate[1])
break
}
return undefined // TODO: Report format error to database maintainers
break
default:
// eslint-disable-next-line no-case-declarations
const matchArrayType = type.match(/^(number|string|boolean|date)\[([^\]]+)\]$/)
if (!matchArrayType) {
return undefined
}
if (!rawProp) {
element[prop] = []
} else {
const arrayType = matchArrayType[1]
const delimiter = matchArrayType[2]
switch (arrayType) {
case "string":
element[prop] = rawProp.split(delimiter)
break
case "number":
element[prop] = _.map(rawProp.split(delimiter), (val) => +val)
break
case "boolean":
element[prop] = _.map(
rawProp.split(delimiter),
(val) => val !== "0" && val !== ""
)
break
case "date":
// eslint-disable-next-line no-case-declarations
const rawDates = rawProp.split(delimiter)
element[prop] = []
// eslint-disable-next-line no-case-declarations
const rightFormat = rawDates.every((rawDate) => {
const matchDateArray = rawDate.match(
/^([0-9]+)\/([0-9]+)\/([0-9]+)$/
)
if (!matchDateArray) {
return false
}
element[prop].push(
new Date(
+matchDateArray[3],
+matchDateArray[2] - 1,
+matchDateArray[1]
)
)
return true
})
if (!rightFormat) {
return undefined
}
break
default:
}
}
}
return element
},
JSON.parse(JSON.stringify(specimen))
)
return fullElement
}
// eslint-disable-next-line @typescript-eslint/ban-types
function stringifyElement<Element extends object>(
element: Element,
types: Record<keyof Element, string>
): Record<keyof Element, string> | undefined {
const rawElement: Record<keyof Element, string> | undefined = _.reduce(
types,
(
stringifiedElement: Record<keyof Element, string> | undefined,
type: string,
prop: string
) => {
if (stringifiedElement === undefined) {
return undefined
}
const value = element[prop as keyof Element]
switch (type) {
case "string":
stringifiedElement[prop as keyof Element] = formulaSafe(`${value}`)
break
case "number":
stringifiedElement[prop as keyof Element] = `${value}`
break
case "boolean":
stringifiedElement[prop as keyof Element] = value ? "X" : ""
break
case "date":
if (value instanceof Date) {
stringifiedElement[prop as keyof Element] = `${value.getDate()}/${
value.getMonth() + 1
}/${value.getFullYear()}`
break
} else {
console.error("Wrong date format in stringifyElement")
return undefined // TODO: Report format error to database maintainers
}
default:
// eslint-disable-next-line no-case-declarations
const matchArrayType = type.match(/^(number|string|boolean|date)\[([^\]]+)\]$/)
if (!matchArrayType || !_.isArray(value)) {
console.error("Unknown matchArrayType or not an array in stringifyElement")
return undefined
}
// eslint-disable-next-line no-case-declarations
const arrayType = matchArrayType[1]
// eslint-disable-next-line no-case-declarations
const delimiter = matchArrayType[2]
switch (arrayType) {
case "string":
if (!_.every(value, _.isString)) {
return undefined
}
stringifiedElement[prop as keyof Element] = formulaSafe(
value.join(delimiter)
)
break
case "number":
if (!_.every(value, _.isNumber)) {
return undefined
}
stringifiedElement[prop as keyof Element] = value.join(delimiter)
break
case "boolean":
if (!_.every(value, _.isBoolean)) {
return undefined
}
stringifiedElement[prop as keyof Element] = _.map(value, (val) =>
val ? "X" : ""
).join(delimiter)
break
case "date":
if (!_.every(value, _.isDate)) {
return undefined
}
stringifiedElement[prop as keyof Element] = _.map(
value,
(val) =>
`${val.getDate()}/${val.getMonth() + 1}/${val.getFullYear()}`
).join(delimiter)
break
default:
return undefined
}
}
return stringifiedElement
},
JSON.parse(JSON.stringify(element))
)
return rawElement
}
function formulaSafe(value: string): string {
return value.replace(/^=+/, "")
}
export { SCOPES }

View File

@@ -5,6 +5,7 @@ import { Helmet } from "react-helmet"
import { AppState, AppThunk } from "../../store"
import { fetchJeuxJavListIfNeed } from "../../store/jeuxJavList"
import { fetchEnvieListIfNeed } from "../../store/envieList"
import { JeuxJavList } from "../../components"
import styles from "./styles.module.scss"
@@ -33,12 +34,17 @@ function useList(stateToProp: (state: AppState) => any, fetchDataIfNeed: () => A
const Home: FC<Props> = (): JSX.Element => (
<div className={styles.Home}>
<Helmet title="Home" />
{/* {useList((state: AppState) => state.envieList, fetchEnvieListifNeed)()} */}
{useList((state: AppState) => state.jeuxJavList, fetchJeuxJavListIfNeed)()}
{/* <button type="button" onClick={() => setList([{id: 3, joueurs: 4, duree: 5, description: "abcd"}])}>
Set list!
</button> */}
</div>
)
// Fetch server-side data here
export const loadData = (): AppThunk[] => [
fetchEnvieListIfNeed(),
fetchJeuxJavListIfNeed(),
// More pre-fetched actions...
]

View File

@@ -11,6 +11,7 @@ import devServer from "./devServer"
import ssr from "./ssr"
import { getJeuxJavList } from "../gsheets/jeuxJav"
import { getEnvieList, setEnvieList } from "../gsheets/envies"
import config from "../config"
const app = express()
@@ -32,6 +33,8 @@ if (__DEV__) devServer(app)
// Google Sheets requests
app.get("/JeuxJav", getJeuxJavList)
app.get("/GetList", getEnvieList)
app.post("/SetList", setEnvieList)
// Use React server-side rendering middleware
app.get("*", ssr)

36
src/services/envies.ts Normal file
View File

@@ -0,0 +1,36 @@
import axios from "axios"
import config from "../config"
export class Envie {
domaine = ""
envies = ""
precisions = ""
equipes = []
dateAjout = new Date(0)
}
export interface EnviesResponse {
data?: Envie[]
error?: Error
}
export const getEnvieList = async (): Promise<EnviesResponse> => {
try {
const { data } = await axios.get(`${config.API_URL}/GetList`)
return { data }
} catch (error) {
return { error: error as Error }
}
}
export const setEnvieList = async (list: Envie[]): Promise<EnviesResponse> => {
try {
const { data } = await axios.post(`${config.API_URL}/SetList`, list)
return { data }
} catch (error) {
return { error: error as Error }
}
}

View File

@@ -2,22 +2,36 @@ import axios from "axios"
import config from "../config"
export interface JeuxJav {
id: number
titre: string
auteur: string
editeur: string
minJoueurs: number
maxJoueurs: number
duree: number
type: "Ambiance" | "Famille" | "Expert" | ""
poufpaf: string
bggId: number
exemplaires: number // Defaults to 1
dispoPret: number
nonRangee: number
ean: string
bggPhoto: string
export class JeuxJav {
id = 0
titre = ""
auteur = ""
editeur = ""
minJoueurs = 0
maxJoueurs = 0
duree = 0
type: "Ambiance" | "Famille" | "Expert" | "" = ""
poufpaf = ""
bggId = 0
exemplaires = 1
dispoPret = 0
nonRangee = 0
ean = ""
bggPhoto = ""
}
export interface JeuxJavList {

57
src/store/envieList.ts Normal file
View File

@@ -0,0 +1,57 @@
import { PayloadAction, createSlice } from "@reduxjs/toolkit"
import { Envie, getEnvieList } from "../services/envies"
import { AppThunk, AppState } from "."
interface EnvieList {
readyStatus: string
items: Envie[]
error: string | null
}
export const initialState: EnvieList = {
readyStatus: "invalid",
items: [],
error: null,
}
const envieList = createSlice({
name: "envieList",
initialState,
reducers: {
getRequesting: (state: EnvieList) => {
state.readyStatus = "request"
},
getSuccess: (state, { payload }: PayloadAction<Envie[]>) => {
state.readyStatus = "success"
state.items = payload
},
getFailure: (state, { payload }: PayloadAction<string>) => {
state.readyStatus = "failure"
state.error = payload
},
},
})
export default envieList.reducer
export const { getRequesting, getSuccess, getFailure } = envieList.actions
export const fetchEnvieList = (): AppThunk => async (dispatch) => {
dispatch(getRequesting())
const { error, data } = await getEnvieList()
if (error) {
dispatch(getFailure(error.message))
} else {
dispatch(getSuccess(data as Envie[]))
}
}
const shouldFetchEnvieList = (state: AppState) => state.envieList.readyStatus !== "success"
export const fetchEnvieListIfNeed = (): AppThunk => (dispatch, getState) => {
if (shouldFetchEnvieList(getState())) return dispatch(fetchEnvieList())
return null
}

View File

@@ -4,6 +4,7 @@ import { connectRouter } from "connected-react-router"
import userList from "./userList"
import userData from "./userData"
import jeuxJavList from "./jeuxJavList"
import envieList from "./envieList"
// Use inferred return type for making correctly Redux types
// eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types
@@ -11,6 +12,7 @@ export default (history: History) => ({
userList,
userData,
jeuxJavList,
envieList,
router: connectRouter(history) as any,
// Register more reducers...
})