diff --git a/.changelog/lang_zh-Hans.json b/.changelog/lang_zh-Hans.json
index d51d4276..d1c2444a 100644
--- a/.changelog/lang_zh-Hans.json
+++ b/.changelog/lang_zh-Hans.json
@@ -1,14 +1,14 @@
{
"$data": {
"v6.7.0": {
- "released_date": "2025/09/28",
+ "released_date": "2025/10/02",
"feature": [
"zip 模块, 用于文件压缩与解压缩操作 (Ref to [Auto.js Pro](https://g.pro.autojs.org/)) (参阅 项目文档 > [Zip](https://docs.autojs6.com/#/zip))",
"mediainfo 模块, 用于查看媒体文件的详细信息 (参阅 项目文档 > [媒体信息](https://docs.autojs6.com/#/mediainfo))",
"UiObject#isShifted 方法, 用于检测控件位置变化",
"structuredClone 全局方法, 用于深拷贝 JavaScript 对象 (参阅 [MDN](https://developer.mozilla.org/zh-CN/docs/Web/API/Window/structuredClone))",
"设置页面支持应用启动器图标设置选项 _[`issue #405`](http://issues.autojs6.com/405)_",
- "JS 脚本工具 (run-scrapers.mjs) 用于自动更新 Gradle 构建脚本锚点数据/README 通用数据/README 模板数据"
+ "Scrapers 工具 (run-scrapers.mjs) 用于自动更新 Gradle 构建脚本结构化数据/README 通用数据/README 模板数据等"
],
"fix": [
"isJavaClass/isJavaPackage 等全局方法无效的问题",
@@ -39,8 +39,9 @@
"应用启动器图标支持自适应图标特性 _[`issue #405`](http://issues.autojs6.com/405)_",
"使用 LiveData 及 SharedFlow 替代已弃用的 LocalBroadcastManager",
"Gradle 构建脚本提升 7z 格式文件的解压效率",
+ "Gradle 构建脚本支持获取详细的 Android Studio IDE 版本 (如 \"2025.1.4.7\")",
"使用版本目录 (Version Catalogs) 集中管理 Gradle 依赖和插件版本",
- "模块化 Gradle 脚本, 将共享构建逻辑迁移至 buildSrc 并抽象为约定插件",
+ "模块化 Gradle 构建脚本, 将共享构建逻辑迁移至 buildSrc 并抽象为约定插件",
"使用 Toolchain 替代 sourceCompatibility/targetCompatibility 以降低构建环境差异"
],
"dependency": [
diff --git a/.utils/declarations/index.d.ts b/.utils/declarations/index.d.ts
index bbbdcd3c..0e7b2829 100644
--- a/.utils/declarations/index.d.ts
+++ b/.utils/declarations/index.d.ts
@@ -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'}`;
}
diff --git a/.utils/fetch-and-parse-android-studio-archives.mjs b/.utils/fetch-and-parse-android-studio-archives.mjs
index 194bcaef..b10d5672 100644
--- a/.utils/fetch-and-parse-android-studio-archives.mjs
+++ b/.utils/fetch-and-parse-android-studio-archives.mjs
@@ -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).
- * zh-CN: 在所有 frame (含主文档) 中查找 "同意" 按钮.
- *
- * @param {Page} page
- * @param {number} [timeoutMs=30000]
- * @returns {Promise<{ handle: ElementHandle, frame: Frame }>}
+ * @returns {Promise}
*/
-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} */
- 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.
- * zh-CN: 在所有 frame 中等待某个选择器出现, 并返回该 frame.
- *
- * @param {Page} page
- * @param {string} selector
- * @param [timeoutMs=30000]
- * @returns {Promise}
+ * @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}
+ * @param {AndroidStudioReleaseDownloadItem[]} downloadItems
+ * @param {RegExp|null} [linkFilter=null]
+ * @returns {Promise}
*/
-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);
}
diff --git a/.utils/fetch-and-parse-android-studio-latest-stable-version.mjs b/.utils/fetch-and-parse-android-studio-latest-stable-version.mjs
deleted file mode 100644
index e03d2cfd..00000000
--- a/.utils/fetch-and-parse-android-studio-latest-stable-version.mjs
+++ /dev/null
@@ -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}
- */
-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}
- */
-async function parseItemsFromHtml(html) {
- const $ = cheerio.load(html);
- const rows = [];
-
- /** @type {import('cheerio').Cheerio} */
- 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)
- * Returns array like: [ { platform, filename, size, sha256, url, kind: 'exe'|'zip'|'tar' } ]
- * zh-CN:
- * 导出: 获取最新稳定版档案的下载信息 (exe, zip, tar)
- * 返回形如: [ { platform, filename, size, sha256, url, kind: 'exe'|'zip'|'tar' } ]
- *
- * @param {string} [sourceUrl=URL]
- * @returns {Promise}
- */
-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;
- });
-}
diff --git a/.utils/run-scrapers.mjs b/.utils/run-scrapers.mjs
index b1db374f..c85b626c 100644
--- a/.utils/run-scrapers.mjs
+++ b/.utils/run-scrapers.mjs
@@ -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 = [];
diff --git a/.utils/scrape-and-inject-agp-gradle-compatibility-list.mjs b/.utils/scrape-and-inject-agp-gradle-compatibility-map.mjs
similarity index 67%
rename from .utils/scrape-and-inject-agp-gradle-compatibility-list.mjs
rename to .utils/scrape-and-inject-agp-gradle-compatibility-map.mjs
index 0783c8ad..e4bd665f 100644
--- a/.utils/scrape-and-inject-agp-gradle-compatibility-list.mjs
+++ b/.utils/scrape-and-inject-agp-gradle-compatibility-map.mjs
@@ -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);
diff --git a/.utils/scrape-and-inject-agp-releases.mjs b/.utils/scrape-and-inject-agp-releases-list.mjs
similarity index 53%
rename from .utils/scrape-and-inject-agp-releases.mjs
rename to .utils/scrape-and-inject-agp-releases-list.mjs
index ec230fd7..69e1dbfe 100644
--- a/.utils/scrape-and-inject-agp-releases.mjs
+++ b/.utils/scrape-and-inject-agp-releases-list.mjs
@@ -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);
diff --git a/.utils/scrape-and-inject-android-studio-agp-version-map.mjs b/.utils/scrape-and-inject-android-studio-agp-version-map.mjs
index 8871bdd5..6bc2ea51 100644
--- a/.utils/scrape-and-inject-android-studio-agp-version-map.mjs
+++ b/.utils/scrape-and-inject-android-studio-agp-version-map.mjs
@@ -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);
diff --git a/.utils/scrape-and-inject-android-studio-codename-maps.mjs b/.utils/scrape-and-inject-android-studio-codename-maps.mjs
new file mode 100644
index 00000000..afdf37cf
--- /dev/null
+++ b/.utils/scrape-and-inject-android-studio-codename-maps.mjs
@@ -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).
+ * Default is first letter, e.g. 'Meerkat' and 'Bumblebee' gives { Meerkat: 'M', Bumblebee: 'B' }.
+ * When first letter conflict, conflicts are auto-resolved,
+ * e.g. 'Camel' and 'Catfish' gives { Camel: 'CAM', Catfish: 'CAT' }.
+ * For extreme cases where conflicts cannot be auto-resolved,
+ * e.g. 'Cat' and 'Catfish', manual prefix mapping is needed, like { Cat: 'CT', Catfish: 'CTF' }.
+ * zh-CN:
+ * 手动覆盖代号映射 (可用于解决代号前缀冲突).
+ * 默认为首字母, 如 'Meerkat' 与 'Bumblebee', 得到 { Meerkat: 'M', Bumblebee: 'B' }.
+ * 首字母重复时自动消解冲突, 如 'Camel' 与 'Catfish', 得到 { Camel: 'CAM', Catfish: 'CAT' }.
+ * 极端情况无法自动消解冲突, 如 'Cat' 与 'Catfish', 此时需要手动指定前缀, 如 { Cat: 'CT', Catfish: 'CTF' }.
+ * @example Object
+ * {
+ * 'Camel': 'CM',
+ * 'Cat': 'CT',
+ * 'Catfish': 'CTF',
+ * ... ...
+ * }
+ * @type {Object}
+ */
+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.
+ * zh-CN: 用最新稳定版更新 common.json.
+ *
+ * @param {AndroidStudioReleaseItem[]} releases
+ * @returns {Promise}
+ */
+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.
+ * 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 [Feature Drop]".
+ // zh-CN: 捕获 "Android Studio [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.
+ * zh-CN:
+ * 生成唯一代码: 按顺序, 出现冲突则对冲突组统一递增长度; 覆盖映射优先考虑.
+ *
+ * @example Map
+ * 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).
+ * zh-CN: 原始代号 (保留大小写与空格, 如 "Arctic Fox", "Bumblebee", "Narwhal" 等).
+ * @param {Object} overrides
+ * Manual override mappings.
+ * zh-CN: 手动覆盖映射.
+ * @returns {Map}
+ */
+ 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
+ * Map(14) {
+ * 'N' => [ 0 ],
+ * 'M' => [ 1 ],
+ * ... ...
+ * 'B' => [ 12 ],
+ * 'A' => [ 13 ]
+ * }
+ * @type {Map}
+ */
+ 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
+ * 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}
+ */
+ const versionToLetter = new Map();
+
+ /**
+ * @example Map
+ * 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}
+ */
+ 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}
+ */
+ 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).
+ * zh-CN:
+ * 当主版本系列相同的版本 (如 `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}
+ */
+ const rawVersionLetterMap = {};
+ /**
+ * @example
+ * Set(1) { '2023.3' }
+ * @type {Set}
+ */
+ const excludedVersionSet = new Set();
+ /**
+ * @example
+ * {
+ * '2023.3.2.1': 'J',
+ * '2023.3.2.2': 'K',
+ * '2023.3.1': 'J',
+ * }
+ * @type {Object}
+ */
+ 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).
+ * zh-CN:
+ * 当主版本系列相同的版本 (如 `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;
+});
diff --git a/.utils/scrape-and-inject-android-studio-codename_maps.mjs b/.utils/scrape-and-inject-android-studio-codename_maps.mjs
deleted file mode 100644
index d39bedc6..00000000
--- a/.utils/scrape-and-inject-android-studio-codename_maps.mjs
+++ /dev/null
@@ -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).
- * Default is first letter, e.g. 'Meerkat' and 'Bumblebee' gives { Meerkat: 'M', Bumblebee: 'B' }.
- * When first letters conflict, conflicts are auto-resolved,
- * e.g. 'Camel' and 'Catfish' gives { Camel: 'CAM', Catfish: 'CAT' }.
- * For extreme cases where conflicts cannot be auto-resolved,
- * e.g. 'Cat' and 'Catfish', manual prefix mapping is needed, like { Cat: 'CT', Catfish: 'CTF' }.
- * zh-CN:
- * 手动覆盖代号映射 (可用于解决代号前缀冲突).
- * 默认为首字母, 如 'Meerkat' 与 'Bumblebee', 得到 { Meerkat: 'M', Bumblebee: 'B' }.
- * 首字母重复时自动消解冲突, 如 'Camel' 与 'Catfish', 得到 { Camel: 'CAM', Catfish: 'CAT' }.
- * 极端情况无法自动消解冲突, 如 'Cat' 与 'Catfish', 此时需要手动指定前缀, 如 { Cat: 'CT', Catfish: 'CTF' }.
- * @example Object
- * {
- * 'Camel': 'CM',
- * 'Cat': 'CT',
- * 'Catfish': 'CTF',
- * ... ...
- * }
- * @type {Object}
- */
-const manualCodenameOverrides = {};
-
-/**
- * Use the latest stable version's checksum/filename to locate the entry in archives,
- * complete and update common.json.
- * zh-CN: 用 "最新稳定版" 的校验和/文件名, 在归档中定位条目, 补全并更新 common.json.
- *
- * @param {AndroidStudioArchiveItem[]} archives
- * @returns {Promise}
- */
-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.
- * 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.
- * 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 [Feature Drop]".
- // zh-CN: 捕获 "Android Studio [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.
- * zh-CN:
- * 生成唯一代码: 按顺序, 出现冲突则对冲突组统一递增长度; 覆盖映射优先考虑.
- *
- * @example Map
- * 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).
- * zh-CN: 原始代号 (保留大小写与空格, 如 "Arctic Fox", "Bumblebee", "Narwhal" 等).
- * @param {Object} overrides
- * Manual override mappings.
- * zh-CN: 手动覆盖映射.
- * @returns {Map}
- */
- 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
- * Map(14) {
- * 'N' => [ 0 ],
- * 'M' => [ 1 ],
- * ... ...
- * 'B' => [ 12 ],
- * 'A' => [ 13 ]
- * }
- * @type {Map}
- */
- 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>
- * 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>}
- */
- const versionToLetters = new Map();
-
- /**
- * @example Map
- * 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}
- */
- 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}
- */
- 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).
- * zh-CN:
- * 当次版本系列相同的版本 (如 `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;
-});
diff --git a/.utils/scrape-and-inject-gradle-kotlin-compatibility-list.mjs b/.utils/scrape-and-inject-gradle-kotlin-compatibility-list.mjs
deleted file mode 100644
index 1c720541..00000000
--- a/.utils/scrape-and-inject-gradle-kotlin-compatibility-list.mjs
+++ /dev/null
@@ -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;
-});
diff --git a/.utils/scrape-and-inject-gradle-kotlin-compatibility-map.mjs b/.utils/scrape-and-inject-gradle-kotlin-compatibility-map.mjs
new file mode 100644
index 00000000..fbf8e271
--- /dev/null
+++ b/.utils/scrape-and-inject-gradle-kotlin-compatibility-map.mjs
@@ -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
+ * Map(10) {
+ * ... ...
+ * "8.12" => "2.0.21",
+ * "8.11" => "2.0.20",
+ * ... ...
+ * }
+ * @type {Map}
+ */
+ 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;
+});
diff --git a/.utils/scrape-and-inject-java-gradle-compatibility-list.mjs b/.utils/scrape-and-inject-java-gradle-compatibility-map.mjs
similarity index 51%
rename from .utils/scrape-and-inject-java-gradle-compatibility-list.mjs
rename to .utils/scrape-and-inject-java-gradle-compatibility-map.mjs
index 61288dd5..13e92c7e 100644
--- a/.utils/scrape-and-inject-java-gradle-compatibility-list.mjs
+++ b/.utils/scrape-and-inject-java-gradle-compatibility-map.mjs
@@ -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);
diff --git a/.utils/scrape-and-inject-ksp-releases.mjs b/.utils/scrape-and-inject-ksp-releases-map.mjs
similarity index 79%
rename from .utils/scrape-and-inject-ksp-releases.mjs
rename to .utils/scrape-and-inject-ksp-releases-map.mjs
index f1406648..c9e6c2e8 100644
--- a/.utils/scrape-and-inject-ksp-releases.mjs
+++ b/.utils/scrape-and-inject-ksp-releases-map.mjs
@@ -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);
diff --git a/.utils/scrape-and-inject-latest-gradle-wrapper.mjs b/.utils/scrape-and-inject-latest-gradle-wrapper.mjs
index 2a200ed8..cbd6f262 100644
--- a/.utils/scrape-and-inject-latest-gradle-wrapper.mjs
+++ b/.utils/scrape-and-inject-latest-gradle-wrapper.mjs
@@ -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);
diff --git a/.utils/scrape-and-update-readme-template-contributors-table.mjs b/.utils/scrape-and-update-readme-template-contributors-table.mjs
index bcdbd97e..db2c4d1e 100644
--- a/.utils/scrape-and-update-readme-template-contributors-table.mjs
+++ b/.utils/scrape-and-update-readme-template-contributors-table.mjs
@@ -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 {
diff --git a/.utils/utils/anchors.mjs b/.utils/utils/anchors.mjs
deleted file mode 100644
index 3588fabb..00000000
--- a/.utils/utils/anchors.mjs
+++ /dev/null
@@ -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.
- * 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.
- * 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.
- * zh-CN:
- * 替换锚点块中的某个 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).
- * 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 };
-}
diff --git a/.utils/utils/print.mjs b/.utils/utils/print.mjs
index d28b21ad..2961e540 100644
--- a/.utils/utils/print.mjs
+++ b/.utils/utils/print.mjs
@@ -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} src
+ * @param {Map} 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} map
+ * @returns {string}
+ */
+function mapToLines(map) {
+ return Array.from(map.entries()).map(([ k, v ]) => `"${k}" to "${v}"`).join('\n');
+}
\ No newline at end of file
diff --git a/.utils/utils/properties.mjs b/.utils/utils/properties.mjs
index bfc6ea62..2d404ffd 100644
--- a/.utils/utils/properties.mjs
+++ b/.utils/utils/properties.mjs
@@ -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}
+ * @param {MapSortable['sort']} [sortingPattern=null]
+ * @returns {Map}
*/
-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