From 9cbe857a2aeb899a3006532ddc19e162d8c75f71 Mon Sep 17 00:00:00 2001 From: t8y2 <1156263951@qq.com> Date: Tue, 7 Jul 2026 00:26:30 +0800 Subject: [PATCH] =?UTF-8?q?feat(update):=20=E5=BA=94=E7=94=A8=E5=86=85?= =?UTF-8?q?=E6=9B=B4=E6=96=B0=E6=8F=90=E7=A4=BA=E6=94=AF=E6=8C=81=E8=8B=B1?= =?UTF-8?q?=E6=96=87=20release=20notes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/scripts/sync-changelog.mjs | 72 +++++++++++++++++-- .github/workflows/sync-changelog.yml | 5 ++ .gitignore | 1 + apps/desktop/src/composables/useAppUpdater.ts | 3 +- apps/desktop/src/lib/backend/http.ts | 5 +- apps/desktop/src/lib/backend/tauri.ts | 4 +- crates/dbx-core/src/jdbc.rs | 3 +- crates/dbx-core/src/update.rs | 67 ++++++++++++++++- crates/dbx-web/src/routes/update.rs | 13 +++- packages/app-tests/syncChangelog.test.ts | 56 +++++++++++++++ src-tauri/src/commands/update.rs | 5 +- 11 files changed, 214 insertions(+), 20 deletions(-) diff --git a/.github/scripts/sync-changelog.mjs b/.github/scripts/sync-changelog.mjs index bf0c93b4b..0676312bd 100644 --- a/.github/scripts/sync-changelog.mjs +++ b/.github/scripts/sync-changelog.mjs @@ -10,6 +10,7 @@ const GITHUB_TOKEN = process.env.GITHUB_TOKEN || ""; const DEEPSEEK_API_KEY = process.env.DEEPSEEK_API_KEY || ""; const OUT_CN = "releases-cn.json"; const OUT_EN = "releases-en.json"; +const LATEST_EN_OUT = "latest-en.json"; const EN_CACHE_URL = process.env.CHANGELOG_EN_CACHE_URL || "https://dl.dbxio.com/changelog/releases-en.json"; const SECTION_MAP = { @@ -49,6 +50,31 @@ export function stripDownloadSection(body) { return body.slice(0, idx).trim(); } +// 剥离 release notes 里的 issue 引用 (closes #xxx),保留贡献者署名 (contributed by @xxx)。 +// 同时处理纯 closes 括号和混在 contributed by 括号里的 closes 片段。 +function stripIssueRefs(text) { + return text + // 纯 closes 括号整体去掉,如 (closes #123) 或 (closes #123, closes #456) + .replace(/\s*\(closes #\d+(?:,\s*closes #\d+)*\)/g, "") + // closes 在前、contributed by 在后,如 (closes #88, contributed by @xxx) → (contributed by @xxx) + .replace(/\(closes #\d+,\s*/g, "(") + // contributed by 在前、closes 在后,如 (contributed by @xxx, closes #123) → (contributed by @xxx) + .replace(/,\s*closes #\d+/g, ""); +} + +// 对整份 releases JSON 统一剥离 closes # 引用。cache 复用的英文翻译来自 R2, +// 可能是旧版脚本生成的(desc 仍含 closes #),在写文件前统一清理一次。 +function stripIssueRefsInReleases(json) { + for (const release of json.releases || []) { + for (const section of release.sections || []) { + for (const item of section.items || []) { + if (item.desc) item.desc = stripIssueRefs(item.desc); + } + } + } + return json; +} + export function parseBody(body) { const cleaned = stripDownloadSection(body); const sections = []; @@ -68,7 +94,7 @@ export function parseBody(body) { const itemMatch = line.match(/^-\s+\*\*(.+?)\*\*\s*[—–-]\s*(.+)/); if (itemMatch) { - current.items.push({ title: itemMatch[1].trim(), desc: itemMatch[2].trim() }); + current.items.push({ title: itemMatch[1].trim(), desc: stripIssueRefs(itemMatch[2].trim()) }); continue; } @@ -119,6 +145,17 @@ function releaseToMarkdown(release) { .join("\n\n"); } +// 给应用内更新提示用的英文 notes:只取最新一条版本(releases 已按 published_at 降序), +// 转成 md。version 用 tag(如 v0.5.47),应用端 normalize_version 后与 latest.json 的 version 校验。 +function buildLatestEnNotes(enReleasesJson) { + const latest = enReleasesJson.releases?.[0]; + if (!latest) return null; + return { + version: latest.tag, + notes: releaseToMarkdown(latest), + }; +} + export async function fetchCachedEnglish({ cacheUrl = EN_CACHE_URL, fetchImpl = fetch } = {}) { try { const res = await fetchImpl(cacheUrl, { headers: { Accept: "application/json" } }); @@ -134,15 +171,29 @@ export async function fetchCachedEnglish({ cacheUrl = EN_CACHE_URL, fetchImpl = } export async function translateToEnglish(cnJson, { cachedEnJson = null, deepseekApiKey = DEEPSEEK_API_KEY, fetchImpl = fetch, sleep = (ms) => new Promise((r) => setTimeout(r, ms)) } = {}) { - if (!deepseekApiKey) { - console.warn("DEEPSEEK_API_KEY not set, skipping translation"); - return null; - } - const cachedByTag = new Map((cachedEnJson?.releases || []).map((release) => [release.tag, release])); const enReleases = []; let reusedCount = 0; let translatedCount = 0; + let skippedCount = 0; + + // 无 API key:仅复用 R2 上的英文缓存,未命中的条目回退中文以保证文件完整。 + // 这样本地(无 key)只要 R2 缓存新鲜也能产出英文 CHANGELOG;CI 有 key 时走正常翻译流程。 + if (!deepseekApiKey) { + console.warn("DEEPSEEK_API_KEY not set, falling back to cached English translations only"); + for (const release of cnJson.releases) { + const cachedRelease = cachedByTag.get(release.tag); + if (cachedRelease?._sourceHash === release._sourceHash) { + enReleases.push({ ...cachedRelease, name: release.name, date: release.date, _sourceHash: release._sourceHash }); + reusedCount++; + } else { + enReleases.push(release); + skippedCount++; + } + } + console.log(`English changelog cache reused ${reusedCount}, skipped ${skippedCount} (no API key)`); + return { updatedAt: cnJson.updatedAt, releases: enReleases }; + } for (const release of cnJson.releases) { const cachedRelease = cachedByTag.get(release.tag); @@ -217,8 +268,17 @@ async function main() { console.log("Translating to English..."); const enJson = await translateToEnglish(cnJson, { cachedEnJson }); if (enJson) { + // cache 复用的英文翻译可能来自旧版 R2 数据(desc 含 closes # 引用),统一剥离一次 + stripIssueRefsInReleases(enJson); writeFileSync(OUT_EN, JSON.stringify(enJson, null, 2)); console.log(`Wrote ${OUT_EN}`); + + // 应用内更新提示用的英文 notes(单条最新版本) + const latestEn = buildLatestEnNotes(enJson); + if (latestEn) { + writeFileSync(LATEST_EN_OUT, JSON.stringify(latestEn, null, 2)); + console.log(`Wrote ${LATEST_EN_OUT}`); + } } console.log("Done!"); diff --git a/.github/workflows/sync-changelog.yml b/.github/workflows/sync-changelog.yml index d43f93ade..009d02d4f 100644 --- a/.github/workflows/sync-changelog.yml +++ b/.github/workflows/sync-changelog.yml @@ -34,6 +34,11 @@ jobs: --endpoint-url "https://${R2_ACCOUNT_ID}.r2.cloudflarestorage.com" \ --content-type application/json fi + if [ -f latest-en.json ]; then + aws s3 cp latest-en.json "s3://${R2_BUCKET_NAME}/changelog/latest-en.json" \ + --endpoint-url "https://${R2_ACCOUNT_ID}.r2.cloudflarestorage.com" \ + --content-type application/json + fi env: AWS_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }} AWS_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY }} diff --git a/.gitignore b/.gitignore index b93f20db0..880af71ae 100644 --- a/.gitignore +++ b/.gitignore @@ -55,6 +55,7 @@ tmp/ # Generated changelog data releases-*.json +latest-en.json test.pdb portable/ diff --git a/apps/desktop/src/composables/useAppUpdater.ts b/apps/desktop/src/composables/useAppUpdater.ts index 1e3a89193..130a676bc 100644 --- a/apps/desktop/src/composables/useAppUpdater.ts +++ b/apps/desktop/src/composables/useAppUpdater.ts @@ -6,6 +6,7 @@ import * as api from "@/lib/backend/api"; import { useSettingsStore } from "@/stores/settingsStore"; import type { UpdateDownloadSource as SettingsUpdateDownloadSource } from "@/stores/settingsStore"; import type { UpdateDownloadProgress } from "@/lib/backend/tauri"; +import { currentLocale } from "@/i18n"; export function shouldOpenUpdateDialog(options: { silent?: boolean }) { return options.silent !== true; @@ -74,7 +75,7 @@ export function useAppUpdater() { checkingUpdates.value = true; updateCheckMessage.value = ""; try { - const info = await api.checkForUpdates(); + const info = await api.checkForUpdates(currentLocale()); updateInfo.value = info; if (info.update_available) { if (shouldOpenUpdateDialog({ silent: options.silent })) { diff --git a/apps/desktop/src/lib/backend/http.ts b/apps/desktop/src/lib/backend/http.ts index 832f06a62..c3c19f523 100644 --- a/apps/desktop/src/lib/backend/http.ts +++ b/apps/desktop/src/lib/backend/http.ts @@ -2042,8 +2042,9 @@ export async function deleteHistoryEntry(id: string): Promise { // Updates // --------------------------------------------------------------------------- -export async function checkForUpdates(): Promise { - return get("/api/update/check"); +export async function checkForUpdates(locale?: string): Promise { + const query = locale ? `?locale=${encodeURIComponent(locale)}` : ""; + return get(`/api/update/check${query}`); } export async function checkMcpServerStatus(): Promise { diff --git a/apps/desktop/src/lib/backend/tauri.ts b/apps/desktop/src/lib/backend/tauri.ts index c4730be06..cbbb2a516 100644 --- a/apps/desktop/src/lib/backend/tauri.ts +++ b/apps/desktop/src/lib/backend/tauri.ts @@ -1270,8 +1270,8 @@ export async function installMcpServer(): Promise { return invoke("install_mcp_server"); } -export async function checkForUpdates(): Promise { - return invoke("check_for_updates"); +export async function checkForUpdates(locale?: string): Promise { + return invoke("check_for_updates", { locale }); } export async function getSystemProxyUrl(): Promise { diff --git a/crates/dbx-core/src/jdbc.rs b/crates/dbx-core/src/jdbc.rs index 273901c8c..bb6106a5e 100644 --- a/crates/dbx-core/src/jdbc.rs +++ b/crates/dbx-core/src/jdbc.rs @@ -363,7 +363,8 @@ fn build_plugin_status( } async fn latest_jdbc_plugin() -> Option { - fetch_latest_release().await.ok().and_then(|release| release.jdbc_plugin) + // 只需 jdbc_plugin,不关心 release notes;传中文 locale 跳过英文 notes 拉取 + fetch_latest_release("zh-CN").await.ok().and_then(|release| release.jdbc_plugin) } async fn download_jdbc_plugin_zip_with_progress(progress: &impl Fn(AgentProgressEvent)) -> Result, String> { diff --git a/crates/dbx-core/src/update.rs b/crates/dbx-core/src/update.rs index 33c4dbb75..9502c1675 100644 --- a/crates/dbx-core/src/update.rs +++ b/crates/dbx-core/src/update.rs @@ -2,6 +2,7 @@ use serde::{Deserialize, Serialize}; const LATEST_JSON_PATH: &str = "https://github.com/t8y2/dbx/releases/latest/download/latest.json"; const LATEST_JSON_R2_PATH: &str = "releases/latest/latest.json"; +const LATEST_EN_NOTES_R2_PATH: &str = "changelog/latest-en.json"; const GITHUB_RELEASE_API_PREFIX: &str = "https://api.github.com/repos/t8y2/dbx/releases/tags/v"; const RELEASE_URL_PREFIX: &str = "https://github.com/t8y2/dbx/releases/tag/v"; @@ -14,6 +15,10 @@ pub struct TauriRelease { pub jdbc_plugin: Option, #[serde(skip)] pub github: Option, + // 英文 release notes,由 R2 latest-en.json 填充(latest.json 不含此字段)。 + // 仅当用户界面非中文时拉取,build_update_info 优先用它作为 release_notes。 + #[serde(skip)] + pub notes_en: Option, } #[derive(Debug, Clone, Deserialize)] @@ -41,7 +46,7 @@ pub struct UpdateInfo { pub release_notes: String, } -pub async fn fetch_latest_release() -> Result { +pub async fn fetch_latest_release(locale: &str) -> Result { let client = build_update_http_client()?; let resp = crate::race_download(&client, LATEST_JSON_PATH, LATEST_JSON_R2_PATH, "dbx-update-checker") @@ -52,9 +57,44 @@ pub async fn fetch_latest_release() -> Result { if let Ok(github) = fetch_github_release_metadata(&client, &release.version).await { release.github = Some(github); } + // 非中文界面用户额外拉取英文 release notes;失败/版本不匹配则保持 None,上层回退中文。 + if !is_chinese_locale(locale) { + if let Ok(notes_en) = fetch_latest_release_notes_en(&client, &release.version).await { + release.notes_en = Some(notes_en); + } + } Ok(release) } +// 拉取 R2 上的英文 release notes(仅最新版本)。version 必须与 latest.json 的 version 一致才采用, +// 防止 sync-changelog 尚未更新时拿到旧版本英文 notes。 +async fn fetch_latest_release_notes_en(client: &reqwest::Client, expected_version: &str) -> Result { + let url = format!("{}{LATEST_EN_NOTES_R2_PATH}", crate::R2_CDN_BASE); + let resp = client + .get(&url) + .header(reqwest::header::USER_AGENT, "dbx-update-checker") + .send() + .await + .and_then(|r| r.error_for_status()) + .map_err(|e| format!("Failed to fetch English release notes: {e}"))?; + let data: LatestEnNotes = resp.json().await.map_err(|e| format!("Failed to parse English release notes: {e}"))?; + if normalize_version(&data.version) == normalize_version(expected_version) { + Ok(data.notes) + } else { + Err(format!("English release notes version {} mismatch expected {}", data.version, expected_version)) + } +} + +fn is_chinese_locale(locale: &str) -> bool { + locale == "zh-CN" || locale == "zh-TW" +} + +#[derive(Debug, Deserialize)] +struct LatestEnNotes { + version: String, + notes: String, +} + fn build_update_http_client() -> Result { let mut builder = reqwest::Client::builder().timeout(std::time::Duration::from_secs(10)).user_agent("dbx-update-checker"); @@ -212,9 +252,9 @@ async fn fetch_github_release_metadata( pub fn build_update_info(release: TauriRelease, current_version: &str) -> UpdateInfo { let latest_version = normalize_version(&release.version); let github = release.github.as_ref(); - let release_notes = github - .and_then(|metadata| non_empty(metadata.body.as_deref())) + let release_notes = non_empty(release.notes_en.as_deref()) .map(ToOwned::to_owned) + .or_else(|| github.and_then(|metadata| non_empty(metadata.body.as_deref())).map(ToOwned::to_owned)) .or(release.notes) .unwrap_or_default(); let release_name = github @@ -386,6 +426,7 @@ HKEY_CURRENT_USER\Software\Microsoft\Windows\CurrentVersion\Internet Settings html_url: Some("https://github.com/t8y2/dbx/releases/tag/v0.5.3".to_string()), body: Some("### 新功能\n\n真实发布说明".to_string()), }), + notes_en: None, }; let info = build_update_info(release, "0.5.2"); @@ -395,4 +436,24 @@ HKEY_CURRENT_USER\Software\Microsoft\Windows\CurrentVersion\Internet Settings assert_eq!(info.release_notes, "### 新功能\n\n真实发布说明"); assert!(!info.portable_mode); } + + #[test] + fn update_info_prefers_english_notes_when_present() { + // 非中文界面用户:notes_en 命中时优先于 GitHub 中文 body,应用内更新提示展示英文 + let release = TauriRelease { + version: "0.5.3".to_string(), + notes: Some("See the assets below to download and install.".to_string()), + jdbc_plugin: None, + github: Some(GithubReleaseMetadata { + name: Some("DBX v0.5.3".to_string()), + html_url: Some("https://github.com/t8y2/dbx/releases/tag/v0.5.3".to_string()), + body: Some("### 新功能\n\n真实发布说明".to_string()), + }), + notes_en: Some("### New Features\n\nReal release notes".to_string()), + }; + + let info = build_update_info(release, "0.5.2"); + + assert_eq!(info.release_notes, "### New Features\n\nReal release notes"); + } } diff --git a/crates/dbx-web/src/routes/update.rs b/crates/dbx-web/src/routes/update.rs index 2cad13a62..e3ca0bd4e 100644 --- a/crates/dbx-web/src/routes/update.rs +++ b/crates/dbx-web/src/routes/update.rs @@ -1,4 +1,4 @@ -use axum::Json; +use axum::{extract::Query, Json}; use dbx_core::update; use crate::error::AppError; @@ -7,8 +7,15 @@ pub async fn get_version() -> Json { Json(serde_json::json!({ "version": env!("CARGO_PKG_VERSION") })) } -pub async fn check_for_updates() -> Result, AppError> { - let release = update::fetch_latest_release().await.map_err(AppError)?; +#[derive(serde::Deserialize)] +pub struct UpdateCheckParams { + #[serde(default)] + pub locale: Option, +} + +pub async fn check_for_updates(Query(params): Query) -> Result, AppError> { + let locale = params.locale.unwrap_or_else(|| "zh-CN".to_string()); + let release = update::fetch_latest_release(&locale).await.map_err(AppError)?; let info = update::build_update_info(release, env!("CARGO_PKG_VERSION")); Ok(Json(serde_json::to_value(info).map_err(|e| AppError(e.to_string()))?)) } diff --git a/packages/app-tests/syncChangelog.test.ts b/packages/app-tests/syncChangelog.test.ts index c88ed54fb..deb491714 100644 --- a/packages/app-tests/syncChangelog.test.ts +++ b/packages/app-tests/syncChangelog.test.ts @@ -64,3 +64,59 @@ test("translateToEnglish reuses cached release translations when source hash is assert.equal(enJson.releases[0].sections[0].title, "Added"); assert.equal(enJson.releases[0]._sourceHash, cnJson.releases[0]._sourceHash); }); + +test("translateToEnglish falls back to cached translations when API key is missing", async () => { + const cnJson = syncChangelog.buildReleasesJson( + [ + { + tag_name: "v1.1.0", + name: "DBX v1.1.0", + published_at: "2026-05-18T00:00:00Z", + body: "### 新功能\n- **新增导出** — 支持导出表数据", + draft: false, + prerelease: false, + }, + { + tag_name: "v1.0.0", + name: "DBX v1.0.0", + published_at: "2026-05-17T00:00:00Z", + body: "### 修复\n- **修复连接** — 避免重复连接", + draft: false, + prerelease: false, + }, + ], + new Date("2026-05-18T01:00:00Z"), + ); + const cachedRelease = { + ...cnJson.releases[1], + sections: [{ type: "fixed", title: "Fixed", items: [{ title: "Connection fix", desc: "Avoid duplicate connects" }] }], + }; + const cachedEnJson = { updatedAt: "2026-05-17T01:00:00.000Z", releases: [cachedRelease] }; + + let translationCalls = 0; + const enJson = await syncChangelog.translateToEnglish(cnJson, { + cachedEnJson, + deepseekApiKey: "", + fetchImpl: async () => { + translationCalls++; + return { ok: true, json: async () => ({}) }; + }, + sleep: async () => {}, + }); + + // 无 key 时不调用翻译 API + assert.equal(translationCalls, 0); + // cache 命中的条目复用英文翻译 + assert.deepEqual(enJson.releases[1], cachedRelease); + // cache 未命中的条目回退中文原文,保证 CHANGELOG 条目不缺失 + assert.equal(enJson.releases[0].sections[0].title, "新功能"); +}); + +test("parseBody strips closes # issue refs but keeps contributor attribution", () => { + const sections = syncChangelog.parseBody( + "### 新功能\n- **导出** — 支持导出表数据 (contributed by @wuxiemian, closes #2577, closes #2473)\n- **清理** — 删除旧资源 (closes #2586)\n- **校验** — 预校验路径 (closes #88, contributed by @Mukesh)", + ); + assert.equal(sections[0].items[0].desc, "支持导出表数据 (contributed by @wuxiemian)"); + assert.equal(sections[0].items[1].desc, "删除旧资源"); + assert.equal(sections[0].items[2].desc, "预校验路径 (contributed by @Mukesh)"); +}); diff --git a/src-tauri/src/commands/update.rs b/src-tauri/src/commands/update.rs index dc4826b15..ea80cac06 100644 --- a/src-tauri/src/commands/update.rs +++ b/src-tauri/src/commands/update.rs @@ -90,8 +90,9 @@ fn tag_version(version: &str) -> String { } #[tauri::command] -pub async fn check_for_updates() -> Result { - let release = dbx_core::update::fetch_latest_release().await?; +pub async fn check_for_updates(locale: Option) -> Result { + let locale = locale.unwrap_or_else(|| "zh-CN".to_string()); + let release = dbx_core::update::fetch_latest_release(&locale).await?; let current_version = env!("CARGO_PKG_VERSION"); let mut info = dbx_core::update::build_update_info(release, current_version); info.portable_mode = crate::data_dir::is_portable_mode();