feat(update): 应用内更新提示支持英文 release notes

This commit is contained in:
t8y2 2026-07-07 00:26:30 +08:00
parent f45011977f
commit 9cbe857a2a
11 changed files with 214 additions and 20 deletions

View File

@ -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 缓存新鲜也能产出英文 CHANGELOGCI 有 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!");

View File

@ -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 }}

1
.gitignore vendored
View File

@ -55,6 +55,7 @@ tmp/
# Generated changelog data
releases-*.json
latest-en.json
test.pdb
portable/

View File

@ -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 })) {

View File

@ -2042,8 +2042,9 @@ export async function deleteHistoryEntry(id: string): Promise<void> {
// Updates
// ---------------------------------------------------------------------------
export async function checkForUpdates(): Promise<UpdateInfo> {
return get("/api/update/check");
export async function checkForUpdates(locale?: string): Promise<UpdateInfo> {
const query = locale ? `?locale=${encodeURIComponent(locale)}` : "";
return get(`/api/update/check${query}`);
}
export async function checkMcpServerStatus(): Promise<import("@/lib/backend/tauri").McpServerStatus> {

View File

@ -1270,8 +1270,8 @@ export async function installMcpServer(): Promise<string> {
return invoke("install_mcp_server");
}
export async function checkForUpdates(): Promise<UpdateInfo> {
return invoke("check_for_updates");
export async function checkForUpdates(locale?: string): Promise<UpdateInfo> {
return invoke("check_for_updates", { locale });
}
export async function getSystemProxyUrl(): Promise<string | null> {

View File

@ -363,7 +363,8 @@ fn build_plugin_status(
}
async fn latest_jdbc_plugin() -> Option<JdbcPluginLatest> {
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<Vec<u8>, String> {

View File

@ -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<JdbcPluginLatest>,
#[serde(skip)]
pub github: Option<GithubReleaseMetadata>,
// 英文 release notes由 R2 latest-en.json 填充latest.json 不含此字段)。
// 仅当用户界面非中文时拉取build_update_info 优先用它作为 release_notes。
#[serde(skip)]
pub notes_en: Option<String>,
}
#[derive(Debug, Clone, Deserialize)]
@ -41,7 +46,7 @@ pub struct UpdateInfo {
pub release_notes: String,
}
pub async fn fetch_latest_release() -> Result<TauriRelease, String> {
pub async fn fetch_latest_release(locale: &str) -> Result<TauriRelease, String> {
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<TauriRelease, String> {
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<String, String> {
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<reqwest::Client, String> {
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");
}
}

View File

@ -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<serde_json::Value> {
Json(serde_json::json!({ "version": env!("CARGO_PKG_VERSION") }))
}
pub async fn check_for_updates() -> Result<Json<serde_json::Value>, AppError> {
let release = update::fetch_latest_release().await.map_err(AppError)?;
#[derive(serde::Deserialize)]
pub struct UpdateCheckParams {
#[serde(default)]
pub locale: Option<String>,
}
pub async fn check_for_updates(Query(params): Query<UpdateCheckParams>) -> Result<Json<serde_json::Value>, 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()))?))
}

View File

@ -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)");
});

View File

@ -90,8 +90,9 @@ fn tag_version(version: &str) -> String {
}
#[tauri::command]
pub async fn check_for_updates() -> Result<UpdateInfo, String> {
let release = dbx_core::update::fetch_latest_release().await?;
pub async fn check_for_updates(locale: Option<String>) -> Result<UpdateInfo, String> {
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();