6.7.0 - Alpha6 - 重构 scrapers 工具并增加中英双语注释内容

This commit is contained in:
SuperMonster003
2025-09-24 16:01:46 +08:00
parent ef076d1cf8
commit c1ee107bc3
25 changed files with 1512 additions and 1072 deletions

View File

@@ -1,35 +1,49 @@
// utils/anchors.mjs
/**
* @typedef {Object} AnchoredBlockUpdateOption
* @property {'map' | 'list' | 'custom'} type
* @property {string} anchorTag
* @property {string} [mapName]
* @property {string} [listName]
* @property {string[]} lines
* @property {number} [linesIndent=4]
* @property {string} [updatedLabel]
* @property {(srcInBlock: string, options: { toUpdatedStamp?: (date?: Date) => string }) => { newBlock: string, changed: boolean }} [replacer]
*/
import * as fsp from 'node:fs/promises';
import * as path from 'node:path';
import { escapeRegExp } from './format.mjs';
import { toUpdatedStamp } from './date.mjs';
/**
* @param {string} s
* @returns {string}
*/
const normalize = s => String(s).replace(/\s+/g, '');
const normalize = (s) => String(s).replace(/\s+/g, '');
/**
* 在指定 Anchor 块中, 用给定的替换函数生成新块内容.
* 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 }} - 返回 { src: 新源码, changed: 是否发生变更 }. 若找不到锚点, 原样返回.
* @returns {{ src: string, changed: boolean }}
*/
export function replaceInAnchoredBlock(src, anchorTag, replaceBlockFn) {
const beginTag = `// @AnchorBegin ${anchorTag}`;
const endTag = `// @AnchorEnd ${anchorTag}`;
const beginIdx = src.indexOf(beginTag);
if (beginIdx === -1) return { src, changed: false };
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) return { src, changed: false };
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); // 不包含 endTag
const block = src.slice(beginIdx, endIdx);
const after = src.slice(endIdx);
const { newBlock, changed } = replaceBlockFn(block) || {};
@@ -39,16 +53,18 @@ export function replaceInAnchoredBlock(src, anchorTag, replaceBlockFn) {
}
/**
* 替换锚点块中的某个 map 声明 (如 mapOf(...)), 并在变更时自动刷新 @Updated 日期.
* 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 - 变量名, 如 agpVersionMap
* @param {string[]} options.lines - map 体内的每行 (不含缩进, 由函数自动缩进)
* @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 }}}
* @param {(date?: Date) => string} [options.toUpdatedStamp=toUpdatedStamp]
* @returns {{ src: string, changed: boolean }}
*/
export function replaceAnchoredMapBlock(src, {
anchorTag,
@@ -60,7 +76,8 @@ export function replaceAnchoredMapBlock(src, {
return replaceInAnchoredBlock(src, anchorTag, (block) => {
let changed = false;
const re = new RegExp(`([\\t\\x20]*)(va[lr]\\s+)?${mapName}\\s*=\\s*mapOf\\([\\s\\S]*?\\)(,?)`, 'm');
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');
@@ -80,16 +97,19 @@ export function replaceAnchoredMapBlock(src, {
}
/**
* 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 - 变量名, 如 modules 或 libs
* @param {string[]} options.lines - list 体内的每行 (不含缩进, 由函数自动缩进)
* @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 }}}
* @param {(date?: Date) => string} [options.toUpdatedStamp=toUpdatedStamp]
* @returns {{ src: string, changed: boolean }}
*/
export function replaceAnchoredListBlock(src, {
anchorTag,
@@ -121,16 +141,14 @@ export function replaceAnchoredListBlock(src, {
}
/**
* 高层封装: 读取文件 -> 替换锚点 map -> 若有变更则写回 -> 打印日志.
*
* @param {string} filePath
* @param {Object} options
* @param {string} options.anchorTag - 块的锚点名
* @param {string} options.mapName - 变量名, 如 agpVersionMap
* @param {string[]} options.lines - map 体内的每行 (不含缩进, 由函数自动缩进)
* @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 {(date?: Date) => string} [options.toUpdatedStamp=toUpdatedStamp]
* @param {Console} [options.logger=console]
* @returns {Promise<{ changed: boolean, content: string }>}
*/
@@ -149,24 +167,22 @@ export async function updateAnchoredMapInFile(filePath, {
if (changed) {
await fsp.writeFile(filePath, updated, 'utf8');
logger.log(`[${filename}] 已更新` + (updatedLabel ? ` (${updatedLabel})` : ''));
logger.log(`[${filename}] Updated` + (updatedLabel ? ` (${updatedLabel})` : ''));
} else {
// logger.log(`[${filename}] 无需更新` + (updatedLabel ? ` (${updatedLabel})` : ''));
// logger.log(`[${filename}] No update needed` + (updatedLabel ? ` (${updatedLabel})` : ''));
}
return { changed, content: updated };
}
/**
* 高层封装: 读取文件 -> 替换锚点 list -> 若有变更则写回 -> 打印日志.
*
* @param {string} filePath
* @param {Object} options
* @param {string} options.anchorTag - 块的锚点名
* @param {string} options.listName - 变量名, 如 modules 或 libs
* @param {string[]} options.lines - list 体内的每行 (不含缩进, 由函数自动缩进)
* @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 {(date?: Date) => string} [options.toUpdatedStamp=toUpdatedStamp]
* @param {Console} [options.logger=console]
* @returns {Promise<{ changed: boolean, content: string }>}
*/
@@ -185,31 +201,22 @@ export async function updateAnchoredListInFile(filePath, {
if (changed) {
await fsp.writeFile(filePath, updated, 'utf8');
logger.log(`[${filename}] 已更新` + (updatedLabel ? ` (${updatedLabel})` : ''));
logger.log(`[${filename}] Updated` + (updatedLabel ? ` (${updatedLabel})` : ''));
} else {
// logger.log(`[${filename}] 无需更新` + (updatedLabel ? ` (${updatedLabel})` : ''));
// logger.log(`[${filename}] No update needed` + (updatedLabel ? ` (${updatedLabel})` : ''));
}
return { changed, content: updated };
}
/**
* @typedef {Object} AnchoredBlockUpdateOption
* @property {'map' | 'list' | 'custom'} type
* @property {string} anchorTag
* @property {string} [mapName]
* @property {string} [listName]
* @property {string[]} lines
* @property {number} [linesIndent=4]
* @property {string} [updatedLabel]
* @property {(srcInBlock: string, options: { toUpdatedStamp?: (date?: Date) => string }) => { newBlock: string, changed: boolean }} [replacer]
*/
/**
* 批量在同一文件内进行多锚点替换 (map 与 list 都支持, 读一次/写一次).
* 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 {(date?: Date) => string} [extraOptions.toUpdatedStamp=toUpdatedStamp]
* @param {Console} [extraOptions.logger=console]
* @returns {Promise<{ changed: boolean, content: string }>}
*/
@@ -221,38 +228,38 @@ export async function batchUpdateAnchoredBlocks(filePath, optionList, {
let raw = await fsp.readFile(filePath, 'utf8');
let changedAny = false;
for (const op of optionList) {
for (const opt of optionList) {
let res = { src: raw, changed: false };
if (op.type === 'map') {
if (opt.type === 'map') {
res = replaceAnchoredMapBlock(raw, {
anchorTag: op.anchorTag,
mapName: op.mapName,
lines: op.lines,
linesIndent: op.linesIndent,
anchorTag: opt.anchorTag,
mapName: opt.mapName,
lines: opt.lines,
linesIndent: opt.linesIndent,
toUpdatedStamp: toStamp,
});
} else if (op.type === 'list') {
} else if (opt.type === 'list') {
res = replaceAnchoredListBlock(raw, {
anchorTag: op.anchorTag,
listName: op.listName,
lines: op.lines,
linesIndent: op.linesIndent,
anchorTag: opt.anchorTag,
listName: opt.listName,
lines: opt.lines,
linesIndent: opt.linesIndent,
toUpdatedStamp: toStamp,
});
} else if (op.type === 'custom' && typeof op.replacer === 'function') {
res = replaceInAnchoredBlock(raw, op.anchorTag, (block) => op.replacer(block, { toUpdatedStamp: toStamp }));
} else if (opt.type === 'custom' && typeof opt.replacer === 'function') {
res = replaceInAnchoredBlock(raw, opt.anchorTag, (block) => opt.replacer(block, { toUpdatedStamp: toStamp }));
} else {
logger.warn(`[${filename}] 未知操作类型或缺少参数:`, op);
logger.warn(`[${filename}] Unknown operation type or missing parameters:`, opt);
continue;
}
if (res.changed) {
changedAny = true;
raw = res.src;
logger.log(`[${filename}] 已更新 (${op['updatedLabel'] ?? op.anchorTag})`);
logger.log(`[${filename}] Updated (${opt.updatedLabel ?? opt.anchorTag})`);
} else {
// logger.log(`[${filename}] 无需更新 (${op['updatedLabel'] ?? op.anchorTag})`);
// logger.log(`[${filename}] No update needed (${op['updatedLabel'] ?? op.anchorTag})`);
}
}

View File

@@ -1,15 +1,18 @@
// utils/date.mjs
/**
* @example string
* "Aug 23, 2025"
* @param {Date} [date=new Date()]
* @returns {string}
*/
export function toUpdatedStamp(date = new Date()) {
/* e.g. "Aug 23, 2025". */
return date.toLocaleString('en-US', { month: 'short', day: 'numeric', year: 'numeric' });
}
/**
* @example string
* "2025/09/20"
* @param {string} [dateText='']
* @returns {string | null}
*/
@@ -23,11 +26,17 @@ export function toYYYYMMDD(dateText = '') {
}
/**
* 生成 .properties 文件头部注释时间戳.
* 形如: "#Thu Aug 28 12:05:55 CST 2025" (Properties.store 风格, en-US + short tz)
* Generate timestamp for .properties file header comment.<br>
* Note: The timezone abbreviation depends on runtime environment, may display as GMT+08/PDT etc.<br>
* zh-CN:<br>
* 生成 .properties 文件头部注释时间戳.<br>
* 注: 时区缩写依赖运行环境, 可能显示为 GMT+08/PDT 等.
*
* @example string
* "#ThuAug 28 12:05:55 CST 2025"
*
* @param {Date} [date=new Date()]
* @param {string} [timeZone="Asia/Shanghai"] 可选时区, 如 "Asia/Shanghai"
* @param {string} [timeZone="Asia/Shanghai"]
* @returns {string}
*/
export function generatePropertiesFileTimestamp(date = new Date(), timeZone) {
@@ -43,11 +52,15 @@ export function generatePropertiesFileTimestamp(date = new Date(), timeZone) {
timeZoneName: 'short',
...(timeZone ? { timeZone } : { timeZone: 'Asia/Shanghai' }),
});
/**
* @example Intl.DateTimeFormatOptions
* { weekday: 'Thu', month:'Aug', day:'28', hour:'12', minute:'05', second:'55', timeZoneName:'CST', year:'2025' }
* @type {Intl.DateTimeFormatOptions}
*/
const parts = fmt.formatToParts(date).reduce((acc, p) => {
acc[p.type] = p.value;
return acc;
}, /** @type {Object<string, string>} */ ({}));
// parts 示例: { weekday: 'Thu', month:'Aug', day:'28', hour:'12', minute:'05', second:'55', timeZoneName:'CST', year:'2025' }
}, {});
const stamp = `${parts.weekday} ${parts.month} ${parts.day} ${parts.hour}:${parts.minute}:${parts.second} ${parts.timeZoneName} ${parts.year}`;
return `#${stamp}`;
}

View File

@@ -2,17 +2,21 @@
/** @typedef {import('@octokit/types').Endpoints['GET /repos/{owner}/{repo}/commits']['response']['data']} CommitsData */
import fetch from 'node-fetch';
import * as dotenv from 'dotenv';
import * as https from 'https';
import nodeFetch from 'node-fetch';
import { buildUrl } from './format.mjs';
import { toYYYYMMDD } from './date.mjs';
dotenv.config({ path: '../.env', quiet: true });
/**
* 获取远程文件真实大小.
* Get the actual size of a remote file.<br>
* zh-CN: 获取远程文件真实大小.
*
* @param {string} url
* @param {{timeout?: number}} [options]
* @param {Object} [options]
* @param {number} [options.timeout=30000]
* @returns {Promise<number | null>}
*/
export async function getRemoteFileSizeBytes(url, { timeout = 30000 } = {}) {
@@ -21,10 +25,11 @@ export async function getRemoteFileSizeBytes(url, { timeout = 30000 } = {}) {
'accept': '*/*',
};
// 1) 尝试 HEAD
// Attempt HEAD.
// zh-CN: 尝试 HEAD.
try {
const res = await fetch(url, {
const res = await nodeFetch(url, {
method: 'HEAD',
redirect: 'follow',
headers,
@@ -37,7 +42,8 @@ export async function getRemoteFileSizeBytes(url, { timeout = 30000 } = {}) {
/* Ignored. */
}
// 2) 尝试 Range GET (bytes=0-0), Content-Range 解析总长度
// Attempt Range GET (bytes=0-0), parse total length from Content-Range.
// zh-CN: 尝试 Range GET (bytes=0-0), 从 Content-Range 解析总长度.
try {
const ac = new AbortController();
@@ -56,7 +62,8 @@ export async function getRemoteFileSizeBytes(url, { timeout = 30000 } = {}) {
const m = /bytes\s+\d+-\d+\/(\d+)/i.exec(cr);
if (m) return Number(m[1]);
}
// 退化: 仍然尝试 content-length
// Fallback, still try content-length.
// zh-CN: 退化, 仍然尝试 content-length.
const len = res.headers.get('content-length');
if (len && /^\d+$/.test(len)) return Number(len);
}
@@ -70,12 +77,12 @@ export async function getRemoteFileSizeBytes(url, { timeout = 30000 } = {}) {
/**
* @param {string} owner
* @param {string} repo
* @return {Promise<string>}
* @returns {Promise<string>}
*/
export async function getLatestCommitDate(owner, repo) {
const token = process.env.GITHUB_TOKEN; // 可选:避免频繁请求受限
const url = `https://api.github.com/repos/${owner}/${repo}/commits?per_page=1`;
const token = process.env.GITHUB_TOKEN;
/** @type {import('node-fetch').HeadersInit} */
const headers = {
accept: 'application/vnd.github+json',
@@ -85,16 +92,63 @@ export async function getLatestCommitDate(owner, repo) {
const res = await fetch(url, { headers });
if (!res.ok) {
throw new Error(`GitHub API 请求失败: ${res.status} ${res.statusText}`);
throw new Error(`GitHub API request failed: ${res.status} ${res.statusText}`);
}
const data = /** @type {CommitsData} */ await res.json();
const latest = Array.isArray(data) ? data[0] : null;
if (!latest?.commit) throw new Error('未获取到最新提交');
if (!latest?.commit) throw new Error('Failed to get latest commit');
// 优先使用 committer 的提交时间fallback 到 author
const iso = latest.commit.committer?.date ?? latest.commit.author?.date;
if (!iso) throw new Error('提交对象缺少日期字段');
if (!iso) throw new Error('Commit object missing date field');
return toYYYYMMDD(iso);
}
/**
* @param {string} url
* @param {Object} [options={}]
* @param {Object<string, any>} [options.query={}]
* @param {import('http').OutgoingHttpHeaders} [options.headers={}]
* @param {number} [options.timeout=15000]
* @returns {Promise<*>}
*/
export function httpFetch(url, options = {}) {
return new Promise((resolve, reject) => {
const opts = {
method: 'GET',
headers: options.headers ?? {},
timeout: options.timeout ?? 15000,
};
const niceUrl = options.query ? buildUrl(url, options.query) : url;
const req = https.request(niceUrl, opts, (res) => {
const { statusCode } = res;
const chunks = [];
res.on('data', (d) => chunks.push(d));
res.on('end', () => {
if (statusCode < 200 || statusCode >= 300) {
return reject(`HTTP ${statusCode}`);
}
let body = null;
try {
body = Buffer.concat(chunks).toString('utf8');
} catch (_) {
/* Ignored. */
}
if (body == null) {
throw new Error('Failed to read response body');
}
try {
resolve(JSON.parse(body));
} catch {
resolve(body);
}
});
});
req.on('error', reject);
req.on('timeout', () => {
req.destroy(new Error('Request timed out'));
});
req.end();
});
}

View File

@@ -3,9 +3,41 @@
/**
* @param {number | null} bytes
* @param {number} [fractionDigits=2]
* @returns {string | null}
*/
export function bytes2GiB(bytes, fractionDigits = 2) {
if (bytes == null) return null;
const gib = bytes / 1024 ** 3;
return `${gib.toFixed(fractionDigits)} GiB`;
}
}
/**
* Constructs a URL with query parameters.<br>
* zh-CN: 使用查询参数构造 URL.
*
* @example
* // https://example.com?a=1&b=2
* url('https://example.com', { a: 1, b: 2 });
*
* @param {string} url
* @param {Object} query
* @returns {string}
*/
export function buildUrl(url, query) {
if (!query) return url;
const q = Object.entries(query)
.map(([key, value]) => `${key}=${encodeURIComponent(value)}`)
.join('&');
return `${url}?${q}`;
}
/**
* @example
* "Up | Down ?" -> "Up \| Down \?"
*
* @param {string} string
* @returns {string}
*/
export function escapeRegExp(string) {
return string.replace(/[.*+?^${}()|\[\]\\]/g, '\\$&');
}

View File

@@ -5,6 +5,105 @@ import * as fsp from 'node:fs/promises';
import { compareVersionStrings } from './versioning.mjs';
import { generatePropertiesFileTimestamp } from './date.mjs';
/**
* Convert JS string to .properties format escaping rules (store format)
* - Escape: backslash, whitespace/control chars, delimiters (= :), comment chars (# !)
* - All non-ASCII chars are encoded as \uXXXX (aligned with java.util.Properties.store)<br>
* zh-CN:<br>
* 将 JS 字符串按 .properties 规范转义 (store 格式)
* - 转义: backslash, 空白/控制字符, 分隔符 (= :), 注释首字符 (# !)
* - 非 ASCII 均编码为 \uXXXX (与 java.util.Properties.store 对齐)
*
* @example string
* escapeProperty('https://www.example.com'); // 'https\://www.example.com'
* escapeProperty('a==b'); // 'a\=\=b'
* escapeProperty('\n\r\t\f'); // '\n\r\t\f'
*
* escapeProperty('#comment', true); // \#comment
* escapeProperty('#comment', false); // \#comment
* escapeProperty('comment#today', true); // comment\#today
* escapeProperty('comment#today', false); // comment#today
*
* escapeProperty(' comment', true); // \ comment
* escapeProperty(' comment', false); // \ comment
* escapeProperty('comment today', true); // comment\ today
* escapeProperty('comment today', false); // comment today
*
* @param {string} str
* @param {boolean} isKey
* @returns {string}
*/
function escapeProperty(str, isKey) {
if (!str) return '';
let out = '';
for (let i = 0; i < str.length; i++) {
const ch = str[i];
const code = ch.codePointAt(0);
// Handle non-ASCII or control chars uniformly.
// zh-CN: 统一处理非 ASCII 或控制字符.
if (code < 0x20 || code > 0x7e) {
if (ch === '\t') {
out += '\\t';
continue;
}
if (ch === '\n') {
out += '\\n';
continue;
}
if (ch === '\r') {
out += '\\r';
continue;
}
if (ch === '\f') {
out += '\\f';
continue;
}
// 其他非 ASCII -> \uXXXX
const hex = code.toString(16).padStart(4, '0');
out += '\\u' + hex.slice(-4);
continue;
}
switch (ch) {
case '\\':
out += '\\\\';
break;
case '=':
case ':':
// Escape both key and value for compatibility.
// zh-CN: 在 key 和 value 中都转义, 保证兼容性.
out += '\\' + ch;
break;
case ' ':
// Spaces in key need to be escaped; leading spaces in value need to be escaped.
// zh-CN: key 中任意空格需要转义; value 的前导空格需要转义.
if (isKey || out === '') out += '\\ ';
else out += ' ';
break;
case '\t':
case '\n':
case '\r':
case '\f':
// Redundant protection, control chars have been handled above.
// zh-CN: 冗余保护, 已在上方控制字符分支处理.
out += ch === '\t' ? '\\t'
: ch === '\n' ? '\\n'
: ch === '\r' ? '\\r'
: '\\f';
break;
case '#':
case '!':
// When used as key or as first char of value, need to escape to avoid being parsed as comment.
// zh-CN: 作为 key 时或 value 的首字符, 为避免被解析为注释, 需转义.
if (isKey || out === '') out += '\\' + ch;
else out += ch;
break;
default:
out += ch;
}
}
return out;
}
/**
* @param {string} str
* @returns {string}
@@ -37,7 +136,8 @@ function unescapeProperty(str) {
out += String.fromCharCode(parseInt(hex, 16));
i += 4;
} else {
// 非法 \u 序列, 按字面量保留
// Illegal \u sequence, keep as literal.
// zh-CN: 非法 \u 序列, 按字面量保留.
out += '\\u';
}
break;
@@ -49,109 +149,40 @@ function unescapeProperty(str) {
out += next;
break;
default:
// 未知转义, 保留第二个字符
// Unknown escape sequence, keep (but without the preceding "\").
// zh-CN: 未知转义, 保留 (但不包含前面的 "\").
out += next;
}
}
return out;
}
/**
* 将 JS 字符串按 .properties 规范转义 (store 格式)
* - 转义: backslash, 空白/控制字符, 分隔符 (= :), 注释首字符 (# !)
* - 非 ASCII 均编码为 \uXXXX (与 java.util.Properties.store 对齐)
* @param {string} str
* @param {boolean} isKey
* @returns {string}
*/
function escapeProperty(str, isKey) {
if (!str) return '';
let out = '';
for (let i = 0; i < str.length; i++) {
const ch = str[i];
const code = ch.codePointAt(0);
// 统一处理非 ASCII 或控制字符
if (code < 0x20 || code > 0x7e) {
if (ch === '\t') {
out += '\\t';
continue;
}
if (ch === '\n') {
out += '\\n';
continue;
}
if (ch === '\r') {
out += '\\r';
continue;
}
if (ch === '\f') {
out += '\\f';
continue;
}
// 其他非 ASCII -> \uXXXX
const hex = code.toString(16).padStart(4, '0');
out += '\\u' + hex.slice(-4);
continue;
}
switch (ch) {
case '\\':
out += '\\\\';
break;
case '=':
case ':':
// 在 key 和 value 中都转义, 保证兼容性
out += '\\' + ch;
break;
case ' ':
// key 中任意空格需要转义; value 的前导空格需要转义
if (isKey || out === '') out += '\\ ';
else out += ' ';
break;
case '\t':
case '\n':
case '\r':
case '\f':
// 已在上方控制字符分支处理, 这里冗余保护
out += ch === '\t' ? '\\t'
: ch === '\n' ? '\\n'
: ch === '\r' ? '\\r'
: '\\f';
break;
case '#':
case '!':
// 作为 key 时或 value 的首字符, 为避免被解析为注释, 需转义
if (isKey || out === '') out += '\\' + ch;
else out += ch;
break;
default:
out += ch;
}
}
return out;
}
/**
* @param {string} text
* @returns {Object<string, string>}
*/
export function parseProperties(text) {
function parseProperties(text) {
const props = Object.create(null);
if (!text) return props;
const lines = [];
const rawLines = text.split(/\r?\n/);
// 合并续行 (以反斜杠结尾且反斜杠未被转义)
// Merge continuation lines (lines ending with an unescaped backslash).
// zh-CN: 合并续行 (以反斜杠结尾且反斜杠未被转义).
for (let i = 0; i < rawLines.length; i++) {
let line = rawLines[i];
if (line == null) continue;
// 去除行尾 CR (兼容 \r\n 已 split 的情况, 一般无需此步)
// Remove trailing CR (for \r\n already split cases, usually not needed).
// zh-CN: 去除行尾 CR (兼容 \r\n 已 split 的情况, 一般无需此步).
line = line.replace(/\r$/, '');
// 合并续行
// Merge continuation lines.
// zh-CN: 合并续行.
while (true) {
// 统计结尾连续反斜杠数量, 奇数表示续行
// Count consecutive backslashes at end, odd number indicates continuation.
// zh-CN: 统计结尾连续反斜杠数量, 奇数表示续行.
let backslashes = 0;
for (let j = line.length - 1; j >= 0 && line[j] === '\\'; j--) backslashes++;
const isContinuation = backslashes % 2 === 1;
@@ -159,7 +190,8 @@ export function parseProperties(text) {
if (!isContinuation) break;
const next = rawLines[++i];
if (next == null) break;
// 去掉一个续行用的反斜杠, 再拼接后续行, 续行处按规范会吞掉换行
// Remove one continuation backslash, append next line, newline is discarded at continuation point per spec.
// zh-CN: 去掉一个续行用的反斜杠, 再拼接后续行, 续行处按规范会吞掉换行.
line = line.slice(0, -1) + next;
}
lines.push(line);
@@ -169,12 +201,14 @@ export function parseProperties(text) {
const line = raw.trim();
if (!line || line.startsWith('#') || line.startsWith('!')) continue;
// 键值分隔: 第一个 =/: 或未转义空白
// Key-value separator: first =/: or unescaped whitespace.
// zh-CN: 键值分隔: 第一个 =/: 或未转义空白.
let key = '';
let value = '';
let sepIdx = -1;
// 逐字符扫描, 识别未转义的分隔符
// Scan character by character to identify unescaped separators.
// zh-CN: 逐字符扫描, 识别未转义的分隔符.
let escaped = false;
for (let i = 0; i < line.length; i++) {
const ch = line[i];
@@ -196,13 +230,16 @@ export function parseProperties(text) {
} else {
key = line.slice(0, sepIdx);
value = line.slice(sepIdx + 1);
// 如果分隔符是空白, value 应该从第一个非空白处开始
// If separator is whitespace, value should start from first non-whitespace.
// zh-CN: 如果分隔符是空白, value 应该从第一个非空白处开始.
if (/^\s$/.test(line[sepIdx])) {
value = value.replace(/^\s+/, '');
}
}
key = key.replace(/\s+$/, ''); // 规范里 key 前部空白可作为分隔符, 末尾空白需要去掉
// In spec, leading whitespace in key can be separator, trailing whitespace should be removed.
// zh-CN: 规范里 key 前部空白可作为分隔符, 末尾空白需要去掉.
key = key.replace(/\s+$/, '');
const k = unescapeProperty(key);
const v = unescapeProperty(value.trim());
@@ -238,7 +275,7 @@ export function readPropertiesSync(filePath = '../version.properties', { encodin
* @param {Object<string,string>} [props={}]
* @param {Object} options
* @param {BufferEncoding} [options.encoding='utf8']
* @return {Promise<void>}
* @returns {Promise<void>}
*/
export async function writeProperties(filePath = '../version.properties', props = {}, { encoding = 'utf8' } = {}) {
const lines = [];
@@ -259,7 +296,7 @@ export async function writeProperties(filePath = '../version.properties', props
* @param {Object<string,string>} [props={}]
* @param {Object} options
* @param {BufferEncoding} [options.encoding='utf8']
* @return {void}
* @returns {void}
*/
export function writePropertiesSync(filePath = '../version.properties', props = {}, { encoding = 'utf8' } = {}) {
const lines = [];
@@ -289,7 +326,10 @@ export function getMinSupportedAgpVersion(filePath = '../version.properties', {
minSupportedVersion = value;
}
});
return minSupportedVersion ?? '8.0';
if (!minSupportedVersion) {
throw new Error('Could not determine minSupportedAgpVersion from "version.properties" file');
}
return minSupportedVersion;
}
/**
@@ -306,7 +346,10 @@ export function getMinSupportedGradleVersion(filePath = '../version.properties',
minSupportedVersion = value;
}
});
return minSupportedVersion ?? '8.0';
if (!minSupportedVersion) {
throw new Error('Could not determine minSupportedGradleVersion from "version.properties" file');
}
return minSupportedVersion;
}
/**
@@ -323,29 +366,40 @@ export function getMinSupportedJavaVersionInt(filePath = '../version.properties'
* @param {string} [filePath='../version.properties']
* @param {Object} options
* @param {BufferEncoding} [options.encoding='utf8']
* @returns {{ currentJavaVersionInt: number, minSupportedJavaVersionInt: number, minSuggestedJavaVersionInt: number, maxSupportedJavaVersionInt: number }}
* @returns {{ minSupportedJavaVersionInt: number, minSuggestedJavaVersionInt: number, maxSupportedJavaVersionInt: number }}
*/
export function getJavaVersionInfo(filePath = '../version.properties', { encoding = 'utf8' } = {}) {
let currentVersion = 0;
let minSuggestedVersion = 19;
let minSupportedVersion = 17;
let maxSupportedVersion = 0;
let minSuggestedVer = Infinity;
let minSupportedVer = Infinity;
let maxSupportedVer = -Infinity;
Object.entries(readPropertiesSync(filePath, { encoding })).forEach(([ key, value ]) => {
const versionNumber = parseInt(value, 10);
if (/^java.version$/i.test(key)) {
currentVersion = Math.max(currentVersion, versionNumber);
} else if (/java.version.*min.suggested|min.suggested.*java.version/i.test(key)) {
minSuggestedVersion = Math.min(minSuggestedVersion, versionNumber);
const currentVer = parseInt(value, 10);
if (/java.version.*min.suggested|min.suggested.*java.version/i.test(key)) {
minSuggestedVer = Math.min(minSuggestedVer, currentVer);
} else if (/java.version.*min.supported|min.supported.*java.version/i.test(key)) {
minSupportedVersion = Math.min(minSupportedVersion, versionNumber);
minSupportedVer = Math.min(minSupportedVer, currentVer);
} else if (/java.version.*max.supported|max.supported.*java.version/i.test(key)) {
maxSupportedVersion = Math.max(maxSupportedVersion, versionNumber);
maxSupportedVer = Math.max(maxSupportedVer, currentVer);
}
});
return {
currentJavaVersionInt: currentVersion,
minSuggestedJavaVersionInt: minSuggestedVersion,
minSupportedJavaVersionInt: minSupportedVersion,
maxSupportedJavaVersionInt: maxSupportedVersion,
/**
* @param {number} target
* @param {string} variableName
* @throws {Error}
*/
const validate = (target, variableName) => {
if (!isFinite(target)) throw new Error(`Could not determine ${variableName} from "version.properties" file`);
};
}
validate(minSuggestedVer, 'minSuggestedJavaVersionInt');
validate(minSupportedVer, 'minSupportedJavaVersionInt');
validate(maxSupportedVer, 'maxSupportedJavaVersionInt');
return {
minSuggestedJavaVersionInt: minSuggestedVer,
minSupportedJavaVersionInt: minSupportedVer,
maxSupportedJavaVersionInt: maxSupportedVer,
};
}

View File

@@ -1,10 +1,88 @@
// utils/puppeteer-helpers.mjs
/** @typedef {import('puppeteer').Page} Page */
/** @typedef {string | RegExp | ((s: string) => boolean)} FindTargetRowsFilter */
/** @typedef {string} TableDataStructureItemName */
/** @typedef {RegExp | ((s: string) => boolean | string)} TableDataStructureItem */
/** @typedef {string | RegExp | ((s: string) => boolean | string)} TableDataStructureItemForPageEvaluate */
/**
* @typedef {object} FindTargetRowsOptions
* @property {string} [tableSelector='table']
* @property {{ [selector: string]: FindTargetRowsFilter | FindTargetRowsFilter[] }}[tableFilter={}]
* @property {string} [tableRowSelector='tbody tr']
* @property {string} [tableDataSelector='td']
* @property {Array<{ [dataItemName: TableDataStructureItemName]: TableDataStructureItem } | TableDataStructureItemName>} [tableDataStructure=[]]
*/
/**
* @typedef {object} FindTargetRowsOptionsForPageEvaluate
* @property {string} [tableSelector='table']
* @property {{ [selector: string]: FindTargetRowsFilter | FindTargetRowsFilter[] }}[tableFilter={}]
* @property {string} [tableRowSelector='tbody tr']
* @property {string} [tableDataSelector='td']
* @property {Array<{ [dataItemName: TableDataStructureItemName]: TableDataStructureItemForPageEvaluate } | TableDataStructureItemName>} [tableDataStructure=[]]
*/
/**
* @typedef {object} PuppeteerOptions
* @property {string} url
* @property {number} [pageGoToTimeout=120000]
* @property {number} [findTargetRowsTimeout=30000]
*/
import puppeteer from 'puppeteer';
import { sleep } from './async.mjs';
const REGEX_ID_FOR_PAGE_EVALUATE = ':RegExp:';
const FUNCTION_ID_FOR_PAGE_EVALUATE = ':Function:';
/**
* @param {RegExp} regex
* @returns {string}
*/
function encodeRegexTag(regex) {
const flags = regex.flags.split('').sort().join('');
return `${REGEX_ID_FOR_PAGE_EVALUATE}${flags ? `${flags}:` : ''}${regex.source}`;
}
/**
* @param {FindTargetRowsOptions} options
* @returns {FindTargetRowsOptionsForPageEvaluate}
*/
function toEncodedRegexTagOptions(options) {
/**
* @param {Object} obj
* @returns {Object}
*/
function traverse(obj) {
if (!obj) return obj;
if (typeof obj !== 'object' && typeof obj !== 'function') return obj;
for (const key in obj) {
const value = obj[key];
if (value instanceof RegExp) {
obj[key] = encodeRegexTag(value);
} else if (typeof value === 'function') {
obj[key] = `${FUNCTION_ID_FOR_PAGE_EVALUATE}${value.toString()}`;
} else if (Array.isArray(value)) {
obj[key] = value.map(item => {
if (item instanceof RegExp) {
return encodeRegexTag(item);
}
if (typeof item === 'function') {
return `${FUNCTION_ID_FOR_PAGE_EVALUATE}${item.toString()}`;
}
return traverse(item);
});
} else if (typeof value === 'object') {
traverse(value);
}
}
return obj;
}
return structuredClone(traverse(options));
}
/**
* @param {Page} page
* @returns {Promise<void>}
@@ -26,21 +104,16 @@ export async function autoScroll(page) {
});
}
/**
* @typedef {object} FindTargetRowsOptions
* @property {string} [tableSelector='table']
* @property {{ [selector: string]: string | string[] }}[tableFilter={}]
* @property {string} [tableRowSelector='tbody tr']
* @property {string} [tableDataSelector='td']
* @property {Array<{ [dataItemName: string]: string } | string>} [tableDataStructure=[]]
*/
/**
* @param {Page} page
* @param {FindTargetRowsOptions} [options={}]
* @returns {Promise<Array<{ [dataItemName: string]: (string | null) }>>}
*/
async function findTargetRowsWithPage(page, options = {}) {
return await page.evaluate((options) => {
/** @type {FindTargetRowsOptionsForPageEvaluate} */
const opts = toEncodedRegexTagOptions(options);
return await page.evaluate((options, consts) => {
const regex = new RegExp(consts.regexId + String.raw`(?:(\w+):)?(.+)`);
const targets = Array.from(document.querySelectorAll(options.tableSelector ?? 'table'));
const target = targets.find(t => {
for (const [ selector, filter ] of Object.entries(options.tableFilter ?? {})) {
@@ -48,37 +121,45 @@ async function findTargetRowsWithPage(page, options = {}) {
if (Array.isArray(filter)) {
if (!filter.some(f => elements.some(e => {
if (typeof f === 'string') {
if (!f.startsWith(':RegExp:')) {
return e.textContent.trim() === f;
if (f.startsWith(consts.regexId)) {
const [ _, flags, pattern ] = f.match(regex);
const re = new RegExp(pattern, flags);
return re.test(e.textContent.trim());
}
const [ _, flags, pattern ] = f.match(/:RegExp:(?:(\w+):)?(.+)/);
const re = new RegExp(pattern, flags);
return re.test(e.textContent.trim());
if (f.startsWith(consts.functionId)) {
const src = f.replace(consts.functionId, '');
const fn = new Function(`return ${src}`)();
return Boolean(fn(e.textContent.trim()));
}
return e.textContent.trim() === f;
}
throw TypeError(`Unknown type of filter (${f})`);
}))) {
return false;
}
} else {
if (!elements.some(e => {
if (typeof filter === 'string') {
if (!filter.startsWith(':RegExp:')) {
return e.textContent.trim() === filter;
}
const [ _, flags, pattern ] = filter.match(/:RegExp:(?:(\w+):)?(.+)/);
const re = new RegExp(pattern, flags);
return re.test(e.textContent.trim());
}
} else if (!elements.some(e => {
if (typeof filter !== 'string') {
throw TypeError(`Unknown type of filter (${filter})`);
})) {
return false;
}
if (filter.startsWith(consts.regexId)) {
const [ _, flags, pattern ] = filter.match(regex);
const re = new RegExp(pattern, flags);
return re.test(e.textContent.trim());
}
if (filter.startsWith(consts.functionId)) {
const src = filter.replace(consts.functionId, '');
const fn = new Function(`return ${src}`)();
return Boolean(fn(e.textContent.trim()));
}
return e.textContent.trim() === filter;
})) {
return false;
}
}
return true;
});
if (!target) {
throw Error('No target table found');
throw new Error('No target table found');
}
const tableRows = Array.from(target.querySelectorAll(options.tableRowSelector ?? 'tbody tr'));
@@ -88,60 +169,71 @@ async function findTargetRowsWithPage(page, options = {}) {
const tds = Array.from(tr.querySelectorAll(options.tableDataSelector ?? 'td'));
const tableDataStructure = options.tableDataStructure ?? [];
if (tds.length === 0 || tableDataStructure.length === 0) return null;
if (options.tableDataStructure.length > tds.length) {
throw Error(`Table data size (${tds.length}) is less than table data structure (${options.tableDataStructure.length})`);
if (tableDataStructure.length > tds.length) {
throw new Error(`Table data size (${tds.length}) is less than table data structure (${tableDataStructure.length})`);
}
for (let i = 0; i < options.tableDataStructure.length; i++) {
const o = options.tableDataStructure[i];
for (let i = 0; i < tableDataStructure.length; i++) {
const o = tableDataStructure[i];
let dataItemName = null;
let dataItemFilter = null;
let dataItemChecker = null;
if (typeof o === 'string') {
dataItemName = o;
} else if (typeof o === 'object' && o !== null) {
if (Object.keys(o).length !== 1) {
throw Error(`Table data structure (${options.tableDataStructure}) must be a string or an object with only one key`);
throw new Error(`Table data structure (${tableDataStructure}) must be a string or an object with only one key`);
}
dataItemName = Object.keys(o)[0];
dataItemFilter = o[dataItemName];
dataItemChecker = o[dataItemName];
} else {
throw Error(`Unknown type of table data structure (${options.tableDataStructure})`);
throw new Error(`Unknown type of table data structure (${o})`);
}
const dataItemValueRaw = tds[i].textContent.trim();
if (dataItemFilter == null) {
if (!dataItemChecker) {
tableData[dataItemName] = dataItemValueRaw;
} else if (typeof dataItemFilter === 'string') {
if (!dataItemFilter.startsWith(':RegExp:')) {
tableData[dataItemName] = dataItemValueRaw === dataItemFilter ? dataItemValueRaw : null;
} else {
const [ _, flags, pattern ] = dataItemFilter.match(/:RegExp:(?:(\w+):)?(.+)/);
continue;
}
if (typeof dataItemChecker === 'string') {
if (dataItemChecker.startsWith(consts.regexId)) {
const [ _, flags, pattern ] = dataItemChecker.match(regex);
const re = new RegExp(pattern, flags);
tableData[dataItemName] = dataItemValueRaw.match(re)?.[0] ?? null;
continue;
}
if (dataItemChecker.startsWith(consts.functionId)) {
const src = dataItemChecker.replace(consts.functionId, '');
const fn = new Function(`return ${src}`)();
const result = fn(dataItemValueRaw);
if (typeof result === 'boolean') {
tableData[dataItemName] = result ? dataItemValueRaw : null;
continue;
}
if (typeof result === 'string') {
tableData[dataItemName] = result;
continue;
}
throw new Error(`Function ${fn.name} must return a boolean or a string`);
}
}
throw new Error(`Unknown type of data item checker (${dataItemChecker})`);
}
tableDataList.push(tableData);
});
return tableDataList;
}, options);
}, opts, {
regexId: REGEX_ID_FOR_PAGE_EVALUATE,
functionId: FUNCTION_ID_FOR_PAGE_EVALUATE,
});
}
/**
* @typedef {object} PuppeteerOptions
* @property {string} url
* @property {number} [pageGoToTimeout=120000]
* @property {number} [findTargetRowsTimeout=30000]
*/
/**
* @param {FindTargetRowsOptions & PuppeteerOptions} options
* @returns {Promise<Array<{[dataItemName: string]: string | null}>>}
* @returns {Promise<Array<{ [dataItemName: string]: (string | null) }>>}
*/
export async function findTargetRows(options) {
const browser = await puppeteer.launch({ headless: true });
const page = await browser.newPage();
try {
await page.goto(options.url, { waitUntil: 'networkidle0', timeout: options.pageGoToTimeout ?? 120000 });
// 页面为懒加载: 滚动并多次尝试, 直到目标表格出现或超时
let rows = null;
const deadline = Date.now() + (options.findTargetRowsTimeout ?? 30000);
while (Date.now() < deadline) {
@@ -157,4 +249,4 @@ export async function findTargetRows(options) {
} finally {
await browser.close();
}
}
}

View File

@@ -1,80 +1,165 @@
// utils/versioning.mjs
const SUFFIX_PRIORITY = {
// 预发布 (越小越靠前)
'canary': 1, 'nightly': 1, 'snapshot': 1, 'dev': 1,
'pre-alpha': 2, 'prealpha': 2, 'preview': 2, 'eap': 2, 'milestone': 2,
'alpha': 3,
'beta': 4,
'rc': 5,
// 正式/稳定
'': 10, 'stable': 10, 'ga': 10, 'final': 10, 'release': 10, 'lts': 10,
};
const PRIORITY_STABLE = 100;
const SUFFIX_PRIORITY = Object.fromEntries([
[ [ 'canary', 'nightly', 'snapshot', 'dev', 'experimental', 'dev-experimental', 'wip', 'prototype' ], 5 ],
[ [ 'pre-alpha', 'preview', 'tech-preview', 'eap', 'milestone' ], 10 ],
[ [ 'alpha' ], 15 ],
[ [ 'beta' ], 20 ],
[ [ 'rc', 'ga-candidate' ], 25 ],
[ [ '', 'stable', 'ga', 'final', 'release', 'lts', 'rtm', 'sp', 'patch', 'maintenance' ], PRIORITY_STABLE ],
].map((/** @type {[string[], number]} */ [ suffixes, priority ]) => {
return suffixes.map(suffix => [ suffix, priority ]);
}).flat());
/**
* 统一后缀别名到规范键.
* @param {string} v1
* @param {string} v2
* @returns {number}
*/
export function compareVersionStrings(v1, v2) {
const [ n1, s1 ] = toVersionParts(v1);
const [ n2, s2 ] = toVersionParts(v2);
return compareVersionParts(n1, n2) || compareVersionSuffix(s1, s2);
}
/**
* @param {string} v1
* @param {string} v2
* @returns {number}
*/
export function compareVersionStringsDescending(v1, v2) {
const [ n1, s1 ] = toVersionParts(v1);
const [ n2, s2 ] = toVersionParts(v2);
return compareVersionParts(n2, n1) || compareVersionSuffix(s2, s1);
}
/**
* @param {string} version
* @returns {boolean}
*/
export function isVersionStable(version) {
const [ _, [ suffixName ] ] = toVersionParts(version);
return getSuffixPriority(suffixName) === PRIORITY_STABLE;
}
/**
* @param {string} version
* @param {Object} options
* @param {string} [options.min]
* @param {string} [options.max]
* @returns {boolean}
*/
export function isVersionInRange(version, { min, max } = {}) {
return (min == null || compareVersionStrings(version, min) >= 0)
&& (max == null || compareVersionStrings(version, max) <= 0);
}
/**
* @param {string} suffix
* @returns {number}
*/
function getSuffixPriority(suffix) {
return SUFFIX_PRIORITY[normalizeSuffixName(suffix)] ?? Number.MAX_SAFE_INTEGER;
}
/**
* Normalize suffix aliases to standard keys.<br>
* zh-CN: 统一后缀别名到规范键.
*
* @param {string} nameRaw
* @return {string}
* @returns {string}
*/
function normalizeSuffixName(nameRaw) {
const n = String(nameRaw || '').toLowerCase();
const n = String(nameRaw || '').toLowerCase().replaceAll('_', '-');
switch (n) {
case 'a':
return 'alpha';
case 'b':
return 'beta';
case 'exp':
return 'experimental';
case 'cr':
case 'release-candidate':
return 'rc';
case 'general-availability':
return 'ga';
case 'm':
return 'milestone';
case 'pre':
case 'prev':
return 'preview';
case 'canary':
case 'nightly':
case 'snapshot':
case 'dev':
case 'pre-alpha':
case 'prealpha':
case 'preview':
case 'eap':
case 'milestone':
case 'alpha':
case 'beta':
case 'rc':
case 'stable':
case 'ga':
case 'final':
case 'release':
case 'lts':
return n;
return 'pre-alpha';
case 'proto':
return 'prototype';
default:
return n; // 未知后缀维持原样, 优先级将落在默认分支
return n;
}
}
/**
* @example [ numberPart[], [ suffixName, suffixNumber ] ]
* // All results below will be: [ [ 4, 1, 1 ], [ 'alpha', 2 ] ].
*
* toVersionParts('4.1.1 Alpha2');
* toVersionParts('4.1.1 alpha2');
* toVersionParts('4.1.1alpha2');
* toVersionParts('4.1.1alpha 2');
* toVersionParts('4.1.1 alpha 2');
* toVersionParts('4.1.1-alpha2');
* toVersionParts('4.1.1-alpha-2');
* toVersionParts('4.1.1 - alpha 2');
* toVersionParts('4.1.1_alpha_2');
* toVersionParts('4.1.1a2');
*
* // All results below will be: [ [ 2024, 3, 2 ], [ 'beta', 1 ] ].
*
* toVersionParts('2024.3.2 Beta');
* toVersionParts('2024.3.2 Beta1');
* toVersionParts('2024.3.2 Beta01');
* toVersionParts('2024.3.2 Beta.1');
* toVersionParts('2024.3.2beta1');
* toVersionParts('2024.3.2b1');
* toVersionParts('2024.3.2b');
*
* @param {string} version
* @return { [number[], [string, number]]}
* @returns { [number[], [string, number]] }
*/
export function toVersionParts(version) {
const parts = version.split(/[\s+-]/);
const numberParts = parts[0].split('.').map(part => {
const num = parseInt(String(part), 10);
function toVersionParts(version) {
const ver = version.trim();
const parts = ver.split(/[\s_+-]+/);
const numberParts = parts[0].split('.').map((partRaw, idx, arr) => {
const part = String(partRaw);
if (idx === arr.length - 1) {
const matched = part.match(/^\d+([A-Za-z]+)(\d*)$/);
if (matched) {
const suffix = matched[1];
const suffixNum = matched[2];
if (suffixNum) {
parts.splice(1, 0, suffix, suffixNum);
} else {
parts.splice(1, 0, suffix);
}
}
}
const num = parseInt(part, 10);
if (Number.isNaN(num)) {
throw new Error(`Invalid version part: '${part}' in version: '${version}'`);
throw new Error(`Invalid version part: '${part}' in version: '${ver}'`);
}
return num;
});
// 解析后缀, 支持 rc1 / rc 1 / rc.1 / RC1 等; 默认数字为 1
const suffixPattern = /([A-Za-z]+)[\s._-]*(\d*)|([A-Za-z]*)[\s._-]*(\d+)/;
const suffixStr = parts[1] || '';
const suffixStr = parts.slice(1).join('') || '';
const m = suffixStr.match(suffixPattern);
if (!m) return [ numberParts, [ '', 0 ] ];
const rawName = (m[1] ?? m[3] ?? '');
const rawNum = (m[2] ?? m[4] ?? '');
const rawName = m[1] ?? m[3] ?? '';
const rawNum = m[2] ?? m[4] ?? '';
const suffixName = normalizeSuffixName(rawName);
const suffixNumberParsed = parseInt(rawNum || '1', 10);
const suffixNumber = Number.isNaN(suffixNumberParsed) ? 1 : suffixNumberParsed;
@@ -84,9 +169,9 @@ export function toVersionParts(version) {
/**
* @param {number[]} a
* @param {number[]} b
* @return {number}
* @returns {number}
*/
export function compareVersionParts(a, b) {
function compareVersionParts(a, b) {
const max = Math.max(a.length, b.length);
for (let i = 0; i < max; i++) {
const x = a[i] ?? 0;
@@ -99,62 +184,14 @@ export function compareVersionParts(a, b) {
/**
* @param {[string, number]} s1
* @param {[string, number]} s2
* @return {number}
* @returns {number}
*/
export function compareVersionSuffix(s1, s2) {
function compareVersionSuffix(s1, s2) {
const [ name1Raw, num1 ] = s1;
const [ name2Raw, num2 ] = s2;
const name1 = normalizeSuffixName(name1Raw);
const name2 = normalizeSuffixName(name2Raw);
const p1 = SUFFIX_PRIORITY[name1] ?? Number.MAX_SAFE_INTEGER;
const p2 = SUFFIX_PRIORITY[name2] ?? Number.MAX_SAFE_INTEGER;
const p1 = getSuffixPriority(name1Raw);
const p2 = getSuffixPriority(name2Raw);
if (p1 !== p2) return p1 > p2 ? 1 : -1;
if (num1 !== num2) return num1 > num2 ? 1 : -1;
return 0;
}
/**
* @param {string} v1
* @param {string} v2
* @return {number}
*/
export function compareVersionStrings(v1, v2) {
const [ n1, s1 ] = toVersionParts(v1);
const [ n2, s2 ] = toVersionParts(v2);
const cmp = compareVersionParts(n1, n2);
return cmp !== 0 ? cmp : compareVersionSuffix(s1, s2);
}
/**
* @param {string} v1
* @param {string} v2
* @return {number}
*/
export function compareVersionStringsDescending(v1, v2) {
const [ n1, s1 ] = toVersionParts(v1);
const [ n2, s2 ] = toVersionParts(v2);
const cmp = compareVersionParts(n2, n1);
return cmp !== 0 ? cmp : compareVersionSuffix(s2, s1);
}
/**
* @param {string} v
* @return {boolean}
*/
export function isVersionStable(v) {
const [ _, [ suffixName ] ] = toVersionParts(v);
const normalizedName = normalizeSuffixName(suffixName);
return (SUFFIX_PRIORITY[normalizedName] ?? Number.MAX_SAFE_INTEGER) === 10;
}
/**
* @param {string} v
* @param {Object} options
* @param {string} [options.min]
* @param {string} [options.max]
* @return {boolean}
*/
export function isVersionInRange(v, { min, max } = {}) {
return (min == null || compareVersionStrings(v, min) >= 0)
&& (max == null || compareVersionStrings(v, max) <= 0);
}