mirror of
https://github.com/kuaifan/dootask.git
synced 2026-08-07 13:28:39 +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
|
```shell
|
||||||
npm run translate # 翻译(可选)
|
|
||||||
npm run version # 生成版本
|
|
||||||
npm run build # 编译前端
|
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 app/Models/clearHelper.php"
|
||||||
container_exec php "php artisan ide-helper:models -W"
|
container_exec php "php artisan ide-helper:models -W"
|
||||||
;;
|
;;
|
||||||
"translate")
|
|
||||||
shift 1
|
|
||||||
container_exec php "cd /var/www/language && php translate.php"
|
|
||||||
;;
|
|
||||||
"restart")
|
"restart")
|
||||||
shift 1
|
shift 1
|
||||||
$COMPOSE stop "$@"
|
$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.",
|
"description": "DooTask is task management system.",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"start": "./cmd dev",
|
"start": "./cmd dev",
|
||||||
"build": "./cmd prod",
|
"build": "./cmd prod"
|
||||||
"version": "node ./bin/version.js",
|
|
||||||
"translate": "./cmd translate"
|
|
||||||
},
|
},
|
||||||
"author": {
|
"author": {
|
||||||
"name": "KuaiFan",
|
"name": "KuaiFan",
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user