feat(update): 应用内更新提示支持英文 release notes
This commit is contained in:
parent
f45011977f
commit
9cbe857a2a
|
|
@ -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!");
|
||||
|
|
|
|||
|
|
@ -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 }}
|
||||
|
|
|
|||
|
|
@ -55,6 +55,7 @@ tmp/
|
|||
|
||||
# Generated changelog data
|
||||
releases-*.json
|
||||
latest-en.json
|
||||
|
||||
test.pdb
|
||||
portable/
|
||||
|
|
|
|||
|
|
@ -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 })) {
|
||||
|
|
|
|||
|
|
@ -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> {
|
||||
|
|
|
|||
|
|
@ -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> {
|
||||
|
|
|
|||
|
|
@ -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> {
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()))?))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)");
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
Loading…
Reference in New Issue