mirror of
https://github.com/Paris-est-Ludique/intranet.git
synced 2025-09-11 05:46:28 +02:00
Adds GSheet read&write API
This commit is contained in:
25
src/gsheets/envies.ts
Normal file
25
src/gsheets/envies.ts
Normal 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()
|
||||
}
|
||||
}
|
@@ -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)
|
||||
|
@@ -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 }
|
||||
|
@@ -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...
|
||||
]
|
||||
|
@@ -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
36
src/services/envies.ts
Normal 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 }
|
||||
}
|
||||
}
|
@@ -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
57
src/store/envieList.ts
Normal 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
|
||||
}
|
@@ -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...
|
||||
})
|
||||
|
Reference in New Issue
Block a user