// scrape-and-update-android-releases.mjs import * as fs from 'fs'; import * as path from 'path'; import { fetchAndroidReleases } from './fetch-and-parse-android-releases.mjs'; import { printLinesDiffs } from './utils/print.mjs'; const CSV_PATH = path.resolve(process.cwd(), '../build-logic/ksp-version-codes-processor/src/main/resources/version-codes.csv'); const TXT_PATH = path.resolve(process.cwd(), '../build-logic/ksp-version-codes-processor/src/main/resources/version-codes.txt'); const readFileSafe = (/** @type {string} */ p) => (fs.existsSync(p) ? fs.readFileSync(p, 'utf8') : ''); const quoteCsv = (/** @type {string | number} */ v) => { const s = String(v ?? ''); if (s === '""') return s; return /[",\r\n]/.test(s) ? `"${s.replace(/"/g, '""')}"` : s; }; /** * @param {AndroidReleaseCsv[]} rows * @returns {string} */ const toCsv = (rows) => { /** @type {(keyof AndroidReleaseCsv)[]} */ const headers = [ 'VERSION_CODE', 'RELEASE_NAME', 'INTERNAL_CODENAME', 'PLATFORM_VERSION', 'API_LEVEL', 'RELEASE_DATE', ]; const lines = rows.map(r => headers.map(k => quoteCsv(r[k])).join()); return [ headers.join(), ...lines ].join('\n'); }; /** * @param {AndroidReleaseCsv[]} data * @returns {void} */ function updateCsv(data) { fs.mkdirSync(path.dirname(CSV_PATH), { recursive: true }); fs.writeFileSync(CSV_PATH, toCsv(data), 'utf8'); } /** * @param {AndroidReleaseCsv[]} data * @param {object} options * @param {boolean} [options.showIndex=false] * @param {{ mode: 'all' | 'not-all-numbers' | 'none', style: 'single' | 'double' }} [options.quote] * @param {{ key: string, compare: (a: AndroidReleaseCsv, b: AndroidReleaseCsv) => number } | { key: string, direction: 'asc' | 'desc' }} [options.sortBy] * @param {{ [key: string]: string }} [options.headerMap] * @param {'lower' | 'upper' | 'snake' | 'kebab' | 'title' | 'camel' | 'pascal' | null} [options.headerCase] * @param {string[]} [options.headers] * @param {{ [key: string]: (v: any, row: AndroidReleaseCsv, header: string) => string }} [options.renderers] * @returns {void} */ function updateTxt(data, options) { const defaultOptions = { showIndex: false, quote: { mode: 'not-all-numbers', style: 'single', }, sortBy: null, headerMap: null, headerCase: null, headers: [ 'VERSION_CODE', 'RELEASE_NAME', 'INTERNAL_CODENAME', 'PLATFORM_VERSION', 'API_LEVEL', 'RELEASE_DATE', ], renderers: {}, }; const opts = Object.assign(defaultOptions, options ?? {}); const headers = opts.headers.slice(); const toWords = (/** @type {any} */ s) => String(s ?? '') .replace(/[_\-\s]+/g, ' ') .replace(/([a-z0-9])([A-Z])/g, '$1 $2') .trim() .split(/\s+/); /** * @param {any} s * @returns {string} */ const toCase = (s) => { const words = toWords(s); switch (opts.headerCase) { case 'lower': return words.join(' ').toLowerCase(); case 'upper': return words.join(' ').toUpperCase(); case 'snake': return words.map(w => w.toLowerCase()).join('_'); case 'kebab': return words.map(w => w.toLowerCase()).join('-'); case 'title': return words.map(w => w[0] ? (w[0].toUpperCase() + w.slice(1).toLowerCase()) : w).join(' '); case 'camel': return words.map((w, i) => i === 0 ? w.toLowerCase() : (w[0]?.toUpperCase() + w.slice(1).toLowerCase())).join(''); case 'pascal': return words.map(w => w[0]?.toUpperCase() + w.slice(1).toLowerCase()).join(''); default: return s; } }; /** * Copy and sort data.
* zh-CN: 数据拷贝与排序. * @type {AndroidReleaseCsv[]} */ const rows = data.slice(); if (opts.sortBy) { if (typeof opts.sortBy === 'function') { rows.sort(opts.sortBy); } else if (opts.sortBy && typeof opts.sortBy === 'object' && opts.sortBy.key) { const key = opts.sortBy.key; const dir = (opts.sortBy.direction || 'asc').toLowerCase() === 'desc' ? -1 : 1; const cmp = typeof opts.sortBy.compare === 'function' ? opts.sortBy.compare : (/** @type {AndroidReleaseCsv} */ a, /** @type {AndroidReleaseCsv} */ b) => { const va = a?.[key]; const vb = b?.[key]; if (va == null && vb == null) return 0; if (va == null) return -1; if (vb == null) return 1; if (va < vb) return -1; if (va > vb) return 1; return 0; }; rows.sort((a, b) => dir * cmp(a, b)); } } /** * Prepare column values (strings) and determine column quotation in "not-all-numbers" mode.
* zh-CN: 先把每列值准备好 (字符串), 并在 "not-all-numbers" 模式下做整列判定. * @type {string[][]} */ const tableValues = rows.map(r => { return headers.map(h => { const v = r[h]; const renderer = opts.renderers?.[h]; const rendered = renderer ? renderer(v, r, h) : (v ?? ''); return String(rendered); }); }); /** * Column headers (remapping + case transformation + affixes).
* zh-CN: 列标题 (重映射 + 大小写 + 前后缀). * @type {string[]} */ const headerLabels = headers.map(h => { const mapped = opts.headerMap?.[h] ?? h; return toCase(mapped); }); const finalHeader = opts.showIndex ? [ '(index)', ...headerLabels ] : headerLabels; const qStyle = opts.quote?.style === 'double' ? `"` : `'`; const isNumericLike = (/** @type {any} */ v) => /^-?\d+(\.\d+)?$/.test(String(v ?? '').trim()); /** @type {boolean[]} */ const quotedColumns = tableValues[0] ? new Array(tableValues[0].length).fill(false) : []; if (opts.quote?.mode === 'all') { for (let c = 0; c < quotedColumns.length; c++) { quotedColumns[c] = true; } } else if (opts.quote?.mode === 'not-all-numbers' || !opts.quote?.mode) { for (let c = 0; c < quotedColumns.length; c++) { const col = tableValues.map(row => row[c]); const allNumeric = col.length > 0 && col.every(isNumericLike); quotedColumns[c] = !allNumeric; } } /** * Apply quotes to data.
* zh-CN: 应用引号到数据. * @type {string[][]} */ const quotedTable = tableValues.map(row => { return row.map((cell, c) => quotedColumns[c] ? qStyle + cell + qStyle : cell === `""` ? qStyle + qStyle : cell); }); /** * Calculate the width of each column.
* zh-CN: 计算每列宽度. * @type {string[][]} */ const rowsForWidth = []; rowsForWidth.push(finalHeader); if (opts.showIndex) { for (let i = 0; i < quotedTable.length; i++) { rowsForWidth.push([ String(i), ...quotedTable[i] ]); } } else { rowsForWidth.push(...quotedTable); } const colCount = rowsForWidth[0]?.length ?? 0; /** @type {number[]} */ const colWidths = new Array(colCount).fill(0); for (const r of rowsForWidth) { for (let c = 0; c < colCount; c++) { colWidths[c] = Math.max(colWidths[c], String(r[c] ?? '').length); } } /** * @param {any} s * @param {number} w * @returns {string} */ const pad = (s, w) => { const str = String(s ?? ''); const len = str.length; return str + (len < w ? ' '.repeat(w - len) : ''); }; const joinRow = (/** @type {any[]} */ cells) => `| ${cells.map((v, i) => pad(v, colWidths[i])).join(' | ')} |`; const divider = `+-${colWidths.map(w => '-'.repeat(w)).join('-+-')}-+`; const lines = []; lines.push(divider); lines.push(joinRow(finalHeader)); lines.push(divider); if (quotedTable.length > 0) { for (let i = 0; i < quotedTable.length; i++) { const cells = opts.showIndex ? [ String(i), ...quotedTable[i] ] : quotedTable[i]; lines.push(joinRow(cells)); } } lines.push(divider); fs.mkdirSync(path.dirname(TXT_PATH), { recursive: true }); fs.writeFileSync(TXT_PATH, lines.join('\n'), 'utf8'); } (async function main() { const filename = path.basename(CSV_PATH); const releases = await fetchAndroidReleases(); const prev = readFileSafe(CSV_PATH).replaceAll('\r\n', '\n'); if (prev !== toCsv(releases)) { updateCsv(releases); updateTxt(releases, { showIndex: false, quote: { mode: 'none', style: 'single', }, }); console.log(`[${filename}] Updated (Android releases)`); printLinesDiffs(prev, toCsv(releases)); } else { // console.log('[${filename}] No update needed (Android releases)'); } })().catch(err => { console.error(err); process.exitCode = 1; });