import { Types as T } from "@lingdocs/inflect"; import * as FT from "../../website/src/types/functions-types"; import { standardizeEntry } from "@lingdocs/inflect"; import type { sheets_v4 } from "@googleapis/sheets"; import { dictionaryEntryBooleanFields, dictionaryEntryNumberFields, dictionaryEntryTextFields, simplifyPhonetics, standardizePashto, } from "@lingdocs/inflect"; const validFields = [ ...dictionaryEntryTextFields, ...dictionaryEntryBooleanFields, ...dictionaryEntryNumberFields, ]; export type Sheets = { spreadsheetId: string; spreadsheets: sheets_v4.Resource$Spreadsheets; }; async function getTsIndex(sheets: Sheets): Promise { const values = await getRange(sheets, "A2:A"); return values.map((r) => parseInt(r[0])); } async function getFirstEmptyRow(sheets: Sheets): Promise { const values = await getRange(sheets, "A2:A"); return values.length + 2; } export async function getEntriesFromSheet({ spreadsheets, spreadsheetId, }: Sheets): Promise { const keyInfo = await getKeyInfo({ spreadsheets, spreadsheetId }); const { data } = await spreadsheets.values.get({ spreadsheetId, range: `A2:${keyInfo.lastCol}`, }); if (!data.values) { throw new Error("data not found"); } function processRow(row: string[]) { // TODO: optimize this const processedRow = row.flatMap< [keyof T.DictionaryEntry, string | boolean | number] >((x, i) => { if (x === "") { return []; } const k = keyInfo.keyRow[i]; // @ts-expect-error if (dictionaryEntryNumberFields.includes(k)) { return [[k, parseInt(x)]]; } // @ts-expect-error if (dictionaryEntryBooleanFields.includes(k)) { return [[k, x.toLowerCase() === "true"]]; } return [[k, k.endsWith("p") ? standardizePashto(x.trim()) : x.trim()]]; }); return processedRow; } const entries = data.values.map(processRow).map((pr) => { return Object.fromEntries(pr) as T.DictionaryEntry; }); entries.sort((a, b) => a.p.localeCompare(b.p, "ps")); const entriesLength = entries.length; // add index and g for (let i = 0; i < entriesLength; i++) { entries[i].i = i; entries[i].g = simplifyPhonetics(entries[i].f); } return entries; } export async function updateDictionaryEntries( { spreadsheets, spreadsheetId }: Sheets, edits: FT.EntryEdit[] ) { if (edits.length === 0) { return; } const entries = edits.map((e) => e.entry); const tsIndex = await getTsIndex({ spreadsheets, spreadsheetId }); const { keyRow, lastCol } = await getKeyInfo({ spreadsheets, spreadsheetId }); function entryToRowArray(e: T.DictionaryEntry): any[] { return keyRow.slice(1).map((k) => e[k] || ""); } const data = entries.flatMap((entry) => { const rowNum = getRowNumFromTs(tsIndex, entry.ts); if (rowNum === undefined) { console.error(`couldn't find ${entry.ts} ${JSON.stringify(entry)}`); return []; } const values = [entryToRowArray(entry)]; return [ { range: `B${rowNum}:${lastCol}${rowNum}`, values, }, ]; }); await spreadsheets.values.batchUpdate({ spreadsheetId, requestBody: { data, valueInputOption: "RAW", }, }); } export async function addDictionaryEntries( { spreadsheets, spreadsheetId }: Sheets, additions: FT.NewEntry[] ) { if (additions.length === 0) { return; } const entries = additions.map((x) => standardizeEntry(x.entry)); const endRow = await getFirstEmptyRow({ spreadsheets, spreadsheetId }); const { keyRow, lastCol } = await getKeyInfo({ spreadsheets, spreadsheetId }); const ts = Date.now(); function entryToRowArray(e: T.DictionaryEntry): any[] { return keyRow.slice(1).map((k) => e[k] || ""); } const values = entries.map((entry, i) => [ts + i, ...entryToRowArray(entry)]); await spreadsheets.values.batchUpdate({ spreadsheetId, requestBody: { data: [ { range: `A${endRow}:${lastCol}${endRow + (values.length - 1)}`, values, }, ], valueInputOption: "RAW", }, }); } export async function updateDictionaryFields( { spreadsheets, spreadsheetId }: Sheets, edits: { ts: number; col: keyof T.DictionaryEntry; val: any }[] ) { const tsIndex = await getTsIndex({ spreadsheets, spreadsheetId }); const { colMap } = await getKeyInfo({ spreadsheets, spreadsheetId }); const data = edits.flatMap((edit) => { const rowNum = getRowNumFromTs(tsIndex, edit.ts); if (rowNum === undefined) { console.error(`couldn't find ${edit.ts} ${JSON.stringify(edit)}`); return []; } const col = colMap[edit.col]; return [ { range: `${col}${rowNum}:${col}${rowNum}`, values: [[edit.val]], }, ]; }); await spreadsheets.values.batchUpdate({ spreadsheetId, requestBody: { data, valueInputOption: "RAW", }, }); } export async function deleteEntry( { spreadsheets, spreadsheetId }: Sheets, sheetId: number, ed: FT.EntryDeletion ) { const tsIndex = await getTsIndex({ spreadsheets, spreadsheetId }); const row = getRowNumFromTs(tsIndex, ed.ts); if (!row) { console.error(`${ed.ts} not found to do delete`); return; } const requests = [ { deleteDimension: { range: { sheetId, dimension: "ROWS", startIndex: row - 1, endIndex: row, }, }, }, ]; await spreadsheets.batchUpdate({ spreadsheetId, requestBody: { requests, includeSpreadsheetInResponse: false, responseRanges: [], }, }); } function getRowNumFromTs(tsIndex: number[], ts: number): number | undefined { const res = tsIndex.findIndex((x) => x === ts); if (res === -1) { return undefined; } return res + 2; } async function getKeyInfo(sheets: Sheets): Promise<{ colMap: Record; colMapN: Record; keyRow: (keyof T.DictionaryEntry)[]; lastCol: string; }> { const headVals = await getRange(sheets, "A1:1"); const headRow: string[] = headVals[0]; const colMap: Record = {}; const colMapN: Record = {}; headRow.forEach((c, i) => { if (validFields.every((v) => c !== v)) { throw new Error(`Invalid spreadsheet field ${c}`); } colMap[c] = getColumnLetters(i); colMapN[c] = i; }); return { colMap: colMap as Record, colMapN: colMapN as Record, keyRow: headRow as (keyof T.DictionaryEntry)[], lastCol: getColumnLetters(headRow.length - 1), }; } async function getRange( { spreadsheets, spreadsheetId }: Sheets, range: string ): Promise { const { data } = await spreadsheets.values.get({ spreadsheetId, range, }); if (!data.values) { throw new Error("data not found"); } return data.values; } function getColumnLetters(num: number) { let letters = ""; while (num >= 0) { letters = "ABCDEFGHIJKLMNOPQRSTUVWXYZ"[num % 26] + letters; num = Math.floor(num / 26) - 1; } return letters; }