6.7.0 - Alpha6 - Scrapers 工具的更新机制由 "锚点更新" 替换为 "结构化更新"

This commit is contained in:
SuperMonster003
2025-10-02 13:11:54 +08:00
parent f3e04b9ca2
commit 0b01bba618
33 changed files with 1599 additions and 1565 deletions

View File

@@ -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'}`;
}

View File

@@ -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);
}

View File

@@ -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;
});
}

View File

@@ -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 = [];

View File

@@ -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);

View File

@@ -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);

View File

@@ -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);

View 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;
});

View File

@@ -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;
});

View File

@@ -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;
});

View 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;
});

View File

@@ -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);

View File

@@ -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);

View File

@@ -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);

View File

@@ -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 {

View File

@@ -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 };
}

View File

@@ -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');
}

View File

@@ -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
View 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}`);
}
}

View 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;
}