6.7.0 - Alpha6 - 新增 Android 发行版本 scraper 脚本及信息打印脚本
This commit is contained in:
15
.utils/declarations/index.d.ts
vendored
15
.utils/declarations/index.d.ts
vendored
@@ -1,3 +1,6 @@
|
||||
type Cheerio<T> = import('cheerio').Cheerio<T>;
|
||||
type DomElement = import('domhandler').Element;
|
||||
|
||||
type Page = import('puppeteer').Page;
|
||||
type Frame = import('puppeteer').Frame;
|
||||
type ElementHandle<T extends Node> = import('puppeteer').ElementHandle<T>;
|
||||
@@ -13,6 +16,18 @@ type TableDataStructureItemName = string;
|
||||
type TableDataStructureItem = RegExp | ((s: string) => boolean | string);
|
||||
type TableDataStructureItemForPageEvaluate = string | RegExp | ((s: string) => boolean | string);
|
||||
|
||||
interface AndroidReleaseWiki {
|
||||
RELEASE_NAME: string;
|
||||
INTERNAL_CODENAME: string;
|
||||
PLATFORM_VERSION: string;
|
||||
API_LEVEL: number;
|
||||
RELEASE_DATE: string;
|
||||
}
|
||||
|
||||
interface AndroidReleaseCsv extends AndroidReleaseWiki {
|
||||
VERSION_CODE: string;
|
||||
}
|
||||
|
||||
interface AndroidStudioRelease {
|
||||
content: {
|
||||
item: AndroidStudioReleaseItem[];
|
||||
|
||||
230
.utils/fetch-and-parse-android-releases.mjs
Normal file
230
.utils/fetch-and-parse-android-releases.mjs
Normal file
@@ -0,0 +1,230 @@
|
||||
// fetch-and-parse-android-releases.mjs
|
||||
|
||||
import * as cheerio from 'cheerio';
|
||||
import fetch from 'node-fetch';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const versionCodeBlacklist = [ 'CUR_DEVELOPMENT' ];
|
||||
|
||||
const WIKI_URL = 'https://en.wikipedia.org/wiki/Android_version_history';
|
||||
const ANDROID_VC_URL = 'https://developer.android.com/reference/android/os/Build.VERSION_CODES';
|
||||
|
||||
const norm = (/** @type {string} */ s) => (s ?? '').trim().replace(/\s+/g, ' ');
|
||||
const trimAndNormDash = (/** @type {string} */ s) => s.replace(/\s*[\u002d\u2013\u2014]\s*/g, '-');
|
||||
const stripSupportPrefix = (/** @type {string} */ s) => s.replace(/^(un)?supported:\s*/ig, '');
|
||||
const handleCsvEmptyString = (/** @type {string} */ s) => /^-?$/.test(s) ? '""' : s;
|
||||
|
||||
/**
|
||||
* @param {AndroidReleaseCsv} o
|
||||
* @returns {AndroidReleaseCsv}
|
||||
*/
|
||||
function quoteIfNeeded(o) {
|
||||
Object.keys(o).forEach((k) => {
|
||||
if (typeof o[k] === 'string') {
|
||||
o[k] = handleCsvEmptyString(o[k]);
|
||||
}
|
||||
});
|
||||
return o;
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove style, script, and sup.reference elements before getting text.<br>
|
||||
* zh-CN: 获取文本前, 先移除 style, script 及 sup.reference.
|
||||
*
|
||||
* @param {Cheerio<DomElement>} cell
|
||||
* @returns {string}
|
||||
*/
|
||||
function cleanCell(cell) {
|
||||
const cloned = cell.clone();
|
||||
cloned.find('style,script,sup.reference').remove();
|
||||
return cloned.text().trim().replace(/\s+/g, ' ');
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch official VERSION_CODE and API_LEVEL from Android documentation.<br>
|
||||
* zh-CN: 抓取官方 VERSION_CODE 与 API_LEVEL.
|
||||
*
|
||||
* @example Map<apiLevel, versionCode>
|
||||
* @returns {Promise<Map<number, string>>}
|
||||
*/
|
||||
async function fetchOfficialVC() {
|
||||
const html = await (await fetch(ANDROID_VC_URL)).text();
|
||||
const $ = cheerio.load(html);
|
||||
|
||||
/**
|
||||
* @example Map<apiLevel, versionCode>
|
||||
* @type {Map<number, string>}
|
||||
*/
|
||||
const map = new Map();
|
||||
$('h3.api-name').each((_i, h3) => {
|
||||
const $h3 = $(h3);
|
||||
const versionCode = norm($h3.attr('id') || $h3.text());
|
||||
if (!versionCode || versionCodeBlacklist.includes(versionCode.toUpperCase())) return;
|
||||
|
||||
const container = $h3.parent();
|
||||
const apiLevelText = container.find('.api-level').first().text();
|
||||
const apiLevel = (/* @IIFE */ () => {
|
||||
// "Constant Value: X"
|
||||
const constValMatched = container.text().match(/Constant\s+Value:\s*(\d+)/i);
|
||||
if (constValMatched) return parseInt(constValMatched[1], 10);
|
||||
// "Added in API level X"
|
||||
const addedInMatched = apiLevelText.match(/API level\s+(\d+)/i);
|
||||
if (addedInMatched) return parseInt(addedInMatched[1], 10);
|
||||
})();
|
||||
if (apiLevel && Number.isFinite(apiLevel)) {
|
||||
if (!map.has(apiLevel)) {
|
||||
map.set(apiLevel, versionCode);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return map;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch all fields except VERSION_CODE from Wikipedia.<br>
|
||||
* zh-CN: 抓取维基除 VERSION_CODE 外的字段.
|
||||
*
|
||||
* @returns {Promise<AndroidReleaseWiki[]>}
|
||||
*/
|
||||
async function fetchWikiRows() {
|
||||
const $ = cheerio.load(await (await fetch(WIKI_URL)).text());
|
||||
|
||||
const re = /^(Name|Internal\s*codename|Version\s*number(\(s\)|s)?|API\s*level|Release\s*date)$/i;
|
||||
const table = $('table.wikitable').filter((_, table) => {
|
||||
return Array.from($(table).find('tr th')).filter(th => {
|
||||
const extractedText = $(th).text().replace(/\[\d+]/g, '').trim();
|
||||
return re.test(extractedText);
|
||||
}).length >= 3;
|
||||
}).first();
|
||||
|
||||
/** @type {AndroidReleaseWiki[]} */
|
||||
const rows = [];
|
||||
|
||||
/**
|
||||
* Maintain column state.<br>
|
||||
* zh-CN: 维护列状态.
|
||||
*
|
||||
* @example Record<columnIndex, { text: string; rowSpansLeft: number }>
|
||||
* { 2: { text: "Gingerbread"; rowSpansLeft: 3 } }
|
||||
*
|
||||
* @type {Record<number, { cell: Cheerio<DomElement>; rowSpansLeft: number }>}
|
||||
*/
|
||||
const rowSpansState = {};
|
||||
|
||||
table.find('tr').each((_i, tr) => {
|
||||
const $tr = $(tr);
|
||||
const tds = $tr.find('td');
|
||||
if (!tds.length) return;
|
||||
|
||||
/** @type {Cheerio<DomElement>[]} */
|
||||
const cells = [];
|
||||
|
||||
let chosenTdsIdx = 0;
|
||||
|
||||
for (let i = 0; i < 5; i += 1) {
|
||||
let cell = null;
|
||||
if (i in rowSpansState) {
|
||||
cell = rowSpansState[i].cell;
|
||||
cells.push(cell);
|
||||
rowSpansState[i].rowSpansLeft -= 1;
|
||||
} else {
|
||||
cell = tds.eq(chosenTdsIdx++);
|
||||
if (!cell || cell.length === 0) return;
|
||||
cells.push(cell);
|
||||
const rowSpansLeft = parseInt(cell.attr('rowspan') || '1', 10) - 1;
|
||||
if (rowSpansLeft > 0) {
|
||||
rowSpansState[i] = { cell, rowSpansLeft };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const [ nameCell, codenameCell, versionCell, apiCell, dateCell ] = cells;
|
||||
|
||||
const name = cleanCell(nameCell);
|
||||
const codename = trimAndNormDash(cleanCell(codenameCell));
|
||||
|
||||
// Version prefers data-sort-value (can be on current td or its child elements).
|
||||
// zh-CN: Version 优先使用 data-sort-value (可在当前 td 或其子元素上).
|
||||
const sortVal = versionCell.attr('data-sort-value') || versionCell.find('[data-sort-value]').attr('data-sort-value');
|
||||
const versionText = trimAndNormDash(stripSupportPrefix(sortVal || cleanCell(versionCell)));
|
||||
|
||||
const api = cleanCell(apiCell);
|
||||
const date = cleanCell(dateCell);
|
||||
|
||||
rows.push({
|
||||
RELEASE_NAME: name,
|
||||
INTERNAL_CODENAME: codename,
|
||||
PLATFORM_VERSION: versionText,
|
||||
API_LEVEL: parseInt(api, 10),
|
||||
RELEASE_DATE: date,
|
||||
});
|
||||
|
||||
for (const k of Object.keys(rowSpansState)) {
|
||||
if (rowSpansState[k].rowSpansLeft === 0) {
|
||||
delete rowSpansState[k];
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return rows;
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns {Promise<AndroidReleaseCsv[]>}
|
||||
*/
|
||||
export async function fetchAndroidReleases() {
|
||||
const official = await fetchOfficialVC();
|
||||
const wiki = await fetchWikiRows();
|
||||
|
||||
/** @type {Map<number, AndroidReleaseCsv>} */
|
||||
const mergedByApi = new Map();
|
||||
|
||||
// First, populate with wiki data.
|
||||
// zh-CN: 先放入 wiki 数据.
|
||||
for (const r of wiki) {
|
||||
const api = r.API_LEVEL;
|
||||
if (!Number.isFinite(api)) continue;
|
||||
const prev = mergedByApi.get(api) || {
|
||||
VERSION_CODE: '', RELEASE_NAME: '', INTERNAL_CODENAME: '',
|
||||
PLATFORM_VERSION: '', API_LEVEL: api, RELEASE_DATE: '',
|
||||
};
|
||||
mergedByApi.set(api, quoteIfNeeded({
|
||||
...prev,
|
||||
RELEASE_NAME: r.RELEASE_NAME || prev.RELEASE_NAME,
|
||||
INTERNAL_CODENAME: r.INTERNAL_CODENAME || prev.INTERNAL_CODENAME,
|
||||
PLATFORM_VERSION: r.PLATFORM_VERSION || prev.PLATFORM_VERSION,
|
||||
RELEASE_DATE: r.RELEASE_DATE || prev.RELEASE_DATE,
|
||||
}));
|
||||
}
|
||||
|
||||
// Override VERSION_CODE with official data.
|
||||
// zh-CN: 用 official 覆盖 VERSION_CODE.
|
||||
for (const [ api, versionCode ] of official.entries()) {
|
||||
const prev = mergedByApi.get(api) || {
|
||||
VERSION_CODE: '', RELEASE_NAME: '', INTERNAL_CODENAME: '',
|
||||
PLATFORM_VERSION: '', API_LEVEL: api, RELEASE_DATE: '',
|
||||
};
|
||||
mergedByApi.set(api, { ...prev, VERSION_CODE: versionCode, API_LEVEL: api });
|
||||
}
|
||||
|
||||
// Output: Sort by API_LEVEL in descending order,
|
||||
// keep only records with VERSION_CODE (officially released/named).
|
||||
// zh-CN: 输出: 按 API_LEVEL 降序, 仅保留存在 VERSION_CODE 的记录 (即官方已发布/命名的).
|
||||
return Array.from(mergedByApi.values())
|
||||
.filter(r => r.VERSION_CODE)
|
||||
.sort((a, b) => b.API_LEVEL - a.API_LEVEL);
|
||||
}
|
||||
|
||||
async function main() {
|
||||
console.table(await fetchAndroidReleases());
|
||||
}
|
||||
|
||||
// Determine if this file is being run directly.
|
||||
// zh-CN: 判断是否为直接执行该文件.
|
||||
if (fileURLToPath(import.meta.url) === process.argv[1]) {
|
||||
main().catch(err => {
|
||||
console.error(err);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
}
|
||||
32
.utils/print-android-version-codes.bat
Normal file
32
.utils/print-android-version-codes.bat
Normal file
@@ -0,0 +1,32 @@
|
||||
@ECHO OFF
|
||||
|
||||
:RUN
|
||||
node "print-android-version-codes.mjs"
|
||||
ECHO.
|
||||
|
||||
ECHO Press [R] to rerun, [Shift+R] to clear screen then rerun, or [ESC]/[Enter]/[Space] to exit...
|
||||
powershell -NoLogo -NoProfile -Command ^
|
||||
"$ErrorActionPreference='Stop';" ^
|
||||
"while($true){" ^
|
||||
" $k=$Host.UI.RawUI.ReadKey('NoEcho,IncludeKeyDown');" ^
|
||||
" if($k.VirtualKeyCode -eq 27 -or $k.VirtualKeyCode -eq 13 -or $k.Character -eq ' '){ exit 1 }" ^
|
||||
" elseif($k.VirtualKeyCode -eq 82){" ^
|
||||
" $isShift = ($k.ControlKeyState -band 0x0010) -ne 0 -or ($k.ControlKeyState -band 0x0080) -ne 0;" ^
|
||||
" if($isShift){ exit 2 } else { exit 0 }" ^
|
||||
" }" ^
|
||||
"}"
|
||||
|
||||
IF ERRORLEVEL 2 GOTO SHIFT_R
|
||||
IF ERRORLEVEL 1 GOTO EXIT
|
||||
|
||||
echo.
|
||||
GOTO RUN
|
||||
|
||||
:SHIFT_R
|
||||
CLS
|
||||
echo.
|
||||
GOTO RUN
|
||||
|
||||
:EXIT
|
||||
echo.
|
||||
EXIT /B
|
||||
110
.utils/print-android-version-codes.mjs
Normal file
110
.utils/print-android-version-codes.mjs
Normal file
@@ -0,0 +1,110 @@
|
||||
// print-android-version-codes.mjs
|
||||
|
||||
import * as fsp from 'node:fs/promises';
|
||||
import * as path from 'path';
|
||||
|
||||
const CSV_PATH = path.resolve(process.cwd(), '../build-logic/ksp-version-codes-processor/src/main/resources/version-codes.csv');
|
||||
|
||||
/**
|
||||
* Simple CSV parsing: supports double quoted fields, commas and line breaks.<br>
|
||||
* zh-CN: 简易 CSV 解析: 支持双引号字段, 逗号, 换行.
|
||||
*
|
||||
* @param {string} text
|
||||
* @returns {string[][]}
|
||||
*/
|
||||
function parseCsv(text) {
|
||||
/** @type {string[][]} */
|
||||
const rows = [];
|
||||
/** @type {string[]} */
|
||||
const row = [];
|
||||
|
||||
let i = 0;
|
||||
let field = '';
|
||||
let inQuotes = false;
|
||||
|
||||
const pushField = () => {
|
||||
row.push(field);
|
||||
field = '';
|
||||
};
|
||||
const pushRow = () => {
|
||||
if (row.length) rows.push(row.slice());
|
||||
row.splice(0);
|
||||
};
|
||||
|
||||
while (i < text.length) {
|
||||
const c = text[i];
|
||||
if (inQuotes) {
|
||||
if (c === '"') {
|
||||
if (i + 1 < text.length && text[i + 1] === '"') {
|
||||
field += '"';
|
||||
i += 2;
|
||||
continue;
|
||||
}
|
||||
inQuotes = false;
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
field += c;
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
if (c === '"') {
|
||||
inQuotes = true;
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
if (c === ',') {
|
||||
pushField();
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
if (c === '\r') {
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
if (c === '\n') {
|
||||
pushField();
|
||||
pushRow();
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
field += c;
|
||||
i++;
|
||||
}
|
||||
// Last cell or last row.
|
||||
// zh-CN: 最后一格或最后一行.
|
||||
if (field.length || row.length) {
|
||||
pushField();
|
||||
pushRow();
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string[][]} rows
|
||||
* @returns {Object<string, string|number>[]}
|
||||
*/
|
||||
function toObjects(rows) {
|
||||
if (!rows.length) return [];
|
||||
/** @type {string[]} */
|
||||
const header = rows[0].map(h => h.trim());
|
||||
return rows.slice(1).map(r => {
|
||||
/** @type {Object<string, string|number>} */
|
||||
const o = {};
|
||||
for (let i = 0; i < header.length; i++) {
|
||||
const key = header[i];
|
||||
const value = (r[i] ?? '').trim();
|
||||
o[key] = key === 'API_LEVEL' ? parseInt(value, 10) : value;
|
||||
}
|
||||
return o;
|
||||
});
|
||||
}
|
||||
|
||||
(async function main() {
|
||||
const text = await fsp.readFile(CSV_PATH, 'utf8');
|
||||
const objs = toObjects(parseCsv(text));
|
||||
console.table(objs);
|
||||
})().catch(err => {
|
||||
console.error(err);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
@@ -20,6 +20,7 @@ const SCRIPT_LIST = [
|
||||
'scrape-and-inject-agp-releases-list.mjs',
|
||||
'scrape-and-inject-ksp-releases-map.mjs',
|
||||
'scrape-and-update-foojay-resolver-version.mjs',
|
||||
'scrape-and-update-android-releases.mjs',
|
||||
'scrape-and-update-readme-template-contributors-table.mjs',
|
||||
];
|
||||
|
||||
|
||||
268
.utils/scrape-and-update-android-releases.mjs
Normal file
268
.utils/scrape-and-update-android-releases.mjs
Normal file
@@ -0,0 +1,268 @@
|
||||
// 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.<br>
|
||||
* 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.<br>
|
||||
* 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).<br>
|
||||
* 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.<br>
|
||||
* 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.<br>
|
||||
* 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;
|
||||
});
|
||||
Reference in New Issue
Block a user