diff --git a/.github/release-notes/v1.2.31.md b/.github/release-notes/v1.2.31.md new file mode 100644 index 0000000..5c74af6 --- /dev/null +++ b/.github/release-notes/v1.2.31.md @@ -0,0 +1,7 @@ +- 邮件正文新增“自动翻译”开关并默认开启;打开邮件时仅在检测到正文语言与当前界面语言明显不同时自动翻译,避免中文邮件产生无意义请求。 +- 自动翻译开关会保存在当前浏览器中,关闭后继续显示原文,下次访问仍沿用用户选择。 +- 缓存同一封邮件、同一目标语言的翻译结果;返回列表后再次打开邮件可直接显示译文,减少重复等待和翻译请求。 +- 保留“显示原文”“显示译文”和“重新翻译”操作;重新翻译会主动刷新缓存中的译文。 +- 优化服务端翻译流程,纯文本正文与 HTML 正文改为并行处理,HTML 文本节点使用受控并发翻译,复杂排版邮件的翻译速度更快。 +- 翻译后的邮件继续保留原有 HTML 结构、图片和样式,并跳过代码、预格式文本、脚本及样式内容。 +- 增加 HTML 翻译测试和并发检测,确保排版结构不被破坏且没有数据竞争。 diff --git a/VERSION b/VERSION index d20e748..0848465 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.2.30 +1.2.31 diff --git a/apps/api/internal/app/mail_translate.go b/apps/api/internal/app/mail_translate.go index f2dde79..18a52aa 100644 --- a/apps/api/internal/app/mail_translate.go +++ b/apps/api/internal/app/mail_translate.go @@ -10,6 +10,7 @@ import ( "net/http" "net/url" "strings" + "sync" "time" "unicode/utf8" @@ -64,16 +65,22 @@ func (a *App) handleTranslateMailMessage(w http.ResponseWriter, r *http.Request) maxChars = 8000 } text, truncated := truncateRunes(text, maxChars) + translatedHTMLResult := make(chan string, 1) + if strings.TrimSpace(msg.BodyHTML) != "" { + go func() { + translatedHTML, _ := translateHTMLTextNodes(r.Context(), a.policy, msg.BodyHTML, target, maxChars) + translatedHTMLResult <- translatedHTML + }() + } else { + translatedHTMLResult <- "" + } translated, source, err := googleFreeTranslate(r.Context(), text, target) if err != nil { a.log.Warn("mail translation failed", "message_id", msg.ID, "target", target, "error", err) respondError(w, http.StatusBadGateway, "translation failed") return } - translatedHTML := "" - if strings.TrimSpace(msg.BodyHTML) != "" { - translatedHTML, _ = translateHTMLTextNodes(r.Context(), a.policy, msg.BodyHTML, target, maxChars) - } + translatedHTML := <-translatedHTMLResult respondJSON(w, http.StatusOK, translateMailMessageResponse{TranslatedText: translated, TranslatedHTML: translatedHTML, SourceLanguage: source, TargetLanguage: target, Truncated: truncated}) } @@ -131,53 +138,97 @@ func (a *App) handleTranslateExternalIMAPMessage(w http.ResponseWriter, r *http. maxChars = 8000 } text, truncated := truncateRunes(text, maxChars) + translatedHTMLResult := make(chan string, 1) + if err == nil && strings.TrimSpace(stored.BodyHTML) != "" { + go func() { + translatedHTML, _ := translateHTMLTextNodes(r.Context(), a.policy, stored.BodyHTML, target, maxChars) + translatedHTMLResult <- translatedHTML + }() + } else { + translatedHTMLResult <- "" + } translated, source, err := googleFreeTranslate(r.Context(), text, target) if err != nil { a.log.Warn("external mail translation failed", "account_id", account.ID, "remote_id", chi.URLParam(r, "remoteId"), "target", target, "error", err) respondError(w, http.StatusBadGateway, "translation failed") return } - translatedHTML := "" - if err == nil && strings.TrimSpace(stored.BodyHTML) != "" { - translatedHTML, _ = translateHTMLTextNodes(r.Context(), a.policy, stored.BodyHTML, target, maxChars) - } + translatedHTML := <-translatedHTMLResult respondJSON(w, http.StatusOK, translateMailMessageResponse{TranslatedText: translated, TranslatedHTML: translatedHTML, SourceLanguage: source, TargetLanguage: target, Truncated: truncated}) } func translateHTMLTextNodes(ctx context.Context, policy *HTMLPolicy, bodyHTML, target string, maxChars int) (string, error) { + return translateHTMLTextNodesWith(ctx, policy, bodyHTML, target, maxChars, googleFreeTranslate) +} + +type htmlTextTranslator func(context.Context, string, string) (string, string, error) + +func translateHTMLTextNodesWith(ctx context.Context, policy *HTMLPolicy, bodyHTML, target string, maxChars int, translator htmlTextTranslator) (string, error) { nodes, err := html.ParseFragment(strings.NewReader(bodyHTML), nil) if err != nil { return "", err } + type translationJob struct { + node *html.Node + original string + text string + } remaining := maxChars - var translateNode func(*html.Node) error - translateNode = func(n *html.Node) error { + jobs := make([]translationJob, 0) + var collect func(*html.Node) + collect = func(n *html.Node) { if n.Type == html.ElementNode && shouldSkipHTMLTranslationElement(n.Data) { - return nil + return } if n.Type == html.TextNode { text := strings.TrimSpace(n.Data) if text != "" && containsTranslatableLetter(text) && remaining > 0 { limited, _ := truncateRunes(text, remaining) remaining -= utf8.RuneCountInString(limited) - translated, _, err := googleFreeTranslate(ctx, limited, target) - if err != nil { - return err - } - n.Data = strings.Replace(n.Data, text, translated, 1) + jobs = append(jobs, translationJob{node: n, original: text, text: limited}) } } for c := n.FirstChild; c != nil; c = c.NextSibling { - if err := translateNode(c); err != nil { - return err - } + collect(c) } - return nil } for _, n := range nodes { - if err := translateNode(n); err != nil { - return "", err - } + collect(n) + } + results := make([]string, len(jobs)) + jobIndexes := make(chan int) + errCh := make(chan error, 1) + workers := min(4, len(jobs)) + var wg sync.WaitGroup + for range workers { + wg.Add(1) + go func() { + defer wg.Done() + for index := range jobIndexes { + translated, _, translateErr := translator(ctx, jobs[index].text, target) + if translateErr != nil { + select { + case errCh <- translateErr: + default: + } + continue + } + results[index] = translated + } + }() + } + for index := range jobs { + jobIndexes <- index + } + close(jobIndexes) + wg.Wait() + select { + case translateErr := <-errCh: + return "", translateErr + default: + } + for index, job := range jobs { + job.node.Data = strings.Replace(job.node.Data, job.original, results[index], 1) } var b bytes.Buffer for _, n := range nodes { diff --git a/apps/api/internal/app/mail_translate_test.go b/apps/api/internal/app/mail_translate_test.go index 8519de1..250eb9c 100644 --- a/apps/api/internal/app/mail_translate_test.go +++ b/apps/api/internal/app/mail_translate_test.go @@ -1,6 +1,10 @@ package app -import "testing" +import ( + "context" + "strings" + "testing" +) func TestParseGoogleTranslateResponse(t *testing.T) { raw := []any{ @@ -20,6 +24,22 @@ func TestParseGoogleTranslateResponse(t *testing.T) { } } +func TestTranslateHTMLTextNodesWithPreservesMarkupAndSkipsCode(t *testing.T) { + translator := func(_ context.Context, text, target string) (string, string, error) { + return strings.ToUpper(text) + "-" + target, "en", nil + } + got, err := translateHTMLTextNodesWith(context.Background(), nil, `
Hello world
keep me`, "zh-CN", 100, translator) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(got, `
HELLO-zh-CN WORLD-zh-CN
`) { + t.Fatalf("translated HTML = %q", got) + } + if !strings.Contains(got, `keep me`) { + t.Fatalf("code block was translated: %q", got) + } +} + func TestTruncateRunes(t *testing.T) { got, truncated := truncateRunes("你好world", 4) if got != "你好wo" || !truncated { diff --git a/apps/web/src/pages/mail.tsx b/apps/web/src/pages/mail.tsx index f26e856..a02594d 100644 --- a/apps/web/src/pages/mail.tsx +++ b/apps/web/src/pages/mail.tsx @@ -29,6 +29,7 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@ import { ScrollArea } from "@/components/ui/scroll-area" import { Separator } from "@/components/ui/separator" import { Skeleton } from "@/components/ui/skeleton" +import { Switch } from "@/components/ui/switch" import { Avatar, AvatarFallback } from "@/components/ui/avatar" import { ConfirmDialog } from "@/components/confirm-dialog" import { @@ -3213,23 +3214,41 @@ function CompactMessageDetail({ function TranslatableMailBody({ message, language }: { message: MailMessage; language: Language }) { + const qc = useQueryClient() const [translatedText, setTranslatedText] = React.useState("") const [translatedHtml, setTranslatedHtml] = React.useState("") const [showTranslated, setShowTranslated] = React.useState(false) const [truncated, setTruncated] = React.useState(false) + const [autoTranslate, setAutoTranslate] = React.useState(() => { + try { + return window.localStorage.getItem("newszxcn.mail.auto-translate") !== "false" + } catch { + return true + } + }) const { toast } = useToast() const targetLanguage = normalizeTranslationLanguage(language) const sourceText = React.useMemo(() => (message.bodyText || stripHtml(message.bodyHtml || message.snippet || "")).trim(), [message.bodyHtml, message.bodyText, message.snippet]) const shouldShow = targetLanguage && (message.externalAccountId || message.mailboxId) && shouldOfferMessageTranslation(sourceText, language) + const translationKey = React.useMemo(() => ["mail-translation", message.externalAccountId || "local", message.id, targetLanguage] as const, [message.externalAccountId, message.id, targetLanguage]) const translatedMessage = React.useMemo