6.7.0 - Alpha6 - Scrapers 工具的更新机制由 "锚点更新" 替换为 "结构化更新"
This commit is contained in:
88
.utils/declarations/index.d.ts
vendored
88
.utils/declarations/index.d.ts
vendored
@@ -7,32 +7,43 @@ type ReleasesData = import('@octokit/types').Endpoints['GET /repos/{owner}/{repo
|
||||
type PullsData = import('@octokit/types').Endpoints['GET /repos/{owner}/{repo}/pulls']['response']['data'];
|
||||
type PullCommitsData = import('@octokit/types').Endpoints['GET /repos/{owner}/{repo}/pulls/{pull_number}/commits']['response']['data'];
|
||||
|
||||
type AndroidStudioStableArchiveItemKind = 'exe' | 'zip' | 'tar' | 'other';
|
||||
|
||||
type FindTargetRowsFilter = string | RegExp | ((s: string) => boolean);
|
||||
|
||||
type TableDataStructureItemName = string;
|
||||
type TableDataStructureItem = RegExp | ((s: string) => boolean | string);
|
||||
type TableDataStructureItemForPageEvaluate = string | RegExp | ((s: string) => boolean | string);
|
||||
|
||||
interface AndroidStudioArchiveItem {
|
||||
title: string;
|
||||
date: string;
|
||||
version: string | null;
|
||||
links: Array<{
|
||||
text: string;
|
||||
href: string;
|
||||
}>;
|
||||
checksums: { [filename: string]: string };
|
||||
interface AndroidStudioRelease {
|
||||
content: {
|
||||
item: AndroidStudioReleaseItem[];
|
||||
};
|
||||
version: number;
|
||||
}
|
||||
|
||||
interface AndroidStudioStableArchiveItem {
|
||||
platform: string;
|
||||
filename: string;
|
||||
interface AndroidStudioReleaseItem {
|
||||
/** @example 'September 29, 2025' */
|
||||
date: string;
|
||||
/** @example '251.27812.49' */
|
||||
platformBuild: string;
|
||||
download: AndroidStudioReleaseDownloadItem[];
|
||||
/** @example 'AI-251.27812.49.2514.14171003' */
|
||||
build: string;
|
||||
/** @example '2025.1.5' */
|
||||
platformVersion: string;
|
||||
/** @example 'Android Studio Narwhal 4 Feature Drop | 2025.1.4 RC 2' */
|
||||
name: string;
|
||||
channel: 'Preview' | 'Canary' | 'Beta' | 'RC' | 'Release' | 'Patch';
|
||||
/** @example '2025.1.4.7' */
|
||||
version: string;
|
||||
}
|
||||
|
||||
interface AndroidStudioReleaseDownloadItem {
|
||||
/** @example '1.4 GB' */
|
||||
size: string;
|
||||
sha256: string;
|
||||
url: string | null;
|
||||
kind: AndroidStudioStableArchiveItemKind;
|
||||
/** @example 'https://redirector.gvt1.com/edgedl/android/studio/ide-zips/2025.1.4.7/android-studio-2025.1.4.7-windows.zip' */
|
||||
link: string;
|
||||
/** @example '91b48f1561cda0387e7499fa7e425908aa5f1235ce36aec1383fa7091f5c242a' */
|
||||
checksum: string;
|
||||
}
|
||||
|
||||
interface FindTargetRowsOptionsBase {
|
||||
@@ -114,13 +125,38 @@ interface ScriptItem {
|
||||
abs: string;
|
||||
}
|
||||
|
||||
interface AnchoredBlockUpdateOption {
|
||||
type: 'map' | 'list' | 'custom';
|
||||
anchorTag: string;
|
||||
mapName?: string;
|
||||
listName?: string;
|
||||
lines: string[];
|
||||
linesIndent?: number;
|
||||
updatedLabel?: string;
|
||||
replacer?: (srcInBlock: string, options: { toUpdatedStamp?: (date?: Date) => string }) => { newBlock: string, changed: boolean };
|
||||
interface GradleMapUpdateOptions extends GradleDataUpdateOptions, GradleMapRwOptions {
|
||||
/* Empty body. */
|
||||
}
|
||||
|
||||
interface GradleListUpdateOptions extends GradleDataUpdateOptions, GradleListRwOptions {
|
||||
/* Empty body. */
|
||||
}
|
||||
|
||||
interface GradleLinesUpdateOptions extends GradleDataUpdateOptions {
|
||||
/* Empty body. */
|
||||
}
|
||||
|
||||
interface GradleDataUpdateOptions extends GradleDataRwOptions {
|
||||
label?: string
|
||||
}
|
||||
|
||||
interface GradleMapRwOptions extends GradleDataRwOptions, MapSortable {
|
||||
/* Empty body. */
|
||||
}
|
||||
|
||||
interface GradleListRwOptions extends GradleDataRwOptions, ListSortable {
|
||||
/* Empty body. */
|
||||
}
|
||||
|
||||
interface GradleDataRwOptions {
|
||||
encoding?: BufferEncoding;
|
||||
}
|
||||
|
||||
interface MapSortable {
|
||||
sort?: `${'key' | 'value'}.${'ascending' | 'descending'}` | `${'key' | 'value'}.${'ascending' | 'descending'}.as.${'string' | 'version' | 'number'}`;
|
||||
}
|
||||
|
||||
interface ListSortable {
|
||||
sort?: `${'ascending' | 'descending'}` | `${'ascending' | 'descending'}.as.${'string' | 'version' | 'number'}`;
|
||||
}
|
||||
|
||||
@@ -1,204 +1,79 @@
|
||||
// fetch-and-parse-android-studio-archives.mjs
|
||||
|
||||
import puppeteer from 'puppeteer';
|
||||
import { compareVersionStrings, isVersionStable } from './utils/versioning.mjs';
|
||||
import { bytes2GiB } from './utils/format.mjs';
|
||||
import { compareVersionStrings } from './utils/versioning.mjs';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { getRemoteFileSizeBytes } from './utils/fetch.mjs';
|
||||
import { readPropertiesSync } from './utils/properties.mjs';
|
||||
import { sleep } from './utils/async.mjs';
|
||||
|
||||
const URL = 'https://developer.android.com/studio/archive?hl=en';
|
||||
const SELECTOR_PRIMARY_BUTTON = 'button.button-primary';
|
||||
const SELECTOR_DEVSITE_EXPANDABLE = 'devsite-expandable';
|
||||
const URL = 'https://jb.gg/android-studio-releases-list.json';
|
||||
|
||||
/**
|
||||
* Find "agree" button in frame (including main document).<br>
|
||||
* zh-CN: 在所有 frame (含主文档) 中查找 "同意" 按钮.
|
||||
*
|
||||
* @param {Page} page
|
||||
* @param {number} [timeoutMs=30000]
|
||||
* @returns {Promise<{ handle: ElementHandle<HTMLButtonElement>, frame: Frame }>}
|
||||
* @returns {Promise<AndroidStudioReleaseItem[]>}
|
||||
*/
|
||||
async function waitAndFindAgreeButton(page, timeoutMs = 30000) {
|
||||
/**
|
||||
* @param {string | null} s
|
||||
* @returns {boolean}
|
||||
*/
|
||||
const containsAgreementText = (s) => s && /\b(?:i\s*agree|agree\s*to\s*the\s*terms|^agree$)/i.test(s.trim());
|
||||
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
while (Date.now() < deadline) {
|
||||
|
||||
// Attempt main document.
|
||||
// zh-CN: 尝试主文档.
|
||||
|
||||
const btnList = await page.$$(SELECTOR_PRIMARY_BUTTON);
|
||||
for (const btn of btnList) {
|
||||
const txt = await page.evaluate(el => el.textContent, btn);
|
||||
if (containsAgreementText(txt)) {
|
||||
return { handle: btn, frame: page.mainFrame() };
|
||||
}
|
||||
}
|
||||
|
||||
// Check all sub-frame.
|
||||
// zh-CN: 检查所有子 frame.
|
||||
|
||||
const frames = page.frames();
|
||||
for (const f of frames) {
|
||||
/** @type {ElementHandle<HTMLButtonElement>} */
|
||||
const btn = await f.$(SELECTOR_PRIMARY_BUTTON);
|
||||
if (!btn) continue;
|
||||
const txt = await f.evaluate(el => el.textContent, btn);
|
||||
if (containsAgreementText(txt)) {
|
||||
return { handle: btn, frame: f };
|
||||
}
|
||||
}
|
||||
|
||||
// Trigger lazy-loading: scroll slightly several times.
|
||||
// zh-CN: 触发懒加载, 轻微滚动几次.
|
||||
|
||||
await page.evaluate(() => window.scrollBy(0, 600));
|
||||
await sleep(300);
|
||||
export async function getAndroidStudioReleases() {
|
||||
const res = await fetch(URL);
|
||||
if (!res.ok) {
|
||||
throw new Error(`Failed to fetch Android Studio releases list: ${res.status} ${res.statusText}`);
|
||||
}
|
||||
throw new Error('Unable to find "agree" button in any document (timeout)');
|
||||
/** @type {AndroidStudioRelease} */
|
||||
const json = await res.json();
|
||||
if (!Array.isArray(((json || {}).content || {}).item)) {
|
||||
throw new Error(`Invalid Android Studio releases list format: ${json}`);
|
||||
}
|
||||
return json.content.item;
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait for a selector to appear in any frame and return that frame.<br>
|
||||
* zh-CN: 在所有 frame 中等待某个选择器出现, 并返回该 frame.
|
||||
*
|
||||
* @param {Page} page
|
||||
* @param {string} selector
|
||||
* @param [timeoutMs=30000]
|
||||
* @returns {Promise<Frame>}
|
||||
* @param {AndroidStudioReleaseItem[]} releases
|
||||
* @returns {AndroidStudioReleaseItem}
|
||||
*/
|
||||
async function waitForFrameWithSelector(page, selector, timeoutMs = 30000) {
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
while (Date.now() < deadline) {
|
||||
for (const f of page.frames()) {
|
||||
if (await f.$(selector)) {
|
||||
return f;
|
||||
}
|
||||
}
|
||||
await sleep(300);
|
||||
}
|
||||
throw new Error(`Could not find selector "${selector}" in any frame`);
|
||||
export function extractLatestStableRelease(releases) {
|
||||
return releases.find(release => {
|
||||
return /Release|Patch/i.test(release.channel);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns {Promise<AndroidStudioArchiveItem[]>}
|
||||
* @param {AndroidStudioReleaseDownloadItem[]} downloadItems
|
||||
* @param {RegExp|null} [linkFilter=null]
|
||||
* @returns {Promise<AndroidStudioReleaseDownloadItem[]>}
|
||||
*/
|
||||
export async function getAndroidStudioArchives() {
|
||||
const browser = await puppeteer.launch({
|
||||
headless: true,
|
||||
args: [
|
||||
'--no-sandbox',
|
||||
'--disable-setuid-sandbox',
|
||||
],
|
||||
});
|
||||
export async function refineDownloadItemsWithRealSize(downloadItems, linkFilter = null) {
|
||||
const results = linkFilter
|
||||
? downloadItems.filter(item => linkFilter.test(item.link))
|
||||
: [ ...downloadItems ];
|
||||
return await Promise.all(results.map(async (item) => {
|
||||
item.size = bytes2GiB(await getRemoteFileSizeBytes(item.link));
|
||||
return item;
|
||||
}));
|
||||
}
|
||||
|
||||
const page = await browser.newPage();
|
||||
await page.setUserAgent('Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0 Safari/537.36');
|
||||
await page.goto(URL, { waitUntil: 'networkidle2', timeout: 60000 });
|
||||
|
||||
// Scroll to download area, trigger lazy loading (helpful for injecting iframe containing "agree" button).
|
||||
// zh-CN: 滚动到下载区域, 触发懒加载 (有助于注入承载 "同意" 按钮的 iframe).
|
||||
await page.evaluate(() => {
|
||||
const anchor = document.querySelector('#downloads')
|
||||
|| Array.from(document.querySelectorAll('h2, h3'))
|
||||
.find(h => /download|archive/i.test(h.textContent ?? ''));
|
||||
if (anchor) anchor.scrollIntoView({ behavior: 'instant', block: 'start' });
|
||||
});
|
||||
await sleep(300);
|
||||
|
||||
try {
|
||||
const { handle, frame } = await waitAndFindAgreeButton(page, 30000);
|
||||
await frame.waitForSelector(SELECTOR_PRIMARY_BUTTON, { visible: true, timeout: 15000 }).catch(_ => null);
|
||||
await handle.click();
|
||||
} catch (e) {
|
||||
console.log('No protocol detected or already agreed, continuing parsing...');
|
||||
}
|
||||
|
||||
// Don't wait in main document after agreeing; wait for devsite-expandable in content frame instead.
|
||||
// If not found initially, try slightly scrolling to trigger lazy loading and check again.
|
||||
// zh-CN:
|
||||
// 同意后不要在主文档等待; 改为在包含内容的 frame 里等待 devsite-expandable.
|
||||
// 若首次未出现, 尝试轻微滚动以触发懒加载, 再次检查.
|
||||
|
||||
/** @type {Frame} */
|
||||
let contentFrame;
|
||||
try {
|
||||
contentFrame = await waitForFrameWithSelector(page, SELECTOR_DEVSITE_EXPANDABLE, 20000);
|
||||
} catch {
|
||||
// Attempt to trigger loading by scrolling. (zh-CN: 尝试滚动触发.)
|
||||
for (let i = 0; i < 8; i++) {
|
||||
await page.evaluate(() => window.scrollBy(0, 800));
|
||||
await sleep(300);
|
||||
}
|
||||
// Check again. (zh-CN: 再次检查.)
|
||||
contentFrame = await waitForFrameWithSelector(page, SELECTOR_DEVSITE_EXPANDABLE, 20000);
|
||||
}
|
||||
if (!contentFrame) {
|
||||
throw new Error('Failed to find content frame');
|
||||
}
|
||||
|
||||
/** @type {AndroidStudioArchiveItem[]} */
|
||||
const archives = await contentFrame.$$eval(SELECTOR_DEVSITE_EXPANDABLE, nodes => {
|
||||
const pickText = (/** @type {Node | null} */ el) => String(el?.textContent ?? '').trim();
|
||||
return nodes.map(n => {
|
||||
/** @type {Node} */
|
||||
const titleElement = n.querySelector('.expand-control');
|
||||
const title = pickText(titleElement?.childNodes?.[0] ?? null);
|
||||
const date = pickText(n.querySelector('.expand-control span'))
|
||||
.replace(/^([A-Z][a-z]{2})[a-z]*( \d+, \d+)$/, '$1$2');
|
||||
/** @type {Element[]} */
|
||||
const linkElements = Array.from(n.querySelectorAll('.downloads a[href]'));
|
||||
const links = linkElements.map(a => ({
|
||||
text: pickText(a),
|
||||
href: a.getAttribute('href') ?? '',
|
||||
}));
|
||||
|
||||
/** @type {{ [filename: string]: string }} */
|
||||
const checksums = {};
|
||||
/** @type {HTMLElement} */
|
||||
const downloadsElement = n.querySelector('.downloads');
|
||||
const bodyText = (downloadsElement?.innerText || '').trim();
|
||||
bodyText.split('\n').forEach(line => {
|
||||
const m = /^\s*([a-f0-9]{64})\s+(.+?)\s*$/.exec(line);
|
||||
if (m) {
|
||||
const [ _, sha256, filename ] = m;
|
||||
checksums[filename] = sha256;
|
||||
}
|
||||
});
|
||||
|
||||
/* e.g. "2025.1.3". */
|
||||
const version = title.match(/\d{2,}\.\d+(?:\.\d+)?/)?.[0] ?? null;
|
||||
return { title, date, version, links, checksums };
|
||||
});
|
||||
});
|
||||
|
||||
await browser.close();
|
||||
|
||||
return archives;
|
||||
/**
|
||||
* @example string
|
||||
* 'September 26, 2025' -> 'Sep 26, 2025'
|
||||
* @param {string} date
|
||||
*/
|
||||
export function formatDate(date) {
|
||||
return date.replace(/([A-Z][a-z]{2})\w* (\d+), (\d+)/, '$1 $2, $3');
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const props = readPropertiesSync();
|
||||
const minSupportedAndroidStudioVersion = props['MIN_SUPPORTED_ANDROID_STUDIO_IDE_VERSION'];
|
||||
const getVersionFromTitle = (/** @type {string} */ title) => title.split('|')[1].trim();
|
||||
const archives = await getAndroidStudioArchives();
|
||||
const results = archives.filter(archive => {
|
||||
if (!archive.title.includes('|')) return false;
|
||||
return compareVersionStrings(archive.version ?? '0', minSupportedAndroidStudioVersion) >= 0;
|
||||
}).map(({ title, date, version, links }) => {
|
||||
const windowsZipUrl = links.find(link => link.text.match(/-windows(-exe)?\.zip/i))?.href;
|
||||
const minSupportedAndroidStudioVersion = props.get('MIN_SUPPORTED_ANDROID_STUDIO_IDE_VERSION');
|
||||
const releases = await getAndroidStudioReleases();
|
||||
const results = releases.filter(release => {
|
||||
return compareVersionStrings(release.version, minSupportedAndroidStudioVersion) >= 0;
|
||||
}).map(({ name, date, version, download, channel }) => {
|
||||
const windowsZipUrl = download.find(e => e.link.match(/-windows(-exe)?\.zip/i))?.link;
|
||||
if (!windowsZipUrl) {
|
||||
console.log('Unable to find Windows zip link for:');
|
||||
console.log(links.map(link => link.text).join('\n'));
|
||||
console.log(download.map(e => e.link).join('\n'));
|
||||
}
|
||||
const stable = isVersionStable(getVersionFromTitle(title)) || '-';
|
||||
return { title, date, version, stable, 'link for Windows (zip)': windowsZipUrl };
|
||||
const stable = /Release|Patch/i.test(channel) || '-';
|
||||
return { name, date: formatDate(date), version, stable, 'link for Windows (zip)': windowsZipUrl };
|
||||
}).sort((a, b) => {
|
||||
return compareVersionStrings(getVersionFromTitle(b.title), getVersionFromTitle(a.title));
|
||||
return compareVersionStrings(b.version, a.version);
|
||||
});
|
||||
console.table(results);
|
||||
}
|
||||
|
||||
@@ -1,122 +0,0 @@
|
||||
// fetch-and-parse-android-studio-latest-stable-version.mjs
|
||||
|
||||
import * as cheerio from 'cheerio';
|
||||
import { bytes2GiB } from './utils/format.mjs';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { getRemoteFileSizeBytes } from './utils/fetch.mjs';
|
||||
|
||||
const URL = 'https://developer.android.com/studio?hl=en';
|
||||
|
||||
/**
|
||||
* @param {string} s
|
||||
* @returns {string}
|
||||
*/
|
||||
const normalize = (s) => (s ?? '').replace(/\s+/g, ' ').trim();
|
||||
|
||||
/**
|
||||
* @param {string} filename
|
||||
* @param {string} kind
|
||||
* @returns {string | null}
|
||||
*/
|
||||
function buildDownloadUrlFromFilename(filename, kind) {
|
||||
const m = /android-studio-([\d.]+)-/.exec(filename);
|
||||
if (!m) return null;
|
||||
const version = m[1];
|
||||
const urlType = {
|
||||
'exe': 'install',
|
||||
'zip': 'ide-zips',
|
||||
'tar': 'ide-zips',
|
||||
}[kind];
|
||||
if (!urlType) {
|
||||
throw new Error(`Unsupported kind: ${kind}`);
|
||||
}
|
||||
return `https://redirector.gvt1.com/edgedl/android/studio/${urlType}/${version}/${filename}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} url
|
||||
* @returns {Promise<string>}
|
||||
*/
|
||||
async function fetchHtml(url) {
|
||||
const res = await fetch(url, {
|
||||
headers: {
|
||||
'user-agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124 Safari/537.36',
|
||||
'accept-language': 'en',
|
||||
},
|
||||
});
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status} for ${url}`);
|
||||
return await res.text();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} html
|
||||
* @returns {Promise<AndroidStudioStableArchiveItem[]>}
|
||||
*/
|
||||
async function parseItemsFromHtml(html) {
|
||||
const $ = cheerio.load(html);
|
||||
const rows = [];
|
||||
|
||||
/** @type {import('cheerio').Cheerio<import('domhandler').Element>} */
|
||||
const tableRows = $('table.download tbody tr');
|
||||
await Promise.all(Array.from(tableRows).map(async tr => {
|
||||
const tds = $(tr).find('td');
|
||||
if (tds.length !== 4) return;
|
||||
|
||||
const platform = normalize($(tds[0]).text());
|
||||
if (!/windows|linux/i.test(platform)) return;
|
||||
|
||||
const btn = $(tds[1]).find('button.devsite-dialog-button').first();
|
||||
const filename = normalize(btn.text());
|
||||
if (!filename || !filename.includes('android-studio')) return;
|
||||
|
||||
const sha256 = normalize($(tds[3]).text());
|
||||
|
||||
// @formatter:off
|
||||
const kind = filename.match(/\.exe$/) ? 'exe'
|
||||
: filename.match(/\.zip$/) ? 'zip'
|
||||
: filename.match(/\.tar(\.gz)?$/) ? 'tar'
|
||||
: 'other';
|
||||
// @formatter:on
|
||||
|
||||
const url = buildDownloadUrlFromFilename(filename, kind);
|
||||
const bytes = await getRemoteFileSizeBytes(url);
|
||||
const size = bytes2GiB(bytes);
|
||||
rows.push({ platform, filename, size, sha256, url, kind });
|
||||
}))
|
||||
return rows
|
||||
.filter(r => r.kind === 'exe' || r.kind === 'zip' || r.kind === 'tar')
|
||||
.sort((_, b) => b.kind === 'exe' ? 1 : b.kind === 'zip' ? 1 : -1);
|
||||
}
|
||||
|
||||
/**
|
||||
* Export: Get download information for the latest stable archives (exe, zip, tar)<br>
|
||||
* Returns array like: [ { platform, filename, size, sha256, url, kind: 'exe'|'zip'|'tar' } ]<br>
|
||||
* zh-CN:<br>
|
||||
* 导出: 获取最新稳定版档案的下载信息 (exe, zip, tar)<br>
|
||||
* 返回形如: [ { platform, filename, size, sha256, url, kind: 'exe'|'zip'|'tar' } ]
|
||||
*
|
||||
* @param {string} [sourceUrl=URL]
|
||||
* @returns {Promise<AndroidStudioStableArchiveItem[]>}
|
||||
*/
|
||||
export async function getLatestStableArchives(sourceUrl = URL) {
|
||||
const html = await fetchHtml(sourceUrl);
|
||||
const items = await parseItemsFromHtml(html);
|
||||
if (!items.length) {
|
||||
throw new Error('No latest stable archives found in the page');
|
||||
}
|
||||
return items;
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const items = await getLatestStableArchives(URL);
|
||||
console.table(items.map(({ filename, size, url, sha256 }) => ({ filename, size, url, sha256 })));
|
||||
}
|
||||
|
||||
// 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;
|
||||
});
|
||||
}
|
||||
@@ -9,15 +9,15 @@ import { spawn } from 'node:child_process';
|
||||
const SCRIPT_LIST = [
|
||||
'scrape-and-update-readme-template-contributors-table.mjs',
|
||||
'scrape-and-update-foojay-resolver-version.mjs',
|
||||
'scrape-and-inject-latest-gradle-wrapper.mjs',
|
||||
'scrape-and-inject-agp-releases.mjs',
|
||||
'scrape-and-inject-android-studio-agp-version-map.mjs',
|
||||
'scrape-and-inject-android-studio-codename_maps.mjs',
|
||||
'scrape-and-inject-gradle-kotlin-compatibility-list.mjs',
|
||||
'scrape-and-inject-ksp-releases.mjs',
|
||||
'scrape-and-inject-agp-gradle-compatibility-list.mjs',
|
||||
'scrape-and-inject-java-gradle-compatibility-list.mjs',
|
||||
'scrape-and-inject-rhino-engine-data.mjs',
|
||||
'scrape-and-inject-latest-gradle-wrapper.mjs',
|
||||
'scrape-and-inject-gradle-kotlin-compatibility-map.mjs',
|
||||
'scrape-and-inject-android-studio-codename-maps.mjs',
|
||||
'scrape-and-inject-android-studio-agp-version-map.mjs',
|
||||
'scrape-and-inject-java-gradle-compatibility-map.mjs',
|
||||
'scrape-and-inject-agp-gradle-compatibility-map.mjs',
|
||||
'scrape-and-inject-agp-releases-list.mjs',
|
||||
'scrape-and-inject-ksp-releases-map.mjs',
|
||||
];
|
||||
|
||||
const childProcessOutput = [];
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
// scrape-and-inject-agp-gradle-compatibility-list.mjs
|
||||
// scrape-and-inject-agp-gradle-compatibility-map.mjs
|
||||
|
||||
import { compareVersionStrings } from './utils/versioning.mjs';
|
||||
import { findTargetRows } from './utils/puppeteer-helpers.mjs';
|
||||
import { getMinSupportedAgpVersion, getMinSupportedGradleVersion } from './utils/properties.mjs';
|
||||
import { updateAnchoredListInFile } from './utils/anchors.mjs';
|
||||
import { updateGradleMapData } from './utils/update-helper.mjs';
|
||||
|
||||
const URL = 'https://developer.android.com/build/releases/gradle-plugin#updating-gradle';
|
||||
|
||||
@@ -26,20 +26,15 @@ const URL = 'https://developer.android.com/build/releases/gradle-plugin#updating
|
||||
});
|
||||
const minSupportedAgpVersion = getMinSupportedAgpVersion();
|
||||
const minSupportedGradleVersion = getMinSupportedGradleVersion();
|
||||
const map = {};
|
||||
const map = new Map();
|
||||
for (const { pluginVersion, gradleVersion } of rows) {
|
||||
if (compareVersionStrings(pluginVersion, minSupportedAgpVersion) < 0) continue;
|
||||
if (compareVersionStrings(gradleVersion, minSupportedGradleVersion) < 0) continue;
|
||||
map[pluginVersion] = gradleVersion;
|
||||
map.set(pluginVersion, gradleVersion);
|
||||
}
|
||||
|
||||
await updateAnchoredListInFile('../settings.gradle.kts', {
|
||||
anchorTag: 'AGP_GRADLE_COMPATIBILITY_LIST',
|
||||
listName: 'agpGradleCompatibility',
|
||||
lines: Object.entries(map)
|
||||
.sort((a, b) => compareVersionStrings(b[0], a[0]))
|
||||
.map(([ pluginVersion, gradleVersion ]) => `"${pluginVersion}" to "${gradleVersion}",`),
|
||||
updatedLabel: 'AGP and Gradle compatibility list',
|
||||
await updateGradleMapData('agp-gradle-compat', map, {
|
||||
label: 'AGP and Gradle compatibility map',
|
||||
sort: 'key.descending.as.version',
|
||||
});
|
||||
})().catch(err => {
|
||||
console.error(err);
|
||||
@@ -1,9 +1,9 @@
|
||||
// scrape-and-inject-agp-releases.mjs
|
||||
// scrape-and-inject-agp-releases-list.mjs
|
||||
|
||||
import * as cheerio from 'cheerio';
|
||||
import { compareVersionStrings, compareVersionStringsDescending } from './utils/versioning.mjs';
|
||||
import { compareVersionStrings } from './utils/versioning.mjs';
|
||||
import { getMinSupportedAgpVersion } from './utils/properties.mjs';
|
||||
import { updateAnchoredListInFile } from './utils/anchors.mjs';
|
||||
import { updateGradleListData } from './utils/update-helper.mjs';
|
||||
|
||||
const URL = 'https://developer.android.com/reference/tools/gradle-api';
|
||||
|
||||
@@ -19,14 +19,10 @@ const URL = 'https://developer.android.com/reference/tools/gradle-api';
|
||||
}
|
||||
});
|
||||
const minSupportedVersion = getMinSupportedAgpVersion();
|
||||
const agpList = Array.from(results)
|
||||
.filter(v => compareVersionStrings(v, minSupportedVersion) >= 0)
|
||||
.sort(compareVersionStringsDescending);
|
||||
await updateAnchoredListInFile('../settings.gradle.kts', {
|
||||
anchorTag: 'ANDROID_GRADLE_PLUGIN_RELEASES_LIST',
|
||||
listName: 'agpReleases',
|
||||
lines: agpList.map(v => `"${v}",`),
|
||||
updatedLabel: 'AGP releases list',
|
||||
const agpList = new Set(Array.from(results).filter(v => compareVersionStrings(v, minSupportedVersion) >= 0));
|
||||
await updateGradleListData('agp-releases', agpList, {
|
||||
label: 'AGP releases list',
|
||||
sort: 'descending.as.version',
|
||||
});
|
||||
})().catch(err => {
|
||||
console.error('Failed to fetch AGP releases:', err);
|
||||
@@ -1,39 +1,36 @@
|
||||
// scrape-android-studio-agp_version_maps.mjs
|
||||
// scrape-and-inject-android-studio-agp-version-map.mjs
|
||||
|
||||
import { compareVersionStrings } from './utils/versioning.mjs';
|
||||
import { fetchStudioAgpTable } from './fetch-and-parse-android-studio-agp-compatibility-table.mjs';
|
||||
import { readPropertiesSync } from './utils/properties.mjs';
|
||||
import { updateAnchoredMapInFile } from './utils/anchors.mjs';
|
||||
import { updateGradleMapData } from './utils/update-helper.mjs';
|
||||
|
||||
const props = readPropertiesSync();
|
||||
|
||||
const version = {
|
||||
MIN_IDE: props['MIN_SUPPORTED_ANDROID_STUDIO_IDE_VERSION'],
|
||||
MIN_AGP: props['MIN_SUPPORTED_ANDROID_STUDIO_AGP_VERSION'],
|
||||
MIN_IDE: props.get('MIN_SUPPORTED_ANDROID_STUDIO_IDE_VERSION'),
|
||||
MIN_AGP: props.get('MIN_SUPPORTED_ANDROID_STUDIO_AGP_VERSION'),
|
||||
};
|
||||
|
||||
(async function main() {
|
||||
const agpTable = await fetchStudioAgpTable();
|
||||
/** @type {{ [targetStudioVersion: string]: string }} */
|
||||
const agpMap = {};
|
||||
const map = new Map();
|
||||
|
||||
for (const { studioVersion, agpRange } of agpTable) {
|
||||
const targetStudioVersion = studioVersion.match(/\d{2,}\.\d+\.\d+/)?.[0];
|
||||
if (!targetStudioVersion) continue;
|
||||
|
||||
const [ _, targetAgpVersion ] = agpRange.split('-');
|
||||
if (!(targetStudioVersion in agpMap) || compareVersionStrings(agpMap[targetStudioVersion], targetAgpVersion) > 0) {
|
||||
agpMap[targetStudioVersion] = targetAgpVersion;
|
||||
if (!map.has(targetStudioVersion) || compareVersionStrings(map.get(targetStudioVersion), targetAgpVersion) > 0) {
|
||||
map.set(targetStudioVersion, targetAgpVersion);
|
||||
}
|
||||
if (compareVersionStrings(targetStudioVersion, version.MIN_IDE) <= 0) break;
|
||||
if (compareVersionStrings(targetAgpVersion, version.MIN_AGP) <= 0) break;
|
||||
}
|
||||
|
||||
await updateAnchoredMapInFile('../settings.gradle.kts', {
|
||||
anchorTag: 'ANDROID_STUDIO_AGP_VERSION_MAP',
|
||||
mapName: 'agpVersionMap',
|
||||
lines: Object.entries(agpMap).map(([ studioVer, agpVer ]) => `"${studioVer}" to "${agpVer}",`),
|
||||
updatedLabel: 'AGP version map',
|
||||
await updateGradleMapData('android-studio-agp-compat', map, {
|
||||
label: 'Android Studio and AGP compatibility map',
|
||||
sort: 'key.descending.as.version',
|
||||
});
|
||||
})().catch(err => {
|
||||
console.error(err);
|
||||
|
||||
563
.utils/scrape-and-inject-android-studio-codename-maps.mjs
Normal file
563
.utils/scrape-and-inject-android-studio-codename-maps.mjs
Normal file
@@ -0,0 +1,563 @@
|
||||
// scrape-and-inject-android-studio-codename-maps.mjs
|
||||
|
||||
import * as fsp from 'node:fs/promises';
|
||||
import * as path from 'node:path';
|
||||
import { compareVersionStrings, compareVersionStringsDescending } from './utils/versioning.mjs';
|
||||
import { extractLatestStableRelease, getAndroidStudioReleases, refineDownloadItemsWithRealSize } from './fetch-and-parse-android-studio-archives.mjs';
|
||||
import { toUpdatedStamp, toYYYYMMDD } from './utils/date.mjs';
|
||||
import { readPropertiesSync } from './utils/properties.mjs';
|
||||
import { updateGradleLinesData, updateGradleMapData } from './utils/update-helper.mjs';
|
||||
|
||||
/**
|
||||
* Manual override for codename mappings (for resolving codename prefix conflicts).<br>
|
||||
* Default is first letter, e.g. 'Meerkat' and 'Bumblebee' gives { Meerkat: 'M', Bumblebee: 'B' }.<br>
|
||||
* When first letter conflict, conflicts are auto-resolved,
|
||||
* e.g. 'Camel' and 'Catfish' gives { Camel: 'CAM', Catfish: 'CAT' }.<br>
|
||||
* For extreme cases where conflicts cannot be auto-resolved,
|
||||
* e.g. 'Cat' and 'Catfish', manual prefix mapping is needed, like { Cat: 'CT', Catfish: 'CTF' }.<br>
|
||||
* zh-CN:<br>
|
||||
* 手动覆盖代号映射 (可用于解决代号前缀冲突).<br>
|
||||
* 默认为首字母, 如 'Meerkat' 与 'Bumblebee', 得到 { Meerkat: 'M', Bumblebee: 'B' }.<br>
|
||||
* 首字母重复时自动消解冲突, 如 'Camel' 与 'Catfish', 得到 { Camel: 'CAM', Catfish: 'CAT' }.<br>
|
||||
* 极端情况无法自动消解冲突, 如 'Cat' 与 'Catfish', 此时需要手动指定前缀, 如 { Cat: 'CT', Catfish: 'CTF' }.
|
||||
* @example Object<codename name, codename prefix>
|
||||
* {
|
||||
* 'Camel': 'CM',
|
||||
* 'Cat': 'CT',
|
||||
* 'Catfish': 'CTF',
|
||||
* ... ...
|
||||
* }
|
||||
* @type {Object<string, string>}
|
||||
*/
|
||||
const manualCodenameOverrides = {};
|
||||
|
||||
const MIN_ANDROID_STUDIO_VERSION = (/* @IIFE */ () => {
|
||||
const props = readPropertiesSync('../version.properties');
|
||||
return props.get('MIN_SUPPORTED_ANDROID_STUDIO_IDE_VERSION');
|
||||
})();
|
||||
|
||||
/**
|
||||
* Update common.json with the lastest stable release.<br>
|
||||
* zh-CN: 用最新稳定版更新 common.json.
|
||||
*
|
||||
* @param {AndroidStudioReleaseItem[]} releases
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async function updateCommonDataByLatestRelease(releases) {
|
||||
const latest = extractLatestStableRelease(releases);
|
||||
const latestDownload = await refineDownloadItemsWithRealSize(latest.download, /windows|linux/i);
|
||||
|
||||
/**
|
||||
* @param {RegExp} suffix
|
||||
* @returns {{ name: string, link: string, size: string }}
|
||||
*/
|
||||
const pickWinItem = (suffix) => {
|
||||
const aim = latestDownload.find(l => suffix.test(l.link));
|
||||
if (!aim) throw new Error(`Matched archive entry missing Windows EXE/ZIP or Linux TAR download information (suffix: ${suffix})`);
|
||||
return { name: aim.link.split('/').pop(), link: aim.link, size: aim.size };
|
||||
};
|
||||
const exeItem = pickWinItem(/\.exe$/);
|
||||
const zipItem = pickWinItem(/\.zip$/);
|
||||
const tarItem = pickWinItem(/\.tar(\.gz)?$/);
|
||||
|
||||
// Prepare fields needed to write back to common.json.
|
||||
// zh-CN: 准备写回 common.json 所需字段.
|
||||
|
||||
const latestVersionName = latest.name; // e.g. "Android Studio Narwhal Feature Drop | 2025.1.2"
|
||||
const latestVersionDate = toYYYYMMDD(latest.date) || ''; // e.g. "2025/07/31"
|
||||
|
||||
// Read and update common.json (only update keys containing "android_studio_latest_" fragment and version name date key).
|
||||
// zh-CN: 读取并更新 common.json (仅更新带有 "android_studio_latest_" 片段的键与版本名日期键).
|
||||
|
||||
const commonJsonPath = path.resolve(process.cwd(), '../.readme/common.json');
|
||||
const commonRaw = await fsp.readFile(commonJsonPath, 'utf8');
|
||||
const commonObj = JSON.parse(commonRaw);
|
||||
|
||||
const updatedCommon = {
|
||||
...commonObj,
|
||||
android_studio_latest_recommended_version_name: latestVersionName,
|
||||
var_date_android_studio_latest_recommended_version_name: latestVersionDate,
|
||||
android_studio_latest_recommended_file_name_of_exe: exeItem.name,
|
||||
android_studio_latest_recommended_download_address_of_exe: exeItem.link,
|
||||
android_studio_latest_recommended_file_size_of_exe: exeItem.size,
|
||||
android_studio_latest_recommended_file_name_of_zip: zipItem.name,
|
||||
android_studio_latest_recommended_download_address_of_zip: zipItem.link,
|
||||
android_studio_latest_recommended_file_size_of_zip: zipItem.size,
|
||||
android_studio_latest_recommended_file_name_of_tar: tarItem.name,
|
||||
android_studio_latest_recommended_download_address_of_tar: tarItem.link,
|
||||
android_studio_latest_recommended_file_size_of_tar: tarItem.size,
|
||||
};
|
||||
|
||||
if (JSON.stringify(updatedCommon) !== JSON.stringify(commonObj)) {
|
||||
await fsp.writeFile(commonJsonPath, JSON.stringify(updatedCommon, null, 2), 'utf8');
|
||||
console.log('[common.json] Updated (Android Studio information)');
|
||||
const from = commonObj.android_studio_latest_recommended_version_name;
|
||||
const to = updatedCommon.android_studio_latest_recommended_version_name;
|
||||
if (from !== to) {
|
||||
const maxLength = Math.max(...[ from, to ].map(s => s.length + 5));
|
||||
const SEP_EQ = '='.repeat(maxLength);
|
||||
console.log(SEP_EQ);
|
||||
console.log(`-- "${from}"`);
|
||||
console.log(`-> "${to}"`);
|
||||
console.log(SEP_EQ);
|
||||
}
|
||||
} else {
|
||||
// console.log('[common.json] No update needed (Android Studio information)');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Summarize codename-to-version mappings and codename first release dates.<br>
|
||||
* zh-CN: 汇总代号版本映射以及代号的首发日期.
|
||||
*
|
||||
* @param {AndroidStudioReleaseItem[]} releases
|
||||
*/
|
||||
function getCodenameMapLinesInfo(releases) {
|
||||
|
||||
/**
|
||||
* Parse "codename" from name,
|
||||
* zh-CN: 从名称中解析 "代号",
|
||||
*
|
||||
* @example string
|
||||
* 'Android Studio Meerkat Feature Drop | 2024.3.2 RC 1' -> 'Meerkat'
|
||||
*
|
||||
* @param {string} name
|
||||
* @returns {string | null}
|
||||
*/
|
||||
const codenameFromName = (name) => {
|
||||
// Capture "Android Studio <Codename> [Feature Drop]".
|
||||
// zh-CN: 捕获 "Android Studio <Codename> [Feature Drop]".
|
||||
const m = /Android Studio\s+(.+?)\s*(?:(\s+\d+\s+)?Feature Drop)?(?=\s*\|)/i.exec(name);
|
||||
return m ? m[1].trim() : null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Generate unique codes: sequentially, uniformly increase length for conflict groups;
|
||||
* override mappings take precedence.<br>
|
||||
* zh-CN:<br>
|
||||
* 生成唯一代码: 按顺序, 出现冲突则对冲突组统一递增长度; 覆盖映射优先考虑.
|
||||
*
|
||||
* @example Map<name, prefixCode>
|
||||
* Map(14) {
|
||||
* 'Arctic Fox' => 'A',
|
||||
* 'Bumblebee' => 'B',
|
||||
* ... ...
|
||||
* 'Narwhal' => 'N'
|
||||
* }
|
||||
*
|
||||
* @param {string[]} names
|
||||
* Original codenames (preserves case and spaces, e.g. "Arctic Fox", "Bumblebee", "Narwhal" etc).<br>
|
||||
* zh-CN: 原始代号 (保留大小写与空格, 如 "Arctic Fox", "Bumblebee", "Narwhal" 等).
|
||||
* @param {Object<string, string>} overrides
|
||||
* Manual override mappings.<br>
|
||||
* zh-CN: 手动覆盖映射.
|
||||
* @returns {Map<string, string>}
|
||||
*/
|
||||
const buildUniquePrefixes = (names, overrides) => {
|
||||
|
||||
/**
|
||||
* @param {string} s
|
||||
* @returns {string}
|
||||
*/
|
||||
const normalize = (s) => s.replace(/\s+/g, '');
|
||||
|
||||
/**
|
||||
* @param {string} s
|
||||
* @param {string} def
|
||||
* @returns {string}
|
||||
*/
|
||||
const normalizeOrDefault = (s, def) => s && normalize(s) || normalize(def);
|
||||
|
||||
/**
|
||||
* @param {string }s
|
||||
* @param {number} def
|
||||
* @returns {number}
|
||||
*/
|
||||
const normalizeLength = (s, def) => s && normalize(s).length || def;
|
||||
|
||||
const entries = names.map(name => {
|
||||
const override = overrides[name]?.toUpperCase();
|
||||
const base = normalize(name);
|
||||
const len = normalizeLength(override, 1);
|
||||
const code = normalizeOrDefault(override, base.slice(0, len)).toUpperCase();
|
||||
const locked = Boolean(override);
|
||||
return { name, base, len, code, locked };
|
||||
});
|
||||
|
||||
// Detect and resolve conflicts. (zh-CN: 检测并解决冲突.)
|
||||
const maxLenByName = new Map(entries.map(e => [ e.name, e.base.length ]));
|
||||
// Loop limit protection to prevent infinite loops in extreme cases.
|
||||
// zh-CN: 循环上限保护, 防止极端情况下死循环.
|
||||
for (let step = 0; step < 1 << 10; step++) {
|
||||
/**
|
||||
* Count conflict groups. (zh-CN: 统计冲突组.)
|
||||
* @example Map<code, indices>
|
||||
* Map(14) {
|
||||
* 'N' => [ 0 ],
|
||||
* 'M' => [ 1 ],
|
||||
* ... ...
|
||||
* 'B' => [ 12 ],
|
||||
* 'A' => [ 13 ]
|
||||
* }
|
||||
* @type {Map<string, number[]>}
|
||||
*/
|
||||
const bucket = new Map();
|
||||
entries.forEach((e, idx) => {
|
||||
const key = e.code;
|
||||
if (!bucket.has(key)) bucket.set(key, []);
|
||||
bucket.get(key).push(idx);
|
||||
});
|
||||
|
||||
const conflicts = Array.from(bucket.entries()).filter(([ _, indices ]) => indices.length >= 2);
|
||||
if (conflicts.length === 0) {
|
||||
// No conflicts, or all conflicts have been resolved.
|
||||
// zh-CN: 没有冲突, 或冲突已全部解决.
|
||||
break;
|
||||
}
|
||||
|
||||
for (const [ code, indices ] of conflicts) {
|
||||
const lockedIndices = indices.filter(i => entries[i].locked);
|
||||
if (lockedIndices.length >= 2) {
|
||||
const groupNames = indices.map(i => entries[i].name);
|
||||
throw new Error(`[CodenameMap] Manual override mapping conflict: "${code}" -> [ ${groupNames.join(', ')} ]`);
|
||||
}
|
||||
for (const i of indices) {
|
||||
const e = entries[i];
|
||||
if (e.locked) continue;
|
||||
const maxLen = maxLenByName.get(e.name);
|
||||
if (e.len >= maxLen) {
|
||||
const groupNames = indices.map(i => entries[i].name);
|
||||
throw new Error(`[CodenameMap] Unable to auto-resolve conflicts: [ ${groupNames.join(', ')} ]`);
|
||||
}
|
||||
e.len += 1;
|
||||
e.code = e.base.slice(0, e.len);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return new Map(entries.map(e => [ e.name, e.code ]));
|
||||
};
|
||||
|
||||
const codenames = [ ...new Set(releases.map(o => codenameFromName(o.name)).filter(Boolean)) ];
|
||||
const nameToCode = buildUniquePrefixes(codenames, manualCodenameOverrides);
|
||||
|
||||
/**
|
||||
* @example Map<version, prefixCode>
|
||||
* Map(22) {
|
||||
* ... ...
|
||||
* '2025.1.4.7' => 'N',
|
||||
* '2025.1.4.6' => 'N',
|
||||
* '2025.1.4.5' => 'N',
|
||||
* '2025.1.4.4' => 'N',
|
||||
* '2025.1.3.7' => 'N',
|
||||
* '2025.1.4.3' => 'N',
|
||||
* '2025.1.3.6' => 'N',
|
||||
* '2025.1.2.13' => 'N',
|
||||
* '2025.1.4.2' => 'N',
|
||||
* '2025.1.2.12' => 'N',
|
||||
* '2025.1.4.1' => 'N',
|
||||
* '2025.1.3.5' => 'N',
|
||||
* '2025.1.3.4' => 'N',
|
||||
* ... ...
|
||||
* }
|
||||
* @type {Map<string, string>}
|
||||
*/
|
||||
const versionToLetter = new Map();
|
||||
|
||||
/**
|
||||
* @example Map<prefixCode, { name, born }>
|
||||
* Map(14) {
|
||||
* 'N' => { name: 'Narwhal', born: 2025-03-18T16:00:00.000Z },
|
||||
* 'M' => { name: 'Meerkat', born: 2024-11-11T16:00:00.000Z },
|
||||
* ... ...
|
||||
* 'B' => { name: 'Bumblebee', born: 2021-05-17T16:00:00.000Z },
|
||||
* 'A' => { name: 'Arctic Fox', born: 2021-01-25T16:00:00.000Z }
|
||||
* }
|
||||
* @type {Map<string, { name: string, born: Date } >}
|
||||
*/
|
||||
const letterBorn = new Map();
|
||||
|
||||
const buildVersionMap = new Map();
|
||||
|
||||
for (const release of releases) {
|
||||
if (!release.version) continue;
|
||||
|
||||
if (compareVersionStrings(release.version, MIN_ANDROID_STUDIO_VERSION) >= 0) {
|
||||
buildVersionMap.set(release.build.replace(/[^\d.]*/g, ''), release.version);
|
||||
}
|
||||
|
||||
const cname = codenameFromName(release.name);
|
||||
if (!cname) continue;
|
||||
|
||||
const code = nameToCode.get(cname);
|
||||
if (!code) continue;
|
||||
|
||||
if (versionToLetter.has(release.version)) {
|
||||
if (versionToLetter.get(release.version) !== code) {
|
||||
throw new Error(`[CodenameMap] Duplicate version: ${release.version}`);
|
||||
}
|
||||
} else {
|
||||
versionToLetter.set(release.version, code);
|
||||
}
|
||||
|
||||
const d = new Date(release.date);
|
||||
const existed = letterBorn.get(code);
|
||||
if (!existed || d < existed.born) {
|
||||
letterBorn.set(code, { name: cname, born: d });
|
||||
}
|
||||
}
|
||||
|
||||
const sortedVersions = Array.from(versionToLetter.keys()).sort(compareVersionStringsDescending);
|
||||
|
||||
/**
|
||||
* @example Array<[version, prefixCode]>
|
||||
* [
|
||||
* ... ..
|
||||
* [ '2025.1.4.7', 'N' ],
|
||||
* [ '2025.1.4.6', 'N' ],
|
||||
* [ '2025.1.4.5', 'N' ],
|
||||
* [ '2025.1.4.4', 'N' ],
|
||||
* [ '2025.1.4.3', 'N' ],
|
||||
* [ '2025.1.4.2', 'N' ],
|
||||
* [ '2025.1.4.1', 'N' ],
|
||||
* [ '2025.1.3.7', 'N' ],
|
||||
* [ '2025.1.3.6', 'N' ],
|
||||
* [ '2025.1.3.5', 'N' ],
|
||||
* [ '2025.1.3.4', 'N' ],
|
||||
* [ '2025.1.3.3', 'N' ],
|
||||
* [ '2025.1.3.2', 'N' ],
|
||||
* [ '2025.1.3.1', 'N' ],
|
||||
* ... ...
|
||||
* ]
|
||||
* @type {Array<[string, string]>}
|
||||
*/
|
||||
const versionLetterList = sortedVersions.map(v => [ v, versionToLetter.get(v) ]);
|
||||
|
||||
/**
|
||||
* @example { majorSeries: { revision: letter } }
|
||||
* {
|
||||
* ... ...
|
||||
* '2025.1.4': { '1': 'N', '2': 'N', '3': 'N', ..., '6': 'N', '7': 'N' },
|
||||
* '2025.1.3': { '1': 'N', '2': 'N', '3': 'N', ..., '6': 'N', '7': 'N' },
|
||||
* '2025.1.2': { '1': 'N', '2': 'N', '3': 'N', ..., '6': 'N', '7': 'N', ..., '13': 'N' },
|
||||
* '2025.1.1': { '1': 'N', '2': 'N', '3': 'N', ..., '6': 'N', '7': 'N', ..., '14': 'N' },
|
||||
* '2024.3.2': { '1': 'M', '2': 'M', '3': 'M', ..., '6': 'M', '7': 'M', ..., '15': 'M' },
|
||||
* '2024.3.1': { '1': 'M', '2': 'M', '3': 'M', ..., '6': 'M', '7': 'M', ..., '15': 'M' },
|
||||
* '2024.2.2': { '1': 'L', '2': 'L', '3': 'L', ..., '6': 'L', '7': 'L', ..., '15': 'L' },
|
||||
* '2024.2.1': { '1': 'L', '2': 'L', '3': 'L', ..., '6': 'L', '7': 'L', ..., '12': 'L' },
|
||||
* '2024.1.3': { '1': 'L', '2': 'L', '3': 'L' },
|
||||
* '2024.1.2': { '1': 'K', '2': 'K', '3': 'K', ..., '6': 'K', '7': 'K', ..., '13': 'K' },
|
||||
* '2024.1.1': { '1': 'K', '2': 'K', '3': 'K', ..., '6': 'K', '7': 'K', ..., '13': 'K' },
|
||||
* '2023.3.2': { '1': 'J', '2': 'K' },
|
||||
* '2023.3.1': { '1': 'J', '2': 'J', '3': 'J', ..., '6': 'J', '7': 'J', ..., '20': 'J' },
|
||||
* '2023.2.1': { '1': 'I', '2': 'I', '3': 'I', ..., '6': 'I', '7': 'I', ..., '25': 'I' },
|
||||
* '2023.1.1': { '1': 'H', '2': 'H', '3': 'H', ..., '6': 'H', '7': 'H', ..., '28': 'H' },
|
||||
* '2022.3.1': { '1': 'G', '2': 'G', '3': 'G', ..., '6': 'G', '7': 'G', ..., '22': 'G' },
|
||||
* ... ...
|
||||
* }
|
||||
* @type {Object<string, { [revision: number]: string }>}
|
||||
*/
|
||||
const rawFirstlyVersionLetterMap = {};
|
||||
for (let i = 0; i < versionLetterList.length; i++) {
|
||||
const [ v, letter ] = versionLetterList[i];
|
||||
const matched = v.match(/(^\d+\.\d+\.\d+)\.(\d+)/);
|
||||
if (!matched) continue;
|
||||
const [ , majorSeries, revision ] = matched;
|
||||
if (majorSeries in rawFirstlyVersionLetterMap) {
|
||||
rawFirstlyVersionLetterMap[majorSeries][revision] = letter;
|
||||
} else {
|
||||
rawFirstlyVersionLetterMap[majorSeries] = { [revision]: letter };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* When all versions with the same major series prefix (like `2025.1.x.x`)
|
||||
* point to the same codename prefix (like `'N'`),
|
||||
* they can be merged (like `{ '2025.1.4' : 'N' }`, where `2025.1.4` is the major series prefix),
|
||||
* otherwise retain the original split form (like `2023.3.2.x` cannot be merged).<br>
|
||||
* zh-CN:<br>
|
||||
* 当主版本系列相同的版本 (如 `2025.1.x.x`) 全部指向同一个代号前缀 (如 `'N'`) 时,
|
||||
* 可进行合并 (如 `{ '2025.1.4' : 'N' }`, 其中 `2025.1.4` 为主版本系列),
|
||||
* 否则保留原始的拆分形式 (如 `2023.3.2.x` 不可合并).
|
||||
* @example { version: letter }
|
||||
* {
|
||||
* ... ...
|
||||
* '2025.1.4': 'N',
|
||||
* '2025.1.3': 'N',
|
||||
* '2025.1.2': 'N',
|
||||
* '2025.1.1': 'N',
|
||||
* '2024.3.2': 'M',
|
||||
* '2024.3.1': 'M',
|
||||
* '2024.2.2': 'L',
|
||||
* '2024.2.1': 'L',
|
||||
* '2024.1.3': 'L',
|
||||
* '2024.1.2': 'K',
|
||||
* '2024.1.1': 'K',
|
||||
* '2023.3.2.1': 'J',
|
||||
* '2023.3.2.2': 'K',
|
||||
* '2023.3.1': 'J',
|
||||
* '2023.2.1': 'I',
|
||||
* '2023.1.1': 'H',
|
||||
* '2022.3.1': 'G',
|
||||
* ... ...
|
||||
* }
|
||||
* @type {Object<[version: string], string>}
|
||||
*/
|
||||
const combinedFirstlyVersionLetterMap = {};
|
||||
|
||||
Object.entries(rawFirstlyVersionLetterMap).forEach(([ majorSeries, revisionToLetter ]) => {
|
||||
const letterValues = Object.values(revisionToLetter);
|
||||
if (new Set(letterValues).size === 1) {
|
||||
/* Combine. (zh-CN: 合并.) */
|
||||
combinedFirstlyVersionLetterMap[majorSeries] = letterValues[0];
|
||||
} else {
|
||||
/* Keep expanded. (zh-CN: 保持展开.) */
|
||||
Object.entries(revisionToLetter).forEach(([ revision, letter ]) => {
|
||||
combinedFirstlyVersionLetterMap[`${majorSeries}.${revision}`] = letter;
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* @example { majorSeries: { patch: letter } }
|
||||
* {
|
||||
* ... ...
|
||||
* '2025.1': { '1': 'N', '2': 'N', '3': 'N', '4': 'N' },
|
||||
* '2024.3': { '1': 'M', '2': 'M' },
|
||||
* '2024.2': { '1': 'L', '2': 'L' },
|
||||
* '2024.1': { '1': 'K', '2': 'K', '3': 'L' },
|
||||
* '2023.2': { '1': 'I' },
|
||||
* '2023.1': { '1': 'H' },
|
||||
* '2022.3': { '1': 'G' },
|
||||
* ... ...
|
||||
* }
|
||||
* @type {Object<string, { [patch: number]: string }>}
|
||||
*/
|
||||
const rawVersionLetterMap = {};
|
||||
/**
|
||||
* @example
|
||||
* Set(1) { '2023.3' }
|
||||
* @type {Set<string>}
|
||||
*/
|
||||
const excludedVersionSet = new Set();
|
||||
/**
|
||||
* @example
|
||||
* {
|
||||
* '2023.3.2.1': 'J',
|
||||
* '2023.3.2.2': 'K',
|
||||
* '2023.3.1': 'J',
|
||||
* }
|
||||
* @type {Object<string, string>}
|
||||
*/
|
||||
const excludedVersionLetterMap = {};
|
||||
const entries = Object.entries(combinedFirstlyVersionLetterMap).filter(([ v, letter ]) => {
|
||||
if (v.split('.').length > 3) {
|
||||
excludedVersionLetterMap[v] = letter;
|
||||
excludedVersionSet.add(v.split('.').slice(0, 2).join('.'));
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
for (let i = 0; i < entries.length; i++) {
|
||||
const [ v, letter ] = entries[i];
|
||||
if (excludedVersionSet.has(v.split('.').slice(0, 2).join('.'))) {
|
||||
excludedVersionLetterMap[v] = letter;
|
||||
continue;
|
||||
}
|
||||
const matched = v.match(/(^\d+\.\d+)\.(\d+)/);
|
||||
if (!matched) continue;
|
||||
const [ , majorSeries, patch ] = matched;
|
||||
if (majorSeries in rawVersionLetterMap) {
|
||||
rawVersionLetterMap[majorSeries][patch] = letter;
|
||||
} else {
|
||||
rawVersionLetterMap[majorSeries] = { [patch]: letter };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* When all versions with the same major series prefix (like `2025.1.x`)
|
||||
* point to the same codename prefix (like `'N'`),
|
||||
* they can be merged (like `{ '2025.1' : 'N' }`, where `2025.1` is the major series prefix),
|
||||
* otherwise retain the original split form (like `2024.1` cannot be merged).<br>
|
||||
* zh-CN:<br>
|
||||
* 当主版本系列相同的版本 (如 `2025.1.x`) 全部指向同一个代号前缀 (如 `'N'`) 时,
|
||||
* 可进行合并 (如 `{ '2025.1' : 'N' }`, 其中 `2025.1` 为主版本系列),
|
||||
* 否则保留原始的拆分形式 (如 `2024.1` 不可合并).
|
||||
* @example { version: letter }
|
||||
* {
|
||||
* ... ...
|
||||
* '2025.1.4': 'N',
|
||||
* '2025.1.3': 'N',
|
||||
* '2025.1.2': 'N',
|
||||
* '2025.1.1': 'N',
|
||||
* '2024.3.2': 'M',
|
||||
* '2024.3.1': 'M',
|
||||
* '2024.2.2': 'L',
|
||||
* '2024.2.1': 'L',
|
||||
* '2024.1.3': 'L',
|
||||
* '2024.1.2': 'K',
|
||||
* '2024.1.1': 'K',
|
||||
* '2023.3.2.1': 'J',
|
||||
* '2023.3.2.2': 'K',
|
||||
* '2023.3.1': 'J',
|
||||
* '2023.2.1': 'I',
|
||||
* '2023.1.1': 'H',
|
||||
* '2022.3.1': 'G',
|
||||
* ... ...
|
||||
* }
|
||||
* @type {Object<[version: string], string>}
|
||||
*/
|
||||
const combinedVersionLetterMap = structuredClone(excludedVersionLetterMap);
|
||||
|
||||
Object.entries(rawVersionLetterMap).forEach(([ majorSeries, patchToLetter ]) => {
|
||||
const letterValues = Object.values(patchToLetter);
|
||||
if (new Set(letterValues).size === 1) {
|
||||
/* Combine. (zh-CN: 合并.) */
|
||||
combinedVersionLetterMap[majorSeries] = letterValues[0];
|
||||
} else {
|
||||
/* Keep expanded. (zh-CN: 保持展开.) */
|
||||
Object.entries(patchToLetter).forEach(([ patch, letter ]) => {
|
||||
combinedVersionLetterMap[`${majorSeries}.${patch}`] = letter;
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
const codenameVersionMap = new Map(Object.entries(combinedVersionLetterMap));
|
||||
|
||||
const codenameLines = Array.from(letterBorn.entries())
|
||||
.sort((a, b) => {
|
||||
return new Date(b[1].born).getTime() - new Date(a[1].born).getTime();
|
||||
})
|
||||
.map(([ code, { name, born } ]) => {
|
||||
const bornStr = toUpdatedStamp(born);
|
||||
// e.g. [ "#Born on Nov 12, 2024", "M=Meerkat" ]
|
||||
return [ `#Born on ${bornStr}`, `${code}=${name}` ];
|
||||
})
|
||||
.flat(1);
|
||||
|
||||
return { buildVersionMap, codenameVersionMap, codenameLines };
|
||||
}
|
||||
|
||||
(async function main() {
|
||||
const releases = await getAndroidStudioReleases();
|
||||
await updateCommonDataByLatestRelease(releases);
|
||||
|
||||
const { buildVersionMap, codenameVersionMap, codenameLines } = getCodenameMapLinesInfo(releases);
|
||||
|
||||
await updateGradleMapData('android-studio-build-version', buildVersionMap, {
|
||||
label: 'Android Studio build version map',
|
||||
sort: 'value.descending.as.version',
|
||||
});
|
||||
|
||||
await updateGradleMapData('android-studio-codename-version', codenameVersionMap, {
|
||||
label: 'Android Studio codename version map',
|
||||
sort: 'key.descending.as.version',
|
||||
});
|
||||
|
||||
await updateGradleLinesData('android-studio-codename', codenameLines, {
|
||||
label: 'Android Studio codename map',
|
||||
});
|
||||
})().catch(err => {
|
||||
console.error(err);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
@@ -1,467 +0,0 @@
|
||||
// scrape-and-inject-android-studio-codename_maps.mjs
|
||||
|
||||
import * as fsp from 'node:fs/promises';
|
||||
import * as path from 'node:path';
|
||||
import { batchUpdateAnchoredBlocks } from './utils/anchors.mjs';
|
||||
import { bytes2GiB } from './utils/format.mjs';
|
||||
import { compareVersionStrings } from './utils/versioning.mjs';
|
||||
import { getAndroidStudioArchives } from './fetch-and-parse-android-studio-archives.mjs';
|
||||
import { getLatestStableArchives } from './fetch-and-parse-android-studio-latest-stable-version.mjs';
|
||||
import { getRemoteFileSizeBytes } from './utils/fetch.mjs';
|
||||
import { toUpdatedStamp, toYYYYMMDD } from './utils/date.mjs';
|
||||
|
||||
/**
|
||||
* Manual override for codename mappings (for resolving codename prefix conflicts).<br>
|
||||
* Default is first letter, e.g. 'Meerkat' and 'Bumblebee' gives { Meerkat: 'M', Bumblebee: 'B' }.<br>
|
||||
* When first letters conflict, conflicts are auto-resolved,
|
||||
* e.g. 'Camel' and 'Catfish' gives { Camel: 'CAM', Catfish: 'CAT' }.<br>
|
||||
* For extreme cases where conflicts cannot be auto-resolved,
|
||||
* e.g. 'Cat' and 'Catfish', manual prefix mapping is needed, like { Cat: 'CT', Catfish: 'CTF' }.<br>
|
||||
* zh-CN:<br>
|
||||
* 手动覆盖代号映射 (可用于解决代号前缀冲突).<br>
|
||||
* 默认为首字母, 如 'Meerkat' 与 'Bumblebee', 得到 { Meerkat: 'M', Bumblebee: 'B' }.<br>
|
||||
* 首字母重复时自动消解冲突, 如 'Camel' 与 'Catfish', 得到 { Camel: 'CAM', Catfish: 'CAT' }.<br>
|
||||
* 极端情况无法自动消解冲突, 如 'Cat' 与 'Catfish', 此时需要手动指定前缀, 如 { Cat: 'CT', Catfish: 'CTF' }.
|
||||
* @example Object<codename name, codename prefix>
|
||||
* {
|
||||
* 'Camel': 'CM',
|
||||
* 'Cat': 'CT',
|
||||
* 'Catfish': 'CTF',
|
||||
* ... ...
|
||||
* }
|
||||
* @type {Object<string, string>}
|
||||
*/
|
||||
const manualCodenameOverrides = {};
|
||||
|
||||
/**
|
||||
* Use the latest stable version's checksum/filename to locate the entry in archives,
|
||||
* complete and update common.json.<br>
|
||||
* zh-CN: 用 "最新稳定版" 的校验和/文件名, 在归档中定位条目, 补全并更新 common.json.
|
||||
*
|
||||
* @param {AndroidStudioArchiveItem[]} archives
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async function updateLatestArchiveInfo(archives) {
|
||||
const latestRows = await getLatestStableArchives(); // [ { kind, filename, sha256, url, size, ... } ]
|
||||
const latestExe = latestRows.find(x => x.kind === 'exe');
|
||||
const latestZip = latestRows.find(x => x.kind === 'zip');
|
||||
const latestTar = latestRows.find(x => x.kind === 'tar');
|
||||
if (!latestExe || !latestZip || !latestTar) {
|
||||
throw new Error('Latest stable archives missing required "kind" info: exe, zip, or tar');
|
||||
}
|
||||
|
||||
/**
|
||||
* Search the archive: first try to match by sha256, then by filename.<br>
|
||||
* zh-CN: 在归档中查找: 优先用 sha256 命中, 其次用文件名.
|
||||
*
|
||||
* @param {AndroidStudioArchiveItem[]} rows
|
||||
* @param {AndroidStudioStableArchiveItem} target
|
||||
* @returns {AndroidStudioArchiveItem | null}
|
||||
*/
|
||||
const matchArchive = (rows, target) => {
|
||||
for (const arc of rows) {
|
||||
const bySha = target.sha256 && arc.checksums[target.filename] === target.sha256;
|
||||
const byName = arc.links.some(l => l.text === target.filename);
|
||||
if (bySha || byName) return arc;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
const matchedArc = matchArchive(archives, latestExe) || matchArchive(archives, latestZip);
|
||||
if (!matchedArc) {
|
||||
throw new Error('Could not locate entries in the archive corresponding to the latest version (neither by sha256 nor filename)');
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} suffix
|
||||
* @returns {{ filename: string, url: string, sizeGiB?: string | null } | null}
|
||||
*/
|
||||
const pickWinItem = (suffix) => {
|
||||
const link = matchedArc.links.find(l => l.text.endsWith(suffix));
|
||||
if (!link) return null;
|
||||
return { filename: link.text, url: link.href };
|
||||
};
|
||||
const exeItem = pickWinItem('-windows.exe');
|
||||
const zipItem = pickWinItem('-windows.zip');
|
||||
const tarItem = pickWinItem('-linux.tar.gz');
|
||||
|
||||
if (!exeItem || !zipItem || !tarItem) {
|
||||
throw new Error('Matched archive entry missing Windows EXE/ZIP or Linux TAR download information');
|
||||
}
|
||||
|
||||
// Query real file size (concurrent fetch) and format to GiB.
|
||||
// zh-CN: 查询真实文件大小 (并发获取), 格式化为 GiB.
|
||||
|
||||
const [ exeBytes, zipBytes, tarBytes ] = await Promise.all([
|
||||
exeItem ? getRemoteFileSizeBytes(exeItem.url) : Promise.resolve(null),
|
||||
zipItem ? getRemoteFileSizeBytes(zipItem.url) : Promise.resolve(null),
|
||||
tarItem ? getRemoteFileSizeBytes(tarItem.url) : Promise.resolve(null),
|
||||
]);
|
||||
if (exeItem) exeItem.sizeGiB = bytes2GiB(exeBytes);
|
||||
if (zipItem) zipItem.sizeGiB = bytes2GiB(zipBytes);
|
||||
if (tarItem) tarItem.sizeGiB = bytes2GiB(tarBytes);
|
||||
|
||||
// Prepare fields needed to write back to common.json.
|
||||
// zh-CN: 准备写回 common.json 所需字段.
|
||||
|
||||
const latestVersionName = matchedArc.title.trim(); // e.g. "Android Studio Narwhal Feature Drop | 2025.1.2"
|
||||
const latestVersionDate = toYYYYMMDD(matchedArc.date) || ''; // e.g. "2025/07/31"
|
||||
|
||||
// Read and update common.json (only update keys containing "android_studio_latest_" fragment and version name date key).
|
||||
// zh-CN: 读取并更新 common.json (仅更新带有 "android_studio_latest_" 片段的键与版本名日期键).
|
||||
|
||||
const commonJsonPath = path.resolve(process.cwd(), '../.readme/common.json');
|
||||
const commonRaw = await fsp.readFile(commonJsonPath, 'utf8');
|
||||
const commonObj = JSON.parse(commonRaw);
|
||||
|
||||
const updatedCommon = {
|
||||
...commonObj,
|
||||
android_studio_latest_recommended_version_name: latestVersionName,
|
||||
var_date_android_studio_latest_recommended_version_name: latestVersionDate,
|
||||
android_studio_latest_recommended_file_name_of_exe: exeItem.filename,
|
||||
android_studio_latest_recommended_download_address_of_exe: exeItem.url,
|
||||
android_studio_latest_recommended_file_size_of_exe: exeItem.sizeGiB ?? commonObj.android_studio_latest_recommended_file_size_of_exe,
|
||||
android_studio_latest_recommended_file_name_of_zip: zipItem.filename,
|
||||
android_studio_latest_recommended_download_address_of_zip: zipItem.url,
|
||||
android_studio_latest_recommended_file_size_of_zip: zipItem.sizeGiB ?? commonObj.android_studio_latest_recommended_file_size_of_zip,
|
||||
android_studio_latest_recommended_file_name_of_tar: tarItem.filename,
|
||||
android_studio_latest_recommended_download_address_of_tar: tarItem.url,
|
||||
android_studio_latest_recommended_file_size_of_tar: tarItem.sizeGiB ?? commonObj.android_studio_latest_recommended_file_size_of_tar,
|
||||
};
|
||||
|
||||
if (JSON.stringify(updatedCommon) !== JSON.stringify(commonObj)) {
|
||||
await fsp.writeFile(commonJsonPath, JSON.stringify(updatedCommon, null, 2), 'utf8');
|
||||
console.log('[common.json] Updated (Android Studio information)');
|
||||
const from = commonObj.android_studio_latest_recommended_version_name;
|
||||
const to = updatedCommon.android_studio_latest_recommended_version_name;
|
||||
if (from !== to) {
|
||||
const maxLength = Math.max(...[ from, to ].map(s => s.length + 5));
|
||||
const SEP_EQ = '='.repeat(maxLength);
|
||||
console.log(SEP_EQ);
|
||||
console.log(`-- "${from}"`);
|
||||
console.log(`-> "${to}"`);
|
||||
console.log(SEP_EQ);
|
||||
}
|
||||
} else {
|
||||
// console.log('[common.json] No update needed (Android Studio information)');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Summarize codename-to-version mappings and codename first release dates.<br>
|
||||
* zh-CN: 汇总代号版本映射以及代号的首发日期.
|
||||
*
|
||||
* @param {AndroidStudioArchiveItem[]} archives
|
||||
*/
|
||||
function getCodenameMapLinesInfo(archives) {
|
||||
|
||||
/**
|
||||
* Parse "codename and version number" from title,
|
||||
* zh-CN: 从标题中解析 "代号与版本号",
|
||||
*
|
||||
* @example string
|
||||
* 'Android Studio Meerkat Feature Drop | 2024.3.2 RC 1'
|
||||
*
|
||||
* @param {string} title
|
||||
* @returns {string | null}
|
||||
*/
|
||||
const codenameFromTitle = title => {
|
||||
// Capture "Android Studio <Codename> [Feature Drop]".
|
||||
// zh-CN: 捕获 "Android Studio <Codename> [Feature Drop]".
|
||||
const m = /Android Studio\s+(.+?)\s*(?:(\s+\d+\s+)?Feature Drop)?(?=\s*\|)/i.exec(title);
|
||||
return m ? m[1].trim() : null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Generate unique codes: sequentially, uniformly increase length for conflict groups;
|
||||
* override mappings take precedence.<br>
|
||||
* zh-CN:<br>
|
||||
* 生成唯一代码: 按顺序, 出现冲突则对冲突组统一递增长度; 覆盖映射优先考虑.
|
||||
*
|
||||
* @example Map<name, prefixCode>
|
||||
* Map(14) {
|
||||
* 'Arctic Fox' => 'A',
|
||||
* 'Bumblebee' => 'B',
|
||||
* ... ...
|
||||
* 'Narwhal' => 'N'
|
||||
* }
|
||||
*
|
||||
* @param {string[]} names
|
||||
* Original codenames (preserves case and spaces, e.g. "Arctic Fox", "Bumblebee", "Narwhal" etc).<br>
|
||||
* zh-CN: 原始代号 (保留大小写与空格, 如 "Arctic Fox", "Bumblebee", "Narwhal" 等).
|
||||
* @param {Object<string, string>} overrides
|
||||
* Manual override mappings.<br>
|
||||
* zh-CN: 手动覆盖映射.
|
||||
* @returns {Map<string, string>}
|
||||
*/
|
||||
const buildUniquePrefixes = (names, overrides) => {
|
||||
|
||||
/**
|
||||
* @param {string} s
|
||||
* @returns {string}
|
||||
*/
|
||||
const normalize = (s) => s.replace(/\s+/g, '');
|
||||
|
||||
/**
|
||||
* @param {string} s
|
||||
* @param {string} def
|
||||
* @returns {string}
|
||||
*/
|
||||
const normalizeOrDefault = (s, def) => s && normalize(s) || normalize(def);
|
||||
|
||||
/**
|
||||
* @param {string }s
|
||||
* @param {number} def
|
||||
* @returns {number}
|
||||
*/
|
||||
const normalizeLength = (s, def) => s && normalize(s).length || def;
|
||||
|
||||
const entries = names.map(name => {
|
||||
const override = overrides[name]?.toUpperCase();
|
||||
const base = normalize(name);
|
||||
const len = normalizeLength(override, 1);
|
||||
const code = normalizeOrDefault(override, base.slice(0, len)).toUpperCase();
|
||||
const locked = Boolean(override);
|
||||
return { name, base, len, code, locked };
|
||||
});
|
||||
|
||||
// Detect and resolve conflicts. (zh-CN: 检测并解决冲突.)
|
||||
const maxLenByName = new Map(entries.map(e => [ e.name, e.base.length ]));
|
||||
// Loop limit protection to prevent infinite loops in extreme cases.
|
||||
// zh-CN: 循环上限保护, 防止极端情况下死循环.
|
||||
for (let step = 0; step < 1 << 10; step++) {
|
||||
/**
|
||||
* Count conflict groups. (zh-CN: 统计冲突组.)
|
||||
* @example Map<code, indices>
|
||||
* Map(14) {
|
||||
* 'N' => [ 0 ],
|
||||
* 'M' => [ 1 ],
|
||||
* ... ...
|
||||
* 'B' => [ 12 ],
|
||||
* 'A' => [ 13 ]
|
||||
* }
|
||||
* @type {Map<string, number[]>}
|
||||
*/
|
||||
const bucket = new Map();
|
||||
entries.forEach((e, idx) => {
|
||||
const key = e.code;
|
||||
if (!bucket.has(key)) bucket.set(key, []);
|
||||
bucket.get(key).push(idx);
|
||||
});
|
||||
|
||||
const conflicts = Array.from(bucket.entries()).filter(([ _, indices ]) => indices.length >= 2);
|
||||
if (conflicts.length === 0) {
|
||||
// No conflicts, or all conflicts have been resolved.
|
||||
// zh-CN: 没有冲突, 或冲突已全部解决.
|
||||
break;
|
||||
}
|
||||
|
||||
for (const [ code, indices ] of conflicts) {
|
||||
const lockedIndices = indices.filter(i => entries[i].locked);
|
||||
if (lockedIndices.length >= 2) {
|
||||
const groupNames = indices.map(i => entries[i].name);
|
||||
throw new Error(`[CodenameMap] Manual override mapping conflict: "${code}" -> [ ${groupNames.join(', ')} ]`);
|
||||
}
|
||||
for (const i of indices) {
|
||||
const e = entries[i];
|
||||
if (e.locked) continue;
|
||||
const maxLen = maxLenByName.get(e.name);
|
||||
if (e.len >= maxLen) {
|
||||
const groupNames = indices.map(i => entries[i].name);
|
||||
throw new Error(`[CodenameMap] Unable to auto-resolve conflicts: [ ${groupNames.join(', ')} ]`);
|
||||
}
|
||||
e.len += 1;
|
||||
e.code = e.base.slice(0, e.len);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return new Map(entries.map(e => [ e.name, e.code ]));
|
||||
};
|
||||
|
||||
const codenames = [ ...new Set(archives.map(o => codenameFromTitle(o.title)).filter(Boolean)) ];
|
||||
const nameToCode = buildUniquePrefixes(codenames, manualCodenameOverrides);
|
||||
|
||||
/**
|
||||
* @example Map<version, Set<prefixCode>>
|
||||
* Map(22) {
|
||||
* ... ...
|
||||
* '2024.2.1' => Set(1) { 'L' },
|
||||
* '2024.1.3' => Set(1) { 'L' },
|
||||
* '2024.1.2' => Set(1) { 'K' },
|
||||
* '2024.1.1' => Set(1) { 'K' },
|
||||
* '2023.3.2' => Set(2) { 'K', 'J' },
|
||||
* '2023.3.1' => Set(1) { 'J' },
|
||||
* '2023.2.1' => Set(1) { 'I' },
|
||||
* ... ...
|
||||
* }
|
||||
* @type {Map<string, Set<string>>}
|
||||
*/
|
||||
const versionToLetters = new Map();
|
||||
|
||||
/**
|
||||
* @example Map<prefixCode, { name, born }>
|
||||
* Map(14) {
|
||||
* 'N' => { name: 'Narwhal', born: 2025-03-18T16:00:00.000Z },
|
||||
* 'M' => { name: 'Meerkat', born: 2024-11-11T16:00:00.000Z },
|
||||
* ... ...
|
||||
* 'B' => { name: 'Bumblebee', born: 2021-05-17T16:00:00.000Z },
|
||||
* 'A' => { name: 'Arctic Fox', born: 2021-01-25T16:00:00.000Z }
|
||||
* }
|
||||
* @type {Map<string, { name: string, born: Date } >}
|
||||
*/
|
||||
const letterBorn = new Map();
|
||||
|
||||
for (const arc of archives) {
|
||||
if (!arc.version) continue;
|
||||
const cname = codenameFromTitle(arc.title);
|
||||
if (!cname) continue;
|
||||
|
||||
const code = nameToCode.get(cname);
|
||||
if (!code) continue;
|
||||
|
||||
if (!versionToLetters.has(arc.version)) versionToLetters.set(arc.version, new Set());
|
||||
versionToLetters.get(arc.version).add(code);
|
||||
|
||||
const d = new Date(arc.date);
|
||||
const existed = letterBorn.get(code);
|
||||
if (!existed || d < existed.born) {
|
||||
letterBorn.set(code, { name: cname, born: d });
|
||||
}
|
||||
}
|
||||
|
||||
const sortedVersions = Array.from(versionToLetters.keys()).sort((a, b) => {
|
||||
// Split version string into [ y: year, m: minor, p: patch ].
|
||||
// zh-CN: 将版本字符串拆分为 [ y: 年份, m: 次版本号, p: 补丁版本号 ].
|
||||
// e.g. "2024.3.2" -> [ y: 2024, m: 3, p: 2 ].
|
||||
const [ ay, am, ap ] = a.split('.').map(Number);
|
||||
const [ by, bm, bp ] = b.split('.').map(Number);
|
||||
return by - ay || bm - am || bp - ap;
|
||||
});
|
||||
|
||||
/**
|
||||
* @example Array<[version, jointPrefixCode]>
|
||||
* [
|
||||
* ... ..
|
||||
* [ '2024.2.2', 'L' ],
|
||||
* [ '2024.2.1', 'L' ],
|
||||
* [ '2024.1.3', 'L' ],
|
||||
* [ '2024.1.2', 'K' ],
|
||||
* [ '2024.1.1', 'K' ],
|
||||
* [ '2023.3.2', 'J|K' ],
|
||||
* [ '2023.3.1', 'J' ],
|
||||
* [ '2023.2.1', 'I' ],
|
||||
* ... ...
|
||||
* ]
|
||||
* @type {Array<[string, string]>}
|
||||
*/
|
||||
const versionLettersList = sortedVersions.map(v => [ v, Array.from(versionToLetters.get(v)).sort().join('|') ]);
|
||||
|
||||
/**
|
||||
* @example { minorSeries: { patch: letters } }
|
||||
* {
|
||||
* ... ...
|
||||
* '2025.1': { '1': 'N', '2': 'N', '3': 'N', '4': 'N' },
|
||||
* '2024.3': { '1': 'M', '2': 'M' },
|
||||
* '2024.2': { '1': 'L', '2': 'L' },
|
||||
* '2024.1': { '1': 'K', '2': 'K', '3': 'L' },
|
||||
* '2023.3': { '1': 'J', '2': 'J|K' },
|
||||
* '2023.2': { '1': 'I' },
|
||||
* '2023.1': { '1': 'H' },
|
||||
* '2022.3': { '1': 'G' },
|
||||
* ... ...
|
||||
* }
|
||||
* @type {Object<string, { [patch: number]: string }>}
|
||||
*/
|
||||
const rawVersionLettersMap = {};
|
||||
for (let i = 0; i < versionLettersList.length; i++) {
|
||||
const [ v, letters ] = versionLettersList[i];
|
||||
const matched = v.match(/(^\d+\.\d+)(?:\.(\d+))?/);
|
||||
if (!matched) continue;
|
||||
const [ , minorSeries, patch ] = matched;
|
||||
if (minorSeries in rawVersionLettersMap) {
|
||||
rawVersionLettersMap[minorSeries][patch] = letters;
|
||||
} else {
|
||||
rawVersionLettersMap[minorSeries] = { [patch]: letters };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* When all versions with the same minor series prefix (like `2025.1.x`)
|
||||
* point to the same codename prefix (like `'N'`),
|
||||
* they can be merged (like `{ '2025.1' : 'N' }`, where `2025.1` is the minor series prefix),
|
||||
* otherwise retain the original split form (like `2024.1.x` cannot be merged).<br>
|
||||
* zh-CN:<br>
|
||||
* 当次版本系列相同的版本 (如 `2025.1.x`) 全部指向同一个代号前缀 (如 `'N'`) 时,
|
||||
* 可进行合并 (如 `{ '2025.1' : 'N' }`, 其中 `2025.1` 为次版本系列),
|
||||
* 否则保留原始的拆分形式 (如 `2024.1.x` 不可合并).
|
||||
* @example { version: letters }
|
||||
* {
|
||||
* ... ...
|
||||
* '2025.1': 'N',
|
||||
* '2024.3': 'M',
|
||||
* '2024.2': 'L',
|
||||
* '2024.1.1': 'K',
|
||||
* '2024.1.2': 'K',
|
||||
* '2024.1.3': 'L',
|
||||
* '2023.3.1': 'J',
|
||||
* '2023.3.2': 'J|K',
|
||||
* '2023.2': 'I',
|
||||
* '2023.1': 'H',
|
||||
* '2022.3': 'G',
|
||||
* ... ...
|
||||
* }
|
||||
* @type {Object<[version: string], string>}
|
||||
*/
|
||||
const combinedVersionLettersMap = {};
|
||||
|
||||
Object.entries(rawVersionLettersMap).forEach(([ minorSeries, patchToLetters ]) => {
|
||||
const letterValues = Object.values(patchToLetters);
|
||||
if (new Set(letterValues).size === 1) {
|
||||
/* Combine. (zh-CN: 合并.) */
|
||||
combinedVersionLettersMap[minorSeries] = letterValues[0];
|
||||
} else {
|
||||
/* Keep expanded. (zh-CN: 保持展开.) */
|
||||
Object.entries(patchToLetters).forEach(([ patch, letters ]) => {
|
||||
combinedVersionLettersMap[`${minorSeries}.${patch}`] = letters;
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
const versionMapLines = Object.entries(combinedVersionLettersMap)
|
||||
.sort((a, b) => compareVersionStrings(b[0], a[0]))
|
||||
.map(([ v, letters ]) => `"${v}" to "${letters}",`);
|
||||
|
||||
const sortedLetters = Array.from(letterBorn.entries())
|
||||
.sort((a, b) => b[1].born.getTime() - a[1].born.getTime());
|
||||
|
||||
const codenameMapLines = sortedLetters.map(([ code, { name, born } ]) => {
|
||||
const bornStr = toUpdatedStamp(born);
|
||||
// e.g. `"M" to "Meerkat", /* Born on Nov 12, 2024. */`.
|
||||
return `"${code}" to "${name}", /* Born on ${bornStr}. */`;
|
||||
});
|
||||
|
||||
return { versionMapLines, codenameMapLines };
|
||||
}
|
||||
|
||||
(async function main() {
|
||||
const archives = await getAndroidStudioArchives();
|
||||
await updateLatestArchiveInfo(archives);
|
||||
|
||||
const { versionMapLines, codenameMapLines } = getCodenameMapLinesInfo(archives);
|
||||
await batchUpdateAnchoredBlocks('../settings.gradle.kts', [ {
|
||||
type: 'map',
|
||||
anchorTag: 'ANDROID_STUDIO_CODENAME_VERSION_MAP',
|
||||
mapName: 'codenameVersionMap',
|
||||
lines: versionMapLines,
|
||||
updatedLabel: 'Android Studio codename version map',
|
||||
}, {
|
||||
type: 'map',
|
||||
anchorTag: 'ANDROID_STUDIO_CODENAME_MAP',
|
||||
mapName: 'codenameMap',
|
||||
lines: codenameMapLines,
|
||||
updatedLabel: 'Android Studio codename map',
|
||||
} ]);
|
||||
})().catch(err => {
|
||||
console.error(err);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
@@ -1,58 +0,0 @@
|
||||
// scrape-and-inject-gradle-kotlin-compatibility-list.mjs
|
||||
|
||||
import { findTargetRows } from './utils/puppeteer-helpers.mjs';
|
||||
import { compareVersionStrings } from './utils/versioning.mjs';
|
||||
import { getMinSupportedGradleVersion } from './utils/properties.mjs';
|
||||
import { updateAnchoredListInFile } from './utils/anchors.mjs';
|
||||
|
||||
const URL = 'https://docs.gradle.org/current/userguide/compatibility.html#kotlin';
|
||||
|
||||
const unofficialKotlinCompatibilityList = {
|
||||
// '8.14': '2.1.10',
|
||||
// '8.13': '2.1.10',
|
||||
};
|
||||
|
||||
(async function main() {
|
||||
const rows = await findTargetRows({
|
||||
url: URL,
|
||||
tableSelector: 'table.tableblock',
|
||||
tableFilter: {
|
||||
th: /Embedded Kotlin version|Minimum Gradle version|Kotlin Language version/i,
|
||||
},
|
||||
tableDataStructure: [
|
||||
'kotlin',
|
||||
'gradle',
|
||||
'ktLanguage',
|
||||
],
|
||||
});
|
||||
/**
|
||||
* @type {{ [gradle: string]: { kotlin: string, isUnofficial: boolean } }}
|
||||
*/
|
||||
const map = {};
|
||||
const minSupportedGradleVersion = getMinSupportedGradleVersion();
|
||||
|
||||
for (const { kotlin, gradle } of rows) {
|
||||
if (compareVersionStrings(gradle, minSupportedGradleVersion) < 0) continue;
|
||||
map[gradle] = { kotlin, isUnofficial: false };
|
||||
}
|
||||
|
||||
Object.entries(unofficialKotlinCompatibilityList).forEach(([ gradle, kotlin ]) => {
|
||||
if (!(gradle in map)) map[gradle] = { kotlin, isUnofficial: true };
|
||||
});
|
||||
|
||||
await updateAnchoredListInFile('../settings.gradle.kts', {
|
||||
anchorTag: 'GRADLE_KOTLIN_COMPATIBILITY_LIST',
|
||||
listName: 'gradleKotlinCompatibility',
|
||||
lines: Object.entries(map)
|
||||
.sort((a, b) => compareVersionStrings(b[1]['kotlin'], a[1]['kotlin']))
|
||||
.map(([ gradle, { kotlin, isUnofficial } ]) => {
|
||||
return isUnofficial
|
||||
? `"${gradle}" to "${kotlin}", /* Unofficial. */`
|
||||
: `"${gradle}" to "${kotlin}",`;
|
||||
}),
|
||||
updatedLabel: 'Gradle and Kotlin compatibility list',
|
||||
});
|
||||
})().catch(err => {
|
||||
console.error(err);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
48
.utils/scrape-and-inject-gradle-kotlin-compatibility-map.mjs
Normal file
48
.utils/scrape-and-inject-gradle-kotlin-compatibility-map.mjs
Normal file
@@ -0,0 +1,48 @@
|
||||
// scrape-and-inject-gradle-kotlin-compatibility-map.mjs
|
||||
|
||||
import { findTargetRows } from './utils/puppeteer-helpers.mjs';
|
||||
import { compareVersionStrings } from './utils/versioning.mjs';
|
||||
import { getMinSupportedGradleVersion } from './utils/properties.mjs';
|
||||
import { updateGradleMapData } from './utils/update-helper.mjs';
|
||||
|
||||
const URL = 'https://docs.gradle.org/current/userguide/compatibility.html#kotlin';
|
||||
|
||||
(async function main() {
|
||||
const rows = await findTargetRows({
|
||||
url: URL,
|
||||
tableSelector: 'table.tableblock',
|
||||
tableFilter: {
|
||||
th: /Embedded Kotlin version|Minimum Gradle version|Kotlin Language version/i,
|
||||
},
|
||||
tableDataStructure: [
|
||||
'kotlin',
|
||||
'gradle',
|
||||
'ktLanguage',
|
||||
],
|
||||
});
|
||||
/**
|
||||
* @example Map<kotlin,gradle>
|
||||
* Map(10) {
|
||||
* ... ...
|
||||
* "8.12" => "2.0.21",
|
||||
* "8.11" => "2.0.20",
|
||||
* ... ...
|
||||
* }
|
||||
* @type {Map<string, string>}
|
||||
*/
|
||||
const map = new Map();
|
||||
const minSupportedGradleVersion = getMinSupportedGradleVersion();
|
||||
|
||||
for (const { kotlin, gradle } of rows) {
|
||||
if (compareVersionStrings(gradle, minSupportedGradleVersion) < 0) continue;
|
||||
map.set(gradle, kotlin);
|
||||
}
|
||||
|
||||
await updateGradleMapData('gradle-kotlin-compat', map, {
|
||||
label: 'Gradle and Kotlin compatibility map',
|
||||
sort: 'value.descending.as.version',
|
||||
});
|
||||
})().catch(err => {
|
||||
console.error(err);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
@@ -1,15 +1,11 @@
|
||||
// scrape-and-inject-java-gradle-compatibility-list.mjs
|
||||
// scrape-and-inject-java-gradle-compatibility-map.mjs
|
||||
|
||||
import { findTargetRows } from './utils/puppeteer-helpers.mjs';
|
||||
import { getMinSupportedJavaVersionInt } from './utils/properties.mjs';
|
||||
import { updateAnchoredListInFile } from './utils/anchors.mjs';
|
||||
import { updateGradleMapData } from './utils/update-helper.mjs';
|
||||
|
||||
const URL = 'https://docs.gradle.org/current/userguide/compatibility.html#java_runtime';
|
||||
|
||||
const unofficialGradleCompatibilityList = {
|
||||
// 25: '9.0',
|
||||
};
|
||||
|
||||
(async function main() {
|
||||
const rows = await findTargetRows({
|
||||
url: URL,
|
||||
@@ -25,8 +21,7 @@ const unofficialGradleCompatibilityList = {
|
||||
{ gradle: /^N\/A$|\d+\.\d+/ },
|
||||
],
|
||||
});
|
||||
/** @type {{ [javaInt: string]: string }} */
|
||||
const map = {};
|
||||
const map = new Map();
|
||||
const minSupportedJavaVersionInt = getMinSupportedJavaVersionInt();
|
||||
for (const { java, gradle } of rows) {
|
||||
const javaInt = parseInt(java);
|
||||
@@ -34,25 +29,12 @@ const unofficialGradleCompatibilityList = {
|
||||
throw Error(`Invalid java version int: "${java}"`);
|
||||
}
|
||||
if (javaInt >= minSupportedJavaVersionInt) {
|
||||
map[javaInt] = gradle;
|
||||
map.set(`${javaInt}`, gradle);
|
||||
}
|
||||
}
|
||||
|
||||
await updateAnchoredListInFile('../settings.gradle.kts', {
|
||||
anchorTag: 'JAVA_GRADLE_COMPATIBILITY_LIST',
|
||||
listName: 'javaGradleCompatibility',
|
||||
lines: Object.entries(map)
|
||||
.sort((a, b) => Number(b[0]) - Number(a[0]))
|
||||
.map(([ java, gradle ]) => {
|
||||
if (gradle === 'N/A') {
|
||||
const unofficialGradleVersion = unofficialGradleCompatibilityList[java];
|
||||
if (unofficialGradleVersion) {
|
||||
return `${java} to "${unofficialGradleVersion}", /* Unofficial. */`;
|
||||
}
|
||||
}
|
||||
return `${java} to "${gradle}",`;
|
||||
}),
|
||||
updatedLabel: 'Java and Gradle compatibility list',
|
||||
await updateGradleMapData('java-gradle-compat', map, {
|
||||
label: 'Java and Gradle compatibility map',
|
||||
sort: 'key.descending.as.number',
|
||||
});
|
||||
})().catch(err => {
|
||||
console.error(err);
|
||||
@@ -1,10 +1,10 @@
|
||||
// scrape-and-inject-ksp-releases.mjs
|
||||
// scrape-and-inject-ksp-releases-map.mjs
|
||||
// after: [ scrape-and-inject-gradle-kotlin-compatibility-map.mjs ]
|
||||
|
||||
import * as fsp from 'node:fs/promises';
|
||||
import { httpFetch } from './utils/fetch.mjs';
|
||||
import { readProperties } from './utils/properties.mjs';
|
||||
import { readPropertiesSync } from './utils/properties.mjs';
|
||||
import { toUpdatedStamp } from './utils/date.mjs';
|
||||
import { updateAnchoredMapInFile } from './utils/anchors.mjs';
|
||||
import { updateGradleLinesData } from './utils/update-helper.mjs';
|
||||
|
||||
const URL = 'https://api.github.com/repos/google/ksp/releases';
|
||||
const GITHUB_TOKEN = process.env.GITHUB_TOKEN;
|
||||
@@ -65,26 +65,9 @@ async function fetchKspReleases() {
|
||||
}
|
||||
|
||||
async function getMinKotlinVersionToCheck() {
|
||||
const raw = await fsp.readFile('../settings.gradle.kts', 'utf8');
|
||||
const tag = 'GRADLE_KOTLIN_COMPATIBILITY_LIST';
|
||||
const beginIndex = raw.indexOf(`@AnchorBegin ${tag}`);
|
||||
const endIndex = raw.indexOf(`@AnchorEnd ${tag}`);
|
||||
|
||||
if (beginIndex !== -1 && endIndex !== -1) {
|
||||
const props = await readProperties();
|
||||
const minGradle = props['MIN_SUPPORTED_GRADLE_VERSION'];
|
||||
|
||||
const lines = raw.slice(beginIndex + tag.length + 1, endIndex).split('\n');
|
||||
for (const line of lines) {
|
||||
const m = /^"(\d+(?:\.\d+)+)" to "(\d+(?:\.\d+)+)",/.exec(line.trim());
|
||||
if (m) {
|
||||
const [ _, gradleVersion, kotlinVersion ] = m;
|
||||
if (gradleVersion === minGradle) {
|
||||
return kotlinVersion;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
const minGradle = readPropertiesSync('../version.properties').get('MIN_SUPPORTED_GRADLE_VERSION');
|
||||
const mapElement = readPropertiesSync('../gradle/data/gradle-kotlin-compat.properties').get(minGradle);
|
||||
if (mapElement) return mapElement;
|
||||
throw new Error(`Failed to find the minimum Kotlin version to check in settings.gradle.kts`);
|
||||
}
|
||||
|
||||
@@ -175,17 +158,16 @@ function parseReleases(releases) {
|
||||
return b.date.getTime() - a.date.getTime();
|
||||
})
|
||||
.map(({ kotlinVer, kspVer, date }) => {
|
||||
return `"${kotlinVer}" to "${kspVer}", /* ${(toUpdatedStamp(date))}. */`;
|
||||
});
|
||||
return [ `#${toUpdatedStamp(date)}`, `${kotlinVer}=${kspVer}` ];
|
||||
})
|
||||
.flat(1);
|
||||
}
|
||||
|
||||
(async function main() {
|
||||
const releases = await fetchKspReleases();
|
||||
await updateAnchoredMapInFile('../settings.gradle.kts', {
|
||||
anchorTag: 'KSP_VERSION_MAP',
|
||||
mapName: 'kspVersionMap',
|
||||
lines: parseReleases(releases),
|
||||
updatedLabel: 'KSP releases version map',
|
||||
const lines = parseReleases(releases);
|
||||
await updateGradleLinesData('ksp-releases', lines, {
|
||||
label: 'KSP releases version map',
|
||||
});
|
||||
})().catch((err) => {
|
||||
console.error('Failed to fetch KSP releases:', err);
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { compareVersionStrings } from './utils/versioning.mjs';
|
||||
import { fetchGradleReleases } from './fetch-and-parse-gradle-releases.mjs';
|
||||
import { readPropertiesSync, writePropertiesSync } from './utils/properties.mjs';
|
||||
import { readPropertiesSync, writePropertiesSyncWithMap } from './utils/properties.mjs';
|
||||
|
||||
const KEY = 'distributionUrl';
|
||||
const URL_PREFIX = 'https://services.gradle.org/distributions';
|
||||
@@ -67,7 +67,7 @@ async function updateGradleWrapperFileContent({ latestGradleVersion, latestGradl
|
||||
const path = '../gradle/wrapper/' + fileName;
|
||||
const messages = [];
|
||||
const props = readPropertiesSync(path);
|
||||
let propUrl = props[KEY];
|
||||
let propUrl = props.get(KEY);
|
||||
if (!propUrl) {
|
||||
propUrl = latestGradleUrl;
|
||||
messages.push(`Append: ${KEY}=${propUrl}`);
|
||||
@@ -91,7 +91,7 @@ async function updateGradleWrapperFileContent({ latestGradleVersion, latestGradl
|
||||
}
|
||||
|
||||
if (messages.length > 0) {
|
||||
writePropertiesSync(path, props);
|
||||
writePropertiesSyncWithMap(path, props);
|
||||
const maxLength = Math.max(...messages.join('\n').split('\n').map(s => s.length));
|
||||
const SEP_EQ = '='.repeat(maxLength);
|
||||
const SEP_DASH = '-'.repeat(maxLength);
|
||||
|
||||
@@ -4,7 +4,7 @@ import * as fs from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
import { fetchStatistics } from './fetch-and-parse-autojs6-merged-pr-commits-statistics.mjs';
|
||||
import { objectToLines } from './utils/format.mjs';
|
||||
import { printDifferences } from './utils/print.mjs';
|
||||
import { printLinesDiffs } from './utils/print.mjs';
|
||||
import { toYYYYMMDD } from './utils/date.mjs';
|
||||
|
||||
const updateCommonJsonFile = () => {
|
||||
@@ -22,7 +22,7 @@ const updateCommonJsonFile = () => {
|
||||
if (from !== to) {
|
||||
fs.writeFileSync(commonJsonPath, JSON.stringify(updatedCommon, null, 2), 'utf8');
|
||||
console.log('[common.json] Updated (contribution statistics date)');
|
||||
printDifferences(objectToLines(commonObj), objectToLines(updatedCommon), { regexForKeyMatching: /"\w+"(?=:)/ });
|
||||
printLinesDiffs(objectToLines(commonObj), objectToLines(updatedCommon), { regexForKeyMatching: /"\w+"(?=:)/ });
|
||||
}
|
||||
};
|
||||
|
||||
@@ -79,7 +79,7 @@ const markdownToLines = (md) => {
|
||||
fs.writeFileSync(filePath, updated, { encoding: 'utf-8' });
|
||||
console.log(`[${filename}] Updated (contribution statistics list)`);
|
||||
if (oldMarkdown) {
|
||||
printDifferences(markdownToLines(oldMarkdown), markdownToLines(newMarkdown));
|
||||
printLinesDiffs(markdownToLines(oldMarkdown), markdownToLines(newMarkdown));
|
||||
}
|
||||
updateCommonJsonFile();
|
||||
} else {
|
||||
|
||||
@@ -1,266 +0,0 @@
|
||||
// utils/anchors.mjs
|
||||
|
||||
import * as fsp from 'node:fs/promises';
|
||||
import * as path from 'node:path';
|
||||
import { escapeRegExp } from './format.mjs';
|
||||
import { toUpdatedStamp } from './date.mjs';
|
||||
import { printDifferences } from './print.mjs';
|
||||
|
||||
/**
|
||||
* @param {string} s
|
||||
* @returns {string}
|
||||
*/
|
||||
const normalize = (s) => String(s).replace(/\s+/g, '');
|
||||
|
||||
/**
|
||||
* Generate new block content with the given replacement function in the specified Anchor block.<br>
|
||||
* zh-CN: 在指定 Anchor 块中, 用给定的替换函数生成新块内容.
|
||||
*
|
||||
* @param {string} src
|
||||
* @param {string} anchorTag
|
||||
* @param {(block: string) => { newBlock: string, changed: boolean }} replaceBlockFn
|
||||
* @returns {{ src: string, changed: boolean }}
|
||||
*/
|
||||
function replaceInAnchoredBlock(src, anchorTag, replaceBlockFn) {
|
||||
const beginTag = `// @AnchorBegin ${anchorTag}`;
|
||||
const endTag = `// @AnchorEnd ${anchorTag}`;
|
||||
|
||||
const beginIdx = src.indexOf(beginTag);
|
||||
if (beginIdx === -1) throw new Error(`Anchor tag "${anchorTag}" not found in the source code`);
|
||||
|
||||
const endIdx = src.indexOf(endTag, beginIdx + beginTag.length);
|
||||
if (endIdx === -1) throw new Error(`Anchor tag "${anchorTag}" not found in the source code`);
|
||||
|
||||
const before = src.slice(0, beginIdx);
|
||||
const block = src.slice(beginIdx, endIdx);
|
||||
const after = src.slice(endIdx);
|
||||
|
||||
const { newBlock, changed } = replaceBlockFn(block) || {};
|
||||
if (!changed || !newBlock) return { src, changed: false };
|
||||
|
||||
return { src: before + newBlock + after, changed };
|
||||
}
|
||||
|
||||
/**
|
||||
* Replace a map declaration (like mapOf(...)) in an anchor block
|
||||
* and automatically refresh the @Updated date when changed.<br>
|
||||
* zh-CN: 替换锚点块中的某个 map 声明 (如 mapOf(...)), 并在变更时自动刷新 @Updated 日期.
|
||||
*
|
||||
* @param {string} src
|
||||
* @param {Object} options
|
||||
* @param {string} options.anchorTag
|
||||
* @param {string} options.mapName
|
||||
* @param {string[]} options.lines
|
||||
* @param {number} [options.linesIndent=4]
|
||||
* @param {(date?: Date) => string} [options.toUpdatedStamp=toUpdatedStamp]
|
||||
* @returns {{ src: string, changed: boolean }}
|
||||
*/
|
||||
function replaceAnchoredMapBlock(src, {
|
||||
anchorTag,
|
||||
mapName,
|
||||
lines,
|
||||
linesIndent = 4,
|
||||
toUpdatedStamp: toStamp = toUpdatedStamp,
|
||||
}) {
|
||||
return replaceInAnchoredBlock(src, anchorTag, (block) => {
|
||||
let changed = false;
|
||||
|
||||
const re = new RegExp(String.raw`([\t ]*)(va[lr]\s+)?${escapeRegExp(mapName)}\s*=\s*mapOf\(.*?\)(,?)`, 's');
|
||||
|
||||
let updatedBlock = block.replace(re, (/** @type {string} */ original, /** @type {string} */ indent, /** @type {string} */ keyword, /** @type {string} */ comma) => {
|
||||
const kw = keyword ?? '';
|
||||
const body = lines.map(l => `${' '.repeat(linesIndent)}${indent}${l}`).join('\n');
|
||||
const next = `${indent}${kw}${mapName} = mapOf(\n${body}\n${indent})${comma}`;
|
||||
if (normalize(original) !== normalize(next)) changed = true;
|
||||
return next;
|
||||
});
|
||||
if (changed) {
|
||||
updatedBlock = updatedBlock.replace(
|
||||
/(@Updated[^\n]*?\son\s)([A-Z][a-z]{2}\s\d{1,2},\s\d{4})(\.?)/,
|
||||
(_, p1, _old, p3) => `${p1}${(toStamp())}${p3}`,
|
||||
);
|
||||
}
|
||||
|
||||
return { newBlock: updatedBlock, changed };
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Replace a list declaration (like listOf(...)) in an anchor block
|
||||
* and automatically refresh the @Updated date when changed.<br>
|
||||
* zh-CN:<br>
|
||||
* 替换锚点块中的某个 list 声明 (如 listOf(...)), 并在变更时自动刷新 @Updated 日期.
|
||||
*
|
||||
* @param {string} src
|
||||
* @param {Object} options
|
||||
* @param {string} options.anchorTag
|
||||
* @param {string} options.listName
|
||||
* @param {string[]} options.lines
|
||||
* @param {number} [options.linesIndent=4]
|
||||
* @param {(date?: Date) => string} [options.toUpdatedStamp=toUpdatedStamp]
|
||||
* @returns {{ src: string, changed: boolean }}
|
||||
*/
|
||||
function replaceAnchoredListBlock(src, {
|
||||
anchorTag,
|
||||
listName,
|
||||
lines,
|
||||
linesIndent = 4,
|
||||
toUpdatedStamp: toStamp = toUpdatedStamp,
|
||||
}) {
|
||||
return replaceInAnchoredBlock(src, anchorTag, (block) => {
|
||||
let changed = false;
|
||||
|
||||
const re = new RegExp(`([\\t\\x20]*)(va[lr]\\s+)?${listName}\\s*=\\s*listOf\\([\\s\\S]*?\\)(,?)`, 'm');
|
||||
let updatedBlock = block.replace(re, (original, indent, keyword, comma) => {
|
||||
const kw = keyword ?? '';
|
||||
const body = lines.map(l => `${' '.repeat(linesIndent)}${indent}${l}`).join('\n');
|
||||
const next = `${indent}${kw}${listName} = listOf(\n${body}\n${indent})${comma}`;
|
||||
if (normalize(original) !== normalize(next)) changed = true;
|
||||
return next;
|
||||
});
|
||||
if (changed) {
|
||||
updatedBlock = updatedBlock.replace(
|
||||
/(@Updated[^\n]*?\son\s)([A-Z][a-z]{2}\s\d{1,2},\s\d{4})(\.?)/,
|
||||
(_, p1, _old, p3) => `${p1}${(toStamp())}${p3}`,
|
||||
);
|
||||
}
|
||||
|
||||
return { newBlock: updatedBlock, changed };
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} filePath
|
||||
* @param {Object} options
|
||||
* @param {string} options.anchorTag
|
||||
* @param {string} options.mapName
|
||||
* @param {string[]} options.lines
|
||||
* @param {number} [options.linesIndent=4]
|
||||
* @param {string} [options.updatedLabel='']
|
||||
* @param {(date?: Date) => string} [options.toUpdatedStamp=toUpdatedStamp]
|
||||
* @param {RegExp} [options.regexForKeyMatching=null]
|
||||
* @returns {Promise<{ changed: boolean, content: string }>}
|
||||
*/
|
||||
export async function updateAnchoredMapInFile(filePath, {
|
||||
anchorTag,
|
||||
mapName,
|
||||
lines,
|
||||
linesIndent = 4,
|
||||
updatedLabel = '',
|
||||
toUpdatedStamp: toStamp = toUpdatedStamp,
|
||||
regexForKeyMatching = null,
|
||||
}) {
|
||||
const filename = path.basename(filePath);
|
||||
const raw = await fsp.readFile(filePath, 'utf8');
|
||||
const { src: updated, changed } = replaceAnchoredMapBlock(raw, { anchorTag, mapName, lines, linesIndent, toUpdatedStamp: toStamp });
|
||||
|
||||
if (changed) {
|
||||
await fsp.writeFile(filePath, updated, 'utf8');
|
||||
console.log(`[${filename}] Updated` + (updatedLabel ? ` (${updatedLabel})` : ''));
|
||||
printDifferences(raw, updated, { regexForKeyMatching });
|
||||
} else {
|
||||
// console.log(`[${filename}] No update needed` + (updatedLabel ? ` (${updatedLabel})` : ''));
|
||||
}
|
||||
return { changed, content: updated };
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} filePath
|
||||
* @param {Object} options
|
||||
* @param {string} options.anchorTag
|
||||
* @param {string} options.listName
|
||||
* @param {string[]} options.lines
|
||||
* @param {number} [options.linesIndent=4]
|
||||
* @param {string} [options.updatedLabel='']
|
||||
* @param {(date?: Date) => string} [options.toUpdatedStamp=toUpdatedStamp]
|
||||
* @param {RegExp} [options.regexForKeyMatching=null]
|
||||
* @returns {Promise<{ changed: boolean, content: string }>}
|
||||
*/
|
||||
export async function updateAnchoredListInFile(filePath, {
|
||||
anchorTag,
|
||||
listName,
|
||||
lines,
|
||||
linesIndent = 4,
|
||||
updatedLabel = '',
|
||||
toUpdatedStamp: toStamp = toUpdatedStamp,
|
||||
regexForKeyMatching = null,
|
||||
}) {
|
||||
const filename = path.basename(filePath);
|
||||
const raw = await fsp.readFile(filePath, 'utf8');
|
||||
const { src: updated, changed } = replaceAnchoredListBlock(raw, { anchorTag, listName, lines, linesIndent, toUpdatedStamp: toStamp });
|
||||
|
||||
if (changed) {
|
||||
await fsp.writeFile(filePath, updated, 'utf8');
|
||||
console.log(`[${filename}] Updated` + (updatedLabel ? ` (${updatedLabel})` : ''));
|
||||
printDifferences(raw, updated, { regexForKeyMatching });
|
||||
} else {
|
||||
// console.log(`[${filename}] No update needed` + (updatedLabel ? ` (${updatedLabel})` : ''));
|
||||
}
|
||||
return { changed, content: updated };
|
||||
}
|
||||
|
||||
/**
|
||||
* Batch replace multiple anchors within the same file
|
||||
* (supports both map and list, requiring only one read/write operation).<br>
|
||||
* zh-CN: 批量在同一文件内进行多锚点替换 (同时支持 map 与 list, 读写仅需一次).
|
||||
*
|
||||
* @param {string} filePath
|
||||
* @param {AnchoredBlockUpdateOption[]} optionList
|
||||
* @param {Object} [extraOptions={}]
|
||||
* @param {(date?: Date) => string} [extraOptions.toUpdatedStamp=toUpdatedStamp]
|
||||
* @param {RegExp} [extraOptions.regexForKeyMatching=null]
|
||||
* @returns {Promise<{ changed: boolean, content: string }>}
|
||||
*/
|
||||
export async function batchUpdateAnchoredBlocks(filePath, optionList, {
|
||||
toUpdatedStamp: toStamp = toUpdatedStamp,
|
||||
regexForKeyMatching = null,
|
||||
} = {}) {
|
||||
const filename = path.basename(filePath);
|
||||
|
||||
let raw = await fsp.readFile(filePath, 'utf8');
|
||||
let changedAny = false;
|
||||
let updated = null;
|
||||
let updatedLabel = null;
|
||||
|
||||
for (const opt of optionList) {
|
||||
let res = { src: raw, changed: false };
|
||||
|
||||
if (opt.type === 'map') {
|
||||
res = replaceAnchoredMapBlock(raw, {
|
||||
anchorTag: opt.anchorTag,
|
||||
mapName: opt.mapName,
|
||||
lines: opt.lines,
|
||||
linesIndent: opt.linesIndent,
|
||||
toUpdatedStamp: toStamp,
|
||||
});
|
||||
} else if (opt.type === 'list') {
|
||||
res = replaceAnchoredListBlock(raw, {
|
||||
anchorTag: opt.anchorTag,
|
||||
listName: opt.listName,
|
||||
lines: opt.lines,
|
||||
linesIndent: opt.linesIndent,
|
||||
toUpdatedStamp: toStamp,
|
||||
});
|
||||
} else if (opt.type === 'custom' && typeof opt.replacer === 'function') {
|
||||
res = replaceInAnchoredBlock(raw, opt.anchorTag, (block) => opt.replacer(block, { toUpdatedStamp: toStamp }));
|
||||
} else {
|
||||
console.warn(`[${filename}] Unknown operation type or missing parameters:`, opt);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (res.changed) {
|
||||
changedAny = true;
|
||||
updated = res.src;
|
||||
updatedLabel = opt.updatedLabel;
|
||||
}
|
||||
}
|
||||
|
||||
if (changedAny) {
|
||||
await fsp.writeFile(filePath, updated, 'utf8');
|
||||
console.log(`[${filename}] Updated` + (updatedLabel ? ` (${updatedLabel})` : ''));
|
||||
printDifferences(raw, updated, { regexForKeyMatching });
|
||||
} else {
|
||||
// console.log(`[${filename}] No update needed (${op['updatedLabel'] ?? op.anchorTag})`);
|
||||
}
|
||||
return { changed: changedAny, content: changedAny ? updated : raw };
|
||||
}
|
||||
@@ -24,7 +24,7 @@ const findCommonSet = (a1, a2) => {
|
||||
* @param {RegExp} [options.regexForKeyMatching]
|
||||
* @return {void}
|
||||
*/
|
||||
export function printDifferences(src, other, options = {}) {
|
||||
export function printLinesDiffs(src, other, options = {}) {
|
||||
const leftRaw = extractConcernedLines(src);
|
||||
const rightRaw = extractConcernedLines(other);
|
||||
const commonSet = findCommonSet(leftRaw, rightRaw);
|
||||
@@ -134,3 +134,27 @@ export function printDifferences(src, other, options = {}) {
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Map<string,string>} src
|
||||
* @param {Map<string,string>} other
|
||||
*/
|
||||
export function printMapDiffs(src, other) {
|
||||
printLinesDiffs(mapToLines(src), mapToLines(other));
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string[]} src
|
||||
* @param {string[]} other
|
||||
*/
|
||||
export function printListDiffs(src, other) {
|
||||
printLinesDiffs(src.join('\n'), other.join('\n'));
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Map<string,string>} map
|
||||
* @returns {string}
|
||||
*/
|
||||
function mapToLines(map) {
|
||||
return Array.from(map.entries()).map(([ k, v ]) => `"${k}" to "${v}"`).join('\n');
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import * as fs from 'node:fs';
|
||||
import * as fsp from 'node:fs/promises';
|
||||
import { compareVersionStrings } from './versioning.mjs';
|
||||
import { generatePropertiesFileTimestamp } from './date.mjs';
|
||||
import { sortByMap } from './sorting.mjs';
|
||||
|
||||
/**
|
||||
* Convert JS string to .properties format escaping rules (store format)
|
||||
@@ -159,10 +160,11 @@ function unescapeProperty(str) {
|
||||
|
||||
/**
|
||||
* @param {string} text
|
||||
* @returns {Object<string, string>}
|
||||
* @param {MapSortable['sort']} [sortingPattern=null]
|
||||
* @returns {Map<string, string>}
|
||||
*/
|
||||
function parseProperties(text) {
|
||||
const props = Object.create(null);
|
||||
export function parseProperties(text, sortingPattern = null) {
|
||||
const props = new Map();
|
||||
if (!text) return props;
|
||||
|
||||
const lines = [];
|
||||
@@ -243,75 +245,115 @@ function parseProperties(text) {
|
||||
const k = unescapeProperty(key);
|
||||
const v = unescapeProperty(value.trim());
|
||||
|
||||
if (k) props[k] = v;
|
||||
if (k) props.set(k, v);
|
||||
}
|
||||
return props;
|
||||
return new Map(sortByMap(props.entries(), sortingPattern));
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} [filePath='../version.properties']
|
||||
* @param {Object} options
|
||||
* @param {BufferEncoding} [options.encoding='utf8']
|
||||
* @returns {Promise<Object<string, string>>}
|
||||
* @param {GradleMapRwOptions} [options={}]
|
||||
* @returns {Promise<Map<string, string>>}
|
||||
*/
|
||||
export async function readProperties(filePath = '../version.properties', { encoding = 'utf8' } = {}) {
|
||||
export async function readProperties(filePath = '../version.properties', { encoding = 'utf8', sort = null } = {}) {
|
||||
const text = await fsp.readFile(filePath, { encoding });
|
||||
return parseProperties(text);
|
||||
return parseProperties(text, sort);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} [filePath='../version.properties']
|
||||
* @param {Object} options
|
||||
* @param {BufferEncoding} [options.encoding='utf8']
|
||||
* @returns {Object<string, string>}
|
||||
* @param {GradleMapRwOptions} [options={}]
|
||||
* @returns {Map<string, string>}
|
||||
*/
|
||||
export function readPropertiesSync(filePath = '../version.properties', { encoding = 'utf8' } = {}) {
|
||||
export function readPropertiesSync(filePath = '../version.properties', { encoding = 'utf8', sort = null } = {}) {
|
||||
const text = fs.readFileSync(filePath, { encoding });
|
||||
return parseProperties(text);
|
||||
return parseProperties(text, sort);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} [filePath='../version.properties']]
|
||||
* @param {Object<string,string>} [props={}]
|
||||
* @param {Object} options
|
||||
* @param {BufferEncoding} [options.encoding='utf8']
|
||||
* @param {string} [filePath='../version.properties']
|
||||
* @param {Map<string,string>} [map={}]
|
||||
* @param {GradleMapRwOptions} [options={}]
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
export async function writeProperties(filePath = '../version.properties', props = {}, { encoding = 'utf8' } = {}) {
|
||||
export async function writePropertiesWithMap(filePath = '../version.properties', map = new Map(), { encoding = 'utf8', sort = null } = {}) {
|
||||
const lines = [];
|
||||
for (const key in props) {
|
||||
const value = props[key];
|
||||
if (value == null) continue;
|
||||
sortByMap(map.entries(), sort).forEach(([ key, value ]) => {
|
||||
if (value == null) return;
|
||||
const k = escapeProperty(String(key), true);
|
||||
const v = escapeProperty(String(value), false);
|
||||
lines.push(`${k}=${v}`);
|
||||
}
|
||||
});
|
||||
lines.unshift(generatePropertiesFileTimestamp());
|
||||
const text = lines.join('\n') + '\n';
|
||||
return fsp.writeFile(filePath, text, { encoding });
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} [filePath='../version.properties']]
|
||||
* @param {Object<string,string>} [props={}]
|
||||
* @param {Object} options
|
||||
* @param {BufferEncoding} [options.encoding='utf8']
|
||||
* @param {string} [filePath='../version.properties']
|
||||
* @param {Map<string,string>} [map={}]
|
||||
* @param {GradleMapRwOptions} [options={}]
|
||||
* @returns {void}
|
||||
*/
|
||||
export function writePropertiesSync(filePath = '../version.properties', props = {}, { encoding = 'utf8' } = {}) {
|
||||
export function writePropertiesSyncWithMap(filePath = '../version.properties', map = new Map(), { encoding = 'utf8', sort = null } = {}) {
|
||||
const lines = [];
|
||||
for (const key in props) {
|
||||
const value = props[key];
|
||||
if (value == null) continue;
|
||||
sortByMap(map.entries(), sort).forEach(([ key, value ]) => {
|
||||
if (value == null) return;
|
||||
const k = escapeProperty(String(key), true);
|
||||
const v = escapeProperty(String(value), false);
|
||||
lines.push(`${k}=${v}`);
|
||||
}
|
||||
});
|
||||
lines.unshift(generatePropertiesFileTimestamp());
|
||||
const text = lines.join('\n') + '\n';
|
||||
return fs.writeFileSync(filePath, text, { encoding });
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} [filePath='../version.properties']
|
||||
* @param {string[]} [lines=[]]
|
||||
* @param {GradleDataRwOptions} [options={}]
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
export async function writePropertiesWithLines(filePath = '../version.properties', lines = [], { encoding = 'utf8' } = {}) {
|
||||
const results = [];
|
||||
lines.forEach((line) => {
|
||||
if (line.startsWith('#') || line.startsWith('!')) {
|
||||
results.push(line);
|
||||
}
|
||||
const [ key, value ] = parseProperties(line).entries().next().value || [];
|
||||
if (value == null) return;
|
||||
const k = escapeProperty(String(key), true);
|
||||
const v = escapeProperty(String(value), false);
|
||||
results.push(`${k}=${v}`);
|
||||
});
|
||||
results.unshift(generatePropertiesFileTimestamp());
|
||||
const text = results.join('\n') + '\n';
|
||||
return fsp.writeFile(filePath, text, { encoding });
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} [filePath='../version.properties']
|
||||
* @param {string[]} [lines=[]]
|
||||
* @param {GradleDataRwOptions} [options={}]
|
||||
* @returns {void}
|
||||
*/
|
||||
export function writePropertiesSyncWithLines(filePath = '../version.properties', lines = [], { encoding = 'utf8' } = {}) {
|
||||
const results = [];
|
||||
lines.forEach((line) => {
|
||||
if (line.startsWith('#') || line.startsWith('!')) {
|
||||
results.push(line);
|
||||
}
|
||||
const [ key, value ] = parseProperties(line).entries().next().value || [];
|
||||
if (value == null) return;
|
||||
const k = escapeProperty(String(key), true);
|
||||
const v = escapeProperty(String(value), false);
|
||||
results.push(`${k}=${v}`);
|
||||
});
|
||||
results.unshift(generatePropertiesFileTimestamp());
|
||||
const text = results.join('\n') + '\n';
|
||||
return fs.writeFileSync(filePath, text, { encoding });
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} [filePath='../version.properties']
|
||||
* @param {Object} options
|
||||
@@ -320,7 +362,7 @@ export function writePropertiesSync(filePath = '../version.properties', props =
|
||||
*/
|
||||
export function getMinSupportedAgpVersion(filePath = '../version.properties', { encoding = 'utf8' } = {}) {
|
||||
let minSupportedVersion = null;
|
||||
Object.entries(readPropertiesSync(filePath, { encoding })).forEach(([ key, value ]) => {
|
||||
Array.from(readPropertiesSync(filePath, { encoding }).entries()).forEach(([ key, value ]) => {
|
||||
if (!/agp.version.*min.supported|min.supported.*agp.version/i.test(key)) return;
|
||||
if (minSupportedVersion === null || compareVersionStrings(value, minSupportedVersion) < 0) {
|
||||
minSupportedVersion = value;
|
||||
@@ -340,7 +382,7 @@ export function getMinSupportedAgpVersion(filePath = '../version.properties', {
|
||||
*/
|
||||
export function getMinSupportedGradleVersion(filePath = '../version.properties', { encoding = 'utf8' } = {}) {
|
||||
let minSupportedVersion = null;
|
||||
Object.entries(readPropertiesSync(filePath, { encoding })).forEach(([ key, value ]) => {
|
||||
Array.from(readPropertiesSync(filePath, { encoding }).entries()).forEach(([ key, value ]) => {
|
||||
if (!/gradle.version.*min.supported|min.supported.*gradle.version/i.test(key)) return;
|
||||
if (minSupportedVersion === null || compareVersionStrings(value, minSupportedVersion) < 0) {
|
||||
minSupportedVersion = value;
|
||||
@@ -373,7 +415,7 @@ export function getJavaVersionInfo(filePath = '../version.properties', { encodin
|
||||
let minSupportedVer = Infinity;
|
||||
let maxSupportedVer = -Infinity;
|
||||
|
||||
Object.entries(readPropertiesSync(filePath, { encoding })).forEach(([ key, value ]) => {
|
||||
Array.from(readPropertiesSync(filePath, { encoding }).entries()).forEach(([ key, value ]) => {
|
||||
const currentVer = parseInt(value, 10);
|
||||
if (/java.version.*min.suggested|min.suggested.*java.version/i.test(key)) {
|
||||
minSuggestedVer = Math.min(minSuggestedVer, currentVer);
|
||||
|
||||
75
.utils/utils/sorting.mjs
Normal file
75
.utils/utils/sorting.mjs
Normal file
@@ -0,0 +1,75 @@
|
||||
// utils/sorting.mjs
|
||||
|
||||
import { compareVersionStrings } from './versioning.mjs';
|
||||
|
||||
/**
|
||||
* @param {[string, string][] | MapIterator<[string, string]> | Map<string, string>} src
|
||||
* @param {MapSortable['sort'] | null} [pattern=null]
|
||||
* @returns {[string, string][]}
|
||||
*/
|
||||
export function sortByMap(src, pattern = null) {
|
||||
const entries = src instanceof Map ? Array.from(src.entries()) : Array.from(src);
|
||||
switch (pattern) {
|
||||
case null:
|
||||
return entries;
|
||||
case 'key.ascending':
|
||||
case 'key.ascending.as.string':
|
||||
return entries.sort((a, b) => a[0].localeCompare(b[0]));
|
||||
case 'key.ascending.as.number':
|
||||
return entries.sort((a, b) => Number(a[0]) - Number(b[0]));
|
||||
case 'key.ascending.as.version':
|
||||
return entries.sort((a, b) => compareVersionStrings(a[0], b[0]));
|
||||
case 'key.descending':
|
||||
case 'key.descending.as.string':
|
||||
return entries.sort((a, b) => b[0].localeCompare(a[0]));
|
||||
case 'key.descending.as.number':
|
||||
return entries.sort((a, b) => Number(b[0]) - Number(a[0]));
|
||||
case 'key.descending.as.version':
|
||||
return entries.sort((a, b) => compareVersionStrings(b[0], a[0]));
|
||||
case 'value.ascending':
|
||||
case 'value.ascending.as.string':
|
||||
return entries.sort((a, b) => a[1].localeCompare(b[1]));
|
||||
case 'value.ascending.as.number':
|
||||
return entries.sort((a, b) => Number(a[1]) - Number(b[1]));
|
||||
case 'value.ascending.as.version':
|
||||
return entries.sort((a, b) => compareVersionStrings(a[1], b[1]));
|
||||
case 'value.descending':
|
||||
case 'value.descending.as.string':
|
||||
return entries.sort((a, b) => b[1].localeCompare(a[1]));
|
||||
case 'value.descending.as.number':
|
||||
return entries.sort((a, b) => Number(b[1]) - Number(a[1]));
|
||||
case 'value.descending.as.version':
|
||||
return entries.sort((a, b) => compareVersionStrings(b[1], a[1]));
|
||||
default:
|
||||
throw new Error(`Unknown sorting pattern: ${pattern}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string[] | SetIterator<string> | Set<string>} src
|
||||
* @param {ListSortable['sort'] | null} [pattern=null]
|
||||
* @returns {string[]}
|
||||
*/
|
||||
export function sortByList(src, pattern = null) {
|
||||
const values = src instanceof Set ? Array.from(src.values()) : Array.from(src);
|
||||
switch (pattern) {
|
||||
case null:
|
||||
return values;
|
||||
case 'ascending':
|
||||
case 'ascending.as.string':
|
||||
return values.sort((a, b) => a.localeCompare(b));
|
||||
case 'ascending.as.number':
|
||||
return values.sort((a, b) => Number(a) - Number(b));
|
||||
case 'ascending.as.version':
|
||||
return values.sort((a, b) => compareVersionStrings(a, b));
|
||||
case 'descending':
|
||||
case 'descending.as.string':
|
||||
return values.sort((a, b) => b.localeCompare(a));
|
||||
case 'descending.as.number':
|
||||
return values.sort((a, b) => Number(b) - Number(a));
|
||||
case 'descending.as.version':
|
||||
return values.sort((a, b) => compareVersionStrings(b, a));
|
||||
default:
|
||||
throw new Error(`Unknown sorting pattern: ${pattern}`);
|
||||
}
|
||||
}
|
||||
125
.utils/utils/update-helper.mjs
Normal file
125
.utils/utils/update-helper.mjs
Normal file
@@ -0,0 +1,125 @@
|
||||
// utils/update-helper.mjs
|
||||
|
||||
import * as fs from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
import { parseProperties, readPropertiesSync, writePropertiesSyncWithLines, writePropertiesSyncWithMap } from './properties.mjs';
|
||||
import { printListDiffs, printMapDiffs } from './print.mjs';
|
||||
import { sortByList, sortByMap } from './sorting.mjs';
|
||||
import { generatePropertiesFileTimestamp } from './date.mjs';
|
||||
|
||||
/**
|
||||
* @param {string} filename
|
||||
* @param {Map<string, string>} map
|
||||
* @param {GradleMapUpdateOptions} [options={}]
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
export async function updateGradleMapData(filename, map, options = {}) {
|
||||
const niceName = filename.endsWith('.properties') ? filename : `${filename}.properties`;
|
||||
const filePath = path.resolve(process.cwd(), `../gradle/data/${niceName}`);
|
||||
if (!fs.existsSync(filePath)) {
|
||||
throw new Error(`File not found: ${filePath}`);
|
||||
}
|
||||
const rwOptions = {
|
||||
encoding: options.encoding || 'utf8',
|
||||
sort: options.sort,
|
||||
};
|
||||
const original = readPropertiesSync(filePath, rwOptions);
|
||||
const updated = new Map(sortByMap(map.entries(), options.sort));
|
||||
if (!shallowEqualMaps(original, updated)) {
|
||||
writePropertiesSyncWithMap(filePath, updated, rwOptions);
|
||||
console.log(`[${niceName}] Updated` + (options.label ? ` (${options.label})` : ''));
|
||||
printMapDiffs(original, updated);
|
||||
} else {
|
||||
// console.log(`[${filename}] No update needed` + (options.label ? ` (${options.label})` : ''));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} filename
|
||||
* @param {Set<string>} list
|
||||
* @param {GradleListUpdateOptions} [options={}]
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
export async function updateGradleListData(filename, list, options = {}) {
|
||||
const niceName = filename.endsWith('.list') ? filename : `${filename}.list`;
|
||||
const filePath = path.resolve(process.cwd(), `../gradle/data/${niceName}`);
|
||||
if (!fs.existsSync(filePath)) {
|
||||
throw new Error(`File not found: ${filePath}`);
|
||||
}
|
||||
const rwOptions = {
|
||||
encoding: options.encoding || 'utf8',
|
||||
sort: options.sort,
|
||||
};
|
||||
const original = fs.readFileSync(filePath, { encoding: rwOptions.encoding })
|
||||
.split('\n')
|
||||
.map(line => line.trim())
|
||||
.filter(line => line && !line.startsWith('#'));
|
||||
const niceList = Array.from(list)
|
||||
.map(line => line.trim())
|
||||
.filter(line => line && !line.startsWith('#'));
|
||||
const updated = sortByList(niceList, options.sort);
|
||||
if (!shallowEqualLists(original, updated)) {
|
||||
fs.writeFileSync(filePath, generatePropertiesFileTimestamp() + '\n' + updated.join('\n') + '\n', { encoding: rwOptions.encoding });
|
||||
console.log(`[${niceName}] Updated` + (options.label ? ` (${options.label})` : ''));
|
||||
printListDiffs(original, updated);
|
||||
} else {
|
||||
// console.log(`[${filename}] No update needed` + (options.label ? ` (${options.label})` : ''));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} filename
|
||||
* @param {string[]} lines
|
||||
* @param {GradleLinesUpdateOptions} [options={}]
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
export async function updateGradleLinesData(filename, lines, options = {}) {
|
||||
const niceName = filename.endsWith('.properties') ? filename : `${filename}.properties`;
|
||||
const filePath = path.resolve(process.cwd(), `../gradle/data/${niceName}`);
|
||||
if (!fs.existsSync(filePath)) {
|
||||
throw new Error(`File not found: ${filePath}`);
|
||||
}
|
||||
const rwOptions = {
|
||||
encoding: options.encoding || 'utf8',
|
||||
};
|
||||
const original = readPropertiesSync(filePath, rwOptions);
|
||||
const linesToCheck = parseProperties(lines.join('\n'));
|
||||
if (!shallowEqualMaps(original, linesToCheck)) {
|
||||
writePropertiesSyncWithLines(filePath, lines, rwOptions);
|
||||
console.log(`[${niceName}] Updated` + (options.label ? ` (${options.label})` : ''));
|
||||
printMapDiffs(original, linesToCheck);
|
||||
} else {
|
||||
// console.log(`[${filename}] No update needed` + (options.label ? ` (${options.label})` : ''));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Map<string, string>} a
|
||||
* @param {Map<string, string>} b
|
||||
* @returns {boolean}
|
||||
*/
|
||||
function shallowEqualMaps(a, b) {
|
||||
if (a === b) return true;
|
||||
if (!(a instanceof Map) || !(b instanceof Map)) return false;
|
||||
if (a.size !== b.size) return false;
|
||||
for (const [ k, v ] of a) {
|
||||
if (!b.has(k)) return false;
|
||||
if (b.get(k) !== v) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string[]} a
|
||||
* @param {string[]} b
|
||||
* @returns {boolean}
|
||||
*/
|
||||
function shallowEqualLists(a, b) {
|
||||
if (a === b) return true;
|
||||
if (!Array.isArray(a) || !Array.isArray(b)) return false;
|
||||
if (a.length !== b.length) return false;
|
||||
for (let i = 0; i < a.length; i++) {
|
||||
if (a[i] !== b[i]) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
Reference in New Issue
Block a user