mirror of
https://github.com/kuaifan/dootask.git
synced 2026-06-11 09:52:26 +00:00
chore(release): 移除被 dootask-release 技能取代的翻译/版本脚本
- 删 language/translate.php(OpenAI 翻译)+ composer.json/lock + README - 删 bin/version.js(git-cliff + OpenAI 更新日志)+ cliff.toml - package.json 去掉 version/translate 两条 script,cmd 去掉 translate 子命令 - README_PUBLISH 指向 dootask-release 技能 翻译/版本号/更新日志改由 dootask-release 技能完成;CI 不受影响(只读 package.json version + CHANGELOG.md)。 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
035c9d9d3d
commit
7335c59b68
@ -9,9 +9,9 @@
|
||||
|
||||
## 发布版本
|
||||
|
||||
> 翻译、版本号、更新日志改由 `dootask-release` 技能完成(见 `.claude/skills/dootask-release/`)。
|
||||
|
||||
```shell
|
||||
npm run translate # 翻译(可选)
|
||||
npm run version # 生成版本
|
||||
npm run build # 编译前端
|
||||
```
|
||||
|
||||
|
||||
347
bin/version.js
vendored
347
bin/version.js
vendored
@ -1,347 +0,0 @@
|
||||
const fs = require('fs');
|
||||
const path = require("path");
|
||||
const exec = require('child_process').exec;
|
||||
let ProxyAgent = null;
|
||||
try {
|
||||
ProxyAgent = require("undici").ProxyAgent;
|
||||
} catch (error) {
|
||||
ProxyAgent = null;
|
||||
}
|
||||
const packageFile = path.resolve(process.cwd(), "package.json");
|
||||
const changeFile = path.resolve(process.cwd(), "CHANGELOG.md");
|
||||
|
||||
const verOffset = 6394; // 版本号偏移量
|
||||
const codeOffset = 35; // 代码版本号偏移量
|
||||
|
||||
const envFilePath = path.resolve(process.cwd(), ".env");
|
||||
const defaultAiSystemPrompt = "你是一位软件发布日志编辑专家。请产出 Markdown 更新日志,面向普通用户,以通俗友好的简体中文描述更新带来的直接好处,避免技术术语。所有章节标题必须以 `### ` 开头并保持英文 Title Case(例如 `### Features`、`### Bug Fixes`、`### Performance`、`### Documentation` 等)。每个章节内的条目按用户价值和影响范围排序,将更重要、影响更广的更新放在前面。";
|
||||
const defaultOpenAiEndpoint = "https://api.openai.com/v1/chat/completions";
|
||||
|
||||
function loadEnvFile(filePath) {
|
||||
if (!fs.existsSync(filePath)) {
|
||||
return;
|
||||
}
|
||||
const content = fs.readFileSync(filePath, "utf8");
|
||||
content.split(/\r?\n/).forEach(rawLine => {
|
||||
const line = rawLine.trim();
|
||||
if (!line || line.startsWith("#")) {
|
||||
return;
|
||||
}
|
||||
const equalsIndex = line.indexOf("=");
|
||||
if (equalsIndex === -1) {
|
||||
return;
|
||||
}
|
||||
let key = line.slice(0, equalsIndex).trim();
|
||||
if (key.startsWith("export ")) {
|
||||
key = key.slice(7).trim();
|
||||
}
|
||||
let value = line.slice(equalsIndex + 1).trim();
|
||||
if (!value) {
|
||||
value = "";
|
||||
}
|
||||
if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) {
|
||||
value = value.slice(1, -1);
|
||||
} else {
|
||||
const commentIndex = value.indexOf(" #");
|
||||
if (commentIndex !== -1) {
|
||||
value = value.slice(0, commentIndex).trim();
|
||||
}
|
||||
}
|
||||
if (process.env[key] === undefined) {
|
||||
process.env[key] = value;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
loadEnvFile(envFilePath);
|
||||
|
||||
function resolveApiEndpoint(candidate) {
|
||||
const source = (candidate || "").trim();
|
||||
if (!source) {
|
||||
return defaultOpenAiEndpoint;
|
||||
}
|
||||
if (/\/chat\/completions(\?|$)/.test(source)) {
|
||||
return source;
|
||||
}
|
||||
const normalized = source.replace(/\/+$/, "");
|
||||
if (/\/v\d+$/i.test(normalized)) {
|
||||
return `${normalized}/chat/completions`;
|
||||
}
|
||||
return `${normalized}/v1/chat/completions`;
|
||||
}
|
||||
|
||||
function loadSocksProxyAgent(proxyUrl) {
|
||||
try {
|
||||
const { SocksProxyAgent } = require('socks-proxy-agent');
|
||||
return new SocksProxyAgent(proxyUrl);
|
||||
} catch (error) {
|
||||
if (error && error.code === 'MODULE_NOT_FOUND') {
|
||||
console.warn("检测到 SOCKS 代理,但未安装 socks-proxy-agent,请运行 `npm install --save-dev socks-proxy-agent` 后重试。");
|
||||
} else {
|
||||
console.warn(`无法初始化 SOCKS 代理: ${error?.message || error}`);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function createProxyDispatcher(proxyUrl) {
|
||||
if (!proxyUrl) {
|
||||
return null;
|
||||
}
|
||||
let parsedProtocol = '';
|
||||
try {
|
||||
parsedProtocol = new URL(proxyUrl).protocol.replace(':', '').toLowerCase();
|
||||
} catch (error) {
|
||||
console.warn(`代理地址无效 (${proxyUrl}): ${error.message}`);
|
||||
return null;
|
||||
}
|
||||
if (parsedProtocol.startsWith('socks')) {
|
||||
return loadSocksProxyAgent(proxyUrl);
|
||||
}
|
||||
if (!ProxyAgent) {
|
||||
console.warn('未找到 undici.ProxyAgent,无法启用 HTTP 代理。');
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
return new ProxyAgent(proxyUrl);
|
||||
} catch (error) {
|
||||
console.warn(`无法初始化代理 (${proxyUrl}): ${error.message}`);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function buildDefaultUserPrompt(version, changelogSection) {
|
||||
return [
|
||||
"你是一位软件发布日志编辑专家。",
|
||||
"下面是一段通过 git 提交记录自动生成的更新日志文本。",
|
||||
"",
|
||||
"请将其整理为一份「面向普通用户、简洁概览风格」的 changelog,保持 Markdown 格式,包含以下结构:",
|
||||
"",
|
||||
`## [${version}]`,
|
||||
"",
|
||||
"### Features",
|
||||
"",
|
||||
"- ...",
|
||||
"",
|
||||
"### Bug Fixes",
|
||||
"",
|
||||
"- ...",
|
||||
"",
|
||||
"### Performance",
|
||||
"",
|
||||
"- ...",
|
||||
"",
|
||||
"**要求:**",
|
||||
"1. 删除技术性或重复的细节,合并相似项。",
|
||||
"2. 语句自然简洁,用简体中文描述。",
|
||||
"3. 使用贴近日常的词汇,突出更新对普通用户的直接价值,避免开发或管理术语(如\"refactor\"、\"merge branch\"、\"commit lint\")。",
|
||||
"4. 小节标题必须以 `### ` 开头并保持英文 Title Case(例如 `### Features`、`### Bug Fixes`、`### Performance`、`### Documentation`、`### Security`、`### Miscellaneous` 等),不得翻译成中文。",
|
||||
"5. 每个小节内的条目按用户价值和影响范围排序,将更重要、影响更广的更新放在前面。",
|
||||
"6. 若某个小节没有内容,请省略整段小节(包括标题)。",
|
||||
"7. 输出仅为 Markdown changelog 内容,不加其他解释。",
|
||||
"",
|
||||
"以下是原始日志:",
|
||||
"```markdown",
|
||||
changelogSection,
|
||||
"```"
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
function runExec(command) {
|
||||
return new Promise((resolve, reject) => {
|
||||
exec(command, { maxBuffer: 1024 * 1024 * 10 }, (err, stdout, stderr) => {
|
||||
if (err) {
|
||||
reject(err);
|
||||
return;
|
||||
}
|
||||
resolve(stdout.toString());
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function removeDuplicateLines(log) {
|
||||
const logs = log.split(/(\n## \[.*?\])/);
|
||||
return logs.map(str => {
|
||||
const array = [];
|
||||
const items = str.split("\n");
|
||||
items.forEach(item => {
|
||||
if (/^-/.test(item)) {
|
||||
if (array.indexOf(item) === -1) {
|
||||
array.push(item);
|
||||
}
|
||||
} else {
|
||||
array.push(item);
|
||||
}
|
||||
});
|
||||
return array.join("\n");
|
||||
}).join('');
|
||||
}
|
||||
|
||||
function findSectionBounds(content, version) {
|
||||
const heading = `## [${version}]`;
|
||||
const start = content.indexOf(heading);
|
||||
if (start === -1) {
|
||||
return null;
|
||||
}
|
||||
const nextHeadingIndex = content.indexOf("\n## [", start + heading.length);
|
||||
const end = nextHeadingIndex === -1 ? content.length : nextHeadingIndex;
|
||||
return { start, end };
|
||||
}
|
||||
|
||||
function trimCliffOutput(rawOutput, version) {
|
||||
const markerIndex = rawOutput.indexOf("## [");
|
||||
if (markerIndex === -1) {
|
||||
return "";
|
||||
}
|
||||
return rawOutput
|
||||
.slice(markerIndex)
|
||||
.replace("## [Unreleased]", `## [${version}]`)
|
||||
.trim();
|
||||
}
|
||||
|
||||
function buildAiHeaders(apiUrl, apiKey) {
|
||||
const headers = { "Content-Type": "application/json" };
|
||||
const customHeader = process.env.CHANGELOG_AI_AUTH_HEADER;
|
||||
if (customHeader) {
|
||||
const separatorIndex = customHeader.indexOf(":");
|
||||
if (separatorIndex !== -1) {
|
||||
const headerName = customHeader.slice(0, separatorIndex).trim();
|
||||
const headerValue = customHeader.slice(separatorIndex + 1).trim();
|
||||
if (headerName && headerValue) {
|
||||
headers[headerName] = headerValue;
|
||||
}
|
||||
}
|
||||
return headers;
|
||||
}
|
||||
if (apiUrl.includes("openai.azure.com")) {
|
||||
headers["api-key"] = apiKey;
|
||||
} else {
|
||||
headers.Authorization = `Bearer ${apiKey}`;
|
||||
}
|
||||
return headers;
|
||||
}
|
||||
|
||||
async function enhanceWithAI(version, changelogSection) {
|
||||
const apiKey = (process.env.OPENAI_API_KEY || "").trim();
|
||||
if (!apiKey) {
|
||||
console.warn("未设置 OPENAI_API_KEY,跳过 AI 发布日志整理。");
|
||||
return changelogSection;
|
||||
}
|
||||
const proxyUrl = (process.env.OPENAI_PROXY_URL || "").trim();
|
||||
const explicitApiUrl = process.env.CHANGELOG_AI_URL || process.env.OPENAI_API_URL || process.env.OPENAI_BASE_URL;
|
||||
const apiUrl = resolveApiEndpoint(explicitApiUrl);
|
||||
const dispatcher = createProxyDispatcher(proxyUrl);
|
||||
const model = process.env.CHANGELOG_AI_MODEL || process.env.OPENAI_API_MODEL || "gpt-4o-mini";
|
||||
const systemPrompt = process.env.CHANGELOG_AI_SYSTEM_PROMPT || defaultAiSystemPrompt;
|
||||
const userPrompt = process.env.CHANGELOG_AI_PROMPT || buildDefaultUserPrompt(version, changelogSection);
|
||||
|
||||
try {
|
||||
const requestInit = {
|
||||
method: "POST",
|
||||
headers: buildAiHeaders(apiUrl, apiKey),
|
||||
body: JSON.stringify({
|
||||
model,
|
||||
messages: [
|
||||
{ role: "system", content: systemPrompt },
|
||||
{ role: "user", content: userPrompt }
|
||||
],
|
||||
})
|
||||
};
|
||||
if (dispatcher) {
|
||||
requestInit.dispatcher = dispatcher;
|
||||
}
|
||||
const response = await fetch(apiUrl, requestInit);
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
throw new Error(`AI request failed: ${errorText}`);
|
||||
}
|
||||
const data = await response.json();
|
||||
const aiText = data?.choices?.[0]?.message?.content?.trim();
|
||||
if (!aiText) {
|
||||
throw new Error("AI response did not contain content.");
|
||||
}
|
||||
return aiText
|
||||
.replace(/^\s*```markdown\s*/i, "")
|
||||
.replace(/\s*```\s*$/i, "")
|
||||
.trim();
|
||||
} catch (error) {
|
||||
console.warn("AI summarization failed, falling back to original section:", error.message);
|
||||
return changelogSection;
|
||||
}
|
||||
}
|
||||
|
||||
async function generateLatestSection(version) {
|
||||
const rawOutput = await runExec('docker run -t --rm -v "$(pwd)":/app/ orhunp/git-cliff:1.3.0 --unreleased');
|
||||
const section = trimCliffOutput(rawOutput, version);
|
||||
if (!section.trim() || section.trim() === `## [${version}]`) {
|
||||
return "";
|
||||
}
|
||||
return section;
|
||||
}
|
||||
|
||||
function insertChangelogSection(existing, section, version) {
|
||||
const trimmedSection = section.trim();
|
||||
if (!trimmedSection) {
|
||||
return existing;
|
||||
}
|
||||
const bounds = findSectionBounds(existing, version);
|
||||
if (bounds) {
|
||||
return `${existing.slice(0, bounds.start)}${trimmedSection}\n\n${existing.slice(bounds.end).replace(/^(\n)+/, "")}`;
|
||||
}
|
||||
const insertIndex = existing.indexOf("\n## [");
|
||||
if (insertIndex === -1) {
|
||||
return `${existing.trimEnd()}\n\n${trimmedSection}\n`;
|
||||
}
|
||||
const head = existing.slice(0, insertIndex).trimEnd();
|
||||
const tail = existing.slice(insertIndex).replace(/^(\n)+/, "");
|
||||
return `${head}\n\n${trimmedSection}\n\n${tail}`;
|
||||
}
|
||||
|
||||
async function main() {
|
||||
try {
|
||||
const verCountRaw = await runExec("git rev-list --count HEAD");
|
||||
const codeCountRaw = await runExec("git tag --merged pro -l 'v*' | wc -l");
|
||||
const verCount = verCountRaw.trim();
|
||||
const codeCount = codeCountRaw.trim();
|
||||
|
||||
const num = verOffset + parseInt(verCount, 10);
|
||||
if (Number.isNaN(num) || Math.floor(num % 100) < 0) {
|
||||
throw new Error(`get version error ${verCount}`);
|
||||
}
|
||||
const version = `${Math.floor(num / 10000)}.${Math.floor((num % 10000) / 100)}.${Math.floor(num % 100)}`;
|
||||
const codeVersion = codeOffset + parseInt(codeCount, 10);
|
||||
|
||||
let packageContent = fs.readFileSync(packageFile, "utf8");
|
||||
packageContent = packageContent.replace(/"version":\s*"(.*?)"/, `"version": "${version}"`);
|
||||
packageContent = packageContent.replace(/"codeVerson":(.*?)(,|$)/, `"codeVerson": ${codeVersion}$2`);
|
||||
fs.writeFileSync(packageFile, packageContent, "utf8");
|
||||
|
||||
console.log("New version: " + version);
|
||||
console.log("New code verson: " + codeVersion);
|
||||
|
||||
if (!fs.existsSync(changeFile)) {
|
||||
throw new Error("Change file does not exist");
|
||||
}
|
||||
|
||||
const latestSection = await generateLatestSection(version);
|
||||
if (!latestSection) {
|
||||
console.log("No new changelog entries detected.");
|
||||
return;
|
||||
}
|
||||
|
||||
const aiSection = await enhanceWithAI(version, latestSection);
|
||||
|
||||
const changelogContent = fs.readFileSync(changeFile, "utf8");
|
||||
const mergedContent = insertChangelogSection(changelogContent, aiSection, version);
|
||||
const dedupedContent = removeDuplicateLines(mergedContent);
|
||||
|
||||
fs.writeFileSync(changeFile, dedupedContent.trimEnd() + "\n", "utf8");
|
||||
console.log("Log file updated: CHANGELOG.md");
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
process.exitCode = 1;
|
||||
}
|
||||
}
|
||||
|
||||
main();
|
||||
60
cliff.toml
60
cliff.toml
@ -1,60 +0,0 @@
|
||||
# configuration file for git-cliff (0.1.0)
|
||||
|
||||
[changelog]
|
||||
# changelog header
|
||||
header = """
|
||||
# Changelog\n
|
||||
All notable changes to this project will be documented in this file.\n
|
||||
"""
|
||||
# template for the changelog body
|
||||
# https://tera.netlify.app/docs/#introduction
|
||||
body = """
|
||||
{% if version %}\
|
||||
## [{{ version | trim_start_matches(pat="v") }}]
|
||||
{% else %}\
|
||||
## [Unreleased]
|
||||
{% endif %}\
|
||||
{% for group, commits in commits | group_by(attribute="group") %}
|
||||
### {{ group | upper_first }}
|
||||
{% for commit in commits %}
|
||||
- {% if commit.breaking %}[**breaking**] {% endif %}{{ commit.message | upper_first }}\
|
||||
{% endfor %}
|
||||
{% endfor %}\n
|
||||
"""
|
||||
# remove the leading and trailing whitespace from the template
|
||||
trim = true
|
||||
# changelog footer
|
||||
footer = """
|
||||
"""
|
||||
|
||||
[git]
|
||||
# parse the commits based on https://www.conventionalcommits.org
|
||||
conventional_commits = true
|
||||
# filter out the commits that are not conventional
|
||||
filter_unconventional = true
|
||||
# regex for parsing and grouping commits
|
||||
commit_parsers = [
|
||||
{ message = "^feat", group = "Features"},
|
||||
{ message = "^fix", group = "Bug Fixes"},
|
||||
{ message = "^doc", group = "Documentation"},
|
||||
{ message = "^perf", group = "Performance"},
|
||||
{ message = "^pref", group = "Performance"},
|
||||
{ message = "^refactor", group = "Refactor"},
|
||||
{ message = "^style", group = "Styling"},
|
||||
{ message = "^test", group = "Testing"},
|
||||
{ message = "^chore\\(release\\): prepare for", skip = true},
|
||||
{ message = "^chore", group = "Miscellaneous Tasks"},
|
||||
{ body = ".*security", group = "Security"},
|
||||
]
|
||||
# filter out the commits that are not matched by commit parsers
|
||||
filter_commits = true
|
||||
# glob pattern for matching git tags
|
||||
tag_pattern = "v[0-9]*"
|
||||
# regex for skipping tags
|
||||
skip_tags = "v0.1.0-beta.1"
|
||||
# regex for ignoring tags
|
||||
ignore_tags = ""
|
||||
# sort the tags chronologically
|
||||
date_order = false
|
||||
# sort the commits inside sections by oldest/newest order
|
||||
sort_commits = "newest"
|
||||
4
cmd
4
cmd
@ -931,10 +931,6 @@ case "$1" in
|
||||
container_exec php "php app/Models/clearHelper.php"
|
||||
container_exec php "php artisan ide-helper:models -W"
|
||||
;;
|
||||
"translate")
|
||||
shift 1
|
||||
container_exec php "cd /var/www/language && php translate.php"
|
||||
;;
|
||||
"restart")
|
||||
shift 1
|
||||
$COMPOSE stop "$@"
|
||||
|
||||
@ -1,30 +0,0 @@
|
||||
# 语言翻译工具说明
|
||||
|
||||
`language/translate.php` 脚本用于根据 `original-web.txt` 和 `original-api.txt` 中的内容,自动生成/更新 `translate.json` 以及前端使用的多语言文件。
|
||||
|
||||
## 使用步骤
|
||||
|
||||
1. 在项目根目录 `.env` 文件中配置:
|
||||
|
||||
```dotenv
|
||||
OPENAI_API_KEY=你的OpenAI密钥
|
||||
OPENAI_BASE_URL=可选的自定义API地址
|
||||
OPENAI_PROXY_URL=可选的代理地址
|
||||
```
|
||||
|
||||
2. 在 `language` 目录下执行:
|
||||
|
||||
```bash
|
||||
php translate.php
|
||||
```
|
||||
|
||||
3. 查看生成的翻译结果:
|
||||
|
||||
- 翻译详情:`language/translate.json`
|
||||
- API 文件:`public/language/api/*.json`
|
||||
- Web 文件:`public/language/web/*.js`
|
||||
|
||||
## 注意事项
|
||||
|
||||
- 若 `.env` 未设置 `OPENAI_API_KEY`,脚本会直接退出。
|
||||
- `OPENAI_PROXY_URL` 可选,留空时不会设置代理。
|
||||
@ -1,9 +0,0 @@
|
||||
{
|
||||
"name": "dootask/language",
|
||||
"require": {
|
||||
"php": ">=7.4",
|
||||
"ext-curl": "*",
|
||||
"ext-json": "*",
|
||||
"orhanerday/open-ai": "^5.2"
|
||||
}
|
||||
}
|
||||
82
language/composer.lock
generated
82
language/composer.lock
generated
@ -1,82 +0,0 @@
|
||||
{
|
||||
"_readme": [
|
||||
"This file locks the dependencies of your project to a known state",
|
||||
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
|
||||
"This file is @generated automatically"
|
||||
],
|
||||
"content-hash": "ec9d23d3c9171a27ef10589ff18aaf1d",
|
||||
"packages": [
|
||||
{
|
||||
"name": "orhanerday/open-ai",
|
||||
"version": "5.2",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/orhanerday/open-ai.git",
|
||||
"reference": "d8c78fe2f5fed59e0ba458f90b5589ed9f13a367"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/orhanerday/open-ai/zipball/d8c78fe2f5fed59e0ba458f90b5589ed9f13a367",
|
||||
"reference": "d8c78fe2f5fed59e0ba458f90b5589ed9f13a367",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"ext-curl": "*",
|
||||
"ext-json": "*",
|
||||
"php": ">=7.4"
|
||||
},
|
||||
"require-dev": {
|
||||
"friendsofphp/php-cs-fixer": "^3.0",
|
||||
"pestphp/pest": "^1.20",
|
||||
"spatie/ray": "^1.28"
|
||||
},
|
||||
"type": "library",
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"Orhanerday\\OpenAi\\": "src"
|
||||
}
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"MIT"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "Orhan Erday",
|
||||
"email": "orhanerday@gmail.com",
|
||||
"role": "Developer"
|
||||
}
|
||||
],
|
||||
"description": "OpenAI GPT-3 Api Client in PHP",
|
||||
"homepage": "https://github.com/orhanerday/open-ai",
|
||||
"keywords": [
|
||||
"open-ai",
|
||||
"orhanerday"
|
||||
],
|
||||
"support": {
|
||||
"issues": "https://github.com/orhanerday/open-ai/issues",
|
||||
"source": "https://github.com/orhanerday/open-ai/tree/5.2"
|
||||
},
|
||||
"funding": [
|
||||
{
|
||||
"url": "https://github.com/orhanerday",
|
||||
"type": "github"
|
||||
}
|
||||
],
|
||||
"time": "2024-05-29T12:31:54+00:00"
|
||||
}
|
||||
],
|
||||
"packages-dev": [],
|
||||
"aliases": [],
|
||||
"minimum-stability": "stable",
|
||||
"stability-flags": [],
|
||||
"prefer-stable": false,
|
||||
"prefer-lowest": false,
|
||||
"platform": {
|
||||
"php": ">=7.4",
|
||||
"ext-curl": "*",
|
||||
"ext-json": "*"
|
||||
},
|
||||
"platform-dev": [],
|
||||
"plugin-api-version": "2.6.0"
|
||||
}
|
||||
@ -1,352 +0,0 @@
|
||||
<?php
|
||||
@error_reporting(E_ALL & ~E_NOTICE & ~E_WARNING);
|
||||
|
||||
require __DIR__ . '/vendor/autoload.php';
|
||||
|
||||
use Orhanerday\OpenAi\OpenAi;
|
||||
|
||||
// 读取 .env 文件的简单工具函数
|
||||
function language_parse_env_file(string $path): array
|
||||
{
|
||||
$env = [];
|
||||
$lines = @file($path, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES);
|
||||
if ($lines === false) {
|
||||
return $env;
|
||||
}
|
||||
|
||||
foreach ($lines as $line) {
|
||||
$line = trim($line);
|
||||
if ($line === '' || $line[0] === '#') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$delimiterPosition = strpos($line, '=');
|
||||
if ($delimiterPosition === false) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$name = trim(substr($line, 0, $delimiterPosition));
|
||||
if (strpos($name, 'export ') === 0) {
|
||||
$name = trim(substr($name, 7));
|
||||
}
|
||||
if ($name === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$value = trim(substr($line, $delimiterPosition + 1));
|
||||
$length = strlen($value);
|
||||
if ($length >= 2) {
|
||||
$first = $value[0];
|
||||
$last = $value[$length - 1];
|
||||
if (($first === '"' && $last === '"') || ($first === "'" && $last === "'")) {
|
||||
$value = substr($value, 1, $length - 2);
|
||||
}
|
||||
}
|
||||
|
||||
$env[$name] = $value;
|
||||
}
|
||||
|
||||
return $env;
|
||||
}
|
||||
|
||||
// 获取环境变量值的简单工具函数
|
||||
function language_env_value(string $key, array $env): ?string
|
||||
{
|
||||
if (array_key_exists($key, $env)) {
|
||||
return $env[$key];
|
||||
}
|
||||
|
||||
$value = getenv($key);
|
||||
if ($value !== false) {
|
||||
return $value;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
// 读取语言环境配置
|
||||
$languageEnvFile = dirname(__DIR__) . '/.env';
|
||||
$languageEnv = is_readable($languageEnvFile) ? language_parse_env_file($languageEnvFile) : [];
|
||||
|
||||
// 优先从 .env 读取 OPENAI 配置,未找到时再次尝试 getenv 覆盖
|
||||
$openAiKey = trim(language_env_value('OPENAI_API_KEY', $languageEnv) ?? '');
|
||||
if ($openAiKey === '') {
|
||||
fwrite(STDERR, "OPENAI_API_KEY 未设置,请在项目根目录的 .env 中配置。\n");
|
||||
exit(1);
|
||||
}
|
||||
$openAiProxy = trim(language_env_value('OPENAI_PROXY_URL', $languageEnv) ?? '');
|
||||
$openAiBaseUrl = trim(language_env_value('OPENAI_BASE_URL', $languageEnv) ?? '');
|
||||
$openAiModel = trim(language_env_value('OPENAI_API_MODEL', $languageEnv) ?? '');
|
||||
|
||||
// 读取所有要翻译的内容
|
||||
$originals = [];
|
||||
$generateds = [];
|
||||
foreach (['web', 'api'] as $type) {
|
||||
$content = file_exists("original-{$type}.txt") ? file_get_contents("original-{$type}.txt") : "";
|
||||
$array = array_values(array_filter(array_unique(explode("\n", $content))));
|
||||
$generateds[$type] = $array;
|
||||
$originals = array_merge($originals, $array);
|
||||
}
|
||||
|
||||
// 判定是否存在translate.json文件
|
||||
if (!file_exists("translate.json")) {
|
||||
print_r("translate.json not exists");
|
||||
exit;
|
||||
}
|
||||
|
||||
$translations = []; // 翻译数据
|
||||
$regrror = []; // 正则匹配错误的数据
|
||||
$redundants = []; // 多余的数据
|
||||
$needs = []; // 需要翻译的数据
|
||||
|
||||
// 读取翻译数据
|
||||
$tmps = json_decode(file_get_contents("translate.json"), true);
|
||||
foreach ($tmps as $obj) {
|
||||
if (!isset($obj['key'])) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$currentKey = $obj['key'];
|
||||
$originalKey = preg_replace(["/\(%T\d+\)/", "/\(%M\d+\)/"], ["(*)", "(**)"], $currentKey);
|
||||
$translations[$originalKey] = $obj;
|
||||
|
||||
if (!in_array($originalKey, $originals)) {
|
||||
unset($translations[$originalKey]);
|
||||
$redundants[$originalKey] = $obj;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (preg_match_all('/\(%[TM]\d+\)/', $currentKey, $matches)) {
|
||||
foreach ($matches[0] as $match) {
|
||||
foreach ($obj as $k => $v) {
|
||||
if (empty($v)) {
|
||||
continue;
|
||||
}
|
||||
if (!str_contains($v, $match)) {
|
||||
// 正则匹配错误
|
||||
$regrror[$originalKey] = [
|
||||
$k => $v,
|
||||
'match' => $match,
|
||||
'key' => $currentKey,
|
||||
];
|
||||
continue 2;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (count($regrror) > 0) {
|
||||
print_r("正则匹配错误的数据:\n");
|
||||
print_r($regrror);
|
||||
exit();
|
||||
}
|
||||
if (count($redundants) > 0) {
|
||||
print_r("多余的数据:\n");
|
||||
print_r(implode(", ", array_keys($redundants)) . "\n\n");
|
||||
}
|
||||
|
||||
// 需要翻译的数据
|
||||
foreach ($originals as $text) {
|
||||
$key = trim($text);
|
||||
if (!isset($translations[$key])) {
|
||||
$needs[$key] = $key;
|
||||
}
|
||||
}
|
||||
if (count($needs) > 0) {
|
||||
$array = array_chunk($needs, 10, true);
|
||||
$success = [];
|
||||
$error = [];
|
||||
$done = 0;
|
||||
foreach ($array as $index => $keys) {
|
||||
// 生成翻译内容
|
||||
foreach ($keys as &$key) {
|
||||
$c = 1;
|
||||
$key = preg_replace_callback('/\((\*+)\)/', function ($m) use (&$c) {
|
||||
$label = strlen($m[1]) > 1 ? "M" : "T";
|
||||
return "(%" . $label . $c++ . ")";
|
||||
}, $key);
|
||||
}
|
||||
$content = implode("\n", $keys);
|
||||
|
||||
// 开始翻译
|
||||
print_r("正在翻译:" . (count($keys) + $done) . "/" . count($needs) . "...\n");
|
||||
$openAi = new OpenAi($openAiKey);
|
||||
if ($openAiBaseUrl !== '') {
|
||||
$openAi->setBaseURL(rtrim(preg_replace('#/v\d+/?$#', '', $openAiBaseUrl), '/'));
|
||||
}
|
||||
if ($openAiProxy !== '') {
|
||||
$openAi->setProxy($openAiProxy);
|
||||
}
|
||||
$result = $openAi->chat([
|
||||
"model" => $openAiModel,
|
||||
"reasoning_effort" => "low",
|
||||
'messages' => [
|
||||
[
|
||||
"role" => "system",
|
||||
"content" => <<<EOF
|
||||
你是一个专业的翻译器,翻译的结果尽量符合 “项目任务管理系统” 的使用,请将提供的内容按每行一个翻译成:
|
||||
|
||||
```json
|
||||
[
|
||||
{
|
||||
"key": "", // 原文本
|
||||
"zh": "", // 留空(不用翻译)
|
||||
"zh-CHT": "", // 繁体中文
|
||||
"en": "", // 英语
|
||||
"ko": "", // 韩语
|
||||
"ja": "", // 日语
|
||||
"de": "", // 德语
|
||||
"fr": "", // 法语
|
||||
"id": "", // 印度尼西亚语
|
||||
"ru": "" // 俄语
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
请注意:(%T1)、(%T2)、(%T3)、(%M1)、(%M2) ...... 这类以 `小括号(%+内容)` 的字符组合是一个变量,翻译时请保留。
|
||||
|
||||
例子1:
|
||||
原文:此(%T1)已经处于【(%T2)】共享文件夹中,无法重复共享。
|
||||
翻译成英语:This (%T1) is already in the 【(%T2)】 shared folder and cannot be shared again。
|
||||
|
||||
例子2:
|
||||
原文:(%T1)的周报[(%T2)][(%T3)月第(%T4)周]
|
||||
翻译成英语:Weekly report of (%T1) [(%T2)] [(Week (%T4) of (%T3) month)]
|
||||
|
||||
例子3:
|
||||
原文:(%T1)提交的「(%M2)」待你审批
|
||||
翻译成英语:'(%M2)' submitted by (%T1) is waiting for your approval
|
||||
|
||||
例子4:
|
||||
原文:您发起的「(%M1)」已通过
|
||||
翻译成英语:The '(%M1)' you initiated has been approved
|
||||
EOF,
|
||||
],
|
||||
[
|
||||
"role" => "user",
|
||||
"content" => $content,
|
||||
],
|
||||
]
|
||||
]);
|
||||
|
||||
// 处理结果
|
||||
$obj = json_decode($result);
|
||||
$txt = preg_replace('/(^\s*```json\s*|\s*```\s*$)/', "", $obj->choices[0]->message->content);
|
||||
$txt = preg_replace('/\(%([TM]\d+)\)/', '(%$1)', $txt);
|
||||
$arr = json_decode($txt, true);
|
||||
if (!$arr || !is_array($arr)) {
|
||||
$error = array_merge($error, array_flip($keys));
|
||||
print_r("翻译失败:\n" . $content . "\n\n");
|
||||
file_put_contents("translate-gpt.log", json_encode($obj, JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT) . "\n\n", FILE_APPEND);
|
||||
continue;
|
||||
}
|
||||
|
||||
// 验证结果
|
||||
foreach ($arr as $item) {
|
||||
if (empty($item['key'])) {
|
||||
print_r("翻译结果不符合规范:key为空。\n");
|
||||
print_r($item);
|
||||
continue;
|
||||
}
|
||||
foreach (['key', 'zh', 'zh-CHT', 'en', 'ko', 'ja', 'de', 'fr', 'id', 'ru'] as $lang) {
|
||||
if (!isset($item[$lang])) {
|
||||
print_r("翻译结果不符合规范:{$item['key']},缺少:{$lang} 的值。\n");
|
||||
continue 2;
|
||||
}
|
||||
}
|
||||
$currentKey = $item['key'];
|
||||
$originalKey = preg_replace(["/\(%T\d+\)/", "/\(%M\d+\)/"], ["(*)", "(**)"], $currentKey);
|
||||
if (preg_match_all('/\(%[TM]\d+\)/', $currentKey, $matches)) {
|
||||
foreach ($matches[0] as $match) {
|
||||
foreach ($item as $k => $v) {
|
||||
if (empty($v)) {
|
||||
continue;
|
||||
}
|
||||
if (!str_contains($v, $match)) {
|
||||
// 正则匹配错误
|
||||
$error[$originalKey] = [
|
||||
'key' => $currentKey,
|
||||
$k => $v,
|
||||
'match' => $match,
|
||||
];
|
||||
continue 3;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$item['zh'] = "";
|
||||
$translations[$originalKey] = $item;
|
||||
$success[$originalKey] = $item;
|
||||
}
|
||||
print_r("翻译完成:" . (count($keys) + $done) . "/" . count($needs) . "\n\n");
|
||||
$done += count($keys);
|
||||
}
|
||||
|
||||
if (count($error) > 0) {
|
||||
print_r("正则匹配错误的数据:\n");
|
||||
print_r(json_encode(array_values($error), JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT) . "\n\n");
|
||||
}
|
||||
|
||||
// 保存翻译结果
|
||||
file_put_contents("translate.json", json_encode(array_values($translations), JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT));
|
||||
print_r("----------------\n\n");
|
||||
print_r("总翻译:" . count($needs) . " 条\n");
|
||||
print_r("成功:" . count($success) . " 条\n");
|
||||
print_r("错误:" . count($error) . " 条\n\n");
|
||||
print_r("----------------\n\n");
|
||||
}
|
||||
|
||||
// 生成前端使用的文件
|
||||
foreach ($generateds as $type => $array) {
|
||||
$datas = [];
|
||||
foreach ($array as $text) {
|
||||
$text = trim($text);
|
||||
if (isset($translations[$text])) {
|
||||
$datas[] = $translations[$text];
|
||||
}
|
||||
}
|
||||
// 按长度排序
|
||||
$inOrder = [];
|
||||
foreach ($datas as $index => $item) {
|
||||
if (preg_match('/\(%[TM]\d+\)/', $item['key'])) {
|
||||
$inOrder[$index] = strlen($item['key']);
|
||||
} else {
|
||||
$inOrder[$index] = strlen($item['key']) + 10000000000;
|
||||
}
|
||||
}
|
||||
array_multisort($inOrder, SORT_DESC, $datas);
|
||||
// 合成数组
|
||||
$results = [];
|
||||
$index = 0;
|
||||
foreach ($datas as $items) {
|
||||
foreach ($items as $kk => $item) {
|
||||
if (!isset($results)) {
|
||||
$results[$kk] = [];
|
||||
}
|
||||
$results[$kk][] = $item;
|
||||
}
|
||||
}
|
||||
// 生成文件
|
||||
if ($type === 'api') {
|
||||
if (!is_dir("../public/language/api")) {
|
||||
mkdir("../public/language/api", 0777, true);
|
||||
}
|
||||
foreach ($results as $kk => $item) {
|
||||
$file = "../public/language/api/$kk.json";
|
||||
file_put_contents($file, json_encode($item, JSON_UNESCAPED_UNICODE));
|
||||
}
|
||||
} elseif ($type === 'web') {
|
||||
if (!is_dir("../public/language/web")) {
|
||||
mkdir("../public/language/web", 0777, true);
|
||||
}
|
||||
foreach ($results as $kk => $item) {
|
||||
$file = "../public/language/web/$kk.js";
|
||||
file_put_contents($file, "if(typeof window.LANGUAGE_DATA===\"undefined\")window.LANGUAGE_DATA={};window.LANGUAGE_DATA[\"{$kk}\"]=" . json_encode($item, JSON_UNESCAPED_UNICODE));
|
||||
}
|
||||
}
|
||||
print_r("[$type] total: " . count($results['key']) . "\n");
|
||||
}
|
||||
|
||||
print_r("\n任务结束\n");
|
||||
@ -5,9 +5,7 @@
|
||||
"description": "DooTask is task management system.",
|
||||
"scripts": {
|
||||
"start": "./cmd dev",
|
||||
"build": "./cmd prod",
|
||||
"version": "node ./bin/version.js",
|
||||
"translate": "./cmd translate"
|
||||
"build": "./cmd prod"
|
||||
},
|
||||
"author": {
|
||||
"name": "KuaiFan",
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user