import { GoogleSpreadsheet } from 'google-spreadsheet' import { JWT } from 'google-auth-library' function setNestedProperty(obj: any, path: string, value: string) { const keys = path.split('.') let current = obj for (let i = 0; i < keys.length - 1; i++) { const key = keys[i] if (!current[key]) { current[key] = {} } current = current[key] } const lastKey = keys[keys.length - 1] // Check if the key has an array index pattern like "requirements[0]" const arrayMatch = lastKey.match(/^([a-z]+)\[(\d+)\]$/i) if (arrayMatch) { const arrayName = arrayMatch[1] const index = parseInt(arrayMatch[2], 10) // Initialize array if it doesn't exist if (!Array.isArray(current[arrayName])) { current[arrayName] = [] } current[arrayName][index] = value } else { current[lastKey] = value } } export class GoogleSheets { #doc: GoogleSpreadsheet constructor(creds: any, docId: string) { const jwt = new JWT({ email: creds.client_email, key: creds.private_key, scopes: [ 'https://www.googleapis.com/auth/spreadsheets', 'https://www.googleapis.com/auth/drive.file', ], }); this.#doc = new GoogleSpreadsheet(docId, jwt) } async init() { await this.#doc.loadInfo() } async getI18n(languages: string[]): Promise>> { const i18n: Record> = {} for (const [sheetTitle, sheet] of Object.entries(this.#doc.sheetsByTitle)) { if (sheetTitle.includes('result-')) { continue } const rows = await sheet.getRows() if (rows.length === 0) continue // Get header names from the first row to find the correct column names const headerValues = Object.keys(rows[0].toObject()) for (const row of rows) { let root = row.get('Root') const key = row.get('Token') if (!key) { continue } if (!root) { root = sheetTitle } for (const lang of languages) { i18n[lang] = i18n[lang] || {} // Find the column that matches the language (either "locale" or "locale (Country)") const columnName = headerValues.find((header: string) => { const normalizedHeader = header.toLowerCase() return normalizedHeader === lang || normalizedHeader.startsWith(`${lang} (`) }) if (columnName && row.get(columnName)) { i18n[lang][`${root}.${key}`.toLowerCase()] = row.get(columnName) } } } } return i18n } async getResultI18n(languages: string[]): Promise>> { const i18n: Record> = {} for (const [sheetTitle, sheet] of Object.entries(this.#doc.sheetsByTitle)) { if (!sheetTitle.includes('result-')) { continue } const title = sheetTitle.replace('result-', '') const rows = await sheet.getRows() if (rows.length === 0) continue // Get header names from the first row to find the correct column names const headerValues = Object.keys(rows[0].toObject()) for (const row of rows) { const key = row.get('Token') if (!key) { continue } for (const lang of languages) { i18n[lang] = i18n[lang] || {} i18n[lang][title] = i18n[lang][title] || {} // Find the column that matches the language (either "locale" or "locale (Country)") const columnName = headerValues.find((header: string) => { const normalizedHeader = header.toLowerCase() return normalizedHeader === lang || normalizedHeader.startsWith(`${lang} (`) }) if (columnName && row.get(columnName)) { setNestedProperty(i18n[lang][title], key.toLowerCase(), row.get(columnName)) } } } } return i18n } async syncLocaleToSheet(localeData: Record, languages: string[]): Promise { // Get all existing keys from all sheets const existingKeys = new Set() const sheetsByName: Record = {} for (const [sheetTitle, sheet] of Object.entries(this.#doc.sheetsByTitle)) { if (sheetTitle.includes('result-')) { continue } sheetsByName[sheetTitle.toLowerCase()] = sheet const rows = await sheet.getRows() for (const row of rows) { const root = row.get('Root') const key = row.get('Token') if (key) { const fullKey = root ? `${root}.${key}`.toLowerCase() : `${sheetTitle}.${key}`.toLowerCase() existingKeys.add(fullKey) } } } // Find missing keys const missingKeys: Array<{ fullKey: string, sheetName: string, token: string, value: string }> = [] for (const [fullKey, value] of Object.entries(localeData)) { const normalizedKey = fullKey.toLowerCase() if (!existingKeys.has(normalizedKey)) { // Determine sheet name from first word const parts = normalizedKey.split('.') let sheetName = parts[0] let token = parts.slice(1).join('.') // If no matching sheet exists, use 'common' sheet if (!sheetsByName[sheetName]) { sheetName = 'common' token = normalizedKey } missingKeys.push({ fullKey: normalizedKey, sheetName, token, value }) } } if (missingKeys.length === 0) { console.log('No missing keys found') return } console.log(`Found ${missingKeys.length} missing keys, adding to sheets...`) // Group missing keys by sheet const keysBySheet: Record> = {} for (const { sheetName, token, value } of missingKeys) { if (!keysBySheet[sheetName]) { keysBySheet[sheetName] = [] } keysBySheet[sheetName].push({ token, value }) } // Add rows to appropriate sheets for (const [sheetName, keys] of Object.entries(keysBySheet)) { let sheet = sheetsByName[sheetName] // If sheet doesn't exist if (!sheet) { console.log(`Creating new sheet: ${sheetName}`) sheet = await this.#doc.addSheet({ title: sheetName, headerValues: ['Root', 'Token', 'en', ...languages.filter(l => l !== 'en')] }) sheetsByName[sheetName] = sheet } // Get the header row to find column positions await sheet.loadHeaderRow() const headers = sheet.headerValues console.log(`Adding ${keys.length} keys to sheet: ${sheetName}`) // Prepare rows to add const rowsToAdd = keys.map(({ token, value }) => { const row: Record = { 'Root': '', 'Token': token, 'EN': value } return row }) // Add all rows at once await sheet.addRows(rowsToAdd) } console.log('Successfully synced missing keys to sheets') } }